diff --git a/README.md b/README.md index ea7ec8f..98ae058 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ In `src/mypkg/data/__init__.py`, add: from acres import Loader -load_resource = Loader(__package__) +load_resource = Loader(__spec__.name) ``` `mypkg.data.load_resource()` is now a function that will return a `Path` to a @@ -86,20 +86,16 @@ with load_resource.as_path('resourceDir') as resource_dir: Note that `load_resource()` is a shorthand for `load_resource.cached()`, whose explicitness might be more to your taste. -### Type checking +### The `__spec__.name` anchor -Some type checkers may complain on `Loader(__package__)` because `__package__` may be `None`. -To resolve this, add `assert __package__` before the call, for example: +Previous versions recommended using `Loader(__package__)`. +Before Python 3.10, `__package__` might be `None` during a [zipimport][], +and `__package__` has been deprecated in Python 3.13, to be removed in 3.15. -```python -from acres import Loader - -assert __package__ -load_resource = Loader(__package__) -``` - -This does have a runtime cost, so `# type: ignore[reportArgumentType,unused-ignore]` -can also be used to avoid incurring that, if import times are a concern. +[`__spec__.parent`][ModuleSpec.parent] is an exact equivalent for `__package__`, +but for `__init__.py` files, [`__spec__.name`][ModuleSpec.name] is equivalent. +`__spec__.name` is also guaranteed to be a string and not `None`, +which lets it play nicely with type checkers. ## Interpreter-scoped resources, locally scoped loaders @@ -141,3 +137,5 @@ the accessed resources, including providing an interpreter-lifetime scope. [Traversable]: https://docs.python.org/3/library/importlib.resources.abc.html#importlib.resources.abc.Traversable [pathlib.Path]: https://docs.python.org/3/library/pathlib.html#pathlib.Path +[ModuleSpec.name]: https://docs.python.org/3/library/importlib.html#importlib.machinery.ModuleSpec.name +[ModuleSpec.parent]: https://docs.python.org/3/library/importlib.html#importlib.machinery.ModuleSpec.parent diff --git a/changelog.d/20241209_154150_effigies_recommend_spec_name.md b/changelog.d/20241209_154150_effigies_recommend_spec_name.md new file mode 100644 index 0000000..8c9a911 --- /dev/null +++ b/changelog.d/20241209_154150_effigies_recommend_spec_name.md @@ -0,0 +1,46 @@ + + + +### Changed + +- Update recommended usage from `Loader(__package__)` to `Loader(__spec__.name)`. + + + + + + diff --git a/changelog.d/20241209_154445_effigies_recommend_spec_name.md b/changelog.d/20241209_154445_effigies_recommend_spec_name.md new file mode 100644 index 0000000..44a446c --- /dev/null +++ b/changelog.d/20241209_154445_effigies_recommend_spec_name.md @@ -0,0 +1,44 @@ + + +### Added + +- Tests exercise and demonstrate the usage of acres on zipped modules. + + +### Fixed + +- Resolve cache misses when caching the same file from different loaders. + + + + + diff --git a/changelog.d/scriv.ini b/changelog.d/scriv.ini new file mode 100644 index 0000000..33be658 --- /dev/null +++ b/changelog.d/scriv.ini @@ -0,0 +1,5 @@ +[scriv] +format = md +main_branches = main +version = literal: deno.json: version +categories = Added, Changed, Fixed, Deprecated, Removed, Security, Infrastructure diff --git a/src/acres/__init__.py b/src/acres/__init__.py index 4cb5778..7b24497 100644 --- a/src/acres/__init__.py +++ b/src/acres/__init__.py @@ -65,9 +65,9 @@ @cache -def _cache_resource(resource: Traversable) -> Path: +def _cache_resource(anchor: str | ModuleType, segments: tuple[str]) -> Path: # PY310(importlib_resources): no-any-return, PY311+(importlib.resources): unused-ignore - return EXIT_STACK.enter_context(as_file(resource)) # type: ignore[no-any-return,unused-ignore] + return EXIT_STACK.enter_context(as_file(files(anchor).joinpath(*segments))) # type: ignore[no-any-return,unused-ignore] class Loader: @@ -97,7 +97,7 @@ class Loader: from acres import Loader - load_data = Loader(__package__) + load_data = Loader(__spec__.name) :class:`~Loader` objects implement the :func:`callable` interface and generate a docstring, and are intended to be treated and documented @@ -205,7 +205,8 @@ def cached(self, *segments: str) -> Path: data multiple times, but directories and their contents being requested separately may result in some duplication. """ + # Use self._anchor and segments to ensure the cache does not depend on id(self.files) # PY310(importlib_resources): unused-ignore, PY311+(importlib.resources) arg-type - return _cache_resource(self.files.joinpath(*segments)) # type: ignore[arg-type,unused-ignore] + return _cache_resource(self._anchor, segments) # type: ignore[arg-type,unused-ignore] __call__ = cached diff --git a/tests/data/__init__.py b/tests/data/__init__.py index 90c7a30..954c55f 100644 --- a/tests/data/__init__.py +++ b/tests/data/__init__.py @@ -1,3 +1,3 @@ from acres import Loader -load_resource = Loader(__package__) # type: ignore[reportArgumentType,unused-ignore] +load_resource = Loader(__spec__.name) diff --git a/tests/test_zipmodule.py b/tests/test_zipmodule.py new file mode 100644 index 0000000..d885910 --- /dev/null +++ b/tests/test_zipmodule.py @@ -0,0 +1,54 @@ +import os +import sys +import zipfile +from pathlib import Path + +from acres import Loader + + +def test_zipimport(tmp_path: Path) -> None: + # Setup... no need for a fixture for a single test + target_file = tmp_path / 'mymodule.zip' + with zipfile.ZipFile(target_file, mode='w') as mymod: + mymod.writestr( + 'mypkg/__init__.py', + 'from . import data\n', + ) + mymod.writestr( + 'mypkg/data/__init__.py', + 'from acres import Loader\nload_resource = Loader(__spec__.name)\n', + ) + mymod.writestr( + 'mypkg/data/resource.txt', + 'some text\n', + ) + + sys.path.insert(0, str(target_file)) + + # Test + import mypkg # type: ignore[import-not-found] + + assert mypkg.__file__.endswith(os.path.join('mymodule.zip', 'mypkg', '__init__.py')) + + loader = mypkg.data.load_resource + + readable = loader.readable('resource.txt') + assert not isinstance(readable, Path) + assert readable.read_text() == 'some text\n' + + with loader.as_path('resource.txt') as path: + assert isinstance(path, Path) + assert path.exists() + assert path.read_text() == 'some text\n' + assert not path.exists() + + cached = loader.cached('resource.txt') + assert isinstance(cached, Path) + assert cached.exists() + assert cached.read_text() == 'some text\n' + + new_loader = Loader('mypkg.data') + assert new_loader.cached('resource.txt') == cached + + # Teardown + sys.path.pop(0)