Skip to content

Commit ae1965c

Browse files
authored
bpo-45703: Invalidate _NamespacePath cache on importlib.invalidate_ca… (GH-29384)
Consider the following directory structure: . └── PATH1 └── namespace └── sub1 └── __init__.py And both PATH1 and PATH2 in sys path: $ PYTHONPATH=PATH1:PATH2 python3.11 >>> import namespace >>> import namespace.sub1 >>> namespace.__path__ _NamespacePath(['.../PATH1/namespace']) >>> ... While this interpreter still runs, PATH2/namespace/sub2 is created: . ├── PATH1 │ └── namespace │ └── sub1 │ └── __init__.py └── PATH2 └── namespace └── sub2 └── __init__.py The newly created module cannot be imported: >>> ... >>> namespace.__path__ _NamespacePath(['.../PATH1/namespace']) >>> import namespace.sub2 Traceback (most recent call last): File "<stdin>", line 1, in <module> ModuleNotFoundError: No module named 'namespace.sub2' Calling importlib.invalidate_caches() now newly allows to import it: >>> import importlib >>> importlib.invalidate_caches() >>> namespace.__path__ _NamespacePath(['.../PATH1/namespace']) >>> import namespace.sub2 >>> namespace.__path__ _NamespacePath(['.../PATH1/namespace', '.../PATH2/namespace']) This was not previously possible.
1 parent 8ed1495 commit ae1965c

File tree

4 files changed

+54
-1
lines changed

4 files changed

+54
-1
lines changed

Doc/library/importlib.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ Functions
145145

146146
.. versionadded:: 3.3
147147

148+
.. versionchanged:: 3.10
149+
Namespace packages created/installed in a different :data:`sys.path`
150+
location after the same namespace was already imported are noticed.
151+
148152
.. function:: reload(module)
149153

150154
Reload a previously imported *module*. The argument must be a module object,

Lib/importlib/_bootstrap_external.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1231,10 +1231,15 @@ class _NamespacePath:
12311231
using path_finder. For top-level modules, the parent module's path
12321232
is sys.path."""
12331233

1234+
# When invalidate_caches() is called, this epoch is incremented
1235+
# https://bugs.python.org/issue45703
1236+
_epoch = 0
1237+
12341238
def __init__(self, name, path, path_finder):
12351239
self._name = name
12361240
self._path = path
12371241
self._last_parent_path = tuple(self._get_parent_path())
1242+
self._last_epoch = self._epoch
12381243
self._path_finder = path_finder
12391244

12401245
def _find_parent_path_names(self):
@@ -1254,14 +1259,15 @@ def _get_parent_path(self):
12541259
def _recalculate(self):
12551260
# If the parent's path has changed, recalculate _path
12561261
parent_path = tuple(self._get_parent_path()) # Make a copy
1257-
if parent_path != self._last_parent_path:
1262+
if parent_path != self._last_parent_path or self._epoch != self._last_epoch:
12581263
spec = self._path_finder(self._name, parent_path)
12591264
# Note that no changes are made if a loader is returned, but we
12601265
# do remember the new parent path
12611266
if spec is not None and spec.loader is None:
12621267
if spec.submodule_search_locations:
12631268
self._path = spec.submodule_search_locations
12641269
self._last_parent_path = parent_path # Save the copy
1270+
self._last_epoch = self._epoch
12651271
return self._path
12661272

12671273
def __iter__(self):
@@ -1355,6 +1361,9 @@ def invalidate_caches():
13551361
del sys.path_importer_cache[name]
13561362
elif hasattr(finder, 'invalidate_caches'):
13571363
finder.invalidate_caches()
1364+
# Also invalidate the caches of _NamespacePaths
1365+
# https://bugs.python.org/issue45703
1366+
_NamespacePath._epoch += 1
13581367

13591368
@staticmethod
13601369
def _path_hooks(path):

Lib/test/test_importlib/test_namespace_pkgs.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import importlib.machinery
55
import os
66
import sys
7+
import tempfile
78
import unittest
89
import warnings
910

@@ -130,6 +131,40 @@ def test_imports(self):
130131
self.assertEqual(foo.two.attr, 'portion2 foo two')
131132

132133

134+
class SeparatedNamespacePackagesCreatedWhileRunning(NamespacePackageTest):
135+
paths = ['portion1']
136+
137+
def test_invalidate_caches(self):
138+
with tempfile.TemporaryDirectory() as temp_dir:
139+
# we manipulate sys.path before anything is imported to avoid
140+
# accidental cache invalidation when changing it
141+
sys.path.append(temp_dir)
142+
143+
import foo.one
144+
self.assertEqual(foo.one.attr, 'portion1 foo one')
145+
146+
# the module does not exist, so it cannot be imported
147+
with self.assertRaises(ImportError):
148+
import foo.just_created
149+
150+
# util.create_modules() manipulates sys.path
151+
# so we must create the modules manually instead
152+
namespace_path = os.path.join(temp_dir, 'foo')
153+
os.mkdir(namespace_path)
154+
module_path = os.path.join(namespace_path, 'just_created.py')
155+
with open(module_path, 'w', encoding='utf-8') as file:
156+
file.write('attr = "just_created foo"')
157+
158+
# the module is not known, so it cannot be imported yet
159+
with self.assertRaises(ImportError):
160+
import foo.just_created
161+
162+
# but after explicit cache invalidation, it is importable
163+
importlib.invalidate_caches()
164+
import foo.just_created
165+
self.assertEqual(foo.just_created.attr, 'just_created foo')
166+
167+
133168
class SeparatedOverlappingNamespacePackages(NamespacePackageTest):
134169
paths = ['portion1', 'both_portions']
135170

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
When a namespace package is imported before another module from the same
2+
namespace is created/installed in a different :data:`sys.path` location
3+
while the program is running, calling the
4+
:func:`importlib.invalidate_caches` function will now also guarantee the new
5+
module is noticed.

0 commit comments

Comments
 (0)