# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""Resolve configuration-declared filesystem paths against their owning file.
The resolver keeps path expansion separate from configuration storage and
filesystem validation. Callers must opt into this API only for values known to
represent filesystem paths.
.. versionadded:: 2.4
:author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
"""
from __future__ import annotations
import glob
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum
from pathlib import Path, PureWindowsPath
from typing import Self
[docs]
class PathValidationPolicy(Enum):
"""Select an optional existence and type check for a resolved path.
``NONE`` is appropriate for output destinations, which need not exist yet.
.. versionadded:: 2.4
"""
NONE = 'none'
EXISTING_FILE = 'existing_file'
EXISTING_DIRECTORY = 'existing_directory'
[docs]
@dataclass(frozen=True, slots=True)
class PathResolutionContext:
"""Immutable base context for a configuration-declared path.
Use :meth:`from_file` for a path declared in a configuration file. Each
nested configuration file should get a new context of its own. The
:meth:`from_directory` constructor supports existing APIs that already
receive an explicit base directory.
Relative context inputs are anchored to the current directory once, when
the context is created. Path values resolved later do not depend on it.
.. versionadded:: 2.4
"""
base_directory: Path
declaring_file: Path | None = None
def __post_init__(self) -> None:
"""Normalize context paths once, without requiring them to exist."""
if self.declaring_file is not None:
declaring_file = Path(self.declaring_file).expanduser().resolve(strict=False)
object.__setattr__(self, 'declaring_file', declaring_file)
object.__setattr__(self, 'base_directory', declaring_file.parent)
else:
base_directory = Path(self.base_directory).expanduser().resolve(strict=False)
object.__setattr__(self, 'base_directory', base_directory)
[docs]
@classmethod
def from_file(cls, declaring_file: Path | str) -> Self:
"""Create a context whose base is the directory containing a file.
:param declaring_file: Configuration file that owns the path value.
:return: An immutable context with an absolute file and base directory.
"""
file_path = Path(declaring_file).expanduser().resolve(strict=False)
return cls(base_directory=file_path.parent, declaring_file=file_path)
[docs]
@classmethod
def from_directory(cls, directory: Path | str) -> Self:
"""Create a context from an explicit base directory.
:param directory: Base directory for relative values.
:return: An immutable context with an absolute base directory.
"""
return cls(base_directory=Path(directory))
@property
def description(self) -> str:
"""Return a human-readable description for diagnostics."""
if self.declaring_file is not None:
return f'configuration file {str(self.declaring_file)!r}'
return f'base directory {str(self.base_directory)!r}'
[docs]
class PathResolutionError(ValueError):
"""Report an invalid filesystem path value with its owning context.
.. versionadded:: 2.4
"""
def __init__(self, original_value: str | Path, context: PathResolutionContext, detail: str) -> None:
"""Create an error that retains the configured value and context."""
self.original_value = original_value
self.context = context
super().__init__(f'Cannot resolve filesystem path {str(original_value)!r} from {context.description}: {detail}')
[docs]
def resolve_path(
value: str | Path,
context: PathResolutionContext,
*,
validation: PathValidationPolicy = PathValidationPolicy.NONE,
env: Mapping[str, str] | None = None,
) -> Path:
"""Interpolate and resolve a known filesystem path value.
Environment-variable interpolation uses the same syntax and errors as
:func:`mafw.tools.toml_tools.resolve_string` and occurs before home
expansion and path normalization. URI schemes and glob patterns are
rejected because this function handles filesystem paths only.
Normalization produces an absolute path without requiring it to exist.
``validation`` may additionally require an existing file or directory.
Use ``PathValidationPolicy.NONE`` for output destinations; this function
never creates files or directories.
:param value: Configured path value, retained unchanged by the caller.
:param context: Owning configuration-file or explicit base-directory context.
:param validation: Optional existence and type policy.
:param env: Environment mapping for interpolation; defaults to ``os.environ``.
:return: The effective absolute path.
:raises PathResolutionError: If interpolation, classification, normalization,
or requested validation fails.
"""
from mafw.tools.toml_tools import resolve_string
try:
expanded = resolve_string(str(value), os.environ if env is None else env)
except ValueError as exc:
raise PathResolutionError(value, context, f'environment interpolation failed: {exc}') from exc
return _resolve_interpolated_path(expanded, context, validation=validation, original_value=value)
[docs]
def _resolve_interpolated_path(
value: str | Path,
context: PathResolutionContext,
*,
validation: PathValidationPolicy = PathValidationPolicy.NONE,
original_value: str | Path | None = None,
) -> Path:
"""Resolve an already interpolated path for the public API and migrations."""
original = value if original_value is None else original_value
text = str(value)
if _looks_like_uri(text):
raise PathResolutionError(original, context, 'the value has a URI scheme and is not a filesystem path')
if glob.has_magic(text):
raise PathResolutionError(
original, context, 'glob patterns are not filesystem paths; resolve a path prefix first'
)
try:
path = Path(text).expanduser()
if not path.is_absolute():
path = context.base_directory / path
resolved = path.resolve(strict=False)
except (OSError, RuntimeError, ValueError) as exc:
raise PathResolutionError(original, context, f'path normalization failed: {exc}') from exc
if validation is PathValidationPolicy.EXISTING_FILE and not resolved.is_file():
raise PathResolutionError(original, context, f'resolved to {str(resolved)!r}, which is not an existing file')
if validation is PathValidationPolicy.EXISTING_DIRECTORY and not resolved.is_dir():
raise PathResolutionError(
original,
context,
f'resolved to {str(resolved)!r}, which is not an existing directory',
)
return resolved
[docs]
def _looks_like_uri(value: str) -> bool:
"""Recognize URI schemes without mistaking Windows drive paths for URIs."""
if PureWindowsPath(value).drive:
return False
return re.match(r'^[A-Za-z][A-Za-z0-9+.-]*:', value) is not None