def _remove(): # remove the thing if type_ == self.TYPE_FOLDER_ONLY: # TODO: what do we do if the folder isn't empty? with note("Removing dir %s" % path): try: os.rmdir(path) except OSError as err: from errno import ENOTEMPTY if err.errno == ENOTEMPTY: warn("Directory not empty: {}".format(path)) else: raise elif type_ == self.TYPE_FILE_ALL: note("Removing {}".format(path)) os.unlink(path) elif type_ == self.TYPE_FILE_PART: if os.stat(path).st_size == 0: note("Removing empty {}".format(path)) os.unlink(path) else: note("Refusing to remove non-empty {}".format(path)) else: note("Removing link {}".format(path)) os.unlink(path) _discard()
def _tryclean(self, cleaner, conflicts, affected): # if the cleaner is not needed, we get rid of it # FIXME try/except around the isneeded() call if not cleaner.isneeded(): note("{}: Not needed".format(cleaner.description)) return # run the cleaner now with note("Cleaning: {}".format(cleaner.description)): # if there are still helpers with claims over things the cleaner # wants to remove, then the cleaner needs to wait for claim in cleaner.needsclaims(): if claim in self._claims: note("Postponed: Something else claimed %r" % claim) self._addcleaner(cleaner) return try: affected.extend(cleaner.makechanges()) except CleanupObstruction as err: why = err.args[0] if conflicts == self.RAISE: raise if conflicts == self.POSTPONE: note("Postponed: %s" % why) # add the cleaner back in self._addcleaner(cleaner) return # NOTE: eventually we'd like to ask the user what to do, but # for now we just issue a warning assert conflicts in (self.WARN, self.ASK) warn("Aborted: %s" % err.why)
def __init__(self, cfgpath): super(Engine, self).__init__() self._cfgpath = cfgpath self._old_cleaners = [] self._new_cleaners = [] self._helpers = [] self._old_paths_owned = {} self._new_paths_owned = {} self._postponed = set() # keep track of which things we created ourselves self._created = set() self._only = set() self._section = None # another way of keeping track of things we've claimed self._claims = set() if os.path.isfile(cfgpath): with open(cfgpath, 'r') as f: raw = f.read() data = simplejson.loads(raw) if not isinstance(data, dict): raise Exception("Invalid json in %s" % cfgpath) for item in data.get('cleaners', []): cleaner = cleanerfromdict(item) if cleaner is None: warn("No cleaner for %s" % repr(item)) else: self._old_cleaners.append(cleaner) self._old_paths_owned = data.get('paths_owned', {}) for path in data.get('paths_postponed', []): if path in self._old_paths_owned: self._postponed.add(path) for path in data.get('paths_created', []): if path in self._old_paths_owned: self._created.add(path)
def forget(identifier): ''' Tells homely to forget about a dotfiles repository that was previously added. You can then run `homely update` to have homely perform automatic cleanup of anything that was installed by that dotfiles repo. REPO This should be the path to a local dotfiles repository that has already been registered using `homely add`. You may specify multiple REPOs to remove at once. ''' errors = False for one in identifier: cfg = RepoListConfig() info = cfg.find_by_any(one, "ilc") if not info: warn("No repos matching %r" % one) errors = True continue # update the config ... note("Removing record of repo [%s] at %s" % (info.shortid(), info.localrepo.repo_path)) with saveconfig(RepoListConfig()) as cfg: cfg.remove_repo(info.repoid) # if there were errors, then don't try and do an update if errors: sys.exit(1)
def include(pyscript): path = _repopath2real(pyscript, getrepoinfo().localrepo) if not os.path.exists(path): warn("{} not found at {}".format(pyscript, path)) return global _include_num _include_num += 1 name = '__imported_by_homely_{}'.format(_include_num) try: with entersection("/" + pyscript): _loadmodule(name, path) except Exception: import traceback warn("Error while including {}: {}".format(pyscript, traceback.format_exc()))
def execute(cmd, stdout=None, stderr=None, expectexit=0, **kwargs): # Executes `cmd` in a subprocess. Raises a SystemError if the exit code # is different to `expecterror`. # # The stdout and stderr arguments for the most part work just like # homely._ui.run(), with the main difference being that when stdout=None or # stderr=None, these two streams will be filtered through the homely's # logging functions instead of being sent directly to the python process's # stdout/stderr. Also, the stderr argument will default to "STDOUT" so that # the timing of the two streams is recorded more accurately. # # If the process absolutely _must_ talk to a TTY, you can use stdout="TTY", # and a SystemError will be raised if homely is being run in # non-interactive mode. When using stdout="TTY", you should omit the stderr # argument. def outputhandler(data, isend, prefix): # FIXME: if we only get part of a stream, then we have a potential bug # where we only get part of a multi-byte utf-8 character. while len(data): pos = data.find(b"\n") if pos < 0: break # write out the line note(data[0:pos].decode('utf-8'), dash=prefix) data = data[pos + 1:] if isend: if len(data): note(data.decode('utf-8'), dash=prefix) else: # return any remaining data so it can be included at the start of # the next run return data if stdout == "TTY": if not allowinteractive(): raise SystemError("cmd wants interactive mode") assert stderr is None stdout = None else: if stdout is None: prefix = "1> " if stderr is False else "&> " stdout = partial(outputhandler, prefix=prefix) if stderr is None: if stdout in (False, True): stderr = partial(outputhandler, prefix="2> ") else: stderr = "STDOUT" outredir = ' 1> /dev/null' if stdout is False else '' if stderr is None: errredir = ' 2>&1' else: errredir = ' 2> /dev/null' if stderr is False else '' with note('{}$ {}{}{}'.format(kwargs.get('cwd', ''), ' '.join(map(shellquote, cmd)), outredir, errredir)): returncode, out, err = run(cmd, stdout=stdout, stderr=stderr, **kwargs) if type(expectexit) is int: exitok = returncode == expectexit else: exitok = returncode in expectexit if exitok: return returncode, out, err # still need to dump the stdout/stderr if they were captured if out is not None: outputhandler(out, True, '1> ') if err is not None: outputhandler(err, True, '1> ') message = "Unexpected exit code {}. Expected {}".format( returncode, expectexit) warn(message) raise SystemError(message)
def _trycleanpath(self, path, type_, conflicts): def _discard(): note(" Forgetting about %s %s" % (type_, path)) self._old_paths_owned.pop(path) self._postponed.discard(path) self._created.discard(path) self._savecfg() def _remove(): # remove the thing if type_ == self.TYPE_FOLDER_ONLY: # TODO: what do we do if the folder isn't empty? with note("Removing dir %s" % path): try: os.rmdir(path) except OSError as err: from errno import ENOTEMPTY if err.errno == ENOTEMPTY: warn("Directory not empty: {}".format(path)) else: raise elif type_ == self.TYPE_FILE_ALL: note("Removing {}".format(path)) os.unlink(path) elif type_ == self.TYPE_FILE_PART: if os.stat(path).st_size == 0: note("Removing empty {}".format(path)) os.unlink(path) else: note("Refusing to remove non-empty {}".format(path)) else: note("Removing link {}".format(path)) os.unlink(path) _discard() def _postpone(): with note("Postponing cleanup of path: {}".format(path)): self._postponed.add(path) assert path not in self._new_paths_owned self._new_paths_owned[path] = type_ self._old_paths_owned.pop(path) self._savecfg() # if we didn't create the path, then we don't need to clean it up if path not in self._created: return _discard() # if the path no longer exists, we have nothing to do if not _exists(path): return _discard() # if the thing has the wrong type, we'll issue an note() and just skip if type_ in (self.TYPE_FILE_PART, self.TYPE_FILE_ALL): correcttype = os.path.isfile(path) elif type_ in (self.TYPE_FOLDER_ONLY, self.TYPE_FOLDER_ALL): correcttype = os.path.isdir(path) else: assert type_ == self.TYPE_LINK correcttype = os.path.islink(path) if not correcttype: with note("Ignoring: Won't remove {} as it is no longer a {}" .format(path, type_)): return _discard() # work out if there is another path we need to remove first for otherpath in self._old_paths_owned: if otherpath != path and isnecessarypath(path, otherpath): # If there's another path we need to do first, then don't do # anything just yet. NOTE: there is an assertion to ensure that # we can't get stuck in an infinite loop repeatedly not # removing things. return # if any helpers want the path, don't delete it wantedby = None for c in self._new_cleaners: if c.wantspath(path): wantedby = c break if not wantedby: for otherpath in self._new_paths_owned: if isnecessarypath(path, otherpath): wantedby = otherpath if wantedby: # if we previously postponed this path, keep postponing it # while there are still things hanging around that want it if path in self._postponed: return _postpone() if conflicts == self.ASK: raise Exception("TODO: ask the user what to do") # noqa # NOTE: options are: # A) postpone until it can run later # B) discard it if conflicts == self.RAISE: raise CleanupConflict(conflictpath=path, pathwanter=wantedby) if conflicts == self.POSTPONE: return _postpone() assert conflicts == self.WARN warn("Couldn't clean up path {}; it is still needed for {}" .format(path, wantedby)) return _discard() # if nothing else wants this path, clean it up now return _remove()
def run(self, helper): assert isinstance(helper, Helper) cfg_modified = False # what claims does this helper make? self._claims.update(*helper.getclaims()) # take ownership of paths for path, type_ in helper.pathsownable().items(): cfg_modified = True self._new_paths_owned[path] = type_ self._old_paths_owned.pop(path, None) # get a cleaner for this helper cleaner = helper.getcleaner() if helper.isdone(): # if there is already a cleaner for this thing, add and remove it # so it hangs around. If there is no cleaner but the thing is # already done, it means we shouldn't be cleaning it up if cleaner is not None: cfg_modified = True if self._removecleaner(cleaner): self._addcleaner(cleaner) note("{}: Already done".format(helper.description)) else: # remove and add the cleaner so that we know it will try to clean # up, since we know we will be making the change if cleaner is not None: cfg_modified = True self._removecleaner(cleaner) self._addcleaner(cleaner) # if the helper isn't already done, tell it to do its thing now with note("{}: Running ...".format(helper.description)): # take ownership of any paths that don't exist yet! for path, type_ in helper.pathsownable().items(): if type_ in (self.TYPE_FILE_ALL, self.TYPE_FOLDER_ALL): exists = path in self._created elif type_ in (self.TYPE_FILE_PART, self.TYPE_FOLDER_ONLY): exists = os.path.exists(path) else: exists = os.path.islink(path) if not exists: self._created.add(path) cfg_modified = True if cfg_modified: # save the updated config before we try anything self._savecfg() cfg_modified = False try: helper.makechanges() except HelperError as err: warn("Failed: %s" % err.args[0]) self._helpers.append(helper) # save the config now if we were successful if cfg_modified: # save the updated config before we try anything self._savecfg()
def execute(cmd, stdout=None, stderr=None, expectexit=0, **kwargs): # Executes `cmd` in a subprocess. Raises a SystemError if the exit code # is different to `expecterror`. # # The stdout and stderr arguments for the most part work just like # homely._ui.run(), with the main difference being that when stdout=None or # stderr=None, these two streams will be filtered through the homely's # logging functions instead of being sent directly to the python process's # stdout/stderr. Also, the stderr argument will default to "STDOUT" so that # the timing of the two streams is recorded more accurately. # # If the process absolutely _must_ talk to a TTY, you can use stdout="TTY", # and a SystemError will be raised if homely is being run in # non-interactive mode. When using stdout="TTY", you should omit the stderr # argument. def outputhandler(data, isend, prefix): # FIXME: if we only get part of a stream, then we have a potential bug # where we only get part of a multi-byte utf-8 character. while len(data): pos = data.find(b"\n") if pos < 0: break # write out the line note(data[0:pos].decode('utf-8'), dash=prefix) data = data[pos+1:] if isend: if len(data): note(data.decode('utf-8'), dash=prefix) else: # return any remaining data so it can be included at the start of # the next run return data if stdout == "TTY": if not allowinteractive(): raise SystemError("cmd wants interactive mode") assert stderr is None stdout = None else: if stdout is None: prefix = "1> " if stderr is False else "&> " stdout = partial(outputhandler, prefix=prefix) if stderr is None: if stdout in (False, True): stderr = partial(outputhandler, prefix="2> ") else: stderr = "STDOUT" outredir = ' 1> /dev/null' if stdout is False else '' if stderr is None: errredir = ' 2>&1' else: errredir = ' 2> /dev/null' if stderr is False else '' with note('{}$ {}{}{}'.format(kwargs.get('cwd', ''), ' '.join(map(shellquote, cmd)), outredir, errredir)): returncode, out, err = run(cmd, stdout=stdout, stderr=stderr, **kwargs) if type(expectexit) is int: exitok = returncode == expectexit else: exitok = returncode in expectexit if exitok: return returncode, out, err # still need to dump the stdout/stderr if they were captured if out is not None: outputhandler(out, True, '1> ') if err is not None: outputhandler(err, True, '1> ') message = "Unexpected exit code {}. Expected {}".format( returncode, expectexit) warn(message) raise SystemError(message)