Source code for mafw.tools.path_resolution

#  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