diff --git a/CHANGES.md b/CHANGES.md index f649d86..fd00b1b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,7 +4,7 @@ Changes development (main) ------------------ -- +- Enable loaders in `load_name`'s load order to be `pathlib.Path` objects, which get formatted like string template loaders (e.g. `'/path/to/{name}{suffix}'` will be equivalent to `Path('/path/to/{name}{suffix}')`). 0.17.2 (2025-11-25) ------------------- diff --git a/confidence/io.py b/confidence/io.py index f384786..2ff3ae3 100644 --- a/confidence/io.py +++ b/confidence/io.py @@ -158,30 +158,30 @@ class Locality(IntEnum): ENVIRONMENT = 3 #: configuration from environment variables -Loadable = str | typing.Callable[[str, Format], Configuration] +Loadable = str | Path | typing.Callable[[str, Format], Configuration] _LOADERS: typing.Mapping[Locality, typing.Iterable[Loadable]] = { Locality.SYSTEM: ( # system-wide locations read_xdg_config_dirs, - '/etc/{name}/{name}{suffix}', - '/etc/{name}{suffix}', - '/Library/Preferences/{name}/{name}{suffix}', - '/Library/Preferences/{name}{suffix}', + Path('/etc/{name}/{name}{suffix}'), + Path('/etc/{name}{suffix}'), + Path('/Library/Preferences/{name}/{name}{suffix}'), + Path('/Library/Preferences/{name}{suffix}'), partial(read_envvar_dir, 'PROGRAMDATA'), ), Locality.USER: ( # user-local locations read_xdg_config_home, - '~/Library/Preferences/{name}{suffix}', + Path('~/Library/Preferences/{name}{suffix}'), partial(read_envvar_dir, 'APPDATA'), partial(read_envvar_dir, 'LOCALAPPDATA'), - '~/.{name}{suffix}', + Path('~/.{name}{suffix}'), ), Locality.APPLICATION: ( # application-local locations - './{name}{suffix}', + Path('./{name}{suffix}'), ), Locality.ENVIRONMENT: ( # application-specific environment variables @@ -234,6 +234,29 @@ def loaders(*specifiers: Locality | Loadable) -> typing.Iterable[Loadable]: ) +def _format_source(source: str | Path, name: str, format: Format) -> Path: + match source: + case Path(): + # format every part of the path separately, filling in name and suffix + # NB: checking for use of {extension} is omitted here, use of Path templates was added after the deprecation + # of "extension" over "suffix" + return Path(*(part.format(name=name, suffix=format.suffix) for part in source.parts)) + case str(): + # issue warning if "extension" is used as a field name (second element in the parsed spans) in source + if any(span[1] == 'extension' for span in Formatter().parse(source)): + warnings.warn( + 'using "{extension}" in string template loaders has been deprecated, use "{suffix}" instead', + category=DeprecationWarning, + stacklevel=4, # warn about user code calling load_name rather than _format_source + ) + return Path(source.format(name=name, extension=format.suffix.lstrip('.'))) + else: + return Path(source.format(name=name, suffix=format.suffix)) + case _: + # any other type of source is invalid here + raise TypeError(f'cannot format source of type {type(source).__name__}') + + def load(*fps: typing.TextIO, format: Format = YAML, missing: typing.Any = Missing.SILENT) -> Configuration: """ Read a `Configuration` instance from file-like objects. @@ -299,7 +322,7 @@ def load_name( load_order: typing.Iterable[Loadable] = DEFAULT_LOAD_ORDER, format: Format = YAML, missing: typing.Any = Missing.SILENT, - extension: None = None, # NB: parameter is deprecated, see below + extension: None = None, # NB: parameter is deprecated, see _format_source ) -> Configuration: """ Read a `Configuration` instance by name, trying to read from files in @@ -338,18 +361,8 @@ def generate_sources() -> typing.Iterable[typing.Mapping[str, typing.Any]]: if callable(source): yield source(name, format) else: - # collect the field names in the format string, issue warning if "extension" is among them - if 'extension' in {span[1] for span in Formatter().parse(source)}: - warnings.warn( - 'extension name in string template loader has been deprecated, use suffix instead', - category=DeprecationWarning, - stacklevel=3, # warn about user code calling load_name rather than generate_sources - ) - candidate = Path(source.format(name=name, extension=format.suffix.lstrip('.'))) - else: - candidate = Path(source.format(name=name, suffix=format.suffix)) - - yield loadf(candidate, format=format, default=NotConfigured) + source = _format_source(source, name, format) + yield loadf(source, format=format, default=NotConfigured) return Configuration(*generate_sources(), missing=missing) diff --git a/tests/test_io.py b/tests/test_io.py index e0436c1..e445261 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -208,7 +208,7 @@ def test_load_name_single(test_files): def test_load_name_multiple(test_files): - test_path = path.join(test_files, '{name}{suffix}') + test_path = test_files / '{name}{suffix}' # bar has precedence over foo subject = load_name('foo', 'fake', 'bar', load_order=(test_path,)) @@ -444,7 +444,7 @@ def test_load_name_deprecated_extension(): def test_load_name_deprecated_extension_template(test_files): with ( - pytest.warns(DeprecationWarning, match='extension name in string template'), + pytest.warns(DeprecationWarning, match='using "{extension}" in string template'), patch('confidence.io.loadf', return_value=NotConfigured) as mocked_loadf, ): load_name( @@ -465,6 +465,12 @@ def test_load_name_deprecated_extension_template(test_files): ) +def test_load_name_incompatible_loader_type(test_files): + incompatible = bytes(str(test_files / 'config.yaml'), 'utf-8') + with pytest.raises(TypeError, match='source of type bytes'): + load_name('app', load_order=(test_files / 'bar.yaml', test_files / '{name}{suffix}', incompatible)) + + def test_dumps(): subject = dumps(Configuration({'ns.key': 42}))