Skip to content

Commit 8ef3abc

Browse files
committed
Add configuration support
Resolves: #26 Signed-off-by: Arthur Zamarin <arthurzam@gentoo.org>
1 parent 35f4212 commit 8ef3abc

10 files changed

Lines changed: 297 additions & 20 deletions

File tree

src/pkgdev/cli.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
"""Various command-line specific support."""
22

3+
import argparse
4+
import configparser
35
import logging
6+
import os
47

58
from pkgcore.util import commandline
9+
from snakeoil.cli import arghparse
10+
from snakeoil.contexts import patch
11+
from snakeoil.klass import jit_attr_none
12+
from snakeoil.mappings import OrderedSet
13+
14+
from . import const
615

716

817
class Tool(commandline.Tool):
@@ -11,3 +20,116 @@ def main(self):
1120
# suppress all pkgcore log messages
1221
logging.getLogger('pkgcore').setLevel(100)
1322
return super().main()
23+
24+
25+
class ConfigArg(argparse._StoreAction):
26+
"""Store config path string or False when explicitly disabled."""
27+
28+
def __call__(self, parser, namespace, values, option_string=None):
29+
if values.lower() in ('false', 'no', 'n'):
30+
values = False
31+
setattr(namespace, self.dest, values)
32+
33+
34+
class ConfigParser(configparser.ConfigParser):
35+
"""ConfigParser with case-sensitive keys (default forces lowercase)."""
36+
37+
def optionxform(self, option):
38+
return option
39+
40+
41+
class ConfigFileParser:
42+
"""Argument parser that supports loading settings from specified config files."""
43+
44+
default_configs = (const.SYSTEM_CONF_FILE, const.USER_CONF_FILE)
45+
46+
def __init__(self, parser: arghparse.ArgumentParser, configs=(), **kwargs):
47+
super().__init__(**kwargs)
48+
self.parser = parser
49+
self.configs = OrderedSet(configs)
50+
51+
@jit_attr_none
52+
def config(self):
53+
return self.parse_config()
54+
55+
def parse_config(self, configs=()):
56+
"""Parse given config files."""
57+
configs = configs if configs else self.configs
58+
config = ConfigParser(default_section=None)
59+
try:
60+
for f in configs:
61+
config.read(f)
62+
except configparser.ParsingError as e:
63+
self.parser.error(f'parsing config file failed: {e}')
64+
return config
65+
66+
def parse_config_sections(self, namespace, sections):
67+
"""Parse options from a given iterable of config section names."""
68+
assert self.parser.prog.startswith('pkgdev ')
69+
module = self.parser.prog.split(' ', 1)[1] + '.'
70+
with patch('snakeoil.cli.arghparse.ArgumentParser.error', self._config_error):
71+
for section in (x for x in sections if x in self.config):
72+
config_args = ((k.split('.', 1)[1], v) for k, v in self.config.items(section) if k.startswith(module))
73+
config_args = (f'--{k}={v}' if v else f'--{k}' for k, v in config_args)
74+
namespace, args = self.parser.parse_known_optionals(config_args, namespace)
75+
if args:
76+
self.parser.error(f"unknown arguments: {' '.join(args)}")
77+
return namespace
78+
79+
def parse_config_options(self, namespace, configs=()):
80+
"""Parse options from config if they exist."""
81+
configs = list(filter(os.path.isfile, configs))
82+
if not configs:
83+
return namespace
84+
85+
self.configs.update(configs)
86+
# reset jit attr to force reparse
87+
self._config = None
88+
89+
# load default options
90+
namespace = self.parse_config_sections(namespace, ['DEFAULT'])
91+
92+
return namespace
93+
94+
def _config_error(self, message, status=2):
95+
"""Stub to replace error method that notes config failure."""
96+
self.parser.exit(status, f'{self.parser.prog}: failed loading config: {message}\n')
97+
98+
class ArgumentParser(arghparse.ArgumentParser):
99+
"""Parse all known arguments, from command line and config file."""
100+
101+
def __init__(self, parents=(), **kwargs):
102+
self.config_argparser = arghparse.ArgumentParser(suppress=True)
103+
config_options = self.config_argparser.add_argument_group('config options')
104+
config_options.add_argument(
105+
'--config', action=ConfigArg, dest='config_file',
106+
help='use custom pkgdev settings file',
107+
docs="""
108+
Load custom pkgdev scan settings from a given file.
109+
110+
Note that custom user settings override all other system and repo-level
111+
settings.
112+
113+
It's also possible to disable all types of settings loading by
114+
specifying an argument of 'false' or 'no'.
115+
""")
116+
super().__init__(parents=[*parents, self.config_argparser], **kwargs)
117+
118+
def parse_known_args(self, args=None, namespace=None):
119+
temp_namespace, _ = self.config_argparser.parse_known_args(args, namespace)
120+
# parser supporting config file options
121+
config_parser = ConfigFileParser(self)
122+
# always load settings from bundled config
123+
namespace = config_parser.parse_config_options(
124+
namespace, configs=[const.BUNDLED_CONF_FILE])
125+
126+
# load default args from system/user configs if config-loading is allowed
127+
if temp_namespace.config_file is None:
128+
namespace = config_parser.parse_config_options(
129+
namespace, configs=ConfigFileParser.default_configs)
130+
elif temp_namespace.config_file is not False:
131+
namespace = config_parser.parse_config_options(
132+
namespace, configs=(namespace.config_file, ))
133+
134+
# parse command line args to override config defaults
135+
return super().parse_known_args(args, namespace)

