Skip to content

importlib.abc.Traversable.read_text() incompatible with importlib.resources._functional.read_text() usage (Python 3.13) #127012

Description

@kurtmckee

Bug report

Bug description:

I'm writing a custom importer and discovered that the function signature for importlib.abc.Traversable.read_text() is incompatible with the usage in importlib.resources._functional.read_text(), specifically on Python 3.13.

  1. importlib.abc.Traversable.read_text() is a concrete method; its implementation calls the .open() method, which is an abstract method that must be implemented. The expectation is therefore that implementing .open() in a Traversable subclass is sufficient for .read_text() to work.

    Note below that the .read_text() method is not marked as abstract, and includes only one parameter: encoding:

    def read_text(self, encoding: Optional[str] = None) -> str:
    """
    Read contents of self as text
    """
    with self.open(encoding=encoding) as strm:
    return strm.read()

  2. Application code that attempts to read a package resource, like importlib.resources.read_text(module, "resource.txt") ultimately leads to a call to importlib.resources._functional.read_text(), which attempts to call the .read_text() method of a Traversable subclass, but includes an errors parameter that doesn't exist in Traversable's default concrete method:

    def read_text(anchor, *path_names, encoding=_MISSING, errors='strict'):
    """Read and return contents of *resource* within *package* as str."""
    encoding = _get_encoding_arg(path_names, encoding)
    resource = _get_resource(anchor, path_names)
    return resource.read_text(encoding=encoding, errors=errors)

  3. Consequently, it appears to be necessary for all Traversable subclasses to not only re-implement its concrete .read_text() method, but also to override its signature.

I think that the Traversable .read_text() method signature, and the call site in importlib.resources._functional.read_text(), need to align with each other.

I'd like to submit a PR for this! However, I would like confirmation that an errors parameter should be added to the Traversable.read_text() method.

Note that adding an errors parameter was previously discussed in #88368.

Demonstration of TypeError bug

import io
import sys
import typing
import pathlib
import types
import importlib.abc
import importlib.machinery
import importlib.metadata
import importlib.resources.abc


class ExampleFinder(importlib.abc.MetaPathFinder):
    def find_spec(
        self,
        fullname: str,
        path: typing.Sequence[str] | None,
        target: types.ModuleType | None = None,
    ) -> importlib.machinery.ModuleSpec | None:
        if fullname != "demonstrate_error":
            return None

        print(f"ExampleFinder.find_spec('{fullname}')")
        spec = importlib.machinery.ModuleSpec(
            name=fullname,
            loader=ExampleLoader(),
            is_package=True,
        )
        return spec


sys.meta_path.append(ExampleFinder())


class ExampleLoader(importlib.abc.Loader):
    def exec_module(self, module: types.ModuleType) -> None:
        print(f"ExampleLoader.exec_module({module})")
        exec("", module.__dict__)

    def get_resource_reader(self, fullname: str) -> "ExampleTraversableResources":
        print(f"ExampleLoader.get_resource_reader('{fullname}')")
        return ExampleTraversableResources(fullname)


class ExampleTraversableResources(importlib.resources.abc.TraversableResources):
    def __init__(self, fullname: str) -> None:
        self.fullname = fullname

    def files(self) -> "ExampleTraversable":
        print("ExampleTraversableResources.files()")
        return ExampleTraversable(self.fullname)


# ----------------------------------------------------------------------------
# ExampleTraversable implements all five of the Traversable abstract methods.
# Specifically, it is expected that implementing `.open()` will be sufficient,
# but this will not be the case.
#

class ExampleTraversable(importlib.resources.abc.Traversable):
    def __init__(self, path: str):
        self._path = path

    def iterdir(self) -> typing.Iterator["ExampleTraversable"]:
        yield ExampleTraversable("resource.txt")

    def is_dir(self) -> bool:
        return False

    def is_file(self) -> bool:
        return True

    def open(self, mode='r', *args, **kwargs) -> typing.IO[typing.AnyStr]:
        return io.StringIO("Nice! The call to .read_text() succeeded!")

    # Uncomment this `.read_text()` method to make `.read_text()` calls work.
    # It overrides the `Traversable.read_text()` signature.
    #
    # def read_text(self, encoding: str | None, errors: str | None) -> str:
    #     print(f"ExampleTraversable.read_text('{encoding}', '{errors}')")
    #     return str(super().read_text(encoding))

    @property
    def name(self) -> str:
        return pathlib.PurePosixPath(self._path).name


# -------------------------------------------------------------------------------
# Everything above allows us to import this hard-coded module
# and demonstrate a TypeError lurking in the Traversable.read_text() signature.
#

import demonstrate_error


# The next line will raise a TypeError.
# `importlib/resources/_functional.py:read_text()` calls `Traversable.read_text()`
# with an `errors` argument that is not supported by the default concrete method.
print(importlib.resources.read_text(demonstrate_error, "resource.txt"))

CPython versions tested on:

3.13

Operating systems tested on:

Linux

Linked PRs

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

stdlibStandard Library Python modules in the Lib/ directorytopic-importlibtype-bugAn unexpected behavior, bug, or error

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions