Skip to content

Commit e68ae61

Browse files
committed
Fix race condition for import of modules which use apipkg in Python 3.3+
* Update existing module in-place rather than replacing in sys.modules with a new ApiModule instance
1 parent bed13c7 commit e68ae61

File tree

3 files changed

+143
-28
lines changed

3 files changed

+143
-28
lines changed

Diff for: CHANGELOG

+10
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
2.1.0
2+
----------------------------------------
3+
4+
- fix race condition for import of modules using apipkg.initpkg in Python 3.3+
5+
by updating existing modules in-place rather than replacing in sys.modules
6+
with an apipkg.ApiModule instances. This race condition exists for
7+
import statements (and __import__) in Python 3.3+ where sys.modules is
8+
checked before obtaining an import lock, and for importlib.import_module
9+
in Python 3.11+ for the same reason.
10+
111
2.0.1
212
----------------------------------------
313

Diff for: src/apipkg/__init__.py

+68-22
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@
2020
from .version import version as __version__ # NOQA:F401
2121

2222

23+
_PY2 = sys.version_info[0] == 2
24+
_PRESERVED_MODULE_ATTRS = {
25+
"__file__",
26+
"__version__",
27+
"__loader__",
28+
"__path__",
29+
"__package__",
30+
"__doc__",
31+
"__spec__",
32+
"__dict__",
33+
}
34+
35+
2336
def _py_abspath(path):
2437
"""
2538
special version of abspath
@@ -48,33 +61,66 @@ def distribution_version(name):
4861
def initpkg(pkgname, exportdefs, attr=None, eager=False):
4962
""" initialize given package from the export definitions. """
5063
attr = attr or {}
51-
oldmod = sys.modules.get(pkgname)
52-
d = {}
53-
f = getattr(oldmod, "__file__", None)
54-
if f:
55-
f = _py_abspath(f)
56-
d["__file__"] = f
57-
if hasattr(oldmod, "__version__"):
58-
d["__version__"] = oldmod.__version__
59-
if hasattr(oldmod, "__loader__"):
60-
d["__loader__"] = oldmod.__loader__
61-
if hasattr(oldmod, "__path__"):
62-
d["__path__"] = [_py_abspath(p) for p in oldmod.__path__]
63-
if hasattr(oldmod, "__package__"):
64-
d["__package__"] = oldmod.__package__
65-
if "__doc__" not in exportdefs and getattr(oldmod, "__doc__", None):
66-
d["__doc__"] = oldmod.__doc__
67-
d["__spec__"] = getattr(oldmod, "__spec__", None)
68-
d.update(attr)
69-
if hasattr(oldmod, "__dict__"):
70-
oldmod.__dict__.update(d)
71-
mod = ApiModule(pkgname, exportdefs, implprefix=pkgname, attr=d)
72-
sys.modules[pkgname] = mod
64+
mod = sys.modules.get(pkgname)
65+
66+
# In Python 2 we can't update __class__ for an instance of types.Module, and
67+
# imports are protected by the global import lock anyway, so it is safe for a
68+
# module to replace itself during import. Python 3.3+ uses finer grained locking
69+
# for imports, and checks sys.modules before acquiring the lock to avoid the
70+
# overhead of the fine-grained locking. This introduces a race condition when a
71+
# module is imported by multiple threads concurrently - some threads will see the
72+
# initial module and some the replacement ApiModule. We avoid this by updating the
73+
# existing module in-place.
74+
if _PY2:
75+
d = {}
76+
f = getattr(mod, "__file__", None)
77+
if f:
78+
f = _py_abspath(f)
79+
d["__file__"] = f
80+
if hasattr(mod, "__version__"):
81+
d["__version__"] = mod.__version__
82+
if hasattr(mod, "__loader__"):
83+
d["__loader__"] = mod.__loader__
84+
if hasattr(mod, "__path__"):
85+
d["__path__"] = [_py_abspath(p) for p in mod.__path__]
86+
if hasattr(mod, "__package__"):
87+
d["__package__"] = mod.__package__
88+
if "__doc__" not in exportdefs and getattr(mod, "__doc__", None):
89+
d["__doc__"] = mod.__doc__
90+
d["__spec__"] = getattr(mod, "__spec__", None)
91+
d.update(attr)
92+
if hasattr(mod, "__dict__"):
93+
mod.__dict__.update(d)
94+
mod = ApiModule(pkgname, exportdefs, implprefix=pkgname, attr=d)
95+
sys.modules[pkgname] = mod
96+
elif mod is None:
97+
d = {"__file__": None, "__spec__": None}
98+
d.update(attr)
99+
mod = ApiModule(pkgname, exportdefs, implprefix=pkgname, attr=d)
100+
sys.modules[pkgname] = mod
101+
else:
102+
f = getattr(mod, "__file__", None)
103+
if f:
104+
f = _py_abspath(f)
105+
mod.__file__ = f
106+
if hasattr(mod, "__path__"):
107+
mod.__path__ = [_py_abspath(p) for p in mod.__path__]
108+
if "__doc__" in exportdefs and hasattr(mod, "__doc__"):
109+
del mod.__doc__
110+
for name in dir(mod):
111+
if name not in _PRESERVED_MODULE_ATTRS:
112+
delattr(mod, name)
113+
114+
# Updating class of existing module as per importlib.util.LazyLoader
115+
mod.__class__ = ApiModule
116+
mod.__init__(pkgname, exportdefs, implprefix=pkgname, attr=attr)
117+
73118
# eagerload in bypthon to avoid their monkeypatching breaking packages
74119
if "bpython" in sys.modules or eager:
75120
for module in list(sys.modules.values()):
76121
if isinstance(module, ApiModule):
77122
module.__dict__
123+
78124
return mod
79125

80126

Diff for: test_apipkg.py

+65-6
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313

1414
import apipkg
1515

16+
17+
PY2 = sys.version_info[0] == 2
18+
PY3 = sys.version_info[0] == 3
19+
20+
1621
#
1722
# test support for importing modules
1823
#
@@ -270,12 +275,12 @@ def parsenamespace(spec):
270275
return ns
271276

272277

273-
def test_initpkg_replaces_sysmodules(monkeypatch):
278+
def test_initpkg_updates_sysmodules(monkeypatch):
274279
mod = ModuleType("hello")
275280
monkeypatch.setitem(sys.modules, "hello", mod)
276281
apipkg.initpkg("hello", {"x": "os.path:abspath"})
277282
newmod = sys.modules["hello"]
278-
assert newmod != mod
283+
assert (PY2 and newmod != mod) or (PY3 and newmod is mod)
279284
assert newmod.x == os.path.abspath
280285

281286

@@ -286,15 +291,17 @@ def test_initpkg_transfers_attrs(monkeypatch):
286291
mod.__loader__ = "loader"
287292
mod.__package__ = "package"
288293
mod.__doc__ = "this is the documentation"
294+
mod.goodbye = "goodbye"
289295
monkeypatch.setitem(sys.modules, "hello", mod)
290296
apipkg.initpkg("hello", {})
291297
newmod = sys.modules["hello"]
292-
assert newmod != mod
298+
assert (PY2 and newmod != mod) or (PY3 and newmod is mod)
293299
assert newmod.__file__ == os.path.abspath(mod.__file__)
294300
assert newmod.__version__ == mod.__version__
295301
assert newmod.__loader__ == mod.__loader__
296302
assert newmod.__package__ == mod.__package__
297303
assert newmod.__doc__ == mod.__doc__
304+
assert not hasattr(newmod, "goodbye")
298305

299306

300307
def test_initpkg_nodoc(monkeypatch):
@@ -312,7 +319,7 @@ def test_initpkg_overwrite_doc(monkeypatch):
312319
monkeypatch.setitem(sys.modules, "hello", hello)
313320
apipkg.initpkg("hello", {"__doc__": "sys:__doc__"})
314321
newhello = sys.modules["hello"]
315-
assert newhello != hello
322+
assert (PY2 and newhello != hello) or (PY3 and newhello is hello)
316323
assert newhello.__doc__ == sys.__doc__
317324

318325

@@ -324,7 +331,7 @@ def test_initpkg_not_transfers_not_existing_attrs(monkeypatch):
324331
monkeypatch.setitem(sys.modules, "hello", mod)
325332
apipkg.initpkg("hello", {})
326333
newmod = sys.modules["hello"]
327-
assert newmod != mod
334+
assert (PY2 and newmod != mod) or (PY3 and newmod is mod)
328335
assert newmod.__file__ == os.path.abspath(mod.__file__)
329336
assert not hasattr(newmod, "__path__")
330337
assert not hasattr(newmod, "__package__") or mod.__package__ is None
@@ -337,7 +344,7 @@ def test_initpkg_not_changing_jython_paths(monkeypatch):
337344
monkeypatch.setitem(sys.modules, "hello", mod)
338345
apipkg.initpkg("hello", {})
339346
newmod = sys.modules["hello"]
340-
assert newmod != mod
347+
assert (PY2 and newmod != mod) or (PY3 and newmod is mod)
341348
assert newmod.__file__.startswith("__pyclasspath__")
342349
unchanged, changed = newmod.__path__
343350
assert changed != "ichange"
@@ -565,6 +572,58 @@ def run(self):
565572
assert thread.attr == 42
566573

567574

575+
@pytest.mark.skipif("threading" not in sys.modules, reason="requires thread support")
576+
def test_import_race(tmpdir, monkeypatch):
577+
pkgdir = tmpdir.mkdir("importrace")
578+
pkgdir.join("__init__.py").write(
579+
textwrap.dedent(
580+
"""
581+
import time
582+
time.sleep(0.1)
583+
import apipkg
584+
apipkg.initpkg(__name__, exportdefs={
585+
'attr': '.submod:attr',
586+
},
587+
)
588+
"""
589+
)
590+
)
591+
pkgdir.join("submod.py").write(
592+
textwrap.dedent(
593+
"""
594+
attr = 43
595+
"""
596+
)
597+
)
598+
monkeypatch.syspath_prepend(tmpdir)
599+
600+
class TestThread(threading.Thread):
601+
def __init__(self):
602+
super(TestThread, self).__init__()
603+
self.importrace = None
604+
605+
def run(self):
606+
import importrace
607+
608+
self.importrace = importrace
609+
610+
threads = [TestThread() for _ in range(8)]
611+
for thread in threads:
612+
thread.start()
613+
for thread in threads:
614+
thread.join()
615+
for thread in threads:
616+
assert isinstance(thread.importrace, apipkg.ApiModule)
617+
assert thread.importrace.attr == 43
618+
619+
import importrace
620+
assert isinstance(importrace, apipkg.ApiModule)
621+
assert importrace.attr == 43
622+
623+
for thread in threads:
624+
assert thread.importrace is importrace
625+
626+
568627
def test_bpython_getattr_override(tmpdir, monkeypatch):
569628
def patchgetattr(self, name):
570629
raise AttributeError(name)

0 commit comments

Comments
 (0)