src/pkgdev/const.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Internal constants."""
2+
3+
import os
4+
import sys
5+
6+
from snakeoil import mappings
7+
8+
_reporoot = os.path.realpath(__file__).rsplit(os.path.sep, 3)[0]
9+
_module = sys.modules[__name__]
10+
11+
try:
12+
# This is a file written during installation;
13+
# if it exists, we defer to it. If it doesn't, then we're
14+
# running from a git checkout or a tarball.
15+
from . import _const as _defaults
16+
except ImportError: # pragma: no cover
17+
_defaults = object()
18+
19+
20+
def _GET_CONST(attr, default_value):
21+
consts = mappings.ProxiedAttrs(_module)
22+
default_value %= consts
23+
return getattr(_defaults, attr, default_value)
24+
25+
26+
# determine XDG compatible paths
27+
for xdg_var, var_name, fallback_dir in (
28+
('XDG_CONFIG_HOME', 'USER_CONFIG_PATH', '~/.config'),
29+
('XDG_CACHE_HOME', 'USER_CACHE_PATH', '~/.cache'),
30+
('XDG_DATA_HOME', 'USER_DATA_PATH', '~/.local/share')):
31+
setattr(
32+
_module, var_name,
33+
os.environ.get(xdg_var, os.path.join(os.path.expanduser(fallback_dir), 'pkgdev')))
34+
35+
REPO_PATH = _GET_CONST('REPO_PATH', _reporoot)
36+
DATA_PATH = _GET_CONST('DATA_PATH', '%(REPO_PATH)s/data')
37+
38+
USER_CACHE_DIR = getattr(_module, 'USER_CACHE_PATH')
39+
USER_CONF_FILE = os.path.join(getattr(_module, 'USER_CONFIG_PATH'), 'pkgdev.conf')
40+
SYSTEM_CONF_FILE = '/etc/pkgdev/pkgdev.conf'
41+
BUNDLED_CONF_FILE = os.path.join(DATA_PATH, 'pkgdev.conf')

src/pkgdev/scripts/pkgdev_commit.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,19 @@
2626
from snakeoil.mappings import OrderedFrozenSet, OrderedSet
2727
from snakeoil.osutils import pjoin
2828

29-
from .. import git
29+
from .. import cli, git
3030
from ..mangle import GentooMangler, Mangler
3131
from .argparsers import cwd_repo_argparser, git_repo_argparser
3232

3333

34-
class ArgumentParser(arghparse.ArgumentParser):
34+
class ArgumentParser(cli.ArgumentParser):
3535
"""Parse all known arguments, passing unknown arguments to ``git commit``."""
3636

3737
def parse_known_args(self, args=None, namespace=None):
3838
namespace.footer = OrderedSet()
3939
namespace.git_add_files = []
4040
namespace, args = super().parse_known_args(args, namespace)
41+
4142
if namespace.dry_run:
4243
args.append('--dry-run')
4344
if namespace.verbosity:

src/pkgdev/scripts/pkgdev_manifest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
from pkgcore.util.parserestrict import parse_match
66
from snakeoil.cli import arghparse
77

8+
from .. import cli
89
from .argparsers import cwd_repo_argparser
910

10-
manifest = arghparse.ArgumentParser(
11+
manifest = cli.ArgumentParser(
1112
prog='pkgdev manifest', description='update package manifests',
1213
parents=(cwd_repo_argparser,))
1314
manifest.add_argument(

src/pkgdev/scripts/pkgdev_push.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@
22
import shlex
33

44
from pkgcheck import reporters, scan
5-
from snakeoil.cli import arghparse
65
from snakeoil.cli.input import userquery
76

8-
from .. import git
7+
from .. import cli, git
98
from .argparsers import cwd_repo_argparser, git_repo_argparser
109

1110

12-
class ArgumentParser(arghparse.ArgumentParser):
11+
class ArgumentParser(cli.ArgumentParser):
1312
"""Parse all known arguments, passing unknown arguments to ``git push``."""
1413

1514
def parse_known_args(self, args=None, namespace=None):

src/pkgdev/scripts/pkgdev_showkw.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
from pkgcore.ebuild import restricts
77
from pkgcore.util import commandline
88
from pkgcore.util import packages as pkgutils
9-
from snakeoil.cli import arghparse
109
from snakeoil.strings import pluralism
1110

11+
from .. import cli
1212
from .._vendor.tabulate import tabulate, tabulate_formats
1313

1414

15-
showkw = arghparse.ArgumentParser(
15+
showkw = cli.ArgumentParser(
1616
prog='pkgdev showkw', description='show package keywords')
1717
showkw.add_argument(
1818
'targets', metavar='target', nargs='*',

tests/scripts/test_cli.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import textwrap
2+
3+
import pytest
4+
from pkgdev import cli
5+
from snakeoil.cli import arghparse
6+
7+
8+
class TestConfigFileParser:
9+
10+
@pytest.fixture(autouse=True)
11+
def _create_argparser(self, tmp_path):
12+
self.config_file = str(tmp_path / 'config')
13+
self.parser = arghparse.ArgumentParser(prog='pkgdev cli_test')
14+
self.namespace = arghparse.Namespace()
15+
self.config_parser = cli.ConfigFileParser(self.parser)
16+
17+
def test_no_configs(self):
18+
config = self.config_parser.parse_config(())
19+
assert config.sections() == []
20+
namespace = self.config_parser.parse_config_options(self.namespace)
21+
assert vars(namespace) == {}
22+
23+
def test_ignored_configs(self):
24+
# nonexistent config files are ignored
25+
config = self.config_parser.parse_config(('foo', 'bar'))
26+
assert config.sections() == []
27+
28+
def test_bad_config_format_no_section(self, capsys):
29+
with open(self.config_file, 'w') as f:
30+
f.write('foobar\n')
31+
with pytest.raises(SystemExit) as excinfo:
32+
self.config_parser.parse_config((self.config_file,))
33+
out, err = capsys.readouterr()
34+
assert not out
35+
assert 'parsing config file failed: File contains no section headers' in err
36+
assert self.config_file in err
37+
assert excinfo.value.code == 2
38+
39+
def test_bad_config_format(self, capsys):
40+
with open(self.config_file, 'w') as f:
41+
f.write(textwrap.dedent("""
42+
[DEFAULT]
43+
foobar
44+
"""))
45+
with pytest.raises(SystemExit) as excinfo:
46+
self.config_parser.parse_config((self.config_file,))
47+
out, err = capsys.readouterr()
48+
assert not out
49+
assert 'parsing config file failed: Source contains parsing errors' in err
50+
assert excinfo.value.code == 2
51+
52+
def test_nonexistent_config_options(self, capsys):
53+
"""Nonexistent parser arguments don't cause errors."""
54+
with open(self.config_file, 'w') as f:
55+
f.write(textwrap.dedent("""
56+
[DEFAULT]
57+
cli_test.foo=bar
58+
"""))
59+
with pytest.raises(SystemExit) as excinfo:
60+
self.config_parser.parse_config_options(None, configs=(self.config_file,))
61+
out, err = capsys.readouterr()
62+
assert not out
63+
assert 'failed loading config: unknown arguments: --foo=bar' in err
64+
assert excinfo.value.code == 2
65+
66+
def test_config_options_other_prog(self):
67+
self.parser.add_argument('--foo')
68+
with open(self.config_file, 'w') as f:
69+
f.write(textwrap.dedent("""
70+
[DEFAULT]
71+
other.foo=bar
72+
"""))
73+
namespace = self.parser.parse_args(['--foo', 'foo'])
74+
assert namespace.foo == 'foo'
75+
# config args don't override not matching namespace attrs
76+
namespace = self.config_parser.parse_config_options(namespace, configs=[self.config_file])
77+
assert namespace.foo == 'foo'
78+
79+
def test_config_options(self):
80+
self.parser.add_argument('--foo')
81+
with open(self.config_file, 'w') as f:
82+
f.write(textwrap.dedent("""
83+
[DEFAULT]
84+
cli_test.foo=bar
85+
"""))
86+
namespace = self.parser.parse_args(['--foo', 'foo'])
87+
assert namespace.foo == 'foo'
88+
# config args override matching namespace attrs
89+
namespace = self.config_parser.parse_config_options(namespace, configs=[self.config_file])
90+
assert namespace.foo == 'bar'

