Skip to content

Commit 6d85949

Browse files
committedJun 3, 2022
Read user-specific configuration if available
This makes using only the client CLI easier for end users. In the course, refactor how configuration files are processed in duffy.cli. Fixes: #432 Signed-off-by: Nils Philippsen <nils@redhat.com>
1 parent c0d43ff commit 6d85949

File tree

6 files changed

+58
-91
lines changed

6 files changed

+58
-91
lines changed
 

‎duffy/cli.py

+20-21
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import sys
44
from datetime import timedelta
5+
from pathlib import Path
56
from typing import List, Optional, Tuple, Union
67

78
import click
@@ -10,6 +11,7 @@
1011
import uvicorn
1112
except ImportError: # pragma: no cover
1213
uvicorn = None
14+
import xdg.BaseDirectory
1315
import yaml
1416

1517
try:
@@ -49,7 +51,8 @@
4951
from .util import UNSET, SentinelType
5052
from .version import __version__
5153

52-
DEFAULT_CONFIG_FILE = "/etc/duffy"
54+
DEFAULT_CONFIG_LOCATIONS = ("/etc/duffy", f"{xdg.BaseDirectory.xdg_config_home}/duffy")
55+
DEFAULT_CONFIG_PATHS = tuple(Path(loc) for loc in DEFAULT_CONFIG_LOCATIONS)
5356

5457
log = logging.getLogger(__name__)
5558

@@ -119,17 +122,7 @@ def convert(self, value, param, ctx):
119122
NODES_SPEC = NodesSpecType()
120123

121124

122-
# Global setup and CLI options
123-
124-
125-
def init_config(ctx, param, filename):
126-
ctx.ensure_object(dict)
127-
try:
128-
read_configuration(filename, clear=ctx.obj.get("clear_config", True), validate=False)
129-
except FileNotFoundError:
130-
if filename is not DEFAULT_CONFIG_FILE:
131-
raise
132-
ctx.obj["clear_config"] = False
125+
# CLI groups and commands
133126

134127

135128
@click.group(name="duffy")
@@ -146,26 +139,32 @@ def init_config(ctx, param, filename):
146139
default=None,
147140
)
148141
@click.option(
142+
"config_paths",
149143
"-c",
150144
"--config",
151-
type=click.Path(),
152-
default=DEFAULT_CONFIG_FILE,
153-
callback=init_config,
154-
is_eager=True,
155-
expose_value=False,
156-
help="Read configuration from the specified YAML files or directories.",
157-
show_default=True,
145+
type=click.Path(exists=True),
146+
multiple=True,
147+
help=(
148+
"Read configuration from the specified YAML files or directories instead of the default"
149+
f" paths ({', '.join(DEFAULT_CONFIG_LOCATIONS)})"
150+
),
158151
metavar="FILE_OR_DIR",
159152
)
160153
@click.version_option(version=__version__, prog_name="Duffy")
161154
@click.pass_context
162-
def cli(ctx: click.Context, loglevel: Optional[str]):
155+
def cli(ctx: click.Context, loglevel: Optional[str], config_paths: Tuple[Path]):
163156
ctx.ensure_object(dict)
164157
ctx.obj["loglevel"] = loglevel
165158

166159
logging.basicConfig(level=loglevel.upper() if isinstance(loglevel, str) else loglevel)
167160

168-
read_configuration(clear=False, validate=True)
161+
if not config_paths:
162+
# Ignore non-existent default paths
163+
config_paths = tuple(path for path in DEFAULT_CONFIG_PATHS if path.exists())
164+
165+
log.debug(f"Reading configuration from: {', '.join(str(p) for p in config_paths)}")
166+
167+
read_configuration(*config_paths, clear=True, validate=True)
169168

170169

171170
# Check & dump configuration

‎duffy/configuration/main.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from copy import deepcopy
22
from itertools import chain
33
from pathlib import Path
4-
from typing import List, Union
4+
from typing import List, Sequence, Union
55

66
import yaml
77

@@ -26,7 +26,7 @@ def _expand_normalize_config_files(config_files: List[Union[Path, str]]) -> List
2626

2727

2828
def read_configuration(
29-
*config_files: List[Union[Path, str]], clear: bool = True, validate: bool = True
29+
*config_files: Sequence[Union[Path, str]], clear: bool = True, validate: bool = True
3030
):
3131
config_files = _expand_normalize_config_files(config_files)
3232

‎poetry.lock

+13-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ psycopg2 = {version = "^2.9.2", optional = true}
5555
aiodns = {version = "^3.0.0", optional = true}
5656
pydantic = ">=1.6.2"
5757
aiosqlite = {version = ">=0.17.0", optional = true}
58+
pyxdg = ">=0.27"
5859

5960
[tool.poetry.dev-dependencies]
6061
Jinja2 = "^3.0.3"

‎scripts/import_csv.py

+17-20
Original file line numberDiff line numberDiff line change
@@ -2,47 +2,44 @@
22

33
import csv
44
import sys
5+
from pathlib import Path
56

67
import click
8+
import xdg.BaseDirectory
79
import yaml
810
from sqlalchemy import select
911

1012
from duffy.configuration import read_configuration
1113
from duffy.database import init_sync_model, sync_session_maker
1214
from duffy.database.model import Tenant
1315

