Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
-------------------
Expand Down
55 changes: 34 additions & 21 deletions confidence/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
10 changes: 8 additions & 2 deletions tests/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,))
Expand Down Expand Up @@ -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(
Expand All @@ -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}))

Expand Down