tests/scripts/test_pkgdev_commit.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ def _setup(self, tmp_path):
167167
self.cache_dir = str(tmp_path)
168168
self.scan_args = ['--pkgcheck-scan', f'--config no --cache-dir {self.cache_dir}']
169169
# args for running pkgdev like a script
170-
self.args = ['pkgdev', 'commit'] + self.scan_args
170+
self.args = ['pkgdev', 'commit', '--config', 'no'] + self.scan_args
171171

172172
def test_empty_repo(self, capsys, repo, make_git_repo):
173173
git_repo = make_git_repo(repo.location, commit=True)
@@ -914,6 +914,28 @@ def test_failed_scan(self, capsys, repo, make_git_repo):
914914
self.script()
915915
assert excinfo.value.code == 0
916916

917+
def test_config_opts(self, capsys, repo, make_git_repo, tmp_path):
918+
config_file = str(tmp_path / 'config')
919+
with open(config_file, 'w') as f:
920+
f.write(textwrap.dedent("""
921+
[DEFAULT]
922+
commit.scan=
923+
"""))
924+
925+
git_repo = make_git_repo(repo.location)
926+
repo.create_ebuild('cat/pkg-0')
927+
git_repo.add_all('cat/pkg-0')
928+
repo.create_ebuild('cat/pkg-1', license='')
929+
git_repo.add_all('cat/pkg-1', commit=False)
930+
with patch('sys.argv', ['pkgdev', 'commit', '--config', config_file] + self.scan_args), \
931+
pytest.raises(SystemExit) as excinfo, \
932+
chdir(git_repo.path):
933+
self.script()
934+
out, err = capsys.readouterr()
935+
assert excinfo.value.code == 1
936+
assert not err
937+
assert 'MissingLicense' in out
938+
917939
def test_failed_manifest(self, capsys, repo, make_git_repo):
918940
git_repo = make_git_repo(repo.location)
919941
repo.create_ebuild('cat/pkg-0')

tests/scripts/test_pkgdev_push.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class TestPkgdevPush:
6565
@pytest.fixture(autouse=True)
6666
def _setup(self, tmp_path, make_repo, make_git_repo):
6767
self.cache_dir = str(tmp_path / 'cache')
68-
self.scan_args = ['--pkgcheck-scan', f'--config no --cache-dir {self.cache_dir}']
68+
self.scan_args = ['--config', 'no', '--pkgcheck-scan', f'--config no --cache-dir {self.cache_dir}']
6969
# args for running pkgdev like a script
7070
self.args = ['pkgdev', 'push'] + self.scan_args
7171

0 commit comments

Comments
 (0)