]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-127012: `Traversable.read_text` now allows/solicits an `errors` parameter. (#148401)
authorJason R. Coombs <jaraco@jaraco.com>
Sat, 11 Apr 2026 22:25:20 +0000 (18:25 -0400)
committerGitHub <noreply@github.com>
Sat, 11 Apr 2026 22:25:20 +0000 (22:25 +0000)
Applies changes from importlib_resources 6.5.2.

19 files changed:
Lib/importlib/resources/__init__.py
Lib/importlib/resources/_common.py
Lib/importlib/resources/_functional.py
Lib/importlib/resources/abc.py
Lib/importlib/resources/readers.py
Lib/test/test_importlib/resources/_path.py
Lib/test/test_importlib/resources/test_compatibilty_files.py
Lib/test/test_importlib/resources/test_contents.py
Lib/test/test_importlib/resources/test_custom.py
Lib/test/test_importlib/resources/test_files.py
Lib/test/test_importlib/resources/test_functional.py
Lib/test/test_importlib/resources/test_open.py
Lib/test/test_importlib/resources/test_path.py
Lib/test/test_importlib/resources/test_read.py
Lib/test/test_importlib/resources/test_reader.py
Lib/test/test_importlib/resources/test_resource.py
Lib/test/test_importlib/resources/test_util.py [new file with mode: 0644]
Lib/test/test_importlib/resources/util.py
Misc/NEWS.d/next/Library/2026-04-11-17-28-06.gh-issue-127012.h3rLYS.rst [new file with mode: 0644]

index 723c9f9eb33ce1e5f7fb5a70c5c9d62628a93521..27d6c7f89307efe0eb4aa5dcca2b0f32c45fca96 100644 (file)
@@ -8,12 +8,11 @@ for more detail.
 """
 
 from ._common import (
+    Anchor,
+    Package,
     as_file,
     files,
-    Package,
-    Anchor,
 )
-
 from ._functional import (
     contents,
     is_resource,
@@ -23,10 +22,8 @@ from ._functional import (
     read_binary,
     read_text,
 )
-
 from .abc import ResourceReader
 
-
 __all__ = [
     'Package',
     'Anchor',
index d16ebe4520fbbfd5f2c80747900dfcca639b098a..40eec742aeb70a5d5d37959bf29d9f81d7367a84 100644 (file)
@@ -1,14 +1,14 @@
-import os
-import pathlib
-import tempfile
-import functools
 import contextlib
-import types
+import functools
 import importlib
 import inspect
 import itertools
+import os
+import pathlib
+import tempfile
+import types
+from typing import cast, Optional, Union
 
-from typing import Union, Optional, cast
 from .abc import ResourceReader, Traversable
 
 Package = Union[types.ModuleType, str]
@@ -32,7 +32,7 @@ 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[union-attr]
+    reader = getattr(spec.loader, "get_resource_reader", None)  # type: ignore[union-attr]
     if reader is None:
         return None
     return reader(spec.name)  # type: ignore[union-attr]
@@ -50,7 +50,7 @@ def _(cand: str) -> types.ModuleType:
 
 @resolve.register
 def _(cand: None) -> types.ModuleType:
-    return resolve(_infer_caller().f_globals['__name__'])
+    return resolve(_infer_caller().f_globals["__name__"])
 
 
 def _infer_caller():
@@ -62,7 +62,7 @@ def _infer_caller():
         return frame_info.filename == stack[0].filename
 
     def is_wrapper(frame_info):
-        return frame_info.function == 'wrapper'
+        return frame_info.function == "wrapper"
 
     stack = inspect.stack()
     not_this_file = itertools.filterfalse(is_this_file, stack)
@@ -87,7 +87,7 @@ def from_package(package: types.ModuleType):
 @contextlib.contextmanager
 def _tempfile(
     reader,
-    suffix='',
+    suffix="",
     # gh-93353: Keep a reference to call os.remove() in late Python
     # finalization.
     *,
index f59416f2dd627d560b3e393775cc5be14c578370..b08a5c6efe22a234914ed33bdaa1ee0e2432e478 100644 (file)
@@ -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):
index 6750a7aaf14aa9695fa89d4f8bb43b4be13d9fa5..64a6d843dce98e51ac6dcb854aab4ba555f3fbed 100644 (file)
@@ -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 @@ class Traversable(Protocol):
         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 @@ class Traversable(Protocol):
         """
         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:
index 70fc7e2b9c0145b003e4be8d3723b01e31bd52db..5d0ae46d672f53dc65ef3fed47d50c677dc45b89 100644 (file)
@@ -3,15 +3,14 @@ from __future__ import annotations
 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
 
 
index b144628cb73c77d7ee750dc32aab4d5a9d7b1f2f..0033983dc6628682481023ce3e15b64171423ba8 100644 (file)
@@ -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
index bcf608d9e2cbdfd1b2ba6eaec4202b19af82115e..113f9ae6fdb20dad15fb6f75b2c2dc8ee0660e57 100644 (file)
@@ -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,
index 4e4e0e9c337f230ee31790d3f980ebaa205f0ee3..bdc158d85a239fca018efea213888e2835f1e49f 100644 (file)
@@ -1,5 +1,5 @@
+import importlib.resources as resources
 import unittest
-from importlib import resources
 
 from . import util
 
index 640f90fc0dd91a353cbef31ad7339288d9a52e41..a7fc6bc35c5eceaacc1da585a05fce7d3f33fa26 100644 (file)
@@ -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
 
 
index c935b1e10ac87c6f6090dec5faf00260ffc839c3..abb5bf36e68c9f308529ce7578a76b709891fdb0 100644 (file)
@@ -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
@@ -62,7 +63,7 @@ class OpenNamespaceTests(FilesTests, util.DiskSetup, unittest.TestCase):
         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__'
@@ -153,7 +154,9 @@ class ImplicitContextFiles:
         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))
 
