Skip to content

Commit f134e84

Browse files
committed
gh-104212: Add importlib.util.load_source_path() function
1 parent 4426279 commit f134e84

File tree

5 files changed

+142
-0
lines changed

5 files changed

+142
-0
lines changed

Doc/library/importlib.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,20 @@ an :term:`importer`.
12401240
.. versionchanged:: 3.6
12411241
Accepts a :term:`path-like object`.
12421242

1243+
.. function:: load_source_path(module_name, filename)
1244+
1245+
Load a module from a filename: execute the module and cache it to
1246+
:data:`sys.modules`.
1247+
1248+
*module_name* must not contain dots. A package cannot be imported by its
1249+
directory path, whereas its ``__init__.py`` file (ex:
1250+
``package/__init__.py``) can be imported.
1251+
1252+
The module is always executed even if it's already cached in
1253+
:data:`sys.modules`.
1254+
1255+
.. versionadded:: 3.12
1256+
12431257
.. function:: source_hash(source_bytes)
12441258

12451259
Return the hash of *source_bytes* as bytes. A hash-based ``.pyc`` file embeds

Doc/whatsnew/3.12.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,12 @@ fractions
570570
* Objects of type :class:`fractions.Fraction` now support float-style
571571
formatting. (Contributed by Mark Dickinson in :gh:`100161`.)
572572

573+
importlib
574+
---------
575+
576+
* Add :func:`importlib.util.load_source_path` to load a module from a filename.
577+
(Contributed by Victor Stinner in :gh:`104212`.)
578+
573579
inspect
574580
-------
575581

@@ -1371,6 +1377,9 @@ Removed
13711377

13721378
* Replace ``imp.new_module(name)`` with ``types.ModuleType(name)``.
13731379

1380+
* Replace ``imp.load_source(module_name, filename)``
1381+
with ``importlib.util.load_source_path(module_name, filename)``.
1382+
13741383
* Removed the ``suspicious`` rule from the documentation Makefile, and
13751384
removed ``Doc/tools/rstlint.py``, both in favor of `sphinx-lint
13761385
<https://github.com/sphinx-contrib/sphinx-lint>`_.

Lib/importlib/util.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from ._bootstrap_external import decode_source
1111
from ._bootstrap_external import source_from_cache
1212
from ._bootstrap_external import spec_from_file_location
13+
from ._bootstrap_external import SourceFileLoader
1314

1415
import _imp
1516
import sys
@@ -246,3 +247,21 @@ def exec_module(self, module):
246247
loader_state['__class__'] = module.__class__
247248
module.__spec__.loader_state = loader_state
248249
module.__class__ = _LazyModule
250+
251+
252+
def load_source_path(module_name, filename):
253+
"""Load a module from a filename."""
254+
if '.' in module_name:
255+
raise ValueError(f"module name must not contain dots: {module_name!r}")
256+
257+
loader = SourceFileLoader(module_name, filename)
258+
# use spec_from_file_location() to always set the __file__ attribute,
259+
# even if the filename does not end with ".py"
260+
spec = spec_from_file_location(module_name, filename,
261+
loader=loader,
262+
submodule_search_locations=[])
263+
264+
module = module_from_spec(spec)
265+
sys.modules[module.__name__] = module
266+
loader.exec_module(module)
267+
return module

Lib/test/test_importlib/test_util.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from test.test_importlib import fixtures
12
from test.test_importlib import util
23

34
abc = util.import_importlib('importlib.abc')
@@ -12,6 +13,7 @@
1213
import string
1314
import sys
1415
from test import support
16+
from test.support import import_helper, os_helper
1517
import textwrap
1618
import types
1719
import unittest
@@ -758,5 +760,101 @@ def test_complete_multi_phase_init_module(self):
758760
self.run_with_own_gil(script)
759761

760762

