Source code for simvx.core.asset_resolver

"""Asset path resolution.

`resolve_asset_path` is the engine's single canonical way to turn an asset
specification into a filesystem :class:`~pathlib.Path`. It accepts:

- :class:`str` / :class:`os.PathLike` -- a filesystem path, absolute or
  resolved relative to *project_root* (or the current working directory).
- :class:`~simvx.core.resource.Resource` -- a Python-package handle, looked
  up via :mod:`importlib.resources`.
- :class:`importlib.resources.abc.Traversable` -- the raw return type of
  ``importlib.resources.files(pkg) / name``; resolved to a real path via
  :func:`importlib.resources.as_file`.

Strict by default: missing files raise :class:`FileNotFoundError`, missing
packages raise :class:`ModuleNotFoundError`, anything else raises
:class:`TypeError`. There are no URI schemes -- strings are paths.
"""

from __future__ import annotations

import importlib.resources
import os
import sys
from importlib.resources.abc import Traversable
from pathlib import Path
from typing import TYPE_CHECKING, Union

if TYPE_CHECKING:
    from .resource import Resource

AssetSpec = Union[str, os.PathLike, "Resource", Traversable]

[docs] def resolve_asset_path(spec: AssetSpec, project_root: Path | None = None) -> Path: """Resolve *spec* to a filesystem :class:`Path`. - ``str`` / ``os.PathLike``: filesystem path. Absolute paths pass through; relative paths resolve from *project_root* (or the CWD). - ``Resource``: package + name resolved via :mod:`importlib.resources`. - ``Traversable``: package-resource handle resolved via :func:`importlib.resources.as_file`. Raises :class:`TypeError` for any other type, :class:`FileNotFoundError` if the resolved file is missing, and :class:`ModuleNotFoundError` if a Resource references a package that cannot be imported. """ # Local import keeps the module import-time graph small. from .resource import Resource if isinstance(spec, Resource): return _resolve_traversable(_resource_traversable(spec)) if isinstance(spec, Traversable) and not isinstance(spec, (str, os.PathLike)): return _resolve_traversable(spec) if isinstance(spec, (str, os.PathLike)): if isinstance(spec, str) and not spec: raise ValueError("path spec must be a non-empty string") path = Path(os.fspath(spec)) if not path.is_absolute(): root = project_root or Path.cwd() path = (root / path).resolve() if not path.exists(): raise FileNotFoundError(f"Asset not found: {path}") return path raise TypeError( f"resolve_asset_path expected str | os.PathLike | Resource | Traversable, " f"got {type(spec).__name__}" )
def _resource_traversable(resource: "Resource") -> Traversable: try: traversable = importlib.resources.files(resource.package) except (ModuleNotFoundError, TypeError) as exc: raise ModuleNotFoundError( f"Cannot resolve package {resource.package!r}: {exc}" ) from exc for part in resource.name.split("/"): traversable = traversable / part return traversable def _resolve_traversable(traversable: Traversable) -> Path: """Resolve a Traversable to a real filesystem :class:`Path`.""" # Filesystem-backed packages already are PosixPath/WindowsPath. candidate = Path(str(traversable)) if candidate.exists(): return candidate # Zip-backed packages need as_file() to materialise. We intentionally enter # but never exit the context — the temp file stays alive for the process. # Acceptable for game assets loaded once at startup. if sys.version_info >= (3, 12): try: ctx = importlib.resources.as_file(traversable) result = ctx.__enter__() if result.exists(): return result ctx.__exit__(None, None, None) except (TypeError, FileNotFoundError): pass raise FileNotFoundError(f"Asset not found: {traversable!s}")