index e8d25fa4d9faf033587cf2d93e1d6cb574ac89c1..9e1a3a0e2767e337b7ab25bd1707f8da35284662 100644 (file)
@@ -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 @@ class ModuleAnchorMixin:
         return importlib.import_module('data02')
 
 
-class FunctionalAPIBase(util.DiskSetup):
+class FunctionalAPIBase:
     def setUp(self):
         super().setUp()
         self.load_fixture('data02')
@@ -43,6 +38,12 @@ class FunctionalAPIBase(util.DiskSetup):
             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 @@ class FunctionalAPIBase(util.DiskSetup):
         # 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 @@ class FunctionalAPIBase(util.DiskSetup):
         # 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 @@ class FunctionalAPIBase(util.DiskSetup):
             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 @@ class FunctionalAPIBase(util.DiskSetup):
                     )
 
 
-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
index 8c00378ad3cc9cdaa80008a80a645cf2dec6060b..b5a8949d52e532be56e7bd2792b21daff6d5c8db 100644 (file)
@@ -1,6 +1,6 @@
+import importlib.resources as resources
 import unittest
 
-from importlib import resources
 from . import util
 
 
index 903911f57b330662e6cfc028d1fb2009f23b6468..3d158d95b5023a89faffe1657f6ad285ed1bca2f 100644 (file)
@@ -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 @@ class PathTests:
         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'))
 
 
index 59c237d964121e55afc2bfff43c0c48fcfa9eb7a..cd1cc6dd86ff474064e78ba6a91eab17fe54f0ef 100644 (file)
@@ -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
 
index ed5693ab4167988ecb8a07c7d9175a0fdbd6301b..cf23f38f3aaac585a7a6261e80bc01e8b2561772 100644 (file)
@@ -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
 
index fcede14b891a84fbf4b332919c0fca5b5d2ab971..ef69cd049d9b4c2055f6d236416c9d5a8d3fd9f2 100644 (file)
@@ -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 (file)
index 0000000..de304b6
--- /dev/null
@@ -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
index e2d995f596317d00eff850dec90b77467d08be85..d6a99289906e35104b43e003679559026f119b31 100644 (file)
@@ -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 @@ class DiskSetup(ModuleSetup):
         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
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 (file)
index 0000000..eafefb8
--- /dev/null
@@ -0,0 +1,2 @@
+``importlib.abc.Traversable.read_text`` now allows/solicits an
+``errors`` parameter.