763+
class LoadSourcePathTests(unittest.TestCase):
764+
def check_module(self, mod, modname, filename, is_package=False):
765+
abs_filename = os.path.abspath(filename)
766+
767+
self.assertIsInstance(mod, types.ModuleType)
768+
self.assertEqual(mod.__name__, modname)
769+
self.assertEqual(mod.__file__, abs_filename)
770+
self.assertIn(modname, sys.modules)
771+
self.assertIs(sys.modules[modname], mod)
772+
self.assertEqual(mod.__path__, [os.path.dirname(abs_filename)])
773+
774+
loader = mod.__loader__
775+
self.assertEqual(loader.is_package(modname), is_package)
776+
777+
spec = mod.__spec__
778+
self.assertEqual(spec.name, modname)
779+
self.assertEqual(spec.origin, abs_filename)
780+
781+
def test_filename(self):
782+
modname = 'test_load_source_path_mod'
783+
# Filename doesn't have to end with ".py" suffix
784+
filename = 'load_source_path_filename'
785+
side_effect = 'load_source_path_side_effect'
786+
787+
def delete_side_effect():
788+
try:
789+
delattr(sys, side_effect)
790+
except AttributeError:
791+
pass
792+
793+
self.assertNotIn(modname, sys.modules)
794+
self.addCleanup(import_helper.unload, modname)
795+
796+
self.assertFalse(hasattr(sys, side_effect))
797+
self.addCleanup(delete_side_effect)
798+
799+
# Use a temporary directory to remove __pycache__/ subdirectory
800+
with fixtures.tempdir_as_cwd():
801+
with open(filename, "w", encoding="utf8") as fp:
802+
print("attr = 'load_source_path_attr'", file=fp)
803+
print(f"import sys; sys.{side_effect} = 1", file=fp)
804+
805+
mod = importlib.util.load_source_path(modname, filename)
806+
807+
self.check_module(mod, modname, filename)
808+
self.assertEqual(mod.attr, 'load_source_path_attr')
809+
self.assertEqual(getattr(sys, side_effect), 1)
810+
811+
# reload cached in sys.modules: the module is executed again
812+
self.assertIn(modname, sys.modules)
813+
setattr(sys, side_effect, 0)
814+
mod = importlib.util.load_source_path(modname, filename)
815+
self.assertEqual(getattr(sys, side_effect), 1)
816+
817+
# reload uncached in sys.modules: the module is executed again
818+
del sys.modules[modname]
819+
setattr(sys, side_effect, 0)
820+
mod = importlib.util.load_source_path(modname, filename)
821+
self.assertEqual(getattr(sys, side_effect), 1)
822+
823+
def test_dots(self):
824+
modname = 'package.submodule'
825+
filename = __file__
826+
with self.assertRaises(ValueError) as cm:
827+
importlib.util.load_source_path(modname, filename)
828+
829+
err_msg = str(cm.exception)
830+
self.assertIn("module name must not contain dots", err_msg)
831+
self.assertIn(repr(modname), err_msg)
832+
833+
def test_package(self):
834+
modname = 'test_load_source_path_package'
835+
dirname = 'load_source_path_dir'
836+
filename = os.path.join('load_source_path_dir', '__init__.py')
837+
838+
self.assertNotIn(modname, sys.modules)
839+
self.addCleanup(import_helper.unload, modname)
840+
841+
# Use a temporary directory to remove __pycache__/ subdirectory
842+
with fixtures.tempdir_as_cwd():
843+
os.mkdir(dirname)
844+
with open(filename, "w", encoding="utf8") as fp:
845+
print("attr = 'load_source_path_pkg'", file=fp)
846+
847+
# Package cannot be imported from a directory. It can with
848+
# IsADirectoryError on Unix and PermissionError on Windows.
849+
with self.assertRaises(OSError):
850+
importlib.util.load_source_path(modname, dirname)
851+
852+
# whereas loading a package __init__.py file is ok
853+
mod = importlib.util.load_source_path(modname, filename)
854+
855+
self.check_module(mod, modname, filename, is_package=True)
856+
self.assertEqual(mod.attr, 'load_source_path_pkg')
857+
858+
761859
if __name__ == '__main__':
762860
unittest.main()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add :func:`importlib.util.load_source_path` to load a module from a filename.
2+
Patch by Victor Stinner.

0 commit comments

Comments
 (0)