diff options
-rw-r--r-- | src/pkgdev/cli.py | 122 | ||||
-rw-r--r-- | src/pkgdev/const.py | 41 | ||||
-rw-r--r-- | src/pkgdev/scripts/pkgdev_commit.py | 5 | ||||
-rw-r--r-- | src/pkgdev/scripts/pkgdev_manifest.py | 3 | ||||
-rw-r--r-- | src/pkgdev/scripts/pkgdev_push.py | 5 | ||||
-rw-r--r-- | src/pkgdev/scripts/pkgdev_showkw.py | 4 | ||||
-rw-r--r-- | tests/scripts/test_cli.py | 90 | ||||
-rw-r--r-- | tests/scripts/test_pkgdev_commit.py | 24 | ||||
-rw-r--r-- | tests/scripts/test_pkgdev_push.py | 2 | ||||
-rw-r--r-- | tests/scripts/test_pkgdev_showkw.py | 21 |
10 files changed, 297 insertions, 20 deletions
diff --git a/src/pkgdev/cli.py b/src/pkgdev/cli.py index f8bac11..c4ca30e 100644 --- a/src/pkgdev/cli.py +++ b/src/pkgdev/cli.py @@ -1,8 +1,17 @@ """Various command-line specific support.""" +import argparse +import configparser import logging +import os from pkgcore.util import commandline +from snakeoil.cli import arghparse +from snakeoil.contexts import patch +from snakeoil.klass import jit_attr_none +from snakeoil.mappings import OrderedSet + +from . import const class Tool(commandline.Tool): @@ -11,3 +20,116 @@ class Tool(commandline.Tool): # suppress all pkgcore log messages logging.getLogger('pkgcore').setLevel(100) return super().main() + + +class ConfigArg(argparse._StoreAction): + """Store config path string or False when explicitly disabled.""" + + def __call__(self, parser, namespace, values, option_string=None): + if values.lower() in ('false', 'no', 'n'): + values = False + setattr(namespace, self.dest, values) + + +class ConfigParser(configparser.ConfigParser): + """ConfigParser with case-sensitive keys (default forces lowercase).""" + + def optionxform(self, option): + return option + + +class ConfigFileParser: + """Argument parser that supports loading settings from specified config files.""" + + default_configs = (const.SYSTEM_CONF_FILE, const.USER_CONF_FILE) + + def __init__(self, parser: arghparse.ArgumentParser, configs=(), **kwargs): + super().__init__(**kwargs) + self.parser = parser + self.configs = OrderedSet(configs) + + @jit_attr_none + def config(self): + return self.parse_config() + + def parse_config(self, configs=()): + """Parse given config files.""" + configs = configs if configs else self.configs + config = ConfigParser(default_section=None) + try: + for f in configs: + config.read(f) + except configparser.ParsingError as e: + self.parser.error(f'parsing config file failed: {e}') + return config + + def parse_config_sections(self, namespace, sections): + """Parse options from a given iterable of config section names.""" + assert self.parser.prog.startswith('pkgdev ') + module = self.parser.prog.split(' ', 1)[1] + '.' + with patch('snakeoil.cli.arghparse.ArgumentParser.error', self._config_error): + for section in (x for x in sections if x in self.config): + config_args = ((k.split('.', 1)[1], v) for k, v in self.config.items(section) if k.startswith(module)) + config_args = (f'--{k}={v}' if v else f'--{k}' for k, v in config_args) + namespace, args = self.parser.parse_known_optionals(config_args, namespace) + if args: + self.parser.error(f"unknown arguments: {' '.join(args)}") + return namespace + + def parse_config_options(self, namespace, configs=()): + """Parse options from config if they exist.""" + configs = list(filter(os.path.isfile, configs)) + if not configs: + return namespace + + self.configs.update(configs) + # reset jit attr to force reparse + self._config = None + + # load default options + namespace = self.parse_config_sections(namespace, ['DEFAULT']) + + return namespace + + def _config_error(self, message, status=2): + """Stub to replace error method that notes config failure.""" + self.parser.exit(status, f'{self.parser.prog}: failed loading config: {message}\n') + +class ArgumentParser(arghparse.ArgumentParser): + """Parse all known arguments, from command line and config file.""" + + def __init__(self, parents=(), **kwargs): + self.config_argparser = arghparse.ArgumentParser(suppress=True) + config_options = self.config_argparser.add_argument_group('config options') + config_options.add_argument( + '--config', action=ConfigArg, dest='config_file', + help='use custom pkgdev settings file', + docs=""" + Load custom pkgdev scan settings from a given file. + + Note that custom user settings override all other system and repo-level + settings. + + It's also possible to disable all types of settings loading by + specifying an argument of 'false' or 'no'. + """) + super().__init__(parents=[*parents, self.config_argparser], **kwargs) + + def parse_known_args(self, args=None, namespace=None): + temp_namespace, _ = self.config_argparser.parse_known_args(args, namespace) + # parser supporting config file options + config_parser = ConfigFileParser(self) + # always load settings from bundled config + namespace = config_parser.parse_config_options( + namespace, configs=[const.BUNDLED_CONF_FILE]) + + # load default args from system/user configs if config-loading is allowed + if temp_namespace.config_file is None: + namespace = config_parser.parse_config_options( + namespace, configs=ConfigFileParser.default_configs) + elif temp_namespace.config_file is not False: + namespace = config_parser.parse_config_options( + namespace, configs=(namespace.config_file, )) + + # parse command line args to override config defaults + return super().parse_known_args(args, namespace) diff --git a/src/pkgdev/const.py b/src/pkgdev/const.py new file mode 100644 index 0000000..e724f5a --- /dev/null +++ b/src/pkgdev/const.py @@ -0,0 +1,41 @@ +"""Internal constants.""" + +import os +import sys + +from snakeoil import mappings + +_reporoot = os.path.realpath(__file__).rsplit(os.path.sep, 3)[0] +_module = sys.modules[__name__] + +try: + # This is a file written during installation; + # if it exists, we defer to it. If it doesn't, then we're + # running from a git checkout or a tarball. + from . import _const as _defaults +except ImportError: # pragma: no cover + _defaults = object() + + +def _GET_CONST(attr, default_value): + consts = mappings.ProxiedAttrs(_module) + default_value %= consts + return getattr(_defaults, attr, default_value) + + +# determine XDG compatible paths +for xdg_var, var_name, fallback_dir in ( + ('XDG_CONFIG_HOME', 'USER_CONFIG_PATH', '~/.config'), + ('XDG_CACHE_HOME', 'USER_CACHE_PATH', '~/.cache'), + ('XDG_DATA_HOME', 'USER_DATA_PATH', '~/.local/share')): + setattr( + _module, var_name, + os.environ.get(xdg_var, os.path.join(os.path.expanduser(fallback_dir), 'pkgdev'))) + +REPO_PATH = _GET_CONST('REPO_PATH', _reporoot) +DATA_PATH = _GET_CONST('DATA_PATH', '%(REPO_PATH)s/data') + +USER_CACHE_DIR = getattr(_module, 'USER_CACHE_PATH') +USER_CONF_FILE = os.path.join(getattr(_module, 'USER_CONFIG_PATH'), 'pkgdev.conf') +SYSTEM_CONF_FILE = '/etc/pkgdev/pkgdev.conf' +BUNDLED_CONF_FILE = os.path.join(DATA_PATH, 'pkgdev.conf') diff --git a/src/pkgdev/scripts/pkgdev_commit.py b/src/pkgdev/scripts/pkgdev_commit.py index 3bb9044..1c2ff66 100644 --- a/src/pkgdev/scripts/pkgdev_commit.py +++ b/src/pkgdev/scripts/pkgdev_commit.py @@ -26,18 +26,19 @@ from snakeoil.klass import jit_attr from snakeoil.mappings import OrderedFrozenSet, OrderedSet from snakeoil.osutils import pjoin -from .. import git +from .. import cli, git from ..mangle import GentooMangler, Mangler from .argparsers import cwd_repo_argparser, git_repo_argparser -class ArgumentParser(arghparse.ArgumentParser): +class ArgumentParser(cli.ArgumentParser): """Parse all known arguments, passing unknown arguments to ``git commit``.""" def parse_known_args(self, args=None, namespace=None): namespace.footer = OrderedSet() namespace.git_add_files = [] namespace, args = super().parse_known_args(args, namespace) + if namespace.dry_run: args.append('--dry-run') if namespace.verbosity: diff --git a/src/pkgdev/scripts/pkgdev_manifest.py b/src/pkgdev/scripts/pkgdev_manifest.py index b54b90f..26f6534 100644 --- a/src/pkgdev/scripts/pkgdev_manifest.py +++ b/src/pkgdev/scripts/pkgdev_manifest.py @@ -5,9 +5,10 @@ from pkgcore.restrictions import packages from pkgcore.util.parserestrict import parse_match from snakeoil.cli import arghparse +from .. import cli from .argparsers import cwd_repo_argparser -manifest = arghparse.ArgumentParser( +manifest = cli.ArgumentParser( prog='pkgdev manifest', description='update package manifests', parents=(cwd_repo_argparser,)) manifest.add_argument( diff --git a/src/pkgdev/scripts/pkgdev_push.py b/src/pkgdev/scripts/pkgdev_push.py index 9913167..e98e955 100644 --- a/src/pkgdev/scripts/pkgdev_push.py +++ b/src/pkgdev/scripts/pkgdev_push.py @@ -2,14 +2,13 @@ import argparse import shlex from pkgcheck import reporters, scan -from snakeoil.cli import arghparse from snakeoil.cli.input import userquery -from .. import git +from .. import cli, git from .argparsers import cwd_repo_argparser, git_repo_argparser -class ArgumentParser(arghparse.ArgumentParser): +class ArgumentParser(cli.ArgumentParser): """Parse all known arguments, passing unknown arguments to ``git push``.""" def parse_known_args(self, args=None, namespace=None): diff --git a/src/pkgdev/scripts/pkgdev_showkw.py b/src/pkgdev/scripts/pkgdev_showkw.py index 1b5ab8e..27a5a25 100644 --- a/src/pkgdev/scripts/pkgdev_showkw.py +++ b/src/pkgdev/scripts/pkgdev_showkw.py @@ -6,13 +6,13 @@ from functools import partial from pkgcore.ebuild import restricts from pkgcore.util import commandline from pkgcore.util import packages as pkgutils -from snakeoil.cli import arghparse from snakeoil.strings import pluralism +from .. import cli from .._vendor.tabulate import tabulate, tabulate_formats -showkw = arghparse.ArgumentParser( +showkw = cli.ArgumentParser( prog='pkgdev showkw', description='show package keywords') showkw.add_argument( 'targets', metavar='target', nargs='*', diff --git a/tests/scripts/test_cli.py b/tests/scripts/test_cli.py new file mode 100644 index 0000000..752f36d --- /dev/null +++ b/tests/scripts/test_cli.py @@ -0,0 +1,90 @@ +import textwrap + +import pytest +from pkgdev import cli +from snakeoil.cli import arghparse + + +class TestConfigFileParser: + + @pytest.fixture(autouse=True) + def _create_argparser(self, tmp_path): + self.config_file = str(tmp_path / 'config') + self.parser = arghparse.ArgumentParser(prog='pkgdev cli_test') + self.namespace = arghparse.Namespace() + self.config_parser = cli.ConfigFileParser(self.parser) + + def test_no_configs(self): + config = self.config_parser.parse_config(()) + assert config.sections() == [] + namespace = self.config_parser.parse_config_options(self.namespace) + assert vars(namespace) == {} + + def test_ignored_configs(self): + # nonexistent config files are ignored + config = self.config_parser.parse_config(('foo', 'bar')) + assert config.sections() == [] + + def test_bad_config_format_no_section(self, capsys): + with open(self.config_file, 'w') as f: + f.write('foobar\n') + with pytest.raises(SystemExit) as excinfo: + self.config_parser.parse_config((self.config_file,)) + out, err = capsys.readouterr() + assert not out + assert 'parsing config file failed: File contains no section headers' in err + assert self.config_file in err + assert excinfo.value.code == 2 + + def test_bad_config_format(self, capsys): + with open(self.config_file, 'w') as f: + f.write(textwrap.dedent(""" + [DEFAULT] + foobar + """)) + with pytest.raises(SystemExit) as excinfo: + self.config_parser.parse_config((self.config_file,)) + out, err = capsys.readouterr() + assert not out + assert 'parsing config file failed: Source contains parsing errors' in err + assert excinfo.value.code == 2 + + def test_nonexistent_config_options(self, capsys): + """Nonexistent parser arguments don't cause errors.""" + with open(self.config_file, 'w') as f: + f.write(textwrap.dedent(""" + [DEFAULT] + cli_test.foo=bar + """)) + with pytest.raises(SystemExit) as excinfo: + self.config_parser.parse_config_options(None, configs=(self.config_file,)) + out, err = capsys.readouterr() + assert not out + assert 'failed loading config: unknown arguments: --foo=bar' in err + assert excinfo.value.code == 2 + + def test_config_options_other_prog(self): + self.parser.add_argument('--foo') + with open(self.config_file, 'w') as f: + f.write(textwrap.dedent(""" + [DEFAULT] + other.foo=bar + """)) + namespace = self.parser.parse_args(['--foo', 'foo']) + assert namespace.foo == 'foo' + # config args don't override not matching namespace attrs + namespace = self.config_parser.parse_config_options(namespace, configs=[self.config_file]) + assert namespace.foo == 'foo' + + def test_config_options(self): + self.parser.add_argument('--foo') + with open(self.config_file, 'w') as f: + f.write(textwrap.dedent(""" + [DEFAULT] + cli_test.foo=bar + """)) + namespace = self.parser.parse_args(['--foo', 'foo']) + assert namespace.foo == 'foo' + # config args override matching namespace attrs + namespace = self.config_parser.parse_config_options(namespace, configs=[self.config_file]) + assert namespace.foo == 'bar' diff --git a/tests/scripts/test_pkgdev_commit.py b/tests/scripts/test_pkgdev_commit.py index ac3fc8b..550671f 100644 --- a/tests/scripts/test_pkgdev_commit.py +++ b/tests/scripts/test_pkgdev_commit.py @@ -167,7 +167,7 @@ class TestPkgdevCommit: self.cache_dir = str(tmp_path) self.scan_args = ['--pkgcheck-scan', f'--config no --cache-dir {self.cache_dir}'] # args for running pkgdev like a script - self.args = ['pkgdev', 'commit'] + self.scan_args + self.args = ['pkgdev', 'commit', '--config', 'no'] + self.scan_args def test_empty_repo(self, capsys, repo, make_git_repo): git_repo = make_git_repo(repo.location, commit=True) @@ -914,6 +914,28 @@ class TestPkgdevCommit: self.script() assert excinfo.value.code == 0 + def test_config_opts(self, capsys, repo, make_git_repo, tmp_path): + config_file = str(tmp_path / 'config') + with open(config_file, 'w') as f: + f.write(textwrap.dedent(""" + [DEFAULT] + commit.scan= + """)) + + git_repo = make_git_repo(repo.location) + repo.create_ebuild('cat/pkg-0') + git_repo.add_all('cat/pkg-0') + repo.create_ebuild('cat/pkg-1', license='') + git_repo.add_all('cat/pkg-1', commit=False) + with patch('sys.argv', ['pkgdev', 'commit', '--config', config_file] + self.scan_args), \ + pytest.raises(SystemExit) as excinfo, \ + chdir(git_repo.path): + self.script() + out, err = capsys.readouterr() + assert excinfo.value.code == 1 + assert not err + assert 'MissingLicense' in out + def test_failed_manifest(self, capsys, repo, make_git_repo): git_repo = make_git_repo(repo.location) repo.create_ebuild('cat/pkg-0') diff --git a/tests/scripts/test_pkgdev_push.py b/tests/scripts/test_pkgdev_push.py index cc13fb1..4eeee4b 100644 --- a/tests/scripts/test_pkgdev_push.py +++ b/tests/scripts/test_pkgdev_push.py @@ -65,7 +65,7 @@ class TestPkgdevPush: @pytest.fixture(autouse=True) def _setup(self, tmp_path, make_repo, make_git_repo): self.cache_dir = str(tmp_path / 'cache') - self.scan_args = ['--pkgcheck-scan', f'--config no --cache-dir {self.cache_dir}'] + self.scan_args = ['--config', 'no', '--pkgcheck-scan', f'--config no --cache-dir {self.cache_dir}'] # args for running pkgdev like a script self.args = ['pkgdev', 'push'] + self.scan_args diff --git a/tests/scripts/test_pkgdev_showkw.py b/tests/scripts/test_pkgdev_showkw.py index 538744a..51348f2 100644 --- a/tests/scripts/test_pkgdev_showkw.py +++ b/tests/scripts/test_pkgdev_showkw.py @@ -3,8 +3,8 @@ from typing import NamedTuple, List from unittest.mock import patch import pytest +from snakeoil.contexts import chdir from pkgdev.scripts import run -from snakeoil.contexts import chdir, os_environ class Profile(NamedTuple): """Profile record used to create profiles in a repository.""" @@ -19,7 +19,7 @@ class TestPkgdevShowkwParseArgs: def test_missing_target(self, capsys, tool): with pytest.raises(SystemExit): - tool.parse_args(['showkw']) + tool.parse_args(['showkw', '--config', 'no']) captured = capsys.readouterr() assert captured.err.strip() == ( 'pkgdev showkw: error: missing target argument and not in a supported repo') @@ -27,7 +27,7 @@ class TestPkgdevShowkwParseArgs: def test_unknown_arches(self, capsys, tool, make_repo): repo = make_repo(arches=['amd64']) with pytest.raises(SystemExit): - tool.parse_args(['showkw', '-a', 'unknown', '-r', repo.location]) + tool.parse_args(['showkw', '--config', 'no', '-a', 'unknown', '-r', repo.location]) captured = capsys.readouterr() assert captured.err.strip() == ( "pkgdev showkw: error: unknown arch: 'unknown' (choices: amd64)") @@ -35,6 +35,7 @@ class TestPkgdevShowkwParseArgs: class TestPkgdevShowkw: script = partial(run, 'pkgdev') + base_args = ['pkgdev', 'showkw', '--config', 'n'] def _create_repo(self, make_repo): repo = make_repo(arches=['amd64', 'ia64', 'mips', 'x86']) @@ -47,7 +48,7 @@ class TestPkgdevShowkw: return repo def _run_and_parse(self, capsys, *args): - with patch('sys.argv', ['pkgdev', 'showkw', "--format", "presto", *args]), \ + with patch('sys.argv', [*self.base_args, "--format", "presto", *args]), \ pytest.raises(SystemExit) as excinfo: self.script() assert excinfo.value.code == None @@ -63,7 +64,7 @@ class TestPkgdevShowkw: def test_match(self, capsys, make_repo): repo = self._create_repo(make_repo) repo.create_ebuild('foo/bar-0') - with patch('sys.argv', ['pkgdev', 'showkw', '-r', repo.location, 'foo/bar']), \ + with patch('sys.argv', [*self.base_args, '-r', repo.location, 'foo/bar']), \ pytest.raises(SystemExit) as excinfo: self.script() assert excinfo.value.code == None @@ -74,7 +75,7 @@ class TestPkgdevShowkw: def test_match_short_name(self, capsys, make_repo): repo = self._create_repo(make_repo) repo.create_ebuild('foo/bar-0') - with patch('sys.argv', ['pkgdev', 'showkw', '-r', repo.location, 'bar']), \ + with patch('sys.argv', [*self.base_args, '-r', repo.location, 'bar']), \ pytest.raises(SystemExit) as excinfo: self.script() assert excinfo.value.code == None @@ -85,7 +86,7 @@ class TestPkgdevShowkw: def test_match_cwd_repo(self, capsys, make_repo): repo = self._create_repo(make_repo) repo.create_ebuild('foo/bar-0') - with patch('sys.argv', ['pkgdev', 'showkw', 'foo/bar']), \ + with patch('sys.argv', [*self.base_args, 'foo/bar']), \ pytest.raises(SystemExit) as excinfo, \ chdir(repo.location): self.script() @@ -97,7 +98,7 @@ class TestPkgdevShowkw: def test_match_cwd_pkg(self, capsys, make_repo): repo = self._create_repo(make_repo) repo.create_ebuild('foo/bar-0') - with patch('sys.argv', ['pkgdev', 'showkw']), \ + with patch('sys.argv', self.base_args), \ pytest.raises(SystemExit) as excinfo, \ chdir(repo.location + '/foo/bar'): self.script() @@ -107,7 +108,7 @@ class TestPkgdevShowkw: def test_no_matches(self, capsys, make_repo): repo = self._create_repo(make_repo) - with patch('sys.argv', ['pkgdev', 'showkw', '-r', repo.location, 'foo/bar']), \ + with patch('sys.argv', [*self.base_args, '-r', repo.location, 'foo/bar']), \ pytest.raises(SystemExit) as excinfo: self.script() assert excinfo.value.code == 1 @@ -165,7 +166,7 @@ class TestPkgdevShowkw: repo = self._create_repo(make_repo) repo.create_ebuild('foo/bar-0', keywords=('amd64', '~ia64', '~mips', '~x86')) repo.create_ebuild('foo/bar-1', keywords=('~amd64', '~ia64', '~mips', 'x86')) - with patch('sys.argv', ['pkgdev', 'showkw', '-r', repo.location, 'foo/bar', "--collapse", arg]), \ + with patch('sys.argv', [*self.base_args, '-r', repo.location, 'foo/bar', "--collapse", arg]), \ pytest.raises(SystemExit) as excinfo: self.script() out, err = capsys.readouterr() |