From 79eea4c2d2f56ecfffd5d76b277c77e014ee1797 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 11 Apr 2026 17:21:48 -0400 Subject: [PATCH 1/3] Apply changes from importlib_resources 6.5.2. --- Lib/importlib/resources/__init__.py | 7 +- Lib/importlib/resources/_common.py | 12 +- Lib/importlib/resources/_functional.py | 9 +- Lib/importlib/resources/abc.py | 38 ++++-- Lib/importlib/resources/readers.py | 3 +- Lib/test/test_importlib/resources/_path.py | 8 +- .../resources/test_compatibilty_files.py | 4 +- .../test_importlib/resources/test_contents.py | 2 +- .../test_importlib/resources/test_custom.py | 8 +- .../test_importlib/resources/test_files.py | 17 +-- .../resources/test_functional.py | 60 +++++---- .../test_importlib/resources/test_open.py | 2 +- .../test_importlib/resources/test_path.py | 4 +- .../test_importlib/resources/test_read.py | 4 +- .../test_importlib/resources/test_reader.py | 3 +- .../test_importlib/resources/test_resource.py | 3 +- .../test_importlib/resources/test_util.py | 29 +++++ Lib/test/test_importlib/resources/util.py | 117 ++++++++++++++++-- 18 files changed, 248 insertions(+), 82 deletions(-) create mode 100644 Lib/test/test_importlib/resources/test_util.py diff --git a/Lib/importlib/resources/__init__.py b/Lib/importlib/resources/__init__.py index 723c9f9eb33ce1..27d6c7f89307ef 100644 --- a/Lib/importlib/resources/__init__.py +++ b/Lib/importlib/resources/__init__.py @@ -8,12 +8,11 @@ """ from ._common import ( + Anchor, + Package, as_file, files, - Package, - Anchor, ) - from ._functional import ( contents, is_resource, @@ -23,10 +22,8 @@ read_binary, read_text, ) - from .abc import ResourceReader - __all__ = [ 'Package', 'Anchor', diff --git a/Lib/importlib/resources/_common.py b/Lib/importlib/resources/_common.py index 4e9014c45a056e..438d46ff188637 100644 --- a/Lib/importlib/resources/_common.py +++ b/Lib/importlib/resources/_common.py @@ -1,15 +1,15 @@ +import contextlib +import functools +import importlib +import inspect +import itertools import os import pathlib import tempfile -import functools -import contextlib import types -import importlib -import inspect import warnings -import itertools +from typing import Optional, Union, cast -from typing import Union, Optional, cast from .abc import ResourceReader, Traversable Package = Union[types.ModuleType, str] diff --git a/Lib/importlib/resources/_functional.py b/Lib/importlib/resources/_functional.py index f59416f2dd627d..b08a5c6efe22a2 100644 --- a/Lib/importlib/resources/_functional.py +++ b/Lib/importlib/resources/_functional.py @@ -2,8 +2,8 @@ import warnings -from ._common import files, as_file - +from ._common import as_file, files +from .abc import TraversalError _MISSING = object() @@ -42,7 +42,10 @@ def is_resource(anchor, *path_names): Otherwise returns ``False``. """ - return _get_resource(anchor, path_names).is_file() + try: + return _get_resource(anchor, path_names).is_file() + except TraversalError: + return False def contents(anchor, *path_names): diff --git a/Lib/importlib/resources/abc.py b/Lib/importlib/resources/abc.py index 6750a7aaf14aa9..64a6d843dce98e 100644 --- a/Lib/importlib/resources/abc.py +++ b/Lib/importlib/resources/abc.py @@ -1,12 +1,22 @@ import abc -import io import itertools import os import pathlib -from typing import Any, BinaryIO, Iterable, Iterator, NoReturn, Text, Optional -from typing import runtime_checkable, Protocol -from typing import Union - +from typing import ( + Any, + BinaryIO, + Iterable, + Iterator, + Literal, + NoReturn, + Optional, + Protocol, + Text, + TextIO, + Union, + overload, + runtime_checkable, +) StrPath = Union[str, os.PathLike[str]] @@ -82,11 +92,13 @@ def read_bytes(self) -> bytes: with self.open('rb') as strm: return strm.read() - def read_text(self, encoding: Optional[str] = None) -> str: + def read_text( + self, encoding: Optional[str] = None, errors: Optional[str] = None + ) -> str: """ Read contents of self as text """ - with self.open(encoding=encoding) as strm: + with self.open(encoding=encoding, errors=errors) as strm: return strm.read() @abc.abstractmethod @@ -132,8 +144,16 @@ def __truediv__(self, child: StrPath) -> "Traversable": """ return self.joinpath(child) + @overload + def open(self, mode: Literal['r'] = 'r', *args: Any, **kwargs: Any) -> TextIO: ... + + @overload + def open(self, mode: Literal['rb'], *args: Any, **kwargs: Any) -> BinaryIO: ... + @abc.abstractmethod - def open(self, mode='r', *args, **kwargs): + def open( + self, mode: str = 'r', *args: Any, **kwargs: Any + ) -> Union[TextIO, BinaryIO]: """ mode may be 'r' or 'rb' to open as text or binary. Return a handle suitable for reading (same as pathlib.Path.open). @@ -160,7 +180,7 @@ class TraversableResources(ResourceReader): def files(self) -> "Traversable": """Return a Traversable object for the loaded package.""" - def open_resource(self, resource: StrPath) -> io.BufferedReader: + def open_resource(self, resource: StrPath) -> BinaryIO: return self.files().joinpath(resource).open('rb') def resource_path(self, resource: Any) -> NoReturn: diff --git a/Lib/importlib/resources/readers.py b/Lib/importlib/resources/readers.py index 70fc7e2b9c0145..5d0ae46d672f53 100644 --- a/Lib/importlib/resources/readers.py +++ b/Lib/importlib/resources/readers.py @@ -3,15 +3,14 @@ import collections import contextlib import itertools -import pathlib import operator +import pathlib import re import warnings import zipfile from collections.abc import Iterator from . import abc - from ._itertools import only diff --git a/Lib/test/test_importlib/resources/_path.py b/Lib/test/test_importlib/resources/_path.py index b144628cb73c77..0033983dc66286 100644 --- a/Lib/test/test_importlib/resources/_path.py +++ b/Lib/test/test_importlib/resources/_path.py @@ -1,10 +1,6 @@ -import pathlib import functools - -from typing import Dict, Union -from typing import runtime_checkable -from typing import Protocol - +import pathlib +from typing import Dict, Protocol, Union, runtime_checkable #### # from jaraco.path 3.7.1 diff --git a/Lib/test/test_importlib/resources/test_compatibilty_files.py b/Lib/test/test_importlib/resources/test_compatibilty_files.py index bcf608d9e2cbdf..113f9ae6fdb20d 100644 --- a/Lib/test/test_importlib/resources/test_compatibilty_files.py +++ b/Lib/test/test_importlib/resources/test_compatibilty_files.py @@ -1,8 +1,6 @@ +import importlib.resources as resources import io import unittest - -from importlib import resources - from importlib.resources._adapters import ( CompatibilityFiles, wrap_spec, diff --git a/Lib/test/test_importlib/resources/test_contents.py b/Lib/test/test_importlib/resources/test_contents.py index 4e4e0e9c337f23..bdc158d85a239f 100644 --- a/Lib/test/test_importlib/resources/test_contents.py +++ b/Lib/test/test_importlib/resources/test_contents.py @@ -1,5 +1,5 @@ +import importlib.resources as resources import unittest -from importlib import resources from . import util diff --git a/Lib/test/test_importlib/resources/test_custom.py b/Lib/test/test_importlib/resources/test_custom.py index 640f90fc0dd91a..a7fc6bc35c5ece 100644 --- a/Lib/test/test_importlib/resources/test_custom.py +++ b/Lib/test/test_importlib/resources/test_custom.py @@ -1,12 +1,12 @@ -import unittest import contextlib +import importlib.resources as resources import pathlib +import unittest +from importlib.resources import abc +from importlib.resources.abc import ResourceReader, TraversableResources from test.support import os_helper -from importlib import resources -from importlib.resources import abc -from importlib.resources.abc import TraversableResources, ResourceReader from . import util diff --git a/Lib/test/test_importlib/resources/test_files.py b/Lib/test/test_importlib/resources/test_files.py index 3ce44999f98ee5..8644de48a71d6a 100644 --- a/Lib/test/test_importlib/resources/test_files.py +++ b/Lib/test/test_importlib/resources/test_files.py @@ -1,15 +1,16 @@ +import contextlib +import importlib +import importlib.resources as resources import pathlib import py_compile import textwrap import unittest import warnings -import importlib -import contextlib - -from importlib import resources from importlib.resources.abc import Traversable + +from test.support import import_helper, os_helper + from . import util -from test.support import os_helper, import_helper @contextlib.contextmanager @@ -70,7 +71,7 @@ def test_non_paths_in_dunder_path(self): to cause the ``PathEntryFinder`` to be called when searching for packages. In that case, resources should still be loadable. """ - import namespacedata01 + import namespacedata01 # type: ignore[import-not-found] namespacedata01.__path__.append( '__editable__.sample_namespace-1.0.finder.__path_hook__' @@ -161,7 +162,9 @@ def _compile_importlib(self): sources = pathlib.Path(resources.__file__).parent for source_path in sources.glob('**/*.py'): - c_path = c_resources.joinpath(source_path.relative_to(sources)).with_suffix('.pyc') + c_path = c_resources.joinpath(source_path.relative_to(sources)).with_suffix( + '.pyc' + ) py_compile.compile(source_path, c_path) self.fixtures.enter_context(import_helper.DirsOnSysPath(bin_site)) diff --git a/Lib/test/test_importlib/resources/test_functional.py b/Lib/test/test_importlib/resources/test_functional.py index e8d25fa4d9faf0..9e1a3a0e2767e3 100644 --- a/Lib/test/test_importlib/resources/test_functional.py +++ b/Lib/test/test_importlib/resources/test_functional.py @@ -1,17 +1,12 @@ -import unittest -import os import importlib +import importlib.resources as resources +import os +import unittest from test.support import warnings_helper -from importlib import resources - from . import util -# Since the functional API forwards to Traversable, we only test -# filesystem resources here -- not zip files, namespace packages etc. -# We do test for two kinds of Anchor, though. - class StringAnchorMixin: anchor01 = 'data01' @@ -28,7 +23,7 @@ def anchor02(self): return importlib.import_module('data02') -class FunctionalAPIBase(util.DiskSetup): +class FunctionalAPIBase: def setUp(self): super().setUp() self.load_fixture('data02') @@ -43,6 +38,12 @@ def _gen_resourcetxt_path_parts(self): with self.subTest(path_parts=path_parts): yield path_parts + def assertEndsWith(self, string, suffix): + """Assert that `string` ends with `suffix`. + + Used to ignore an architecture-specific UTF-16 byte-order mark.""" + self.assertEqual(string[-len(suffix) :], suffix) + def test_read_text(self): self.assertEqual( resources.read_text(self.anchor01, 'utf-8.file'), @@ -71,7 +72,7 @@ def test_read_text(self): # fail with PermissionError rather than IsADirectoryError with self.assertRaises(OSError): resources.read_text(self.anchor01) - with self.assertRaises(OSError): + with self.assertRaises((OSError, resources.abc.TraversalError)): resources.read_text(self.anchor01, 'no-such-file') with self.assertRaises(UnicodeDecodeError): resources.read_text(self.anchor01, 'utf-16.file') @@ -119,7 +120,7 @@ def test_open_text(self): # fail with PermissionError rather than IsADirectoryError with self.assertRaises(OSError): resources.open_text(self.anchor01) - with self.assertRaises(OSError): + with self.assertRaises((OSError, resources.abc.TraversalError)): resources.open_text(self.anchor01, 'no-such-file') with resources.open_text(self.anchor01, 'utf-16.file') as f: with self.assertRaises(UnicodeDecodeError): @@ -176,17 +177,23 @@ def test_contents(self): set(c), {'utf-8.file', 'utf-16.file', 'binary.file', 'subdirectory'}, ) - with self.assertRaises(OSError), warnings_helper.check_warnings(( - ".*contents.*", - DeprecationWarning, - )): + with ( + self.assertRaises(OSError), + warnings_helper.check_warnings(( + ".*contents.*", + DeprecationWarning, + )), + ): list(resources.contents(self.anchor01, 'utf-8.file')) for path_parts in self._gen_resourcetxt_path_parts(): - with self.assertRaises(OSError), warnings_helper.check_warnings(( - ".*contents.*", - DeprecationWarning, - )): + with ( + self.assertRaises((OSError, resources.abc.TraversalError)), + warnings_helper.check_warnings(( + ".*contents.*", + DeprecationWarning, + )), + ): list(resources.contents(self.anchor01, *path_parts)) with warnings_helper.check_warnings((".*contents.*", DeprecationWarning)): c = resources.contents(self.anchor01, 'subdirectory') @@ -233,17 +240,28 @@ def test_text_errors(self): ) -class FunctionalAPITest_StringAnchor( +class FunctionalAPITest_StringAnchor_Disk( StringAnchorMixin, FunctionalAPIBase, + util.DiskSetup, unittest.TestCase, ): pass -class FunctionalAPITest_ModuleAnchor( +class FunctionalAPITest_ModuleAnchor_Disk( ModuleAnchorMixin, FunctionalAPIBase, + util.DiskSetup, + unittest.TestCase, +): + pass + + +class FunctionalAPITest_StringAnchor_Memory( + StringAnchorMixin, + FunctionalAPIBase, + util.MemorySetup, unittest.TestCase, ): pass diff --git a/Lib/test/test_importlib/resources/test_open.py b/Lib/test/test_importlib/resources/test_open.py index 8c00378ad3cc9c..b5a8949d52e532 100644 --- a/Lib/test/test_importlib/resources/test_open.py +++ b/Lib/test/test_importlib/resources/test_open.py @@ -1,6 +1,6 @@ +import importlib.resources as resources import unittest -from importlib import resources from . import util diff --git a/Lib/test/test_importlib/resources/test_path.py b/Lib/test/test_importlib/resources/test_path.py index 903911f57b3306..3d158d95b5023a 100644 --- a/Lib/test/test_importlib/resources/test_path.py +++ b/Lib/test/test_importlib/resources/test_path.py @@ -1,8 +1,8 @@ +import importlib.resources as resources import io import pathlib import unittest -from importlib import resources from . import util @@ -20,7 +20,7 @@ def test_reading(self): target = resources.files(self.data) / 'utf-8.file' with resources.as_file(target) as path: self.assertIsInstance(path, pathlib.Path) - self.assertEndsWith(path.name, "utf-8.file") + self.assertTrue(path.name.endswith("utf-8.file"), repr(path)) self.assertEqual('Hello, UTF-8 world!\n', path.read_text(encoding='utf-8')) diff --git a/Lib/test/test_importlib/resources/test_read.py b/Lib/test/test_importlib/resources/test_read.py index 59c237d964121e..cd1cc6dd86ff47 100644 --- a/Lib/test/test_importlib/resources/test_read.py +++ b/Lib/test/test_importlib/resources/test_read.py @@ -1,6 +1,6 @@ +import importlib.resources as resources import unittest - -from importlib import import_module, resources +from importlib import import_module from . import util diff --git a/Lib/test/test_importlib/resources/test_reader.py b/Lib/test/test_importlib/resources/test_reader.py index ed5693ab416798..cf23f38f3aaac5 100644 --- a/Lib/test/test_importlib/resources/test_reader.py +++ b/Lib/test/test_importlib/resources/test_reader.py @@ -1,9 +1,8 @@ import os.path import pathlib import unittest - from importlib import import_module -from importlib.readers import MultiplexedPath, NamespaceReader +from importlib.resources.readers import MultiplexedPath, NamespaceReader from . import util diff --git a/Lib/test/test_importlib/resources/test_resource.py b/Lib/test/test_importlib/resources/test_resource.py index fcede14b891a84..ef69cd049d9b4c 100644 --- a/Lib/test/test_importlib/resources/test_resource.py +++ b/Lib/test/test_importlib/resources/test_resource.py @@ -1,7 +1,8 @@ +import importlib.resources as resources import unittest +from importlib import import_module from . import util -from importlib import resources, import_module class ResourceTests: diff --git a/Lib/test/test_importlib/resources/test_util.py b/Lib/test/test_importlib/resources/test_util.py new file mode 100644 index 00000000000000..de304b6f3510a6 --- /dev/null +++ b/Lib/test/test_importlib/resources/test_util.py @@ -0,0 +1,29 @@ +import unittest + +from .util import MemorySetup, Traversable + + +class TestMemoryTraversableImplementation(unittest.TestCase): + def test_concrete_methods_are_not_overridden(self): + """`MemoryTraversable` must not override `Traversable` concrete methods. + + This test is not an attempt to enforce a particular `Traversable` protocol; + it merely catches changes in the `Traversable` abstract/concrete methods + that have not been mirrored in the `MemoryTraversable` subclass. + """ + + traversable_concrete_methods = { + method + for method, value in Traversable.__dict__.items() + if callable(value) and method not in Traversable.__abstractmethods__ + } + memory_traversable_concrete_methods = { + method + for method, value in MemorySetup.MemoryTraversable.__dict__.items() + if callable(value) and not method.startswith("__") + } + overridden_methods = ( + memory_traversable_concrete_methods & traversable_concrete_methods + ) + + assert not overridden_methods diff --git a/Lib/test/test_importlib/resources/util.py b/Lib/test/test_importlib/resources/util.py index e2d995f596317d..d6a99289906e35 100644 --- a/Lib/test/test_importlib/resources/util.py +++ b/Lib/test/test_importlib/resources/util.py @@ -1,18 +1,18 @@ import abc +import contextlib +import functools import importlib import io +import pathlib import sys import types -import pathlib -import contextlib +from importlib.machinery import ModuleSpec +from importlib.resources.abc import ResourceReader, Traversable, TraversableResources -from importlib.resources.abc import ResourceReader from test.support import import_helper, os_helper -from . import zip as zip_ -from . import _path - -from importlib.machinery import ModuleSpec +from . import _path +from . import zip as zip_ class Reader(ResourceReader): @@ -202,5 +202,108 @@ def tree_on_path(self, spec): self.fixtures.enter_context(import_helper.DirsOnSysPath(temp_dir)) +class MemorySetup(ModuleSetup): + """Support loading a module in memory.""" + + MODULE = 'data01' + + def load_fixture(self, module): + self.fixtures.enter_context(self.augment_sys_metapath(module)) + return importlib.import_module(module) + + @contextlib.contextmanager + def augment_sys_metapath(self, module): + finder_instance = self.MemoryFinder(module) + sys.meta_path.append(finder_instance) + yield + sys.meta_path.remove(finder_instance) + + class MemoryFinder(importlib.abc.MetaPathFinder): + def __init__(self, module): + self._module = module + + def find_spec(self, fullname, path, target=None): + if fullname != self._module: + return None + + return importlib.machinery.ModuleSpec( + name=fullname, + loader=MemorySetup.MemoryLoader(self._module), + is_package=True, + ) + + class MemoryLoader(importlib.abc.Loader): + def __init__(self, module): + self._module = module + + def exec_module(self, module): + pass + + def get_resource_reader(self, fullname): + return MemorySetup.MemoryTraversableResources(self._module, fullname) + + class MemoryTraversableResources(TraversableResources): + def __init__(self, module, fullname): + self._module = module + self._fullname = fullname + + def files(self): + return MemorySetup.MemoryTraversable(self._module, self._fullname) + + class MemoryTraversable(Traversable): + """Implement only the abstract methods of `Traversable`. + + Besides `.__init__()`, no other methods may be implemented or overridden. + This is critical for validating the concrete `Traversable` implementations. + """ + + def __init__(self, module, fullname): + self._module = module + self._fullname = fullname + + def _resolve(self): + """ + Fully traverse the `fixtures` dictionary. + + This should be wrapped in a `try/except KeyError` + but it is not currently needed and lowers the code coverage numbers. + """ + path = pathlib.PurePosixPath(self._fullname) + return functools.reduce(lambda d, p: d[p], path.parts, fixtures) + + def iterdir(self): + directory = self._resolve() + if not isinstance(directory, dict): + # Filesystem openers raise OSError, and that exception is mirrored here. + raise OSError(f"{self._fullname} is not a directory") + for path in directory: + yield MemorySetup.MemoryTraversable( + self._module, f"{self._fullname}/{path}" + ) + + def is_dir(self) -> bool: + return isinstance(self._resolve(), dict) + + def is_file(self) -> bool: + return not self.is_dir() + + def open(self, mode='r', encoding=None, errors=None, *_, **__): + contents = self._resolve() + if isinstance(contents, dict): + # Filesystem openers raise OSError when attempting to open a directory, + # and that exception is mirrored here. + raise OSError(f"{self._fullname} is a directory") + if isinstance(contents, str): + contents = contents.encode("utf-8") + result = io.BytesIO(contents) + if "b" in mode: + return result + return io.TextIOWrapper(result, encoding=encoding, errors=errors) + + @property + def name(self): + return pathlib.PurePosixPath(self._fullname).name + + class CommonTests(DiskSetup, CommonTestsBase): pass From 1932dfec5f89c20114a82f218d2ffe70e4c6ef64 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 11 Apr 2026 17:42:27 -0400 Subject: [PATCH 2/3] Add blurb --- .../next/Library/2026-04-11-17-28-06.gh-issue-127012.h3rLYS.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-04-11-17-28-06.gh-issue-127012.h3rLYS.rst diff --git a/Misc/NEWS.d/next/Library/2026-04-11-17-28-06.gh-issue-127012.h3rLYS.rst b/Misc/NEWS.d/next/Library/2026-04-11-17-28-06.gh-issue-127012.h3rLYS.rst new file mode 100644 index 00000000000000..6ee7b8b8065945 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-04-11-17-28-06.gh-issue-127012.h3rLYS.rst @@ -0,0 +1,2 @@ +:method:`importlib.abc.Traversable.read_text` now allows/solicits an +``errors`` parameter. From 0aa8226a0ed37b1e0eebf2b3164e9da109181fa0 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 11 Apr 2026 17:56:17 -0400 Subject: [PATCH 3/3] Remove directive; I'm unsure the syntax. --- .../next/Library/2026-04-11-17-28-06.gh-issue-127012.h3rLYS.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2026-04-11-17-28-06.gh-issue-127012.h3rLYS.rst b/Misc/NEWS.d/next/Library/2026-04-11-17-28-06.gh-issue-127012.h3rLYS.rst index 6ee7b8b8065945..eafefb8a6c0cf5 100644 --- a/Misc/NEWS.d/next/Library/2026-04-11-17-28-06.gh-issue-127012.h3rLYS.rst +++ b/Misc/NEWS.d/next/Library/2026-04-11-17-28-06.gh-issue-127012.h3rLYS.rst @@ -1,2 +1,2 @@ -:method:`importlib.abc.Traversable.read_text` now allows/solicits an +``importlib.abc.Traversable.read_text`` now allows/solicits an ``errors`` parameter.