From bdc32099d08a95bed214b1c2f7505832dcc6a27f Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Thu, 7 Mar 2024 15:39:11 +0100 Subject: [PATCH 1/9] gh-116608: Bring back importlib.resources functional API --- Doc/library/importlib.resources.rst | 161 ++++++++++++++++ Doc/whatsnew/3.13.rst | 39 ++-- Lib/importlib/resources/__init__.py | 17 ++ Lib/importlib/resources/functional.py | 78 ++++++++ .../resources/test_functional.py | 177 ++++++++++++++++++ ...-03-11-17-04-55.gh-issue-116608.30f58-.rst | 8 + 6 files changed, 465 insertions(+), 15 deletions(-) create mode 100644 Lib/importlib/resources/functional.py create mode 100644 Lib/test/test_importlib/resources/test_functional.py create mode 100644 Misc/NEWS.d/next/Library/2024-03-11-17-04-55.gh-issue-116608.30f58-.rst diff --git a/Doc/library/importlib.resources.rst b/Doc/library/importlib.resources.rst index a5adf0b8546dbf..1ca7ffc16e31f1 100644 --- a/Doc/library/importlib.resources.rst +++ b/Doc/library/importlib.resources.rst @@ -97,3 +97,164 @@ for example, a package and its resources can be imported from a zip file using .. versionchanged:: 3.12 Added support for *traversable* representing a directory. + + +.. _importlib_resources_functional: + +Functional API +^^^^^^^^^^^^^^ + +A set of simplified, backwards-compatible helpers is available. +These allow common operations in a single function call. + +For all the following functions: + +- *anchor* is an :class:`~importlib.resources.Anchor`, + as in :func:`~importlib.resources.files`. + Unlike in ``files``, it may not be omitted. + +- *path_names* are components of a resource's path name, relative to + the anchor. + The individual components may not contain path separators. + For example, to get the text of resource named ``info.txt``, use:: + + importlib.resources.read_text(my_module, "info.txt") + + To get the text of ``info/chapter1.txt``, use:: + + importlib.resources.read_text(my_module, "info", "chapter1.txt") + + +.. function:: open_binary(anchor, *path_names) + + Open the named resource for binary reading. + + See :ref:`the introduction ` for + details on *anchor* and *path_names*. + + This function returns a :class:`~typing.BinaryIO` object, + that is, a binary stream open for reading. + + For a single path name *name*, this function is roughly equivalent to:: + + files(anchor).joinpath(name).open('rb') + + .. versionchanged:: 3.13 + Multiple *path_names* are accepted. + + +.. function:: open_text(anchor, *path_names, encoding='utf-8', errors='strict') + + Open the named resource for text reading. + By default, the contents are read as strict UTF-8. + + See :ref:`the introduction ` for + details on *anchor* and *path_names*. + *encoding* and *errors* have the same meaning as in built-in :func:`open`. + + This function returns a :class:`~typing.TextIO` object, + that is, a text stream open for reading. + + For a single path name *name*, this function is roughly equivalent to:: + + files(anchor).joinpath(name).open('r', encoding=encoding) + + .. versionchanged:: 3.13 + Multiple *path_names* are accepted. + *encoding* and *errors* must be given as keyword arguments. + + +.. function:: read_binary(anchor, *path_names) + + Read and return the contents of the named resource as :class:`bytes`. + + See :ref:`the introduction ` for + details on *anchor* and *path_names*. + + For a single path name *name*, this function is roughly equivalent to:: + + files(anchor).joinpath(name).read_bytes() + + .. versionchanged:: 3.13 + Multiple *path_names* are accepted. + + +.. function:: read_text(anchor, *path_names, encoding='utf-8', errors='strict') + + Read and return the contents of the named resource as :class:`str`. + By default, the contents are read as strict UTF-8. + + See :ref:`the introduction ` for + details on *anchor* and *path_names*. + *encoding* and *errors* have the same meaning as in built-in :func:`open`. + + For a single path name *name*, this function is roughly equivalent to:: + + files(anchor).joinpath(name).read_text(encoding=encoding) + + .. versionchanged:: 3.13 + Multiple *path_names* are accepted. + *encoding* and *errors* must be given as keyword arguments. + + +.. function:: path(anchor, *path_names) + + Provides the path to the *resource* as an actual file system path. This + function returns a context manager for use in a :keyword:`with` statement. + The context manager provides a :class:`pathlib.Path` object. + + Exiting the context manager cleans up any temporary files created, e.g. + when the resource needs to be extracted from a zip file. + + For example, the :meth:`~pathlib.Path.stat` method requires + an actual file system path; it can be used like this:: + + with importlib.resources.path(anchor, "resource.txt") as fspath: + result = fspath.stat() + + See :ref:`the introduction ` for + details on *anchor* and *path_names*. + + For a single path name *name*, this function is roughly equivalent to:: + + as_file(files(anchor).joinpath(name)) + + .. versionchanged:: 3.13 + Multiple *path_names* are accepted. + *encoding* and *errors* must be given as keyword arguments. + + +.. function:: is_resource(anchor, *path_names) + + Return ``True`` if the named resource exists, otherwise ``False``. + This function does not consider directories to be resources. + + See :ref:`the introduction ` for + details on *anchor* and *path_names*. + + For a single path name *name*, this function is roughly equivalent to:: + + files(anchor).joinpath(name).is_file() + + .. versionchanged:: 3.13 + Multiple *path_names* are accepted. + + +.. function:: contents(anchor, *path_names) + + Return an iterable over the named items within the package or path. + The iterable returns names of resources (e.g. files) and non-resources + (e.g. directories) as :class:`str`. + The iterable does not recurse into subdirectories. + + See :ref:`the introduction ` for + details on *anchor* and *path_names*. + + For a single path name *name*, this function is roughly equivalent to:: + + for resource in files(anchor).joinpath(name).iterdir(): + yield resource.name + + .. deprecated:: 3.11 + Prefer ``iterdir()`` as above, which offers more control over the + results and richer functionality. diff --git a/Doc/whatsnew/3.13.rst b/Doc/whatsnew/3.13.rst index 51939909000960..fa2dbb65cb01f7 100644 --- a/Doc/whatsnew/3.13.rst +++ b/Doc/whatsnew/3.13.rst @@ -361,6 +361,30 @@ and only logged in :ref:`Python Development Mode ` or on :ref:`Python built on debug mode `. (Contributed by Victor Stinner in :gh:`62948`.) +importlib +--------- + +Previously deprecated :mod:`importlib.resources` functions are un-deprecated: + + * :func:`~importlib.resources.is_resource()` + * :func:`~importlib.resources.open_binary()` + * :func:`~importlib.resources.open_text()` + * :func:`~importlib.resources.path()` + * :func:`~importlib.resources.read_binary()` + * :func:`~importlib.resources.read_text()` + +All now allow for a directory (or tree) of resources, using multiple positional +arguments. + +For text-reading functions, the *encoding* and *errors* must now be given as +keyword arguments. + +The :func:`~importlib.resources.contents()` remains deprecated in favor of +the full-featured :class:`~importlib.resources.Traversable` API. +However, there is now no plan to remove it. + +(Contributed by Petr Viktorin in :gh:`106532`.) + ipaddress --------- @@ -1263,21 +1287,6 @@ configparser importlib --------- -* Remove :mod:`importlib.resources` deprecated methods: - - * ``contents()`` - * ``is_resource()`` - * ``open_binary()`` - * ``open_text()`` - * ``path()`` - * ``read_binary()`` - * ``read_text()`` - - Use :func:`importlib.resources.files()` instead. Refer to `importlib-resources: Migrating from Legacy - `_ - for migration advice. - (Contributed by Jason R. Coombs in :gh:`106532`.) - * Remove deprecated :meth:`~object.__getitem__` access for :class:`!importlib.metadata.EntryPoint` objects. (Contributed by Jason R. Coombs in :gh:`113175`.) diff --git a/Lib/importlib/resources/__init__.py b/Lib/importlib/resources/__init__.py index ae83cd07c4d4fb..0d029abd63adc2 100644 --- a/Lib/importlib/resources/__init__.py +++ b/Lib/importlib/resources/__init__.py @@ -7,6 +7,16 @@ Anchor, ) +from .functional import ( + contents, + is_resource, + open_binary, + open_text, + path, + read_binary, + read_text, +) + from .abc import ResourceReader @@ -16,4 +26,11 @@ 'ResourceReader', 'as_file', 'files', + 'contents', + 'is_resource', + 'open_binary', + 'open_text', + 'path', + 'read_binary', + 'read_text', ] diff --git a/Lib/importlib/resources/functional.py b/Lib/importlib/resources/functional.py new file mode 100644 index 00000000000000..252bef2f0eb682 --- /dev/null +++ b/Lib/importlib/resources/functional.py @@ -0,0 +1,78 @@ +"""Simplified function-based API for importlib.resources + + +""" + +import os +import warnings + +from ._common import files, as_file + + +def open_binary(anchor, *path_names): + """Open for binary reading the *resource* within *package*.""" + return _get_resource(anchor, path_names).open('rb') + + +def open_text(anchor, *path_names, encoding='utf-8', errors='strict'): + """Open for text reading the *resource* within *package*.""" + resource = _get_resource(anchor, path_names) + return resource.open('r', encoding=encoding, errors=errors) + + +def read_binary(anchor, *path_names): + """Read and return contents of *resource* within *package* as bytes.""" + return _get_resource(anchor, path_names).read_bytes() + + +def read_text(anchor, *path_names, encoding='utf-8', errors='strict'): + """Read and return contents of *resource* within *package* as str.""" + resource = _get_resource(anchor, path_names) + return resource.read_text(encoding=encoding, errors=errors) + + +def path(anchor, *path_names): + """Return the path to the *resource* as an actual file system path.""" + return as_file(_get_resource(anchor, path_names)) + + +def is_resource(anchor, *path_names): + """Return ``True`` if there is a resource named *name* in the package, + + Otherwise returns ``False``. + """ + return _get_resource(anchor, path_names).is_file() + + +def contents(anchor, *path_names): + """Return an iterable over the named resources within the package. + + The iterable returns :class:`str` resources (e.g. files). + The iterable does not recurse into subdirectories. + """ + warnings.warn( + "importlib.resources.contents is deprecated. " + "Use files(anchor).iterdir() instead.", + DeprecationWarning, + stacklevel=1, + ) + return ( + resource.name + for resource + in _get_resource(anchor, path_names).iterdir() + ) + + +def _get_resource(anchor, path_names): + if anchor is None: + raise TypeError("anchor must be module or string, got None") + traversable = files(anchor) + for name in path_names: + str_path = str(name) + parent, file_name = os.path.split(str_path) + if parent: + raise ValueError( + 'path name elements must not contain path separators, ' + f'got {name!r}') + traversable = traversable.joinpath(file_name) + return traversable diff --git a/Lib/test/test_importlib/resources/test_functional.py b/Lib/test/test_importlib/resources/test_functional.py new file mode 100644 index 00000000000000..56384d29f0868c --- /dev/null +++ b/Lib/test/test_importlib/resources/test_functional.py @@ -0,0 +1,177 @@ +import unittest +import os + +from test.support.warnings_helper import ignore_warnings, check_warnings + +import importlib.resources + +# 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 = 'test.test_importlib.resources.data01' + anchor02 = 'test.test_importlib.resources.data02' + + +class ModuleAnchorMixin: + from test.test_importlib.resources import data01 as anchor01 + from test.test_importlib.resources import data02 as anchor02 + + +class FunctionalAPIBase(): + def test_read_text(self): + self.assertEqual( + importlib.resources.read_text(self.anchor01, 'utf-8.file'), + 'Hello, UTF-8 world!\n', + ) + self.assertEqual( + importlib.resources.read_text( + self.anchor02, 'subdirectory', 'subsubdir', 'resource.txt', + ), + 'a resource', + ) + with self.assertRaises(IsADirectoryError): + importlib.resources.read_text(self.anchor01) + with self.assertRaises(FileNotFoundError): + importlib.resources.read_text(self.anchor01, 'no-such-file') + with self.assertRaises(UnicodeDecodeError): + importlib.resources.read_text(self.anchor01, 'utf-16.file') + self.assertEqual( + importlib.resources.read_text( + self.anchor01, 'binary.file', encoding='latin1', + ), + '\x00\x01\x02\x03', + ) + self.assertEqual( + importlib.resources.read_text( + self.anchor01, 'utf-16.file', + errors='backslashreplace', + ), + 'Hello, UTF-16 world!\n'.encode('utf-16').decode( + errors='backslashreplace', + ) + ) + + def test_read_binary(self): + self.assertEqual( + importlib.resources.read_binary(self.anchor01, 'utf-8.file'), + b'Hello, UTF-8 world!\n', + ) + self.assertEqual( + importlib.resources.read_binary( + self.anchor02, 'subdirectory', 'subsubdir', 'resource.txt', + ), + b'a resource', + ) + + def test_open_text(self): + with importlib.resources.open_text(self.anchor01, 'utf-8.file') as f: + self.assertEqual(f.read(), 'Hello, UTF-8 world!\n') + with importlib.resources.open_text( + self.anchor02, 'subdirectory', 'subsubdir', 'resource.txt', + ) as f: + self.assertEqual(f.read(), 'a resource') + with self.assertRaises(IsADirectoryError): + importlib.resources.open_text(self.anchor01) + with self.assertRaises(FileNotFoundError): + importlib.resources.open_text(self.anchor01, 'no-such-file') + with importlib.resources.open_text(self.anchor01, 'utf-16.file') as f: + with self.assertRaises(UnicodeDecodeError): + f.read() + with importlib.resources.open_text( + self.anchor01, 'binary.file', encoding='latin1', + ) as f: + self.assertEqual(f.read(), '\x00\x01\x02\x03') + with importlib.resources.open_text( + self.anchor01, 'utf-16.file', + errors='backslashreplace', + ) as f: + self.assertEqual( + f.read(), + 'Hello, UTF-16 world!\n'.encode('utf-16').decode( + errors='backslashreplace', + ) + ) + + def test_open_binary(self): + with importlib.resources.open_binary(self.anchor01, 'utf-8.file') as f: + self.assertEqual(f.read(), b'Hello, UTF-8 world!\n') + with importlib.resources.open_binary( + self.anchor02, 'subdirectory', 'subsubdir', 'resource.txt', + ) as f: + self.assertEqual(f.read(), b'a resource') + + def test_path(self): + with importlib.resources.path(self.anchor01, 'utf-8.file') as path: + with open(str(path)) as f: + self.assertEqual(f.read(), 'Hello, UTF-8 world!\n') + with importlib.resources.path(self.anchor01) as path: + with open(os.path.join(path, 'utf-8.file')) as f: + self.assertEqual(f.read(), 'Hello, UTF-8 world!\n') + + def test_is_resource(self): + is_resource = importlib.resources.is_resource + self.assertTrue(is_resource(self.anchor01, 'utf-8.file')) + self.assertFalse(is_resource(self.anchor01, 'no_such_file')) + self.assertFalse(is_resource(self.anchor01)) + self.assertFalse(is_resource(self.anchor01, 'subdirectory')) + + def test_contents(self): + is_resource = importlib.resources.is_resource + with check_warnings((".*contents.*", DeprecationWarning)): + c = importlib.resources.contents(self.anchor01) + self.assertGreaterEqual( + set(c), + {'utf-8.file', 'utf-16.file', 'binary.file', 'subdirectory'}, + ) + with (self.assertRaises(NotADirectoryError), + check_warnings((".*contents.*", DeprecationWarning)), + ): + importlib.resources.contents(self.anchor01, 'utf-8.file') + with check_warnings((".*contents.*", DeprecationWarning)): + c = importlib.resources.contents(self.anchor01, 'subdirectory') + self.assertGreaterEqual( + set(c), + {'binary.file'}, + ) + + @ignore_warnings(category=DeprecationWarning) + def test_common_errors(self): + for func in ( + importlib.resources.read_text, + importlib.resources.read_binary, + importlib.resources.open_text, + importlib.resources.open_binary, + importlib.resources.path, + importlib.resources.is_resource, + importlib.resources.contents, + ): + with self.subTest(func=func): + # Rejecting path separators + with self.assertRaises(ValueError): + func(self.anchor02, os.path.join( + 'subdirectory', 'subsubdir', 'resource.txt', + )) + # Rejecting None anchor + with self.assertRaises(TypeError): + func(None) + # Rejecting invalid anchor type + with self.assertRaises((TypeError, AttributeError)): + func(1234) + # Unknown module + with self.assertRaises(ModuleNotFoundError): + func('$missing module$') + + +class FunctionalAPITest_StringAnchor( + unittest.TestCase, FunctionalAPIBase, StringAnchorMixin, +): + pass + + +class FunctionalAPITest_ModuleAnchor( + unittest.TestCase, FunctionalAPIBase, ModuleAnchorMixin, +): + pass diff --git a/Misc/NEWS.d/next/Library/2024-03-11-17-04-55.gh-issue-116608.30f58-.rst b/Misc/NEWS.d/next/Library/2024-03-11-17-04-55.gh-issue-116608.30f58-.rst new file mode 100644 index 00000000000000..e63067aeafd91f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2024-03-11-17-04-55.gh-issue-116608.30f58-.rst @@ -0,0 +1,8 @@ +The :mod:`importlib.resources` functions +:func:`~importlib.resources.is_resource()`, +:func:`~importlib.resources.open_binary()`, +:func:`~importlib.resources.open_text()`, +:func:`~importlib.resources.path()`, +:func:`~importlib.resources.read_binary()`, and +:func:`~importlib.resources.read_text()` are un-deprecated, and support +subdirectories via multiple positional arguments. From 9b065b167279bf9f167ad4a76d6ec72c98352841 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Mon, 11 Mar 2024 17:22:52 +0100 Subject: [PATCH 2/9] Docs fix --- Doc/whatsnew/3.13.rst | 2 +- .../next/Library/2024-03-11-17-04-55.gh-issue-116608.30f58-.rst | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Doc/whatsnew/3.13.rst b/Doc/whatsnew/3.13.rst index fa2dbb65cb01f7..f1b2d0be52fa4c 100644 --- a/Doc/whatsnew/3.13.rst +++ b/Doc/whatsnew/3.13.rst @@ -380,7 +380,7 @@ For text-reading functions, the *encoding* and *errors* must now be given as keyword arguments. The :func:`~importlib.resources.contents()` remains deprecated in favor of -the full-featured :class:`~importlib.resources.Traversable` API. +the full-featured :class:`~importlib.resources.abc.Traversable` API. However, there is now no plan to remove it. (Contributed by Petr Viktorin in :gh:`106532`.) diff --git a/Misc/NEWS.d/next/Library/2024-03-11-17-04-55.gh-issue-116608.30f58-.rst b/Misc/NEWS.d/next/Library/2024-03-11-17-04-55.gh-issue-116608.30f58-.rst index e63067aeafd91f..d1536bc47c3ee0 100644 --- a/Misc/NEWS.d/next/Library/2024-03-11-17-04-55.gh-issue-116608.30f58-.rst +++ b/Misc/NEWS.d/next/Library/2024-03-11-17-04-55.gh-issue-116608.30f58-.rst @@ -6,3 +6,5 @@ The :mod:`importlib.resources` functions :func:`~importlib.resources.read_binary()`, and :func:`~importlib.resources.read_text()` are un-deprecated, and support subdirectories via multiple positional arguments. +The :func:`~importlib.resources.contents()` function also allows subdirectories, +but remains deprecated. From 265ae9198be230a3689c15b075a430699795114a Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Mon, 11 Mar 2024 17:02:19 +0100 Subject: [PATCH 3/9] Require explicit encoding arg for ambiguous cases --- Doc/library/importlib.resources.rst | 19 +++++++++++++-- Lib/importlib/resources/functional.py | 23 +++++++++++++++++-- .../resources/test_functional.py | 15 ++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/Doc/library/importlib.resources.rst b/Doc/library/importlib.resources.rst index 1ca7ffc16e31f1..ddd5fb50c4051a 100644 --- a/Doc/library/importlib.resources.rst +++ b/Doc/library/importlib.resources.rst @@ -120,10 +120,17 @@ For all the following functions: importlib.resources.read_text(my_module, "info.txt") - To get the text of ``info/chapter1.txt``, use:: + To get the contents of ``pics/painting.png`` as bytes, use:: - importlib.resources.read_text(my_module, "info", "chapter1.txt") + importlib.resources.read_binary(my_module, "pics", "painting.png") + For backward compatibility reasons, functions that read text require + an explicit *encoding* argument if multiple *path_names* are given. + + So, to get the text of ``info/chapter1.txt``, use:: + + importlib.resources.read_text(my_module, "info", "chapter1.txt", + encoding='utf-8') .. function:: open_binary(anchor, *path_names) @@ -152,6 +159,10 @@ For all the following functions: details on *anchor* and *path_names*. *encoding* and *errors* have the same meaning as in built-in :func:`open`. + For backward compatibility reasons, the *encoding* argument must be given + explicitly if there are multiple *path_names*. + This limitation is scheduled to be removed in Python 3.15. + This function returns a :class:`~typing.TextIO` object, that is, a text stream open for reading. @@ -188,6 +199,10 @@ For all the following functions: details on *anchor* and *path_names*. *encoding* and *errors* have the same meaning as in built-in :func:`open`. + For backward compatibility reasons, the *encoding* argument must be given + explicitly if there are multiple *path_names*. + This limitation is scheduled to be removed in Python 3.15. + For a single path name *name*, this function is roughly equivalent to:: files(anchor).joinpath(name).read_text(encoding=encoding) diff --git a/Lib/importlib/resources/functional.py b/Lib/importlib/resources/functional.py index 252bef2f0eb682..0b032443b8a113 100644 --- a/Lib/importlib/resources/functional.py +++ b/Lib/importlib/resources/functional.py @@ -9,13 +9,16 @@ from ._common import files, as_file +_MISSING = object() + def open_binary(anchor, *path_names): """Open for binary reading the *resource* within *package*.""" return _get_resource(anchor, path_names).open('rb') -def open_text(anchor, *path_names, encoding='utf-8', errors='strict'): +def open_text(anchor, *path_names, encoding=_MISSING, errors='strict'): """Open for text reading the *resource* within *package*.""" + encoding = _get_encoding_arg(path_names, encoding) resource = _get_resource(anchor, path_names) return resource.open('r', encoding=encoding, errors=errors) @@ -25,8 +28,9 @@ def read_binary(anchor, *path_names): return _get_resource(anchor, path_names).read_bytes() -def read_text(anchor, *path_names, encoding='utf-8', errors='strict'): +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) @@ -63,6 +67,21 @@ def contents(anchor, *path_names): ) +def _get_encoding_arg(path_names, encoding): + # For compatibility with versions where *encoding* was a positional + # argument, it needs to be given explicitly when there are multiple + # *path_names*. + # This limitation can be removed in Python 3.15. + if encoding is _MISSING: + if len(path_names) > 1: + raise TypeError( + "'encoding' argument required with multiple path names", + ) + else: + return 'utf-8' + return encoding + + def _get_resource(anchor, path_names): if anchor is None: raise TypeError("anchor must be module or string, got None") diff --git a/Lib/test/test_importlib/resources/test_functional.py b/Lib/test/test_importlib/resources/test_functional.py index 56384d29f0868c..ecae81986604b6 100644 --- a/Lib/test/test_importlib/resources/test_functional.py +++ b/Lib/test/test_importlib/resources/test_functional.py @@ -29,6 +29,7 @@ def test_read_text(self): self.assertEqual( importlib.resources.read_text( self.anchor02, 'subdirectory', 'subsubdir', 'resource.txt', + encoding='utf-8', ), 'a resource', ) @@ -71,6 +72,7 @@ def test_open_text(self): self.assertEqual(f.read(), 'Hello, UTF-8 world!\n') with importlib.resources.open_text( self.anchor02, 'subdirectory', 'subsubdir', 'resource.txt', + encoding='utf-8', ) as f: self.assertEqual(f.read(), 'a resource') with self.assertRaises(IsADirectoryError): @@ -164,6 +166,19 @@ def test_common_errors(self): with self.assertRaises(ModuleNotFoundError): func('$missing module$') + def test_text_errors(self): + for func in ( + importlib.resources.read_text, + importlib.resources.open_text, + ): + with self.subTest(func=func): + # Multiple path arguments need explicit encoding argument. + with self.assertRaises(TypeError): + func( + self.anchor02, 'subdirectory', + 'subsubdir', 'resource.txt', + ) + class FunctionalAPITest_StringAnchor( unittest.TestCase, FunctionalAPIBase, StringAnchorMixin, From 917661909d80d0368e44bf65e0cb94d17610e6f2 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Tue, 12 Mar 2024 10:24:01 +0100 Subject: [PATCH 4/9] tests: Expect OSError -- we can get PermissionError instead of IsADirectoryError --- .../test_importlib/resources/test_functional.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_importlib/resources/test_functional.py b/Lib/test/test_importlib/resources/test_functional.py index ecae81986604b6..b38e7cdc4815f6 100644 --- a/Lib/test/test_importlib/resources/test_functional.py +++ b/Lib/test/test_importlib/resources/test_functional.py @@ -33,9 +33,11 @@ def test_read_text(self): ), 'a resource', ) - with self.assertRaises(IsADirectoryError): + # Use generic OSError, since e.g. attempting to read a directory can + # fail with PermissionError rather than IsADirectoryError + with self.assertRaises(OSError): importlib.resources.read_text(self.anchor01) - with self.assertRaises(FileNotFoundError): + with self.assertRaises(OSError): importlib.resources.read_text(self.anchor01, 'no-such-file') with self.assertRaises(UnicodeDecodeError): importlib.resources.read_text(self.anchor01, 'utf-16.file') @@ -75,9 +77,11 @@ def test_open_text(self): encoding='utf-8', ) as f: self.assertEqual(f.read(), 'a resource') - with self.assertRaises(IsADirectoryError): + # Use generic OSError, since e.g. attempting to read a directory can + # fail with PermissionError rather than IsADirectoryError + with self.assertRaises(OSError): importlib.resources.open_text(self.anchor01) - with self.assertRaises(FileNotFoundError): + with self.assertRaises(OSError): importlib.resources.open_text(self.anchor01, 'no-such-file') with importlib.resources.open_text(self.anchor01, 'utf-16.file') as f: with self.assertRaises(UnicodeDecodeError): @@ -128,7 +132,7 @@ def test_contents(self): set(c), {'utf-8.file', 'utf-16.file', 'binary.file', 'subdirectory'}, ) - with (self.assertRaises(NotADirectoryError), + with (self.assertRaises(OSError), check_warnings((".*contents.*", DeprecationWarning)), ): importlib.resources.contents(self.anchor01, 'utf-8.file') From 56603feb76a07afe1946b61312f566095ec1f47f Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Wed, 20 Mar 2024 15:54:46 +0100 Subject: [PATCH 5/9] Allow separators in path segments --- Doc/library/importlib.resources.rst | 37 ++++++----- Lib/importlib/resources/functional.py | 11 +--- .../resources/test_functional.py | 65 ++++++++++++------- 3 files changed, 63 insertions(+), 50 deletions(-) diff --git a/Doc/library/importlib.resources.rst b/Doc/library/importlib.resources.rst index ddd5fb50c4051a..d892abd2736a31 100644 --- a/Doc/library/importlib.resources.rst +++ b/Doc/library/importlib.resources.rst @@ -115,19 +115,20 @@ For all the following functions: - *path_names* are components of a resource's path name, relative to the anchor. - The individual components may not contain path separators. For example, to get the text of resource named ``info.txt``, use:: importlib.resources.read_text(my_module, "info.txt") - To get the contents of ``pics/painting.png`` as bytes, use:: + Like :meth:`Traversable.joinpath `, + The individual components should use forward slashes `/` as path separators. + The following are equivalent:: + importlib.resources.read_binary(my_module, "pics/painting.png") importlib.resources.read_binary(my_module, "pics", "painting.png") For backward compatibility reasons, functions that read text require an explicit *encoding* argument if multiple *path_names* are given. - - So, to get the text of ``info/chapter1.txt``, use:: + For example, to get the text of ``info/chapter1.txt``, use:: importlib.resources.read_text(my_module, "info", "chapter1.txt", encoding='utf-8') @@ -142,9 +143,9 @@ For all the following functions: This function returns a :class:`~typing.BinaryIO` object, that is, a binary stream open for reading. - For a single path name *name*, this function is roughly equivalent to:: + This function is roughly equivalent to:: - files(anchor).joinpath(name).open('rb') + files(anchor).joinpath(*path_names).open('rb') .. versionchanged:: 3.13 Multiple *path_names* are accepted. @@ -166,9 +167,9 @@ For all the following functions: This function returns a :class:`~typing.TextIO` object, that is, a text stream open for reading. - For a single path name *name*, this function is roughly equivalent to:: + This function is roughly equivalent to:: - files(anchor).joinpath(name).open('r', encoding=encoding) + files(anchor).joinpath(*path_names).open('r', encoding=encoding) .. versionchanged:: 3.13 Multiple *path_names* are accepted. @@ -182,9 +183,9 @@ For all the following functions: See :ref:`the introduction ` for details on *anchor* and *path_names*. - For a single path name *name*, this function is roughly equivalent to:: + This function is roughly equivalent to:: - files(anchor).joinpath(name).read_bytes() + files(anchor).joinpath(*path_names).read_bytes() .. versionchanged:: 3.13 Multiple *path_names* are accepted. @@ -203,9 +204,9 @@ For all the following functions: explicitly if there are multiple *path_names*. This limitation is scheduled to be removed in Python 3.15. - For a single path name *name*, this function is roughly equivalent to:: + This function is roughly equivalent to:: - files(anchor).joinpath(name).read_text(encoding=encoding) + files(anchor).joinpath(*path_names).read_text(encoding=encoding) .. versionchanged:: 3.13 Multiple *path_names* are accepted. @@ -230,9 +231,9 @@ For all the following functions: See :ref:`the introduction ` for details on *anchor* and *path_names*. - For a single path name *name*, this function is roughly equivalent to:: + This function is roughly equivalent to:: - as_file(files(anchor).joinpath(name)) + as_file(files(anchor).joinpath(*path_names)) .. versionchanged:: 3.13 Multiple *path_names* are accepted. @@ -247,9 +248,9 @@ For all the following functions: See :ref:`the introduction ` for details on *anchor* and *path_names*. - For a single path name *name*, this function is roughly equivalent to:: + This function is roughly equivalent to:: - files(anchor).joinpath(name).is_file() + files(anchor).joinpath(*path_names).is_file() .. versionchanged:: 3.13 Multiple *path_names* are accepted. @@ -265,9 +266,9 @@ For all the following functions: See :ref:`the introduction ` for details on *anchor* and *path_names*. - For a single path name *name*, this function is roughly equivalent to:: + This function is roughly equivalent to:: - for resource in files(anchor).joinpath(name).iterdir(): + for resource in files(anchor).joinpath(*path_names).iterdir(): yield resource.name .. deprecated:: 3.11 diff --git a/Lib/importlib/resources/functional.py b/Lib/importlib/resources/functional.py index 0b032443b8a113..80c192f9d10ee0 100644 --- a/Lib/importlib/resources/functional.py +++ b/Lib/importlib/resources/functional.py @@ -85,13 +85,4 @@ def _get_encoding_arg(path_names, encoding): def _get_resource(anchor, path_names): if anchor is None: raise TypeError("anchor must be module or string, got None") - traversable = files(anchor) - for name in path_names: - str_path = str(name) - parent, file_name = os.path.split(str_path) - if parent: - raise ValueError( - 'path name elements must not contain path separators, ' - f'got {name!r}') - traversable = traversable.joinpath(file_name) - return traversable + return files(anchor).joinpath(*path_names) diff --git a/Lib/test/test_importlib/resources/test_functional.py b/Lib/test/test_importlib/resources/test_functional.py index b38e7cdc4815f6..cc7199d1fd5c6a 100644 --- a/Lib/test/test_importlib/resources/test_functional.py +++ b/Lib/test/test_importlib/resources/test_functional.py @@ -21,6 +21,17 @@ class ModuleAnchorMixin: class FunctionalAPIBase(): + def _gen_resourcetxt_path_parts(self): + """Yield various names of a text file in anchor02, each in a subTest + """ + for path_parts in ( + ('subdirectory', 'subsubdir', 'resource.txt'), + ('subdirectory/subsubdir/resource.txt',), + ('subdirectory/subsubdir', 'resource.txt'), + ): + with self.subTest(path_parts=path_parts): + yield path_parts + def test_read_text(self): self.assertEqual( importlib.resources.read_text(self.anchor01, 'utf-8.file'), @@ -33,6 +44,13 @@ def test_read_text(self): ), 'a resource', ) + for path_parts in self._gen_resourcetxt_path_parts(): + self.assertEqual( + importlib.resources.read_text( + self.anchor02, *path_parts, encoding='utf-8', + ), + 'a resource', + ) # Use generic OSError, since e.g. attempting to read a directory can # fail with PermissionError rather than IsADirectoryError with self.assertRaises(OSError): @@ -62,21 +80,21 @@ def test_read_binary(self): importlib.resources.read_binary(self.anchor01, 'utf-8.file'), b'Hello, UTF-8 world!\n', ) - self.assertEqual( - importlib.resources.read_binary( - self.anchor02, 'subdirectory', 'subsubdir', 'resource.txt', - ), - b'a resource', - ) + for path_parts in self._gen_resourcetxt_path_parts(): + self.assertEqual( + importlib.resources.read_binary(self.anchor02, *path_parts), + b'a resource', + ) def test_open_text(self): with importlib.resources.open_text(self.anchor01, 'utf-8.file') as f: self.assertEqual(f.read(), 'Hello, UTF-8 world!\n') - with importlib.resources.open_text( - self.anchor02, 'subdirectory', 'subsubdir', 'resource.txt', - encoding='utf-8', - ) as f: - self.assertEqual(f.read(), 'a resource') + for path_parts in self._gen_resourcetxt_path_parts(): + with importlib.resources.open_text( + self.anchor02, *path_parts, + encoding='utf-8', + ) as f: + self.assertEqual(f.read(), 'a resource') # Use generic OSError, since e.g. attempting to read a directory can # fail with PermissionError rather than IsADirectoryError with self.assertRaises(OSError): @@ -104,10 +122,11 @@ def test_open_text(self): def test_open_binary(self): with importlib.resources.open_binary(self.anchor01, 'utf-8.file') as f: self.assertEqual(f.read(), b'Hello, UTF-8 world!\n') - with importlib.resources.open_binary( - self.anchor02, 'subdirectory', 'subsubdir', 'resource.txt', - ) as f: - self.assertEqual(f.read(), b'a resource') + for path_parts in self._gen_resourcetxt_path_parts(): + with importlib.resources.open_binary( + self.anchor02, *path_parts, + ) as f: + self.assertEqual(f.read(), b'a resource') def test_path(self): with importlib.resources.path(self.anchor01, 'utf-8.file') as path: @@ -123,6 +142,8 @@ def test_is_resource(self): self.assertFalse(is_resource(self.anchor01, 'no_such_file')) self.assertFalse(is_resource(self.anchor01)) self.assertFalse(is_resource(self.anchor01, 'subdirectory')) + for path_parts in self._gen_resourcetxt_path_parts(): + self.assertTrue(is_resource(self.anchor02, *path_parts)) def test_contents(self): is_resource = importlib.resources.is_resource @@ -133,9 +154,14 @@ def test_contents(self): {'utf-8.file', 'utf-16.file', 'binary.file', 'subdirectory'}, ) with (self.assertRaises(OSError), - check_warnings((".*contents.*", DeprecationWarning)), - ): + check_warnings((".*contents.*", DeprecationWarning)), + ): importlib.resources.contents(self.anchor01, 'utf-8.file') + for path_parts in self._gen_resourcetxt_path_parts(): + with (self.assertRaises(OSError), + check_warnings((".*contents.*", DeprecationWarning)), + ): + importlib.resources.contents(self.anchor01, *path_parts) with check_warnings((".*contents.*", DeprecationWarning)): c = importlib.resources.contents(self.anchor01, 'subdirectory') self.assertGreaterEqual( @@ -155,11 +181,6 @@ def test_common_errors(self): importlib.resources.contents, ): with self.subTest(func=func): - # Rejecting path separators - with self.assertRaises(ValueError): - func(self.anchor02, os.path.join( - 'subdirectory', 'subsubdir', 'resource.txt', - )) # Rejecting None anchor with self.assertRaises(TypeError): func(None) From a520a78f5ceddb9e0a7c219783c21d30d8dd05c7 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Thu, 21 Mar 2024 10:50:25 +0100 Subject: [PATCH 6/9] Fix issues & nitbicks found in backport --- Lib/importlib/resources/functional.py | 3 - .../resources/test_functional.py | 84 ++++++++++--------- 2 files changed, 43 insertions(+), 44 deletions(-) diff --git a/Lib/importlib/resources/functional.py b/Lib/importlib/resources/functional.py index 80c192f9d10ee0..9f01680180feac 100644 --- a/Lib/importlib/resources/functional.py +++ b/Lib/importlib/resources/functional.py @@ -1,9 +1,6 @@ """Simplified function-based API for importlib.resources - - """ -import os import warnings from ._common import files, as_file diff --git a/Lib/test/test_importlib/resources/test_functional.py b/Lib/test/test_importlib/resources/test_functional.py index cc7199d1fd5c6a..038d939ce6f30d 100644 --- a/Lib/test/test_importlib/resources/test_functional.py +++ b/Lib/test/test_importlib/resources/test_functional.py @@ -3,7 +3,7 @@ from test.support.warnings_helper import ignore_warnings, check_warnings -import importlib.resources +import importlib.resources as resources # Since the functional API forwards to Traversable, we only test # filesystem resources here -- not zip files, namespace packages etc. @@ -16,8 +16,8 @@ class StringAnchorMixin: class ModuleAnchorMixin: - from test.test_importlib.resources import data01 as anchor01 - from test.test_importlib.resources import data02 as anchor02 + from . import data01 as anchor01 + from . import data02 as anchor02 class FunctionalAPIBase(): @@ -34,11 +34,11 @@ def _gen_resourcetxt_path_parts(self): def test_read_text(self): self.assertEqual( - importlib.resources.read_text(self.anchor01, 'utf-8.file'), + resources.read_text(self.anchor01, 'utf-8.file'), 'Hello, UTF-8 world!\n', ) self.assertEqual( - importlib.resources.read_text( + resources.read_text( self.anchor02, 'subdirectory', 'subsubdir', 'resource.txt', encoding='utf-8', ), @@ -46,7 +46,7 @@ def test_read_text(self): ) for path_parts in self._gen_resourcetxt_path_parts(): self.assertEqual( - importlib.resources.read_text( + resources.read_text( self.anchor02, *path_parts, encoding='utf-8', ), 'a resource', @@ -54,19 +54,19 @@ def test_read_text(self): # Use generic OSError, since e.g. attempting to read a directory can # fail with PermissionError rather than IsADirectoryError with self.assertRaises(OSError): - importlib.resources.read_text(self.anchor01) + resources.read_text(self.anchor01) with self.assertRaises(OSError): - importlib.resources.read_text(self.anchor01, 'no-such-file') + resources.read_text(self.anchor01, 'no-such-file') with self.assertRaises(UnicodeDecodeError): - importlib.resources.read_text(self.anchor01, 'utf-16.file') + resources.read_text(self.anchor01, 'utf-16.file') self.assertEqual( - importlib.resources.read_text( + resources.read_text( self.anchor01, 'binary.file', encoding='latin1', ), '\x00\x01\x02\x03', ) self.assertEqual( - importlib.resources.read_text( + resources.read_text( self.anchor01, 'utf-16.file', errors='backslashreplace', ), @@ -77,20 +77,20 @@ def test_read_text(self): def test_read_binary(self): self.assertEqual( - importlib.resources.read_binary(self.anchor01, 'utf-8.file'), + resources.read_binary(self.anchor01, 'utf-8.file'), b'Hello, UTF-8 world!\n', ) for path_parts in self._gen_resourcetxt_path_parts(): self.assertEqual( - importlib.resources.read_binary(self.anchor02, *path_parts), + resources.read_binary(self.anchor02, *path_parts), b'a resource', ) def test_open_text(self): - with importlib.resources.open_text(self.anchor01, 'utf-8.file') as f: + with resources.open_text(self.anchor01, 'utf-8.file') as f: self.assertEqual(f.read(), 'Hello, UTF-8 world!\n') for path_parts in self._gen_resourcetxt_path_parts(): - with importlib.resources.open_text( + with resources.open_text( self.anchor02, *path_parts, encoding='utf-8', ) as f: @@ -98,17 +98,17 @@ def test_open_text(self): # Use generic OSError, since e.g. attempting to read a directory can # fail with PermissionError rather than IsADirectoryError with self.assertRaises(OSError): - importlib.resources.open_text(self.anchor01) + resources.open_text(self.anchor01) with self.assertRaises(OSError): - importlib.resources.open_text(self.anchor01, 'no-such-file') - with importlib.resources.open_text(self.anchor01, 'utf-16.file') as f: + resources.open_text(self.anchor01, 'no-such-file') + with resources.open_text(self.anchor01, 'utf-16.file') as f: with self.assertRaises(UnicodeDecodeError): f.read() - with importlib.resources.open_text( + with resources.open_text( self.anchor01, 'binary.file', encoding='latin1', ) as f: self.assertEqual(f.read(), '\x00\x01\x02\x03') - with importlib.resources.open_text( + with resources.open_text( self.anchor01, 'utf-16.file', errors='backslashreplace', ) as f: @@ -120,24 +120,24 @@ def test_open_text(self): ) def test_open_binary(self): - with importlib.resources.open_binary(self.anchor01, 'utf-8.file') as f: + with resources.open_binary(self.anchor01, 'utf-8.file') as f: self.assertEqual(f.read(), b'Hello, UTF-8 world!\n') for path_parts in self._gen_resourcetxt_path_parts(): - with importlib.resources.open_binary( + with resources.open_binary( self.anchor02, *path_parts, ) as f: self.assertEqual(f.read(), b'a resource') def test_path(self): - with importlib.resources.path(self.anchor01, 'utf-8.file') as path: + with resources.path(self.anchor01, 'utf-8.file') as path: with open(str(path)) as f: self.assertEqual(f.read(), 'Hello, UTF-8 world!\n') - with importlib.resources.path(self.anchor01) as path: + with resources.path(self.anchor01) as path: with open(os.path.join(path, 'utf-8.file')) as f: self.assertEqual(f.read(), 'Hello, UTF-8 world!\n') def test_is_resource(self): - is_resource = importlib.resources.is_resource + is_resource = resources.is_resource self.assertTrue(is_resource(self.anchor01, 'utf-8.file')) self.assertFalse(is_resource(self.anchor01, 'no_such_file')) self.assertFalse(is_resource(self.anchor01)) @@ -146,24 +146,26 @@ def test_is_resource(self): self.assertTrue(is_resource(self.anchor02, *path_parts)) def test_contents(self): - is_resource = importlib.resources.is_resource + is_resource = resources.is_resource with check_warnings((".*contents.*", DeprecationWarning)): - c = importlib.resources.contents(self.anchor01) + c = resources.contents(self.anchor01) self.assertGreaterEqual( set(c), {'utf-8.file', 'utf-16.file', 'binary.file', 'subdirectory'}, ) - with (self.assertRaises(OSError), + with ( + self.assertRaises(OSError), check_warnings((".*contents.*", DeprecationWarning)), ): - importlib.resources.contents(self.anchor01, 'utf-8.file') + list(resources.contents(self.anchor01, 'utf-8.file')) for path_parts in self._gen_resourcetxt_path_parts(): - with (self.assertRaises(OSError), + with ( + self.assertRaises(OSError), check_warnings((".*contents.*", DeprecationWarning)), ): - importlib.resources.contents(self.anchor01, *path_parts) + list(resources.contents(self.anchor01, *path_parts)) with check_warnings((".*contents.*", DeprecationWarning)): - c = importlib.resources.contents(self.anchor01, 'subdirectory') + c = resources.contents(self.anchor01, 'subdirectory') self.assertGreaterEqual( set(c), {'binary.file'}, @@ -172,13 +174,13 @@ def test_contents(self): @ignore_warnings(category=DeprecationWarning) def test_common_errors(self): for func in ( - importlib.resources.read_text, - importlib.resources.read_binary, - importlib.resources.open_text, - importlib.resources.open_binary, - importlib.resources.path, - importlib.resources.is_resource, - importlib.resources.contents, + resources.read_text, + resources.read_binary, + resources.open_text, + resources.open_binary, + resources.path, + resources.is_resource, + resources.contents, ): with self.subTest(func=func): # Rejecting None anchor @@ -193,8 +195,8 @@ def test_common_errors(self): def test_text_errors(self): for func in ( - importlib.resources.read_text, - importlib.resources.open_text, + resources.read_text, + resources.open_text, ): with self.subTest(func=func): # Multiple path arguments need explicit encoding argument. From e788bf36ba290511d7b3ac26ac1c75fec05d4db6 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Thu, 21 Mar 2024 10:55:14 +0100 Subject: [PATCH 7/9] PEP-8 formatting --- Lib/importlib/resources/functional.py | 4 ++-- Lib/test/test_importlib/resources/test_functional.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/importlib/resources/functional.py b/Lib/importlib/resources/functional.py index 9f01680180feac..9e3ea1547d486a 100644 --- a/Lib/importlib/resources/functional.py +++ b/Lib/importlib/resources/functional.py @@ -1,5 +1,4 @@ -"""Simplified function-based API for importlib.resources -""" +"""Simplified function-based API for importlib.resources""" import warnings @@ -8,6 +7,7 @@ _MISSING = object() + def open_binary(anchor, *path_names): """Open for binary reading the *resource* within *package*.""" return _get_resource(anchor, path_names).open('rb') diff --git a/Lib/test/test_importlib/resources/test_functional.py b/Lib/test/test_importlib/resources/test_functional.py index 038d939ce6f30d..fd02fc7c0e7b15 100644 --- a/Lib/test/test_importlib/resources/test_functional.py +++ b/Lib/test/test_importlib/resources/test_functional.py @@ -20,7 +20,7 @@ class ModuleAnchorMixin: from . import data02 as anchor02 -class FunctionalAPIBase(): +class FunctionalAPIBase: def _gen_resourcetxt_path_parts(self): """Yield various names of a text file in anchor02, each in a subTest """ @@ -72,7 +72,7 @@ def test_read_text(self): ), 'Hello, UTF-16 world!\n'.encode('utf-16').decode( errors='backslashreplace', - ) + ), ) def test_read_binary(self): @@ -116,7 +116,7 @@ def test_open_text(self): f.read(), 'Hello, UTF-16 world!\n'.encode('utf-16').decode( errors='backslashreplace', - ) + ), ) def test_open_binary(self): From 09c7bf7d07deb72a88285b837e80033993b00f53 Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Thu, 21 Mar 2024 11:30:01 +0100 Subject: [PATCH 8/9] Docs/ReST fix --- Doc/library/importlib.resources.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Doc/library/importlib.resources.rst b/Doc/library/importlib.resources.rst index d892abd2736a31..9a5e4c76e7bd8f 100644 --- a/Doc/library/importlib.resources.rst +++ b/Doc/library/importlib.resources.rst @@ -120,8 +120,9 @@ For all the following functions: importlib.resources.read_text(my_module, "info.txt") Like :meth:`Traversable.joinpath `, - The individual components should use forward slashes `/` as path separators. - The following are equivalent:: + The individual components should use forward slashes (``/``) + as path separators. + For example, the following are equivalent:: importlib.resources.read_binary(my_module, "pics/painting.png") importlib.resources.read_binary(my_module, "pics", "painting.png") From e15331ccf52c600de4193c3d5bff8b4be1fc950e Mon Sep 17 00:00:00 2001 From: Petr Viktorin Date: Tue, 2 Apr 2024 16:00:40 +0200 Subject: [PATCH 9/9] Add a leading underscore to the module name --- Lib/importlib/resources/__init__.py | 2 +- Lib/importlib/resources/{functional.py => _functional.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename Lib/importlib/resources/{functional.py => _functional.py} (100%) diff --git a/Lib/importlib/resources/__init__.py b/Lib/importlib/resources/__init__.py index 0d029abd63adc2..ec4441c9116118 100644 --- a/Lib/importlib/resources/__init__.py +++ b/Lib/importlib/resources/__init__.py @@ -7,7 +7,7 @@ Anchor, ) -from .functional import ( +from ._functional import ( contents, is_resource, open_binary, diff --git a/Lib/importlib/resources/functional.py b/Lib/importlib/resources/_functional.py similarity index 100% rename from Lib/importlib/resources/functional.py rename to Lib/importlib/resources/_functional.py