14-
DEFAULT_CONFIG_FILE = "/etc/duffy"
16+
DEFAULT_CONFIG_LOCATIONS = ("/etc/duffy", f"{xdg.BaseDirectory.xdg_config_home}/duffy")
17+
DEFAULT_CONFIG_PATHS = tuple(Path(loc) for loc in DEFAULT_CONFIG_LOCATIONS)
1518

1619

1720
class dump_dialect(csv.unix_dialect):
1821
quotechar = "'"
1922

2023

21-
def init_config(ctx, param, filename):
22-
ctx.ensure_object(dict)
23-
try:
24-
read_configuration(filename, clear=ctx.obj.get("clear_config", True), validate=False)
25-
except FileNotFoundError:
26-
if filename is not DEFAULT_CONFIG_FILE:
27-
raise
28-
ctx.obj["clear_config"] = False
29-
30-
3124
@click.group()
3225
@click.option(
26+
"config_paths",
3327
"--config",
3428
"-c",
35-
type=click.Path(),
36-
default=DEFAULT_CONFIG_FILE,
37-
callback=init_config,
38-
is_eager=True,
39-
expose_value=False,
40-
help="Read configuration from the specified YAML files or directories.",
41-
show_default=True,
29+
type=click.Path(exists=True),
30+
multiple=True,
31+
help=(
32+
"Read configuration from the specified YAML files or directories instead of the default"
33+
f" paths ({', '.join(DEFAULT_CONFIG_LOCATIONS)})"
34+
),
4235
metavar="FILE_OR_DIR",
4336
)
44-
def cli():
45-
read_configuration(clear=False, validate=True)
37+
def cli(config_paths):
38+
if not config_paths:
39+
# Ignore non-existent default paths
40+
config_paths = tuple(path for path in DEFAULT_CONFIG_PATHS if path.exists())
41+
42+
read_configuration(*config_paths, clear=True, validate=True)
4643

4744

4845
def read_csv_files(users_file, userkeys_file):

‎tests/test_cli.py

+5-47
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import copy
22
import logging
33
from datetime import timedelta
4-
from tempfile import TemporaryDirectory
54
from unittest import mock
65
from uuid import uuid4
76

@@ -27,18 +26,10 @@ def runner():
2726

2827

2928
@pytest.fixture(autouse=True, scope="module")
30-
def dont_read_etc_duffy():
31-
# Modify the default value for `--config`. For that, find the right parameter object on the
32-
# (click-wrapped) cli() function, then mock its default below.
33-
for param in cli.params:
34-
if param.name == "config":
35-
break
36-
else: # Oops, didn't find right param object. This shouldn't happen!
37-
raise RuntimeError("Can't find right parameter object for `--config`.")
38-
39-
with TemporaryDirectory(prefix="dont_read_etc_duffy-") as tmpdir, mock.patch(
40-
"duffy.cli.DEFAULT_CONFIG_FILE", new=tmpdir
41-
), mock.patch.object(param, "default", new=tmpdir):
29+
def dont_read_system_user_config():
30+
with mock.patch("duffy.cli.DEFAULT_CONFIG_LOCATIONS", new=()), mock.patch(
31+
"duffy.cli.DEFAULT_CONFIG_PATHS", new=()
32+
):
4233
yield
4334

4435

@@ -102,38 +93,6 @@ def test_convert(self, testcase):
10293
assert converted == {"pool": "test", "quantity": "1"}
10394

10495

105-
@pytest.mark.parametrize(
106-
"testcase", ("default", "default-not-found", "other-not-found", "default-plus-one")
107-
)
108-
@mock.patch("duffy.cli.read_configuration")
109-
def test_init_config(read_configuration, testcase):
110-
if "not-found" in testcase:
111-
read_configuration.side_effect = FileNotFoundError()
112-
113-
if "default" in testcase:
114-
expectation = noop_context()
115-
filename = duffy.cli.DEFAULT_CONFIG_FILE
116-
else:
117-
expectation = pytest.raises(FileNotFoundError)
118-
filename = "boop"
119-
120-
ctx = mock.MagicMock()
121-
ctx.obj = {}
122-
param = mock.MagicMock()
123-
124-
with expectation:
125-
duffy.cli.init_config(ctx, param, filename)
126-
127-
read_configuration.assert_called_once_with(filename, clear=True, validate=False)
128-
129-
if "plus-one" in testcase:
130-
read_configuration.reset_mock(return_value=True, side_effect=True)
131-
132-
duffy.cli.init_config(ctx, param, "foo")
133-
134-
read_configuration.assert_called_once_with("foo", clear=False, validate=False)
135-
136-
13796
def test_cli_version(runner):
13897
result = runner.invoke(cli, ["--version"])
13998
assert result.exit_code == 0
@@ -156,8 +115,7 @@ def test_cli_suggestion(runner):
156115
def test_cli_missing_config(tmp_path, runner):
157116
missing_config_file = tmp_path / "missing_duffy_config.yaml"
158117
result = runner.invoke(cli, [f"--config={missing_config_file.absolute()}"])
159-
assert result.exit_code == 1
160-
assert isinstance(result.exception, FileNotFoundError)
118+
assert result.exit_code != 0
161119

162120

163121
@pytest.mark.duffy_config(example_config=True)

0 commit comments

Comments
 (0)
Please sign in to comment.