PK'M3NNetcmaint/__init__.pyfrom .etcmaint import __version__, __doc__ __doc__ = __doc__.splitlines()[0] PK\}L~AAetcmaint/__main__.pyfrom etcmaint import main if __name__ == '__main__': main() PKk(N(\\etcmaint/etcmaint.py#! /bin/env python """An Arch Linux tool based on git for the maintenance of /etc files.""" import sys import os import io import stat import argparse import pathlib import inspect import configparser import hashlib import itertools import shutil import contextlib import subprocess from textwrap import dedent, wrap from collections import namedtuple __version__ = '0.3' pgm = os.path.basename(sys.argv[0].rstrip(os.sep)) RW_ACCESS = stat.S_IWUSR | stat.S_IRUSR FIRST_COMMIT_MSG = 'First etcmaint commit' CHERRY_PICK_COMMIT_MSG = ('Files updated from new packages versions and' ' customized by user') GIT_USER_CONFIG = ['-c', 'user.email="etcmaint email"', '-c', 'user.name=etcmaint'] EXCLUDE_FILES = 'passwd, group, mtab, udev/hwdb.bin' EXCLUDE_PKGS = '' EXCLUDE_PREFIXES = 'ca-certificates, ssl/certs' ETCMAINT_BRANCHES = ['etc', 'etc-tmp', 'master', 'master-tmp', 'timestamps', 'timestamps-tmp'] # The subdirectory of '--root-dir'. ROOT_SUBDIR = 'etc' class EmtError(Exception): pass def warn(msg): print('*** warning:', msg, file=sys.stderr) def run_cmd(cmd, error='', ignore_failure=False): proc = subprocess.run(cmd, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) if proc.returncode != 0 and not ignore_failure: err_list = [] if error: err_list += [error] err_list += [proc.stdout.strip()] err_list += ['Command line:\n%s' % cmd] raise EmtError('\n'.join(err_list)) return proc def list_rpaths(rootdir, subdir, suffixes=None, prefixes=None): """List of the relative paths of the files in rootdir/subdir. Exclude file names that are a match for one of the suffixes in 'suffixes' and file names that are a match for one of the prefixes in 'prefixes'. """ flist = [] suffixes_len = len(suffixes) if suffixes is not None else 0 prefixes_len = len(prefixes) if prefixes is not None else 0 with change_cwd(os.path.join(rootdir, subdir)): for root, dirs, files in os.walk('.'): for fname in files: rpath = os.path.normpath(os.path.join(root, fname)) # Exclude files ending with one of the suffixes. if suffixes_len: if (len(list(itertools.takewhile(lambda x: not x or not rpath.endswith(x), suffixes))) != suffixes_len): continue # Exclude files starting with one of the prefixes. if prefixes_len: if (len(list(itertools.takewhile(lambda x: not x or not rpath.startswith(x), prefixes))) != prefixes_len): continue flist.append(os.path.join(subdir, rpath)) return flist def repository_dir(): xdg_data_home = os.environ.get('XDG_DATA_HOME') if xdg_data_home is not None: return os.path.join(xdg_data_home, 'etcmaint') try: logname = os.getlogin() except OSError: # Cannot fall back to getpass.getuser() or use the pwd database as # they do not provide the correct value when etcmaint is run with # sudo. raise EmtError('etcmaint requires a controlling terminal') from None return os.path.expanduser('~%s/.local/share/etcmaint' % logname) def copy_file(rpath, rootdir, repodir, repo_file=None): """Copy a file on 'rootdir' to the repository. 'rpath' is the relative path to 'rootdir'. """ if repo_file is None: repo_file = os.path.join(repodir, rpath) dirname = os.path.dirname(repo_file) if dirname and not os.path.isdir(dirname): os.makedirs(dirname) etc_file = os.path.join(rootdir, rpath) # Remove destination if source is a symlink or if destination is a symlink # (in the last case, source would be copied to the file pointed by # destination instead of having the symlink itself being copied). if os.path.lexists(repo_file) and (os.path.islink(etc_file) or os.path.islink(repo_file)): os.remove(repo_file) shutil.copy2(etc_file, repo_file, follow_symlinks=False) @contextlib.contextmanager def change_cwd(path): """Context manager that temporarily changes the cwd.""" saved_dir = os.getcwd() os.chdir(path) try: yield os.getcwd() finally: os.chdir(saved_dir) @contextlib.contextmanager def threadsafe_makedirs(): def _makedirs(*args, **kwds): kwds['exist_ok'] = True saved_makedirs(*args[:2], **kwds) saved_makedirs = os.makedirs try: os.makedirs = _makedirs yield finally: os.makedirs = saved_makedirs class EtcPath(): def __init__(self, basedir, rpath): assert rpath.startswith(ROOT_SUBDIR) self.path = pathlib.PosixPath(basedir, rpath) self._digest = None try: self.is_symlink = self.path.is_symlink() except OSError: self._digest = b'' @property def digest(self): if self._digest is None: try: if self.is_symlink: # The digest is the path to which the symbolic link # points. self._digest = os.readlink(self.path) else: h = hashlib.sha1() with self.path.open('rb') as f: h.update(f.read()) self._digest = h.digest() except OSError: self._digest = b'' return self._digest def __eq__(self, other): return (isinstance(other, EtcPath) and self.digest == other.digest and self.digest != b'') class GitRepo(): """A git repository.""" def __init__(self, root_dir, repodir): self.root_dir = root_dir self.repodir = repodir.rstrip(os.sep) self.curbranch = None self.initial_branch = None self.initialized = False self.git = [] self.root_not_repo_owner = False if os.geteuid() == 0: gitdir = os.path.join(repodir, '.git') try: statinfo = os.stat(gitdir) except FileNotFoundError: pass else: # When running as root and the git repository is not owned by # root, then run git commands as the owner of the repository. if statinfo.st_uid != 0: if shutil.which('setpriv') is None: raise EmtError('setpriv is missing from the ' 'ArchLinux util-linux package') # Some tests mock os.geteuid() to return 0 and setpriv # fails when attempting to set groups as a plain user. set_priv = ['setpriv', '--reuid=%d' % statinfo.st_uid] setgroups = ['--clear-groups', '--regid=%d' % statinfo.st_gid] proc = run_cmd(['setpriv'] + setgroups + ['true'], ignore_failure=True) if proc.returncode == 0: set_priv += setgroups self.root_not_repo_owner = True self.git.extend(set_priv) self.git.extend(['git', '-C', repodir]) def create(self): """Create the git repository.""" if os.path.isdir(self.repodir): if os.listdir(self.repodir): raise EmtError('%s is not empty' % self.repodir) else: os.makedirs(self.repodir) self.git_cmd('init') self.initialized = True def init(self): # Check the first commit message. cmd = self.git + ['rev-list', '--max-parents=0', '--format=%s', 'master', '--'] proc = run_cmd(cmd, error='no git repository at %s' % self.repodir) commit, first_commit_msg = proc.stdout.splitlines() if first_commit_msg != FIRST_COMMIT_MSG: err_msg = ("""\ this is not an etcmaint repository First commit message found: '%s', instead of the expected commit message: '%s'""" % (first_commit_msg, FIRST_COMMIT_MSG)) raise EmtError(dedent(err_msg)) status = self.get_status() if status: tracked = untracked = False for line in status: if line[:2] == '??': untracked = True else: tracked = True msg = 'the %s repository is not clean:\n' % self.repodir msg += '\n'.join(status) msg += '\n' if tracked: msg += dedent(""" Run 'git reset --hard' to discard any change in the working tree and in the index.""") if untracked: msg += dedent(""" Run 'git clean -d -x -f' to clean the working tree by recursively removing files not under version control.""") raise EmtError(msg) # Get the initial branch. proc = run_cmd(self.git + ['symbolic-ref', '--short', 'HEAD'], ignore_failure=True) if proc.returncode == 0: self.initial_branch = proc.stdout.splitlines()[0] self.curbranch = self.initial_branch self.initialized = True def close(self): if self.initialized: branch = 'master' if self.initial_branch in self.branches: branch = self.initial_branch if not self.get_status(): self.checkout(branch) def git_cmd(self, cmd): if type(cmd) == str: cmd = cmd.split() proc = run_cmd(self.git + cmd) output = proc.stdout.rstrip() return output def get_status(self): output = self.git_cmd('status --porcelain') return output.splitlines() def checkout(self, branch, create=False): if create: self.git_cmd('checkout -b %s' % branch) else: if branch == self.curbranch: return self.git_cmd('checkout %s' % branch) self.curbranch = branch def commit(self, commit_msg): self.git_cmd(GIT_USER_CONFIG + ['commit', '-m', commit_msg]) def add_files(self, files, commit_msg): """Add and commit a list of files. 'files' is a dictionary mapping filename to the file content that must be written before the commit. """ paths = [] for rpath in files: path = os.path.join(self.repodir, rpath) paths.append(path) with open(path, 'w') as f: f.write(files[rpath]) if paths: self.git_cmd(['add'] + paths) self.commit(commit_msg) def cherry_pick(self, sha): return run_cmd(self.git + GIT_USER_CONFIG + ['cherry-pick', '-x', sha], ignore_failure=True) def tracked_files(self, branch): """A dictionary of the tracked files in this branch.""" d = {} ls_tree = self.git_cmd('ls-tree -r --name-only --full-tree %s' % branch) for rpath in ls_tree.splitlines(): if rpath == '.gitignore': continue if branch.startswith('timestamps'): d[rpath] = pathlib.PosixPath(self.repodir, rpath) else: if not rpath.startswith(ROOT_SUBDIR): continue d[rpath] = EtcPath(self.repodir, rpath) return d def check_fast_forward(self, branch): """Is a fast-forward merge allowed.""" proc = run_cmd(self.git + ['rev-list', '%s-tmp..%s' % (branch, branch), '--']) if proc.stdout.strip(): # Commits have been made on the main branch since the last update # command. raise EmtError('cannot fast-forward the %s branch, please ' 'run again the update command' % branch) @property def branches(self): branches = self.git_cmd("for-each-ref --format=%(refname:short)") return [b for b in branches.splitlines() if b in ETCMAINT_BRANCHES] class Commit(): """A commit to add/update or to remove a list of files.""" def __init__(self, repo, branch, commit_msg, add=True): self.repo = repo self.branch = branch self.commit_msg = commit_msg self.add = add self.rpaths = [] def commit(self): if not self.rpaths: return self.repo.checkout(self.branch) # Command line length overflow is not expected. # For example on an archlinux box: # find /etc | wc -c -> 57722 # getconf ARG_MAX' -> 2097152 cmd = 'add' if self.add else 'rm' self.repo.git_cmd([cmd] + self.rpaths) self.repo.commit(self.commit_msg) class EtcMaint(): """Provide methods to implement the commands.""" def __init__(self, results): self.results = results def init(self): self.repodir = repository_dir() self.repo = GitRepo(self.root_dir, self.repodir) if not hasattr(self, 'dry_run'): self.dry_run = False self.mode = '[dry-run] ' if self.dry_run else '' if hasattr(self, 'cache_dir') and self.cache_dir is None: cfg = configparser.ConfigParser(allow_no_value=True) with open('/etc/pacman.conf') as f: cfg.read_file(f) self.cache_dir = cfg['options']['CacheDir'] Etc_commits = namedtuple('Etc_commits', ['added', 'cherry_pick', 'removed']) self.etc_commits = Etc_commits( added=Commit(self.repo, 'etc-tmp', 'Files added or updated from new package versions'), cherry_pick=Commit(self.repo, 'etc-tmp', CHERRY_PICK_COMMIT_MSG), removed=Commit(self.repo, 'etc-tmp', 'Files removed that do not exist in /etc', add=False), ) Master_commits = namedtuple('Master_commits', ['added', 'removed', 'user_updated']) self.master_commits = Master_commits( added=Commit(self.repo, 'master-tmp', 'Files added from /etc after extracting new packages'), removed=Commit(self.repo, 'master-tmp', 'Files removed that do not exist in /etc', add=False), user_updated=Commit(self.repo, 'master-tmp', 'Files updated by the user in /etc'), ) def run(self, command): """Run the etcmaint command.""" assert command.startswith('cmd_') self.cmd = command[4:] self.init() method = getattr(self, command) # The sync subcommand is the only command that can be run as root # except when the repository has been created by root. if self.repo.root_not_repo_owner and command != 'cmd_sync': raise EmtError('cannot be executed as root') if command != 'cmd_create': self.repo.init() try: method() finally: self.repo.close() def print(self, text=''): print(text, file=self.results) def print_commits(self, suffix=''): print_footer = False for tmp_branch in ('etc-tmp', 'master-tmp'): branch = tmp_branch[:tmp_branch.index('-tmp')] rev_list = self.repo.git_cmd( 'rev-list --format=%%b%%n%%s %s..%s' % (branch, tmp_branch)) if not rev_list: continue self.print('Commits in the %s%s branch:' % (branch, suffix)) print_footer = True # Print the commits in chronological order. for line in reversed(rev_list.splitlines()): if line.startswith('commit '): sha = line[len('commit '):] diff_tree = self.repo.git_cmd( 'diff-tree --no-commit-id --name-only -r %s' % sha) self.print('\n'.join(' %s' % f for f in sorted(diff_tree.splitlines()))) elif line: self.print(' %s' % line) if print_footer: self.print('---') def cmd_create(self): """Create the git repository and populate the etc and master branches. The git repository is located at $XDG_DATA_HOME/etcmaint if the XDG_DATA_HOME environment variable is set and at $HOME/.local/share/etcmaint otherwise. The 'diff' subcommand may be used now to list the files added to /etc by the user. If any of those files is added (and commited) to the 'master' branch, the 'update' subcommand will track future changes made to those files in /etc and include these changes to the 'master' branch. """ self.repo.create() # Add .gitignore. self.repo.add_files({'.gitignore': '.swp\n'}, FIRST_COMMIT_MSG) # Create the etc and timestamps branches. self.repo.checkout('etc', create=True) self.repo.checkout('timestamps', create=True) self.repo.checkout('master') self.repo.init() self.update_repository() print('Git repository created at %s' % self.repodir) def cmd_update(self): """Update the repository with packages and user changes. The changes are made in temporary branches named 'master-tmp' and 'etc-tmp'. When those changes do not incur a cherry-pick, the 'master-tmp' (resp. 'etc-tmp') branch is merged as a fast-forward into its main branch and the temporary branches deleted. The operation is then complete and the changes can be examined with the git diff command run on the differences between the git tag set at the previous 'update' command, named '-prev', and the branch itself. For example, to list the names of the files that have been changed in the master branch: git diff --name-only master-prev...master Otherwise the fast-forwarding is postponed until the 'sync' command is run and until then it is still possible to start over with a new 'update' command, the previous temporary branches being discarded in that case. To examine the changes that will be merged into each branch by the 'sync' command, use the git diff command run on the differences between the branch itself and the corresponding temporary branch. For example, to list all the changes that will be made by the 'sync' command to the master branch: git diff master...master-tmp """ self.update_repository() results = self.results.getvalue() if results: print('---') print(results, end='') def cmd_diff(self): """Print the list of the /etc files not tracked in the etc branch. These are the /etc files not extracted from an Arch Linux package. Among them and of interest are the files created by a user that one may want to manually add and commit to the 'master' branch of the etcmaint repository so that their changes start being tracked by etcmaint (for example the netctl configuration files). pacnew, pacsave and pacorig files are excluded from this list. """ if self.use_etc_tmp: if 'etc-tmp' in self.repo.branches: self.repo.checkout('etc-tmp') else: print('The etc-tmp branch does not exist') return else: self.repo.checkout('etc') suffixes = ['.pacnew', '.pacsave', '.pacorig'] etc_files = list_rpaths(self.root_dir, ROOT_SUBDIR, suffixes=suffixes, prefixes=self.exclude_prefixes) repo_files = list_rpaths(self.repodir, ROOT_SUBDIR) print('\n'.join(sorted(set(etc_files).difference(repo_files)))) def cmd_sync(self): """Synchronize /etc with changes made by the previous update command. To print the changes that are going to be made to /etc by the 'sync' command, first print the list of files that will be copied: etcmaint sync --dry-run Then for each file in the list, run the following git command where 'rpath' is the relative path name as output by the previous command and that starts with 'etc/': git diff master...master-tmp -- rpath This command must be run as root when using the --root-dir default value. """ if not 'etc-tmp' in self.repo.branches: print(self.mode + 'no file to sync to /etc') return for branch in ('master', 'etc'): self.repo.check_fast_forward(branch) # Find the cherry-pick in the etc-tmp branch. rev_list = self.repo.git_cmd('rev-list --format=%s etc..etc-tmp') cherry_pick_sha = None for line in rev_list.splitlines(): if line.startswith('commit '): sha = line[len('commit '):] elif line == CHERRY_PICK_COMMIT_MSG: cherry_pick_sha = sha break if cherry_pick_sha is None: raise EmtError('cannot find a cherry-pick in the etc-tmp branch') # Copy the files commited in the cherry-pick to /etc. self.repo.checkout('master-tmp') res = self.repo.git_cmd('diff-tree --no-commit-id --name-only -r %s' % cherry_pick_sha) print_header = True for rpath in (f for f in res.splitlines() if f not in self.exclude_files): etc_file = os.path.join(self.root_dir, rpath) if not os.path.lexists(etc_file): warn('%s not synced, does not exist on /etc' % rpath) continue if not self.dry_run: path = os.path.join(self.repodir, rpath) try: if os.path.islink(path) or os.path.islink(etc_file): os.remove(etc_file) shutil.copyfile(path, etc_file, follow_symlinks=False) except OSError as e: raise EmtError(e) from None if print_header: print_header = False print('Files copied from the master-tmp branch to %s:' % self.root_dir) print(' %s' % rpath) if not self.dry_run: self.remove_tmp_branches() print(self.mode + "'sync' command terminated.") def create_tmp_branches(self): print('Creating the temporary branches') branches = self.repo.branches for branch in ('etc', 'master', 'timestamps'): tmp_branch = '%s-tmp' % branch if tmp_branch in branches: self.repo.checkout('master') self.repo.git_cmd('branch --delete --force %s' % tmp_branch) print("Remove the previous unused '%s' branch" % tmp_branch) self.repo.checkout(branch) self.repo.checkout(tmp_branch, create=True) def remove_tmp_branches(self): """Delete tmp branches, but merge first if not dry run.""" if 'master-tmp' in self.repo.branches: print('Removing the temporary branches') if self.repo.curbranch in ('master-tmp', 'etc-tmp', 'timestamps-tmp'): self.repo.checkout('master') for branch in ('master', 'etc', 'timestamps'): tmp_branch = '%s-tmp' % branch if not self.dry_run: if branch in ('master', 'etc'): # If there is a merge to be done then tag the branch # before the merge. if (self.repo.git_cmd('rev-list %s..%s' % (branch, tmp_branch))): self.repo.git_cmd('tag -f %s-prev %s' % (branch, branch)) self.repo.checkout(branch) self.repo.git_cmd('merge %s' % tmp_branch) self.repo.git_cmd('branch -D %s' % tmp_branch) def update_repository(self): self.create_tmp_branches() self.git_removed_files() cherry_pick_sha = self.git_upgraded_pkgs() self.git_user_updates() if cherry_pick_sha is not None: if self.git_cherry_pick(cherry_pick_sha): self.print_commits(suffix='-tmp') self.print(self.mode + dedent("""\ 'update' command terminated, use the 'sync' command to copy the changes to /etc and fast-forward the changes to the master and etc branches.""")) if self.dry_run: self.remove_tmp_branches() else: self.print_commits() self.remove_tmp_branches() self.print(self.mode + "'update' command terminated: no file to sync to /etc.") def git_cherry_pick(self, cherry_pick_sha): self.repo.checkout('master-tmp') for rpath in self.etc_commits.cherry_pick.rpaths: repo_file = os.path.join(self.repodir, rpath) if not os.path.isfile(repo_file): assert False, ('cherry picking %s to the master-tmp branch' ' but this file does not exist' % rpath) # Use first a temporary branch for the cherry-pick. try: self.repo.checkout('cherry-pick', create=True) proc = self.repo.cherry_pick(cherry_pick_sha) if proc.returncode == 0: # Do a fast-forward merge. self.repo.checkout('master-tmp') self.repo.git_cmd('merge cherry-pick') return True else: conflicts = [x[3:] for x in self.repo.get_status() if 'U' in x[:2]] if conflicts: assert os.path.exists(os.path.join(self.repodir, '.git', 'CHERRY_PICK_HEAD')) self.repo.git_cmd('cherry-pick --abort') else: self.repo.git_cmd('reset --hard HEAD') raise EmtError(proc.stdout) finally: self.repo.checkout('master-tmp') self.repo.git_cmd('branch -D cherry-pick') self.print_commits(suffix='-tmp') self.print('List of files with a conflict to resolve:') self.print('\n'.join(' %s' % c for c in sorted(conflicts))) # Do the effective cherry-pick now after having printed the list of # files with a conflict to resolve. if not self.dry_run: self.print() proc = self.repo.cherry_pick(cherry_pick_sha) self.print('This is the output of the git cherry-pick command:') self.print('%s' % ('\n'.join(' %s' % l for l in proc.stdout.splitlines()))) self.print() self.print('Please resolve the conflict%s.' % ('s' if len(conflicts) > 1 else '')) if not os.getcwd().startswith(self.repodir): self.print('You MUST change the current working directory' ' to %s.' % self.repodir) self.print(dedent("""\ At any time you may run 'git cherry-pick --abort' and start over later with another 'etcmaint update' command.""" )) else: self.print(self.mode + "'update' command terminated with conflict%s to resolve." % ('s' if len(conflicts) > 1 else '')) return False def git_removed_files(self): """Remove files that do not exist in /etc.""" etc_tracked = self.repo.tracked_files('etc-tmp') for rpath in etc_tracked: etc_file = os.path.join(self.root_dir, rpath) if not os.path.lexists(etc_file): self.etc_commits.removed.rpaths.append(rpath) self.etc_commits.removed.commit() master_tracked = self.repo.tracked_files('master-tmp') for rpath in master_tracked: etc_file = os.path.join(self.root_dir, rpath) if not os.path.lexists(etc_file): self.master_commits.removed.rpaths.append(rpath) self.master_commits.removed.commit() def git_user_updates(self): """Update master-tmp with the user changes.""" suffixes = ['.pacnew', '.pacsave', '.pacorig'] etc_files = {n: EtcPath(self.root_dir, n) for n in list_rpaths(self.root_dir, ROOT_SUBDIR, suffixes=suffixes)} etc_tracked = self.repo.tracked_files('etc-tmp') # Build the list of etc-tmp files that are different from their # counterpart in /etc. self.repo.checkout('etc-tmp') to_check_in_master = [] for rpath in etc_files: if rpath in etc_tracked: if etc_files[rpath] != etc_tracked[rpath]: to_check_in_master.append(rpath) master_tracked = self.repo.tracked_files('master-tmp') # Build the list of master-tmp files: # * To add when the file does not exist in master-tmp and its # counterpart in etc-tmp is different from the /etc file. # * To update when the file exists in master-tmp and is different # from the /etc file. for rpath in to_check_in_master: if rpath not in master_tracked: self.master_commits.user_updated.rpaths.append(rpath) self.repo.checkout('master-tmp') for rpath in etc_files: if (rpath in master_tracked and rpath not in self.master_commits.added.rpaths): if etc_files[rpath].digest == b'': warn('cannot read %s' % etc_files[rpath].path) elif etc_files[rpath] != master_tracked[rpath]: self.master_commits.user_updated.rpaths.append(rpath) for rpath in self.master_commits.user_updated.rpaths: copy_file(rpath, self.root_dir, self.repodir) self.master_commits.user_updated.commit() def git_upgraded_pkgs(self): """Update the repository with installed or upgraded packages.""" self.extract_from_cachedir() self.etc_commits.added.commit() cherry_pick_sha = None if self.etc_commits.cherry_pick.rpaths: self.etc_commits.cherry_pick.commit() cherry_pick_sha = self.repo.git_cmd('rev-list -1 HEAD --') # Clean the working area of the files that are not under version # control. self.repo.git_cmd('clean -d -x -f') # Update the master-tmp branch with new files. if self.master_commits.added.rpaths: self.repo.checkout('master-tmp') for rpath in self.master_commits.added.rpaths: repo_file = os.path.join(self.repodir, rpath) if os.path.lexists(repo_file): warn('adding %s to the master-tmp branch but this file' ' already exists' % rpath) copy_file(rpath, self.root_dir, self.repodir, repo_file=repo_file) self.master_commits.added.commit() return cherry_pick_sha def list_new_packages(self, cache_dir): """Build the list of new package files.""" def newer_exists_in(packages, name, st_mtime, read_content=True): if name in packages: if read_content: # A 'tracked' timestamps file. with packages[name].open() as f: timestamp = f.read() else: # A 'new_packages' file. timestamp = packages[name].stat().st_mtime return float(st_mtime) <= float(timestamp) return False exclude_pkgs_len = len(self.exclude_pkgs) excluded = [] # 'timestamps' and 'tracked:' # Dictionary {package name: PosixPath with timestamp as content} timestamps = {} tracked = self.repo.tracked_files('timestamps-tmp') # Dictionary {package name: PosixPath of *.pkg.tar.xz pacman file} new_pkgs = {} self.repo.checkout('timestamps-tmp') for root, *remain in os.walk(cache_dir): # scandir() started supporting the context manager protocol in # Python 3.6 (see issue bpo-25994) so we cannot use a context # manager here and must delete 'it' when not used anymore so that # the scandir() file descriptor is explicitly closed by Python. it = os.scandir(root) for direntry in it: if not direntry.is_file(): continue fullname = direntry.name if not fullname.endswith('.pkg.tar.xz'): continue # "Version tags may not include hyphens!" quoting from # https://wiki.archlinux.org/index.php/Arch_package_guidelines name, *remain = fullname.rsplit('-', maxsplit=3) if len(remain) != 3: warn('ignoring incorrect package name: %s' % fullname) continue st_mtime = direntry.stat().st_mtime if (newer_exists_in(tracked, name, st_mtime) or newer_exists_in(new_pkgs, name, st_mtime, False)): continue # Exclude packages. if (name in excluded or len(list(itertools.takewhile(lambda x: not x or not name.startswith(x), self.exclude_pkgs))) != exclude_pkgs_len): if name not in excluded: excluded.append(name) continue timestamps[name] = str(st_mtime) new_pkgs[name] = pathlib.PosixPath(direntry.path) del it # Look the full cache_dir tree only when scanning the 'aur-dir' # directory. if cache_dir != self.aur_dir: break # Commit the new timestamps. if timestamps: # Add files to the timestamps-tmp branch whose name are the # package name and whose content are the modification time. self.repo.add_files(timestamps, 'Add the timestamps of the new packages') self.new_packages = list(pkg.name for pkg in new_pkgs.values()) return new_pkgs.values() def extract(self, packages, tracked): """ Extract configuration files from packages. Return a dictionary mapping extracted configuration file names to the EtcPath instance of the 'original' file before the extraction. """ import tarfile from concurrent.futures import ThreadPoolExecutor def etc_files_filter(members): for tinfo in members: fname = tinfo.name if (fname.startswith(ROOT_SUBDIR) and (tinfo.isfile() or tinfo.issym() or tinfo.islnk()) and fname not in self.exclude_files): yield tinfo def extract_from(pkg): tar = tarfile.open(str(pkg), mode='r:xz') tarinfos = list(etc_files_filter(tar.getmembers())) for tinfo in tarinfos: path = EtcPath(self.repodir, tinfo.name) # Remember the sha1 of the existing file, if it exists, before # extracting it from the tarball (EtcPath.digest is lazily # evaluated). not_used = path.digest extracted[tinfo.name] = path # The Python tarfile implementation fails to create symlinks, # see also issue bpo-10761. if tinfo.issym(): abspath = os.path.join(self.repodir, tinfo.name) try: if os.path.lexists(abspath): os.unlink(abspath) except OSError as err: warn(err) tar.extractall(self.repodir, members=tarinfos) print(pkg.name) extracted = {} print('Extracting configuration files from packages') max_workers = len(os.sched_getaffinity(0)) or 4 # Extracting from tarfiles is not thread safe (see msg315067 in bpo # issue https://bugs.python.org/issue23649). with threadsafe_makedirs(): with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = [executor.submit(extract_from, pkg) for pkg in packages] for f in futures: exc = f.exception() if exc is not None: raise exc for rpath in extracted: if rpath not in tracked: # Ensure that the file can be overwritten on a next # 'update' command. path = os.path.join(self.repodir, rpath) mode = os.lstat(path).st_mode if mode & RW_ACCESS != RW_ACCESS: os.chmod(path, mode | RW_ACCESS) return extracted def extract_from_cachedir(self): """ Extract configuration files from pacman cachedir. The pacman man pages at section 'HANDLING CONFIG FILES describes the logic used to handle configuration files extracted from packages. The pacman terminology: original file in the etc-tmp branch current file in /etc new packaged file State before State after logic applied and applying pacman logic upon entering extract_from_cachedir() case 1: original=X, current=X, new=X unchanged case 2: original=X, current=X, new=Y current=Y, new=Y case 3: original=X, current=Y, new=X unchanged case 4: original=X, current=Y, new=Y unchanged case 5: original=X, current=Y, new=Z cherry-pick changes between new and current into master case 6: original=NULL, current=Y, new=Z idem """ # Extract the configuration files from each new package into the # etc-tmp branch. master_tracked = self.repo.tracked_files('master-tmp') etc_tracked = self.repo.tracked_files('etc-tmp') packages = self.list_new_packages(self.cache_dir) if self.aur_dir is not None: packages = itertools.chain(packages, self.list_new_packages(self.aur_dir)) self.repo.checkout('etc-tmp') original_files = self.extract(packages, etc_tracked) for rpath in original_files: new = EtcPath(self.repodir, rpath) current = EtcPath(self.root_dir, rpath) if current.digest == b'': path = current.path exists = True try: if not path.exists(): exists = False except PermissionError: pass if not exists: # Do not warn on 'create' subcommand to avoid the noise of # all the /etc files removed after packages removal while # their package file is still present in pacman CacheDir. if self.cmd != 'create': warn('skip %s does not exist' % path) else: warn('skip %s not readable' % path) continue # A new package has been installed. if rpath not in etc_tracked: # Add the file to the etc-tmp branch. self.etc_commits.added.rpaths.append(rpath) if new != current: # Case 6. # Add the file name to the list of files to add to the # master-tmp branch (from /etc). self.master_commits.added.rpaths.append(rpath) # A package upgrade. else: if new == current: if new != original_files[rpath]: # Case 2 and 4. # Stage the file in the etc-tmp branch. self.etc_commits.added.rpaths.append(rpath) if rpath in master_tracked: warn('%s should not exist in the master branch' % rpath) else: # Case 1. pass else: # Case 5. # A specific commit is used for the configuration files # whose changes must be cherry-picked into the master # branch. if new != original_files[rpath]: self.etc_commits.cherry_pick.rpaths.append(rpath) else: # Case 3. pass def dispatch_help(args): """Get help on a command.""" command = args.subcommand if command is None: command = 'help' args.parsers[command].print_help() cmd_func = getattr(EtcMaint, 'cmd_%s' % command, None) if cmd_func: lines = cmd_func.__doc__.splitlines() print('\n%s\n' % lines[0]) paragraph = [] for l in dedent('\n'.join(lines[2:])).splitlines(): if l == '': if paragraph: print('\n'.join(wrap(' '.join(paragraph), width=78))) print() paragraph = [] continue paragraph.append(l) if paragraph: print('\n'.join(wrap(' '.join(paragraph), width=78))) def parse_args(argv, namespace): def isdir(path): if not os.path.isdir(path): raise argparse.ArgumentTypeError('%s is not a directory' % path) return path # Instantiate the main parser. main_parser = argparse.ArgumentParser(prog=pgm, formatter_class=argparse.RawDescriptionHelpFormatter, description=__doc__, add_help=False) main_parser.add_argument('--version', '-v', action='version', version='%(prog)s ' + __version__) main_parser.prog = 'etcmaint' # The help subparser handles the help for each command. subparsers = main_parser.add_subparsers(title='etcmaint subcommands') parsers = { 'help': main_parser } parser = subparsers.add_parser('help', add_help=False, help=dispatch_help.__doc__.splitlines()[0]) parser.add_argument('subcommand', choices=parsers, nargs='?', default=None) parser.set_defaults(command='dispatch_help', parsers=parsers) # Add the command subparsers. d = dict(inspect.getmembers(EtcMaint, inspect.isfunction)) for command in sorted(d): if not command.startswith('cmd_'): continue cmd = command[4:] func = d[command] parser = subparsers.add_parser(cmd, help=func.__doc__.splitlines()[0], add_help=False) parser.set_defaults(command=command) if cmd in ('update', 'sync'): parser.add_argument('--dry-run', '-n', help='Perform a trial run' ' with no changes made (default: %(default)s)', action='store_true', default=False) if cmd in ('create', 'update'): parser.add_argument('--cache-dir', help='Set pacman cache' ' directory (override the /etc/pacman.conf setting of the' ' CacheDir option)', type=isdir) parser.add_argument('--aur-dir', help='Set the path of the root ' 'of the directory tree where to look for built AUR packages', type=isdir) parser.add_argument('--exclude-pkgs', default=EXCLUDE_PKGS, type=lambda x: list(y.strip() for y in x.split(',')), help='A comma separated list of prefix of package names' ' to be ignored (default: "%(default)s")', metavar='PFXS') if cmd in ('create', 'update', 'sync'): parser.add_argument('--exclude-files', default=EXCLUDE_FILES, type=lambda x: list(os.path.join(ROOT_SUBDIR, y.strip()) for y in x.split(',')), metavar='FILES', help='A comma separated list of /etc path names to be ignored' ' (default: "%(default)s")') if cmd == 'diff': parser.add_argument('--exclude-prefixes', default=EXCLUDE_PREFIXES, metavar='PFXS', type=lambda x: list(y.strip() for y in x.split(',')), help='A comma separated list of prefixes of /etc path' ' names to be ignored (default: "%(default)s")') parser.add_argument('--use-etc-tmp', help='Use the etc-tmp branch instead (default: %(default)s)', action='store_true', default=False) parser.add_argument('--root-dir', default='/', help='Set the root directory of the etc files, mostly used for' ' testing (default: "%(default)s")', type=isdir) parsers[cmd] = parser main_parser.parse_args(argv[1:], namespace=namespace) if not hasattr(namespace, 'command'): main_parser.error('a command is required') def etcmaint(argv): with io.StringIO() as results: # Assign the parsed args to the EtcMaint instance. emt = EtcMaint(results) parse_args(argv, emt) # Run the command. if emt.command == 'dispatch_help': func = getattr(sys.modules[__name__], 'dispatch_help') func(emt) else: emt.run(emt.command) return emt def main(): try: etcmaint(sys.argv) except EmtError as e: print('*** %s: error:' % pgm, e, file=sys.stderr) sys.exit(1) if __name__ == '__main__': main() PKfLetcmaint/tests/__init__.pyPK4g%N Ogetcmaint/tests/test_commands.py"""etcmaint tests.""" import sys import os import io import tempfile import tarfile import time import shutil import unittest from argparse import ArgumentError from contextlib import contextmanager, ExitStack from textwrap import dedent from collections import namedtuple from unittest import TestCase, skipIf from unittest.mock import patch from etcmaint.etcmaint import (ETCMAINT_BRANCHES, change_cwd, etcmaint, ROOT_SUBDIR, EtcPath, EmtError, EtcMaint) ROOT_DIR = 'root' REPO_DIR = 'repo' CACHE_DIR = 'cache' AUR_DIR = 'aur' ROOT_SUBDIR_LEN = len(ROOT_SUBDIR) PACMAN_CONF = '/etc/pacman.conf' # Set debug to True and: # * Print on stderr the stdout and stderr output of etcmaint. # * Do not remove the temporary directories where the tests are run. debug = 0 @contextmanager def temp_cwd(): """Context manager that temporarily creates and changes the CWD.""" with tempfile.TemporaryDirectory() as temp_path: with change_cwd(temp_path) as cwd_dir: yield cwd_dir @contextmanager def captured_output(): _stdout = getattr(sys, 'stdout') _stderr = getattr(sys, 'stderr') strio = io.StringIO() setattr(sys, 'stdout', strio) setattr(sys, 'stderr', strio) try: yield strio, _stdout, _stderr finally: setattr(sys, 'stdout', _stdout) setattr(sys, 'stderr', _stderr) strio.close() # Index of fields returned by os.stat_result(). ST_UID = ST_GID = None @contextmanager def os_stat_as_root(): def stat(path, **kwds): # Members of the StructSequence returned by os.stat are readonly, so # we must build a new one. global ST_UID, ST_GID st = _stat(path, **kwds) if ST_UID is None: # Find the index of both fields when the StructSequence is used as # a sequence by parsing the string representation. st_str = str(st) for fieldname in ('st_uid', 'st_gid'): idx = st_str[:st_str.index(fieldname)].count('=') if fieldname == 'st_uid': ST_UID = idx else: ST_GID = idx st_list = list(st) st_list[ST_UID] = 0 st_list[ST_GID] = 0 return os.stat_result(st_list) _stat = getattr(os, 'stat') setattr(os, 'stat', stat) try: yield finally: setattr(os, 'stat', _stat) def raise_context_of_exit(func, *args, **kwds): try: func(*args, **kwds) except SystemExit as e: e = e.__context__ if isinstance(e.__context__, Exception) else e raise e from None _has_setpriv = None def skip_unless_setpriv(test): """Skip decorator for tests that require setpriv""" global _has_setpriv if _has_setpriv is None: _has_setpriv = True if shutil.which('setpriv') else False msg = "Requires functional setpriv implementation" return test if _has_setpriv else unittest.skip(msg)(test) SymLink = namedtuple('SymLink', ['linkto', 'abspath']) class Command(): """Helper to build an etcmaint command. 'relative_time' is used to ensure that the modification and access times of packages increment with each addition of a package. """ relative_time = 0 def __init__(self, tmpdir): self.tmpdir = tmpdir self.cache_dir = os.path.join(self.tmpdir, CACHE_DIR) self.root_dir = os.path.join(self.tmpdir, ROOT_DIR) def add_files(self, files, dir_path=''): """'files' dictionary of file names mapped to content or SymLink.""" for fname in files: path = os.path.join(dir_path, ROOT_SUBDIR, fname) dirname = os.path.dirname(path) if not os.path.isdir(dirname): os.makedirs(dirname) val = files[fname] if isinstance(val, SymLink): linkto = val.linkto if val.abspath: linkto = os.path.join(self.root_dir, ROOT_SUBDIR, linkto) if os.path.lexists(path): os.unlink(path) os.symlink(linkto, path) else: with open(path, 'w') as f: f.write(val) def add_etc_files(self, files): self.add_files(files, self.root_dir) def add_package(self, name, files, version='1.0', release='1', cache_dir=None, delta_mtime=None): """Add a package.""" cache_dir = self.cache_dir if cache_dir is None else cache_dir if not os.path.isdir(cache_dir): os.makedirs(cache_dir) pkg_name = os.path.join(cache_dir, '%s-%s-%s-%s.pkg.tar.xz' % (name, version, release, os.uname().machine)) with temp_cwd(): self.add_files(files) with tarfile.open(pkg_name, 'w|xz') as tar: tar.add(ROOT_SUBDIR) # Update the package modification and access times. if delta_mtime is None: delta_mtime = Command.relative_time Command.relative_time += 1 if delta_mtime: st = os.stat(pkg_name) atime = mtime = st.st_mtime + delta_mtime os.utime(pkg_name, (atime, mtime)) return pkg_name def etc_abspath(self, fname): return os.path.join(self.root_dir, ROOT_SUBDIR, fname) def remove_etc_file(self, fname): os.unlink(self.etc_abspath(fname)) def run(self, command, *args, with_rootdir=True): argv = ['etcmaint', command] if command in ('create', 'update'): argv.extend(['--cache-dir', self.cache_dir]) if with_rootdir: argv.extend(['--root-dir', self.root_dir]) argv.extend(args) return etcmaint(argv) class BaseTestCase(TestCase): """The base class of all TestCase classes.""" def setUp(self): self.stack = ExitStack() self.addCleanup(self.stack.close) self.stdout, self._stdout, self._stderr = self.stack.enter_context( captured_output()) self.mkdtemp() def mkdtemp(self): if not debug: self.tmpdir = self.stack.enter_context(temp_cwd()) else: self.tmpdir = tempfile.mkdtemp() os.chdir(self.tmpdir) print('The temporary test directory %s must be removed manually' % self.tmpdir, file =self._stderr) self.cmd = Command(self.tmpdir) def run_cmd(self, command, *args, with_rootdir=True, clear_stdout=True): try: self.emt = self.cmd.run(command, *args, with_rootdir=with_rootdir) finally: if debug: out = self.stdout.getvalue() if out: print(out, file=self._stderr) if clear_stdout: self.clear_stdout() def clear_stdout(self): self.stdout.seek(0) self.stdout.truncate(0) def check_output(self, equal=None, is_in=None, is_notin=None): out = self.stdout.getvalue() if equal is not None: self.assertEqual(equal, out) if is_in is not None: self.assertIn(is_in, out) if is_notin is not None: self.assertNotIn(is_notin, out) class CommandLineTestCase(BaseTestCase): """Test the command line.""" def make_base_dirs(self): os.makedirs(os.path.join(self.tmpdir, ROOT_DIR, ROOT_SUBDIR)) os.makedirs(os.path.join(self.tmpdir, CACHE_DIR)) @skipIf(not os.path.exists(PACMAN_CONF), '%s does not exist' % PACMAN_CONF) def test_cl_pacman_conf(self): # Check that CacheDir may be parsed in /etc/pacman.conf. with io.StringIO() as results: emt = EtcMaint(results) emt.root_dir = '/' emt.cache_dir = None emt.init() self.assertEqual(os.path.isdir(emt.cache_dir), True) def test_cl_main_help(self): self.run_cmd('help', with_rootdir=False, clear_stdout=False) self.assertIn('An Arch Linux tool based on git for the maintenance' ' of /etc files.', self.stdout.getvalue()) def test_cl_main_help_debug(self): global debug try: debug = 1 _stderr = self._stderr self._stderr = io.StringIO() self.mkdtemp() print('debug info') self.test_cl_main_help() finally: debug = 0 out = self._stderr.getvalue() self._stderr.close() self._stderr = _stderr shutil.rmtree(self.tmpdir) self.assertIn('debug info', out) self.assertIn('temporary test directory %s must be removed' % self.tmpdir, out) self.assertIn('An Arch Linux tool based on git for the maintenance' ' of /etc files.', out) def test_cl_create_help(self): self.run_cmd('help', 'create', with_rootdir=False, clear_stdout=False) self.assertIn('Create the git repository', self.stdout.getvalue()) def test_cl_not_a_dir(self): # Check that ROOT_DIR exists. with self.assertRaisesRegex(ArgumentError, '--root-dir.*not a directory'): raise_context_of_exit(self.run_cmd, 'diff') def test_cl_repository_dir(self): with patch('os.getlogin', return_value='root'): from etcmaint.etcmaint import repository_dir self.assertEqual(repository_dir(), '/root/.local/share/etcmaint') def test_cl_repository_dir_XDG_DATA_HOME(self): with patch.dict('os.environ', values={'XDG_DATA_HOME': '/tmp'}): from etcmaint.etcmaint import repository_dir self.assertEqual(repository_dir(), '/tmp/etcmaint') def test_cl_no_command(self): import etcmaint.etcmaint try: _argv = getattr(sys, 'argv') setattr(sys, 'argv', ['etcmaint']) with self.assertRaisesRegex(SystemExit, '2'): etcmaint.etcmaint.main() finally: setattr(sys, 'argv', _argv) self.assertIn('a command is required', self.stdout.getvalue()) def test_cl_no_repo(self): # Check that the repository exists. self.make_base_dirs() with patch('etcmaint.etcmaint.repository_dir', return_value=os.path.join(self.tmpdir, REPO_DIR)): with self.assertRaisesRegex(EmtError, 'no git repository'): self.run_cmd('diff') def test_cl_no_repo_using_main(self): # Check that the repository exists. self.make_base_dirs() with patch('etcmaint.etcmaint.repository_dir', return_value=os.path.join(self.tmpdir, REPO_DIR)): import etcmaint.etcmaint try: _argv = getattr(sys, 'argv') setattr(sys, 'argv', ['etcmaint', 'diff']) with self.assertRaisesRegex(EmtError, 'no git repository'): raise_context_of_exit(etcmaint.etcmaint.main) finally: setattr(sys, 'argv', _argv) def test_cl_invalid_command(self): with self.assertRaisesRegex(ArgumentError, 'invalid choice'): raise_context_of_exit(self.run_cmd, 'foo', with_rootdir=False) class CommandsTestCase(BaseTestCase): """Test the etcmaint commands.""" def setUp(self): super().setUp() pre_patch = patch('etcmaint.etcmaint.repository_dir', return_value=os.path.join(self.tmpdir, REPO_DIR)) self.stack.enter_context(pre_patch) def check_results(self, master, etc, branches=None): def list_files(branch): return [f[ROOT_SUBDIR_LEN+1:] for f in sorted(self.emt.repo.tracked_files(branch).keys())] self.assertEqual(list_files('master'), master) self.assertEqual(list_files('etc'), etc) if branches is not None: self.assertEqual(sorted(self.emt.repo.branches), branches) def check_content(self, branch, fname, expected): content = self.emt.repo.git_cmd('show %s:%s' % (branch, os.path.join(ROOT_SUBDIR, fname))) self.assertEqual(content, expected) def check_is_symlink(self, branch, fname): rpath = os.path.join(ROOT_SUBDIR, fname) tree = self.emt.repo.git_cmd('ls-tree -r %s' % branch) for line in tree.splitlines(): mode, type, object, file = line.split() if file == rpath: self.assertEqual(mode[:3], '120') break else: self.fail('%s not found by git ls-tree' % rpath) def check_status(self, expected): self.assertEqual(self.emt.repo.get_status(), expected) def check_curbranch(self, expected): self.assertEqual(self.emt.repo.curbranch, expected) def add_repo_file(self, branch, fname, content, commit_msg): self.emt.repo.checkout(branch) os.makedirs(os.path.join(self.tmpdir, REPO_DIR, ROOT_SUBDIR)) self.emt.repo.add_files({os.path.join(ROOT_SUBDIR, fname): content}, commit_msg) def simple_cherry_pick(self): content = ['line %d' % n for n in range(5)] user_content = content[:]; user_content[0] = 'user line 0' self.cmd.add_etc_files({'a': '\n'.join(user_content)}) self.cmd.add_package('package_a', {'a': '\n'.join(content)}) self.run_cmd('create') self.check_results(['a'], ['a']) # A cherry-pick occurs. package_content = content[:]; package_content[3] = 'package line 3' self.cmd.add_package('package_a', {'a': '\n'.join(package_content)}) self.run_cmd('update') def check_simple_cherry_pick(self, branch, branches): self.check_results(['a'], ['a'], branches) self.check_content(branch, 'a', dedent("""\ user line 0 line 1 line 2 package line 3 line 4""")) class CreateTestCase(CommandsTestCase): def test_create_plain(self): files = {'a': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a'], ['etc', 'master', 'timestamps']) self.check_content('etc', 'a', 'content') def test_create_aur_package(self): # The AUR package is located in a subdirectory of 'aur-dir'. files = {'a': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) files = {'b': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('aur package', files, cache_dir=os.path.join(self.tmpdir, AUR_DIR, 'subdir')) self.run_cmd('create', '--aur-dir', AUR_DIR) self.check_results([], ['a', 'b']) def test_create_not_readable(self): files = {'a': 'a content', 'b': 'b content'} self.cmd.add_etc_files(files) path = os.path.join(self.cmd.root_dir, ROOT_SUBDIR, 'b') os.chmod(path, 0) self.cmd.add_package('package', files) self.run_cmd('create', clear_stdout=False) self.check_results([], ['a']) out = self.stdout.getvalue() self.assertIn('skip %s not readable' % path, out) def test_create_symlink_abspath(self): files = {'a': 'content', 'b': SymLink('a', True)} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a', 'b']) self.check_content('etc', 'b', self.cmd.etc_abspath('a')) def test_create_symlink_relpath(self): files = {'a': 'content', 'b': SymLink('a', False)} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a', 'b']) self.check_content('etc', 'b', 'a') def test_create_package_and_etc_differ(self): # 'b' in /etc and package differ and is added to the master branch. files = {'a': 'content', 'b': 'content'} self.cmd.add_etc_files(files) files['b'] = 'new content' self.cmd.add_package('package', files) self.run_cmd('create') self.check_results(['b'], ['a', 'b']) self.check_content('master', 'b', 'content') self.check_content('etc', 'a', 'content') self.check_content('etc', 'b', 'new content') def test_create_not_exists_in_package(self): # 'b' /etc file, non-existent in package, is not added to the etc # branch. files = {'a': 'content', 'b': 'content'} self.cmd.add_etc_files(files) del files['b'] self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a']) def test_create_newest_package(self): # Check that the newest package file is used. def create_package(release, add_etc=False, delta_mtime=0): files = {'a': 'release %s' % release} if add_etc: self.cmd.add_etc_files(files) self.cmd.add_package('package', files, release=release, delta_mtime=delta_mtime) create_package('X') # Create the package in the past. create_package('Y', add_etc=True, delta_mtime=-3600) self.run_cmd('create') self.assertNotIn('package-1.0-Y', ('-'.join(p.rsplit('-', maxsplit=3)[:3]) for p in self.emt.new_packages)) self.check_results(['a'], ['a']) # The oldest release file is in master: it is the content of the /etc # file which differs from the content of the newest release (and # pacman would have written a pacnew file). self.check_content('master', 'a', 'release Y') self.check_content('etc', 'a', 'release X') # Remove previous packages and check that an old package is not # updated. cachedir = os.path.join(self.tmpdir, CACHE_DIR) shutil.rmtree(cachedir) os.makedirs(cachedir) create_package('Z', delta_mtime=-3600) self.run_cmd('update') self.assertFalse(hasattr(self.emt, 'new_packages')) def test_create_exclude_packages(self): files = {'a': 'a content', 'b': 'b content', 'c': 'c content'} self.cmd.add_etc_files(files) pkg_a = self.cmd.add_package('a_package', {'a': 'a content'}) pkg_b = self.cmd.add_package('b_package', {'b': 'b content'}) pkg_c = self.cmd.add_package('c_package', {'c': 'c content'}) self.run_cmd('create', '--exclude-pkgs', 'foo, b_, bar', clear_stdout=False) self.check_results([], ['a', 'c']) out = self.stdout.getvalue() self.assertIn(os.path.basename(pkg_a), out) self.assertNotIn(os.path.basename(pkg_b), out) self.assertIn(os.path.basename(pkg_c), out) def test_create_exclude_files(self): files = {'a': 'a content', 'b': 'b content', 'bbb': 'bbb content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create', '--exclude-files', 'foo, b, bar') self.check_results([], ['a', 'bbb']) def test_create_repo_not_empty(self): repo_dir = os.path.join(self.tmpdir, REPO_DIR) os.makedirs(os.path.join(repo_dir, 'some_dir')) # Create the cache and root directories. files = {'a': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) with self.assertRaisesRegex(EmtError, '%s is not empty' % repo_dir): self.run_cmd('create') class UpdateTestCase(CommandsTestCase): def test_update_plain(self): files = {'a': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a'], ['etc', 'master', 'timestamps']) files = {'a': 'new content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('update') self.check_results([], ['a'], ['etc', 'master', 'timestamps']) self.check_content('etc', 'a', 'new content') def test_update_etc_removed(self): # Remove 'b' /etc file and it is removed from the etc branch on # 'update'. files = {'a': 'content', 'b': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package_a', {'a': 'content'}) self.cmd.add_package('package_b', {'b': 'content'}) self.run_cmd('create') self.check_results([], ['a', 'b']) self.cmd.remove_etc_file('b') self.run_cmd('update') self.check_results([], ['a'], ['etc', 'master', 'timestamps']) def test_update_master_removed(self): # An /etc file is created by the user and manually commited to master. # Check that it is removed from master after it has been removed from # /etc. files = {'a': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a']) fname = 'deleted etc file' self.add_repo_file('master', fname, 'content', 'some commit msg') self.run_cmd('update') self.assertIn(os.path.join(ROOT_SUBDIR, fname), self.emt.master_commits.removed.rpaths) def test_update_package_and_etc_differ_removed(self): # Remove 'a' /etc file and it is removed from the etc branch on # 'update', and removed from the master branch. files = {'a': 'content', 'b': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package_a', {'a': 'new content'}) self.cmd.add_package('package_b', {'b': 'content'}) self.run_cmd('create') self.check_results(['a'], ['a', 'b']) self.check_content('master', 'a', 'content') self.check_content('etc', 'a', 'new content') self.cmd.remove_etc_file('a') self.run_cmd('update') self.check_results([], ['b']) def test_update_with_upgraded_package_no_etc_change(self): # Check that a new released package, with no change in the /etc files, # does not add new files to the etc branch. files = {'a': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a']) self.cmd.add_package('package', files, release='X') self.run_cmd('update') self.check_results([], ['a']) self.assertFalse(self.emt.etc_commits.added.rpaths) def test_update_with_new_package(self): self.cmd.add_etc_files({'a': 'content'}) self.cmd.add_package('package_a', {'a': 'content'}) self.run_cmd('create') self.check_results([], ['a']) self.cmd.add_etc_files({'b': 'content'}) self.cmd.add_package('package_b', {'b': 'content'}) self.run_cmd('update') self.check_results([], ['a', 'b']) def test_update_old_package(self): # Check that old packages are not scanned on the next update. files = {'a': 'a content'} self.cmd.add_etc_files(files) pkg_a = self.cmd.add_package('package_a', files) self.run_cmd('create') self.check_results([], ['a'], ['etc', 'master', 'timestamps']) self.check_content('etc', 'a', 'a content') files = {'b': 'b content'} self.cmd.add_etc_files(files) pkg_b = self.cmd.add_package('package_b', files) self.run_cmd('update', clear_stdout=False) self.check_results([], ['a', 'b'], ['etc', 'master', 'timestamps']) self.check_content('etc', 'b', 'b content') self.check_output(is_in=os.path.basename(pkg_b), is_notin=os.path.basename(pkg_a)) def test_update_dry_run(self): # Check that two consecutive updates in dry-run mode give the same # output. self.cmd.add_etc_files({'a': 'content'}) self.cmd.add_package('package_a', {'a': 'content'}) self.run_cmd('create') self.check_results([], ['a']) self.cmd.add_etc_files({'b': 'content'}) self.cmd.add_package('package_b', {'b': 'content'}) out = [] for n in range(2): self.stdout.seek(0) self.stdout.truncate(0) self.run_cmd('update', '--dry-run') out.append(self.stdout.getvalue()) self.assertEqual(out[0], out[1]) def test_update_user_symlink(self): # 'a' /etc file is changed to a symlink by the user. files = {'a': 'content', 'b': 'content'} self.cmd.add_etc_files(files) files['a'] = 'package content' self.cmd.add_package('package', files) self.run_cmd('create') self.check_results(['a'], ['a', 'b']) self.cmd.add_etc_files({'a': SymLink('b', True)}) self.run_cmd('update') self.check_results(['a'], ['a', 'b']) self.check_content('etc', 'a', 'package content') self.check_content('master', 'a', self.cmd.etc_abspath('b')) def test_update_upgrade_symlink(self): # Issue #9. # 'a' /etc file is changed to a symlink by an upgrade. files = {'a': 'a content', 'b': 'b content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a', 'b']) files['a'] = SymLink('b', False) self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('update') self.check_results([], ['a', 'b']) self.check_content('etc', 'a', 'b') self.check_is_symlink('etc', 'a') def test_update_user_customize(self): # File customized by user is added to the master branch upon 'update'. self.cmd.add_etc_files({'a': 'content'}) self.cmd.add_package('package_a', {'a': 'content'}) self.run_cmd('create') self.check_results([], ['a']) self.cmd.add_etc_files({'a': 'new user content'}) self.run_cmd('update') self.check_results(['a'], ['a']) self.check_content('master', 'a', 'new user content') self.check_content('etc', 'a', 'content') def test_update_user_update_customized(self): # File customized by user and updated by user. self.cmd.add_etc_files({'a': 'user content'}) self.cmd.add_package('package_a', {'a': 'package content'}) self.run_cmd('create') self.check_results(['a'], ['a']) self.check_content('master', 'a', 'user content') self.check_content('etc', 'a', 'package content') self.cmd.add_etc_files({'a': 'new user content'}) self.run_cmd('update') self.check_content('master', 'a', 'new user content') def test_update_user_add(self): # 'b' file not from a package, manually added to master and updated by # the user. self.cmd.add_etc_files({'a': 'content'}) self.cmd.add_package('package_a', {'a': 'content'}) self.run_cmd('create') self.check_results([], ['a']) self.cmd.add_etc_files({'b': 'content'}) self.add_repo_file('master', 'b', 'content', 'commit msg') self.check_content('master', 'b', 'content') self.cmd.add_etc_files({'b': 'new content'}) self.run_cmd('update') self.check_results(['b'], ['a']) self.check_content('master', 'b', 'new content') def test_update_cherry_pick(self): # File cherry-picked by git. self.simple_cherry_pick() self.check_simple_cherry_pick('master-tmp', ETCMAINT_BRANCHES) def test_update_cherry_pick_update(self): # Check that an update following an update with a cherry-pick, gives # the same result. self.simple_cherry_pick() self.run_cmd('update') self.check_simple_cherry_pick('master-tmp', ETCMAINT_BRANCHES) def test_update_cherry_pick_dry_run(self): # File cherry-picked by git in dry-run mode: no changes. content = ['line %d' % n for n in range(5)] user_content = content[:]; user_content[0] = 'user line 0' self.cmd.add_etc_files({'a': '\n'.join(user_content)}) self.cmd.add_package('package_a', {'a': '\n'.join(content)}) self.run_cmd('create') self.check_results(['a'], ['a']) package_content = content[:]; package_content[3] = 'package line 3' self.cmd.add_package('package_a', {'a': '\n'.join(package_content)}) self.run_cmd('update', '--dry-run') self.check_results(['a'], ['a'], ['etc', 'master', 'timestamps']) def test_update_plain_conflict(self): # A plain conflict: a package upgrades the content of a user # customized file. self.cmd.add_etc_files({'a': 'user content'}) self.cmd.add_package('package_a', {'a': 'package content'}) self.run_cmd('create') self.check_results(['a'], ['a']) self.check_content('master', 'a', 'user content') self.check_content('etc', 'a', 'package content') self.cmd.add_package('package_a', {'a': 'new package content'}) self.run_cmd('update') self.check_results(['a'], ['a'], ETCMAINT_BRANCHES) self.check_curbranch('master-tmp') self.check_status(['UU %s/a' % ROOT_SUBDIR]) def test_update_cherry_pick_no_master(self): # Check an upgrade with a cherry-pick when there is no corresponding # file in master. This happens after a user change in the /etc file # and the next upgrade causes a cherry-pick. content = ['line %d' % n for n in range(5)] files = {'a': '\n'.join(content)} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a']) etc_content = content[:]; etc_content[0] = '/etc line 0' self.cmd.add_etc_files({'a': '\n'.join(etc_content)}) pkg_content = content[:]; pkg_content[4] = 'package line 4' self.cmd.add_package('package', {'a': '\n'.join(pkg_content)}) self.run_cmd('update') self.check_results([], ['a'], ETCMAINT_BRANCHES) self.check_content('master-tmp', 'a', dedent("""\ /etc line 0 line 1 line 2 line 3 package line 4""")) def test_update_new_package(self): # Check that a package is updated with a new release. files = {'a': 'initial content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files, release='X') self.run_cmd('create') files['a'] = 'new content' self.cmd.add_etc_files(files) pkg_a = self.cmd.add_package('package', files, release='Y') self.run_cmd('update') self.assertNotIn('package-1.0-X', ('-'.join(p.rsplit('-', maxsplit=3)[:3]) for p in self.emt.new_packages)) self.check_results([], ['a']) self.check_content('etc', 'a', 'new content') def test_update_removed_after_upgrade(self): # Issue #8 # A file is upgraded by a new package version and deleted from /etc # before the update command. files = {'a': 'a initial content'} files['b'] = 'b initial content' self.cmd.add_etc_files(files) self.cmd.add_package('package', files, release='X') self.run_cmd('create') files['a'] = 'a new content' files['b'] = 'b new content' self.cmd.add_package('package', files, release='Y') self.cmd.remove_etc_file('b') self.run_cmd('update') def test_update_tracked_changes(self): # Issue #10. files = {'a': 'content'} self.cmd.add_etc_files(files) files['a'] = 'package content' self.cmd.add_package('package', files) self.run_cmd('create') self.check_results(['a'], ['a']) files['a'] = 'changed content in tracked file' self.cmd.add_files(files, self.emt.repodir) with self.assertRaisesRegex(EmtError, "Run 'git reset --hard'"): self.run_cmd('update') def test_update_untracked_changes(self): # Issue #10. files = {'a': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a']) files = {'b': 'content of untracked file'} self.cmd.add_files(files, self.emt.repodir) with self.assertRaisesRegex(EmtError, "Run 'git clean -d -x -f'"): self.run_cmd('update') def test_update_as_root_owned_by_root(self): files = {'a': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a']) files = {'a': 'new content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) with os_stat_as_root(), patch('os.geteuid', return_value=0): self.run_cmd('update') self.check_results([], ['a']) self.check_content('etc', 'a', 'new content') @skipIf(os.geteuid() == 0, "non-root user required") @skip_unless_setpriv def test_update_as_root_not_owned_by_root(self): files = {'a': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a']) files = {'a': 'new content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) with self.assertRaisesRegex(EmtError, 'cannot be executed as root'): with patch('os.geteuid', return_value=0): self.run_cmd('update') def test_update_not_etcmaint_repo(self): files = {'a': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) try: import etcmaint.etcmaint _fist_commit = etcmaint.etcmaint.FIRST_COMMIT_MSG etcmaint.etcmaint.FIRST_COMMIT_MSG = 'not an etcmaint repository' self.run_cmd('create') finally: etcmaint.etcmaint.FIRST_COMMIT_MSG = _fist_commit self.check_results([], ['a'], ['etc', 'master', 'timestamps']) with self.assertRaisesRegex(EmtError, 'this is not an etcmaint repository'): self.run_cmd('update') class SyncTestCase(CommandsTestCase): def test_plain_sync(self): # Sync after a git cherry-pick. self.simple_cherry_pick() self.run_cmd('sync') self.check_simple_cherry_pick('master', ['etc', 'master', 'timestamps']) rpath = os.path.join(ROOT_SUBDIR, 'a') self.assertEqual(EtcPath(REPO_DIR, rpath), EtcPath(ROOT_DIR, rpath)) def test_previous_tag(self): # Check the '-prev' git tag. self.simple_cherry_pick() self.run_cmd('sync') out = self.emt.repo.git_cmd('diff master-prev...master') self.assertIn('-line 3\n+package line 3', out) def update_conflict(self): # A conflict: the file is customized by the user and the package # upgrades its content at the same time. self.cmd.add_etc_files({'a': 'content'}) self.cmd.add_package('package_a', {'a': 'content'}) self.run_cmd('create') self.check_results([], ['a']) self.cmd.add_etc_files({'a': 'new user content'}) self.cmd.add_package('package_a', {'a': 'new package content'}) self.run_cmd('update') self.check_results([], ['a'], ETCMAINT_BRANCHES) self.check_curbranch('master-tmp') self.check_status(['UU %s/a' % ROOT_SUBDIR]) def test_sync_unresolved_conflict(self): # Sync after a git cherry-pick. self.update_conflict() with self.assertRaisesRegex(EmtError, 'repository is not clean'): self.run_cmd('sync') def test_sync_resolved_conflict(self): # Sync after a resolved conflict. self.update_conflict() with open(os.path.join(self.emt.repodir, ROOT_SUBDIR, 'a')) as f: content = f.read() self.assertIn(dedent("""\ <<<<<<< HEAD new user content ======= new package content >>>>>>>"""), content) # Resove the conflict and commit the change. self.emt.repo.add_files( {os.path.join(ROOT_SUBDIR, 'a'): 'after conflict resolution'}, 'Resolve the conflict') self.run_cmd('sync') self.check_results(['a'], ['a'], ['etc', 'master', 'timestamps']) self.check_content('master', 'a', 'after conflict resolution') def test_sync_dry_run(self): # Sync after a git cherry-pick in dry-run mode. self.simple_cherry_pick() self.run_cmd('sync', '--dry-run') self.check_simple_cherry_pick('master-tmp', ETCMAINT_BRANCHES) def test_sync_timestamp(self): # Check that a package added after a cherry-pick and before a sync is # not ignored on the next update. self.simple_cherry_pick() time.sleep(1) files = {'b': 'b content'} self.cmd.add_etc_files(files) self.cmd.add_package('package_b', files) self.run_cmd('sync') self.run_cmd('update') self.check_results(['a'], ['a', 'b'], ['etc', 'master', 'timestamps']) self.check_content('etc', 'b', 'b content') def test_sync_no_cherry_pick(self): files = {'a': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.check_results([], ['a'], ['etc', 'master', 'timestamps']) self.emt.repo.checkout('master') self.emt.repo.checkout('master-tmp', create=True) self.emt.repo.checkout('etc') self.emt.repo.checkout('etc-tmp', create=True) with self.assertRaisesRegex(EmtError, 'cannot find a cherry-pick in the etc-tmp branch'): self.run_cmd('sync') @skipIf(os.geteuid() == 0, "non-root user required") @skip_unless_setpriv def test_plain_sync_as_root_not_owned_by_root(self): # Check that the sync command succeeds when run as root and the # repository is owned by a non-root user. self.simple_cherry_pick() with patch('os.geteuid', return_value=0): self.run_cmd('sync') self.check_simple_cherry_pick('master', ['etc', 'master', 'timestamps']) rpath = os.path.join(ROOT_SUBDIR, 'a') self.assertEqual(EtcPath(REPO_DIR, rpath), EtcPath(ROOT_DIR, rpath)) def test_plain_sync_fforward_failure(self): self.simple_cherry_pick() self.emt.repo.checkout('master') files = {'a': 'content'} self.emt.repo.add_files(files, 'commit message') with self.assertRaisesRegex(EmtError, 'cannot fast-forward'): self.run_cmd('sync') class DiffTestCase(CommandsTestCase): def test_diff(self): files = {f: 'content of %s' % f for f in ('a', 'b', 'c')} self.cmd.add_etc_files(files) self.cmd.add_package('package', {'a': 'package content'}) self.run_cmd('create') self.check_results(['a'], ['a'], ['etc', 'master', 'timestamps']) self.check_content('master', 'a', 'content of a') self.check_content('etc', 'a', 'package content') self.run_cmd('diff', clear_stdout=False) self.check_output(is_in='\n'.join(os.path.join(ROOT_SUBDIR, x) for x in ['b', 'c']), is_notin=os.path.join(ROOT_SUBDIR, 'a')) def test_diff_exclude_suffixes(self): files = {f: 'content of %s' % f for f in ('a', 'b', 'c')} files['b.pacnew'] = 'content of b.pacnew' self.cmd.add_etc_files(files) self.cmd.add_package('package', {'a': 'package content'}) self.run_cmd('create') self.check_results(['a'], ['a'], ['etc', 'master', 'timestamps']) self.run_cmd('diff', clear_stdout=False) self.check_output(is_in='\n'.join(os.path.join(ROOT_SUBDIR, x) for x in ['b', 'c']), is_notin=os.path.join(ROOT_SUBDIR, 'b.pacnew')) def test_diff_exclude_prefixes(self): files = {f: 'content of %s' % f for f in ('%s_file' % n for n in ('a', 'b', 'c'))} self.cmd.add_etc_files(files) self.cmd.add_package('package', {'a_file': 'package content'}) self.run_cmd('create') self.check_results(['a_file'], ['a_file'], ['etc', 'master', 'timestamps']) self.check_content('master', 'a_file', 'content of a_file') self.check_content('etc', 'a_file', 'package content') self.run_cmd('diff', '--exclude-prefixes', 'foo, b_, bar', clear_stdout=False) self.check_output(is_in=os.path.join(ROOT_SUBDIR, 'c_file'), is_notin=os.path.join(ROOT_SUBDIR, 'b_file')) def test_diff_use_etc_tmp_no_tmp(self): files = {'a': 'content'} self.cmd.add_etc_files(files) self.cmd.add_package('package', files) self.run_cmd('create') self.run_cmd('diff', '--use-etc-tmp', clear_stdout=False) self.assertIn('The etc-tmp branch does not exist', self.stdout.getvalue()) def test_diff_use_etc_tmp(self): # File cherry-picked by git. content = ['line %d' % n for n in range(5)] a_content = '\n'.join(content) self.cmd.add_etc_files({'a': a_content}) self.cmd.add_package('package_a', {'a': a_content}) self.run_cmd('create') self.check_results([], ['a']) user_content = content[:]; user_content[0] = 'user line 0' self.cmd.add_etc_files({'a': '\n'.join(user_content)}) package_content = content[:]; package_content[3] = 'package line 3' self.cmd.add_package('package_a', {'a': '\n'.join(package_content)}) self.cmd.add_etc_files({'b': 'b content'}) self.cmd.add_package('package_b', {'b': 'b content'}) self.run_cmd('update') self.check_results([], ['a'], ETCMAINT_BRANCHES) self.run_cmd('diff', clear_stdout=False) self.check_output(is_in=os.path.join(ROOT_SUBDIR, 'b')) self.clear_stdout() self.run_cmd('diff', '--use-etc-tmp', clear_stdout=False) self.check_output(equal='\n') PK!HΔ&3'etcmaint-0.3.dist-info/entry_points.txtN+I/N.,()J-IM+1` +PK\}L4199etcmaint-0.3.dist-info/LICENSEThe MIT License (MIT) Copyright (c) 2018 Xavier de Gaye Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. PK!HSmPOetcmaint-0.3.dist-info/WHEEL HM K-*ϳR03rOK-J,/RH,rzd&Y)r$[)T&UrPK!H4Jetcmaint-0.3.dist-info/METADATAUao6_q)B@ MSIEaeqHlHYn!{od%Zgv ~sx>;tfx*;BI }<4V Z~}mfNMG*EȞpcDע|N[|_9cy%7hCTXUatةƠi0M֩8jR|0(Mm))ƦKh[a#kHڠƫ`\ÃWP9Q>FC >( uFΔrg!(f~! 5!Q&;X 2RpVIR&#\h:B@pˮ;<#vr`j>GARSKsLid7c1O;TYh-g)a4*gmTԑ#k r*$GXTT©HѧE11_?(% #i_OnQ@ی,pnHQJd쨁9G !Rl2vjQ0\xRU$,(TD|>8L?"#?tge$YuI2GО0_k H9\ykk p޶[c[EeA /{E)}'=G1"pw>[iJ[8 |2<5 gV7I1lT󵳴<%-(3u} 곸=L%^l(Θ"n^pUƞLUQ2mCԱz!NVbތIN׮g-egNIꓔ]3&uN{R~F s8pk"kY>WlOyr<^Q@b{6EHUg%=߲OTbE0O*ZҬ%Y}bV}wB򥧚 i{/.}QzңEZ߹2wPK'M3NNetcmaint/__init__.pyPK\}L~AAetcmaint/__main__.pyPKk(N(\\etcmaint/etcmaint.pyPKfLetcmaint/tests/__init__.pyPK4g%N Ogetcmaint/tests/test_commands.pyPK!HΔ&3'vcetcmaint-0.3.dist-info/entry_points.txtPK\}L4199cetcmaint-0.3.dist-info/LICENSEPK!HSmPOVhetcmaint-0.3.dist-info/WHEELPK!H4Jhetcmaint-0.3.dist-info/METADATAPK!Hь  metcmaint-0.3.dist-info/RECORDPK 2o