# Copyright 2026 European Union
# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
# SPDX-License-Identifier: EUPL-1.2
"""Portable logical file references, independent of runtime storage roots."""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import PurePosixPath
[docs]
@dataclass(frozen=True, slots=True)
class LogicalFileReference:
"""Identify a file by storage volume name and a portable relative path.
The serialized form is ``<volume>:<relative/path>``. Volume names contain
only ASCII letters, digits, underscores, and hyphens. Paths use POSIX
separators and cannot be absolute or contain ``..`` components. Redundant
separators and ``.`` components are normalized away. The reference is
lexical; it does not validate a configured volume or resolve a filesystem
path.
:param volume: Case-sensitive logical volume name.
:param relative_path: POSIX-style path relative to that volume.
.. versionadded:: 2.4
"""
volume: str
relative_path: PurePosixPath
def __post_init__(self) -> None:
"""Validate and canonicalize both fields at construction time."""
if not isinstance(self.volume, str):
raise TypeError('volume must be a string')
if not re.fullmatch(r'[A-Za-z0-9_-]+', self.volume, flags=re.ASCII):
raise ValueError(f'Invalid volume name {self.volume!r}; expected [A-Za-z0-9_-]+')
if not isinstance(self.relative_path, (str, PurePosixPath)):
raise TypeError('relative_path must be a POSIX path string or PurePosixPath')
raw_path = str(self.relative_path)
if not raw_path:
raise ValueError('relative_path must not be empty')
if '\\' in raw_path:
raise ValueError("relative_path must use POSIX '/' separators; backslashes are not allowed")
if raw_path.startswith('/'):
raise ValueError('relative_path must be relative, not absolute or UNC')
if re.match(r'^[A-Za-z]:', raw_path, flags=re.ASCII):
raise ValueError('relative_path must not be a Windows drive path')
if ':' in raw_path:
raise ValueError('relative_path components must not contain colons')
if ';' in raw_path:
raise ValueError('relative_path components must not contain semicolons')
raw_parts = raw_path.split('/')
if '..' in raw_parts:
raise ValueError('relative_path must not contain ".." traversal components')
normalized = PurePosixPath(raw_path)
if not normalized.parts or str(normalized) == '.':
raise ValueError('relative_path must identify a file below the volume root')
object.__setattr__(self, 'relative_path', normalized)
[docs]
@classmethod
def from_string(cls, value: str) -> LogicalFileReference:
"""Parse and validate a serialized logical file reference.
:param value: String in ``<volume>:<relative/path>`` form.
:returns: The validated logical reference.
:raises TypeError: If ``value`` is not a string.
:raises ValueError: If the serialized reference is malformed.
.. versionadded:: 2.4
"""
if not isinstance(value, str):
raise TypeError('serialized logical reference must be a string')
if ':' not in value:
raise ValueError('logical reference must have the form <volume>:<relative/path>')
volume, relative_path = value.split(':', maxsplit=1)
return cls(volume=volume, relative_path=PurePosixPath(relative_path))
[docs]
@classmethod
def from_parameter_value(cls, value: object) -> LogicalFileReference:
"""Convert a serialized processor parameter value into this type.
:param value: An existing reference or its serialized string.
:returns: The typed logical file reference.
:raises TypeError: If *value* is neither a reference nor a string.
:raises ValueError: If the serialized reference is malformed.
.. versionadded:: 2.4
Added the optional processor-parameter conversion hook.
"""
if isinstance(value, cls):
return value
if isinstance(value, str):
return cls.from_string(value)
raise TypeError(f'Cannot convert {type(value).__name__} to {cls.__name__} from a processor parameter value')
[docs]
def to_string(self) -> str:
"""Return the canonical portable serialization.
:returns: Canonical ``<volume>:<relative/path>`` representation.
.. versionadded:: 2.4
"""
return f'{self.volume}:{self.relative_path.as_posix()}'