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}")