"""Path utilities for navigating and creating LabArchives tree nodes."""
from __future__ import annotations
from collections.abc import Iterator, Sequence
from enum import Enum
from typing import TYPE_CHECKING, NewType, overload
from typing_extensions import override
from labapi.exceptions import PathError
if TYPE_CHECKING:
from labapi.tree.mixins import AbstractBaseTreeNode
EscapedSegment = NewType("EscapedSegment", str)
UnescapedSegment = NewType("UnescapedSegment", str)
class _LexerState(Enum):
BASE = 1
ESCAPE = 2
def _lexe(*paths: str) -> tuple[Sequence[UnescapedSegment], bool]:
segments: list[UnescapedSegment] = []
segment = ""
lexe_state = _LexerState.BASE
path = "/".join(paths)
for char in path:
match char, lexe_state:
case "\\", _LexerState.BASE:
lexe_state = _LexerState.ESCAPE
case "/", _LexerState.BASE:
if len(segment) > 0:
segments.append(UnescapedSegment(segment))
segment = ""
case _, _LexerState.BASE:
segment += char
case _, _LexerState.ESCAPE:
segment += char
lexe_state = _LexerState.BASE
if lexe_state == _LexerState.ESCAPE:
raise PathError("Path cannot end with an escape character", path=path)
if len(segment):
segments.append(UnescapedSegment(segment))
return segments, path.startswith("/")
def _canonicalize(
*path: UnescapedSegment, from_root: bool
) -> Sequence[UnescapedSegment]:
assert not isinstance(path, str)
canonical: list[UnescapedSegment] = []
for segment in path:
match segment:
case ".":
continue
case ".." if len(canonical) == 0:
if not from_root:
canonical.append(segment)
case ".." if canonical[-1] == "..":
canonical.append(segment)
case "..":
canonical.pop()
case _:
canonical.append(segment)
return canonical
[docs]
class NotebookPath(Sequence[UnescapedSegment]):
r"""A structured path referencing a location in the notebook tree.
Supports absolute and relative paths, plus ``..`` parent navigation.
Empty segments and ``.`` are discarded; ``..`` collapses the preceding
segment, or is kept at the root of a relative path.
If a node name contains a literal ``/``, write it as ``\/`` in a path
string, e.g. ``r"Reports\/2024"``. Iteration, indexing, :attr:`name`, and
:attr:`parts` return node names. ``str()`` adds escapes back so the result
can be parsed again.
Examples::
# From a tree node (always absolute)
path = NotebookPath(folder) # e.g. /Experiments/2024
# From a string
path = NotebookPath("/Experiments/2024") # absolute
path = NotebookPath("2024/Results") # relative
# Combine with /
path = NotebookPath(notebook) / "Experiments" / "2024"
"""
[docs]
def __init__(
self,
part: NotebookPath | AbstractBaseTreeNode | EscapedSegment,
*parts: EscapedSegment,
parent: NotebookPath | AbstractBaseTreeNode | None = None,
):
r"""Construct a ``NotebookPath``.
Additional ``parts`` are appended to ``part``.
:param part: A tree node creates an absolute path, a ``NotebookPath``
is copied, and a string is parsed as path syntax.
:param parts: Additional path strings appended after ``part``. ``/``
separates segments unless it is escaped as ``\/``.
:param parent: Absolute path or node used later to resolve a relative
string path. Must be absolute.
:raises PathError: If ``parent`` is not absolute.
"""
self._parent: NotebookPath | None = None
self._parts: Sequence[UnescapedSegment] = ()
self._absolute: bool = False
other_parts, _ = _lexe(*parts)
if parent is not None:
_parent = NotebookPath(parent)
if not _parent.is_absolute():
raise PathError(
"Parent path must be absolute",
path=str(part),
parent=str(_parent),
)
else:
_parent = None
if isinstance(part, NotebookPath):
self._parts = _canonicalize(
*part._parts, *other_parts, from_root=part._absolute
)
self._absolute = part._absolute
self._parent = part._parent
elif isinstance(part, str):
lexed, is_absolute = _lexe(part)
self._absolute = is_absolute and parent is None
self._parts = _canonicalize(*lexed, *other_parts, from_root=self._absolute)
else:
self._parts = _canonicalize(
*NotebookPath._of_node(part), *other_parts, from_root=True
)
self._absolute = True
if _parent is not None:
self._parent = _parent
def __truediv__(self, other: str | NotebookPath) -> NotebookPath:
"""Append a segment or another path using the ``/`` operator.
When ``other`` is a string it is appended as a new segment. When
``other`` is a relative ``NotebookPath`` it is resolved against
``self``; when it is absolute it is returned as-is.
:param other: A path segment string or another ``NotebookPath``.
:returns: A new ``NotebookPath`` with ``other`` appended or resolved.
"""
if isinstance(other, str):
return NotebookPath(self, EscapedSegment(other))
return other.resolve(self)
[docs]
@staticmethod
def escape(*parts: UnescapedSegment) -> tuple[EscapedSegment, ...]:
"""Add path escapes."""
return tuple(
EscapedSegment(part.replace("\\", "\\\\").replace("/", r"\/"))
for part in parts
)
[docs]
@staticmethod
def unescape(part: EscapedSegment) -> UnescapedSegment:
"""Remove path escapes."""
segment = ""
lexe_state = _LexerState.BASE
for char in part:
match char, lexe_state:
case "\\", _LexerState.BASE:
lexe_state = _LexerState.ESCAPE
case _, _LexerState.BASE:
segment += char
case _, _LexerState.ESCAPE:
segment += char
lexe_state = _LexerState.BASE
return UnescapedSegment(segment)
[docs]
def to_string(self, escape=False) -> str:
r"""Return the path as a slash-separated string.
By default, names are joined as-is. Pass ``escape=True`` to match
``str(path)``.
:returns: Examples include ``"/Experiments/2024"`` and
``"2024/Results"``.
"""
parts = self.escape(*self._parts) if escape else self._parts
if self._absolute:
return f"/{'/'.join(parts)}"
return "/".join(parts)
[docs]
def is_absolute(self) -> bool:
"""Return whether this path is absolute.
An absolute path is rooted at the notebook level and begins with
``/`` in its string form.
:returns: ``True`` if the path is absolute, ``False`` if relative.
"""
return self._absolute
[docs]
def resolve(
self, parent: NotebookPath | None = None, recurse: bool = False
) -> NotebookPath:
"""Return an absolute version of this path.
If the path is already absolute it is returned unchanged. Otherwise
the path is resolved against ``parent`` (if given) or against the
``parent`` anchor stored at construction time.
:param parent: An absolute path to resolve against. Ignored when the
path is already absolute or has a stored parent anchor.
:param recurse: If ``True``, ``parent`` itself is resolved before use.
:returns: A new absolute ``NotebookPath``.
:raises PathError: If the path is relative and no parent is available
to resolve against.
"""
if self._parent is None:
if self.is_absolute():
return self
if parent is not None:
return NotebookPath(
parent.resolve() if recurse else parent,
*self.escape(*self._parts),
)
raise PathError(
"Cannot resolve relative path without an absolute parent",
path=str(self),
)
return NotebookPath(self._parent, *self.escape(*self._parts)).resolve(
parent, recurse
)
[docs]
def startswith(self, other: NotebookPath) -> bool:
"""Return whether this path starts with another path's segments.
Compares raw segments without resolving either path.
:param other: The prefix path to test against.
:returns: ``True`` if the leading segments of this path equal all
segments of ``other``.
"""
if len(self) < len(other):
return False
return self[: len(other)] == other[: len(other)]
[docs]
def is_relative_to(self, other: NotebookPath | AbstractBaseTreeNode) -> bool:
"""Return whether this path is located inside ``other``.
Unanchored relative paths are considered to be relative to any
absolute path.
:param other: The candidate ancestor path or tree node.
:returns: ``True`` if this path is equal to or below ``other``.
"""
if not isinstance(other, NotebookPath):
other = NotebookPath(other)
if not other._absolute and other._parent is None:
if not self._absolute and self._parent is None:
return self.startswith(other)
return False
if not self._absolute and self._parent is None:
return True
return self.resolve().startswith(other.resolve())
[docs]
def relative_to(self, other: NotebookPath | AbstractBaseTreeNode) -> NotebookPath:
"""Return this path made relative to ``other``.
The result is a new relative ``NotebookPath`` whose ``parent`` anchor
is set to the resolved form of ``other``, so it can be resolved back
to an absolute path later.
:param other: The ancestor path or tree node to relativise against.
:returns: A relative ``NotebookPath`` from ``other`` to this path.
:raises PathError: If this path is not located inside ``other``.
"""
if not isinstance(other, NotebookPath):
other = NotebookPath(other)
if not self.is_relative_to(other):
raise PathError(
f'Cannot compute relative path: "{self}" is outside of "{other}"',
path=str(self),
parent=str(other),
)
if not other._absolute and other._parent is None:
return NotebookPath(*self.escape(*self[len(other) :]))
p_origin = other.resolve()
p_endpoint = self.resolve(other)
remaining = list(p_endpoint[len(p_origin) :])
return (
NotebookPath(*self.escape(*remaining), parent=p_origin)
if remaining
else NotebookPath(EscapedSegment(""), parent=p_origin)
)
@property
def name(self) -> UnescapedSegment:
"""The final node name.
Returns ``"."`` for an empty path.
"""
if len(self._parts):
return self._parts[-1]
return UnescapedSegment(".")
@property
def parts(self) -> Sequence[UnescapedSegment]:
"""All node names except the last one.
Analogous to the parent directory in a file path.
"""
return tuple(self._parts[:-1])
@property
def parent(self) -> NotebookPath:
"""The parent path (all segments except the last).
Resolves the path first, then appends ``..`` to obtain the parent.
:returns: An absolute ``NotebookPath`` pointing to the parent location.
"""
if len(self) == 0 and self._absolute:
return self.resolve() / ".."
return self / ".."
@override
def __iter__(self) -> Iterator[UnescapedSegment]:
"""Iterate over node names in order."""
return iter(self._parts)
@override
def __len__(self) -> int:
"""Return the number of segments in the path."""
return len(self._parts)
@overload
def __getitem__(self, idx: int) -> UnescapedSegment: ...
@overload
def __getitem__(self, idx: slice) -> Sequence[UnescapedSegment]: ...
@override
def __getitem__(
self, idx: int | slice
) -> UnescapedSegment | Sequence[UnescapedSegment]:
"""Return node name(s)."""
return self._parts[idx]
@override
def __hash__(self) -> int:
"""Hash based on absoluteness, segments, and parent anchor."""
return hash((self._absolute, tuple(self._parts)))
@override
def __eq__(self, other: object) -> bool:
"""Return ``True`` if ``other`` has the same path semantics.
Equality compares absoluteness, normalized segments, and any stored
parent anchor.
"""
if self is other:
return True
if not isinstance(other, NotebookPath):
return False
try:
a = self.resolve()
b = other.resolve()
return a._absolute == b._absolute and a._parts == b._parts
except PathError:
return False
@override
def __repr__(self) -> str:
"""Return a developer-readable representation, e.g. ``NotebookPath('/a/b')``."""
return f"{type(self).__name__}({self.to_string(escape=True)!r})"
@override
def __str__(self) -> str:
"""Return a reusable path string; use ``to_string()`` for raw names."""
return self.to_string(escape=True)
@staticmethod
def _of_node(a: AbstractBaseTreeNode) -> Sequence[UnescapedSegment]:
"""Return the ordered list of ancestor names from the notebook root to ``a``.
Walks ``a.parent`` until the root is reached, building the segment list
from the bottom up.
:param a: The tree node to derive a path for.
:returns: A sequence of name strings representing the path from root to
``a``, not including the root notebook itself.
"""
stack: list[UnescapedSegment] = []
curr = a
while curr is not curr.root:
stack.append(UnescapedSegment(curr.name))
curr = curr.parent
return stack[::-1]