Skip to content

Commit 4322f61

Browse files
Merge pull request #27 from GlenWalker/fix_py3_import_race
Fix race condition for import of modules which use apipkg in Python 3.3+
2 parents bed13c7 + ed7adce commit 4322f61

File tree

3 files changed

+161
-26
lines changed

3 files changed

+161
-26
lines changed

CHANGELOG

Lines changed: 10 additions & 0 deletions
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

src/apipkg/__init__.py

Lines changed: 85 additions & 20 deletions
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,85 @@ 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)
64+
mod = sys.modules.get(pkgname)
65+
66+
if _PY2:
67+
mod = _initpkg_py2(mod, pkgname, exportdefs, attr=attr)
68+
else:
69+
mod = _initpkg_py3(mod, pkgname, exportdefs, attr=attr)
70+
71+
# eagerload in bypthon to avoid their monkeypatching breaking packages
72+
if "bpython" in sys.modules or eager:
73+
for module in list(sys.modules.values()):
74+
if isinstance(module, ApiModule):
75+
module.__dict__
76+
77+
return mod
78+
79+
80+
def _initpkg_py2(mod, pkgname, exportdefs, attr=None):
81+
"""Python 2 helper for initpkg.
82+
83+
In Python 2 we can't update __class__ for an instance of types.Module, and
84+
imports are protected by the global import lock anyway, so it is safe for a
85+
module to replace itself during import.
86+
87+
"""
5288
d = {}
53-
f = getattr(oldmod, "__file__", None)
89+
f = getattr(mod, "__file__", None)
5490
if f:
5591
f = _py_abspath(f)
5692
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)
93+
if hasattr(mod, "__version__"):
94+
d["__version__"] = mod.__version__
95+
if hasattr(mod, "__loader__"):
96+
d["__loader__"] = mod.__loader__
97+
if hasattr(mod, "__path__"):
98+
d["__path__"] = [_py_abspath(p) for p in mod.__path__]
99+
if hasattr(mod, "__package__"):
100+
d["__package__"] = mod.__package__
101+
if "__doc__" not in exportdefs and getattr(mod, "__doc__", None):
102+
d["__doc__"] = mod.__doc__
103+
d["__spec__"] = getattr(mod, "__spec__", None)
68104
d.update(attr)
69-
if hasattr(oldmod, "__dict__"):
70-
oldmod.__dict__.update(d)
105+
if hasattr(mod, "__dict__"):
106+
mod.__dict__.update(d)
71107
mod = ApiModule(pkgname, exportdefs, implprefix=pkgname, attr=d)
72108
sys.modules[pkgname] = mod
73-
# eagerload in bypthon to avoid their monkeypatching breaking packages
74-
if "bpython" in sys.modules or eager:
75-
for module in list(sys.modules.values()):
76-
if isinstance(module, ApiModule):
77-
module.__dict__
109+
return mod
110+
111+
112+
def _initpkg_py3(mod, pkgname, exportdefs, attr=None):
113+
"""Python 3 helper for initpkg.
114+
115+
Python 3.3+ uses finer grained locking for imports, and checks sys.modules before
116+
acquiring the lock to avoid the overhead of the fine-grained locking. This
117+
introduces a race condition when a module is imported by multiple threads
118+
concurrently - some threads will see the initial module and some the replacement
119+
ApiModule. We avoid this by updating the existing module in-place.
120+
121+
"""
122+
if mod is None:
123+
d = {"__file__": None, "__spec__": None}
124+
d.update(attr)
125+
mod = ApiModule(pkgname, exportdefs, implprefix=pkgname, attr=d)
126+
sys.modules[pkgname] = mod
127+
else:
128+
f = getattr(mod, "__file__", None)
129+
if f:
130+
f = _py_abspath(f)
131+
mod.__file__ = f
132+
if hasattr(mod, "__path__"):
133+
mod.__path__ = [_py_abspath(p) for p in mod.__path__]
134+
if "__doc__" in exportdefs and hasattr(mod, "__doc__"):
135+
del mod.__doc__
136+
for name in dir(mod):
137+
if name not in _PRESERVED_MODULE_ATTRS:
138+
delattr(mod, name)
139+
140+
# Updating class of existing module as per importlib.util.LazyLoader
141+
mod.__class__ = ApiModule
142+
mod.__init__(pkgname, exportdefs, implprefix=pkgname, attr=attr)
78143
return mod
79144

80145

test_apipkg.py

Lines changed: 66 additions & 6 deletions
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,59 @@ 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+
621+
assert isinstance(importrace, apipkg.ApiModule)
622+
assert importrace.attr == 43
623+
624+
for thread in threads:
625+
assert thread.importrace is importrace
626+
627+
568628
def test_bpython_getattr_override(tmpdir, monkeypatch):
569629
def patchgetattr(self, name):
570630
raise AttributeError(name)

0 commit comments

Comments
 (0)