Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-123987: Fix NotADirectoryError in NamespaceReader when sentinel present #124018

Merged
merged 2 commits into from
Jan 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Lib/importlib/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
"""Read resources contained within a package."""
"""
Read resources contained within a package.
This codebase is shared between importlib.resources in the stdlib
and importlib_resources in PyPI. See
https://github.com/python/importlib_metadata/wiki/Development-Methodology
for more detail.
"""

from ._common import (
as_file,
Expand Down
4 changes: 2 additions & 2 deletions Lib/importlib/resources/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@ def get_resource_reader(package: types.ModuleType) -> Optional[ResourceReader]:
# zipimport.zipimporter does not support weak references, resulting in a
# TypeError. That seems terrible.
spec = package.__spec__
reader = getattr(spec.loader, 'get_resource_reader', None) # type: ignore
reader = getattr(spec.loader, 'get_resource_reader', None) # type: ignore[union-attr]
if reader is None:
return None
return reader(spec.name) # type: ignore
return reader(spec.name) # type: ignore[union-attr]


@functools.singledispatch
Expand Down
19 changes: 13 additions & 6 deletions Lib/importlib/resources/readers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import collections
import contextlib
import itertools
Expand All @@ -6,6 +8,7 @@
import re
import warnings
import zipfile
from collections.abc import Iterator

from . import abc

Expand Down Expand Up @@ -135,27 +138,31 @@ class NamespaceReader(abc.TraversableResources):
def __init__(self, namespace_path):
if 'NamespacePath' not in str(namespace_path):
raise ValueError('Invalid path')
self.path = MultiplexedPath(*map(self._resolve, namespace_path))
self.path = MultiplexedPath(*filter(bool, map(self._resolve, namespace_path)))

@classmethod
def _resolve(cls, path_str) -> abc.Traversable:
def _resolve(cls, path_str) -> abc.Traversable | None:
r"""
Given an item from a namespace path, resolve it to a Traversable.
path_str might be a directory on the filesystem or a path to a
zipfile plus the path within the zipfile, e.g. ``/foo/bar`` or
``/foo/baz.zip/inner_dir`` or ``foo\baz.zip\inner_dir\sub``.
path_str might also be a sentinel used by editable packages to
trigger other behaviors (see python/importlib_resources#311).
In that case, return None.
"""
(dir,) = (cand for cand in cls._candidate_paths(path_str) if cand.is_dir())
return dir
dirs = (cand for cand in cls._candidate_paths(path_str) if cand.is_dir())
return next(dirs, None)

@classmethod
def _candidate_paths(cls, path_str):
def _candidate_paths(cls, path_str: str) -> Iterator[abc.Traversable]:
yield pathlib.Path(path_str)
yield from cls._resolve_zip_path(path_str)

@staticmethod
def _resolve_zip_path(path_str):
def _resolve_zip_path(path_str: str):
for match in reversed(list(re.finditer(r'[\\/]', path_str))):
with contextlib.suppress(
FileNotFoundError,
Expand Down
2 changes: 1 addition & 1 deletion Lib/importlib/resources/simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ class ResourceHandle(Traversable):

def __init__(self, parent: ResourceContainer, name: str):
self.parent = parent
self.name = name # type: ignore
self.name = name # type: ignore[misc]

def is_file(self):
return True
Expand Down
50 changes: 44 additions & 6 deletions Lib/test/test_importlib/resources/_path.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,44 @@
import functools

from typing import Dict, Union
from typing import runtime_checkable
from typing import Protocol


####
# from jaraco.path 3.4.1
# from jaraco.path 3.7.1

FilesSpec = Dict[str, Union[str, bytes, 'FilesSpec']] # type: ignore

class Symlink(str):
"""
A string indicating the target of a symlink.
"""


FilesSpec = Dict[str, Union[str, bytes, Symlink, 'FilesSpec']]


@runtime_checkable
class TreeMaker(Protocol):
def __truediv__(self, *args, **kwargs): ... # pragma: no cover

def mkdir(self, **kwargs): ... # pragma: no cover

def write_text(self, content, **kwargs): ... # pragma: no cover

def write_bytes(self, content): ... # pragma: no cover

def build(spec: FilesSpec, prefix=pathlib.Path()):
def symlink_to(self, target): ... # pragma: no cover


def _ensure_tree_maker(obj: Union[str, TreeMaker]) -> TreeMaker:
return obj if isinstance(obj, TreeMaker) else pathlib.Path(obj) # type: ignore[return-value]


def build(
spec: FilesSpec,
prefix: Union[str, TreeMaker] = pathlib.Path(), # type: ignore[assignment]
):
"""
Build a set of files/directories, as described by the spec.
Expand All @@ -25,21 +54,25 @@ def build(spec: FilesSpec, prefix=pathlib.Path()):
... "__init__.py": "",
... },
... "baz.py": "# Some code",
... }
... "bar.py": Symlink("baz.py"),
... },
... "bing": Symlink("foo"),
... }
>>> target = getfixture('tmp_path')
>>> build(spec, target)
>>> target.joinpath('foo/baz.py').read_text(encoding='utf-8')
'# Some code'
>>> target.joinpath('bing/bar.py').read_text(encoding='utf-8')
'# Some code'
"""
for name, contents in spec.items():
create(contents, pathlib.Path(prefix) / name)
create(contents, _ensure_tree_maker(prefix) / name)


@functools.singledispatch
def create(content: Union[str, bytes, FilesSpec], path):
path.mkdir(exist_ok=True)
build(content, prefix=path) # type: ignore
build(content, prefix=path) # type: ignore[arg-type]


@create.register
Expand All @@ -52,5 +85,10 @@ def _(content: str, path):
path.write_text(content, encoding='utf-8')


@create.register
def _(content: Symlink, path):
path.symlink_to(content)


# end from jaraco.path
####
22 changes: 21 additions & 1 deletion Lib/test/test_importlib/resources/test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,26 @@ class OpenZipTests(FilesTests, util.ZipSetup, unittest.TestCase):
class OpenNamespaceTests(FilesTests, util.DiskSetup, unittest.TestCase):
MODULE = 'namespacedata01'

def test_non_paths_in_dunder_path(self):
"""
Non-path items in a namespace package's ``__path__`` are ignored.

As reported in python/importlib_resources#311, some tools
like Setuptools, when creating editable packages, will inject
non-paths into a namespace package's ``__path__``, a
sentinel like
``__editable__.sample_namespace-1.0.finder.__path_hook__``
to cause the ``PathEntryFinder`` to be called when searching
for packages. In that case, resources should still be loadable.
"""
import namespacedata01

namespacedata01.__path__.append(
'__editable__.sample_namespace-1.0.finder.__path_hook__'
)

resources.files(namespacedata01)


class OpenNamespaceZipTests(FilesTests, util.ZipSetup, unittest.TestCase):
ZIP_MODULE = 'namespacedata01'
Expand All @@ -86,7 +106,7 @@ def test_module_resources(self):
"""
A module can have resources found adjacent to the module.
"""
import mod
import mod # type: ignore[import-not-found]

actual = resources.files(mod).joinpath('res.txt').read_text(encoding='utf-8')
assert actual == self.spec['res.txt']
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Fixed issue in NamespaceReader where a non-path item in a namespace path,
such as a sentinel added by an editable installer, would break resource
loading.
Loading