Skip to content

Commit bc1955a

Browse files
committed
mark protect_imports as just for tests
protect_imports is not thread safe and shouldn't be used in actual runtime code; the python_namespaces module now provides other ways to do thread safe imports of modules outside of sys.path which address what this was used for. This is left in place for tests since that scenario is always thread safe for our sync code, and the shorthand is useful. Signed-off-by: Brian Harring <ferringb@gmail.com>
1 parent 1605f3f commit bc1955a

File tree

5 files changed

+84
-35
lines changed

5 files changed

+84
-35
lines changed

src/snakeoil/delayed/__init__.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
__all__ = ("regexp",)
1+
__all__ = ("regexp", "import_module", "is_delayed")
22

33
import functools
44
import importlib
55
import re
6+
import sys
67
import types
8+
import typing
79

8-
from ..obj import DelayedInstantiation
10+
from ..obj import BaseDelayedObject, DelayedInstantiation
911

1012

1113
@functools.wraps(re.compile)
@@ -14,6 +16,19 @@ def regexp(pattern: str, flags: int = 0):
1416
return DelayedInstantiation(re.Pattern, re.compile, pattern, flags)
1517

1618

17-
def import_module(target: str) -> types.ModuleType:
18-
"""Import a module at time of access. This is a shim for python's lazy import in 3.15"""
19+
def import_module(target: str, force_proxy=False) -> types.ModuleType:
20+
"""Import a module at time of access if it's not already imported. This is a shim for python's lazy import in 3.15
21+
22+
:param target: the python namespace path of what to import. `snakeoil.klass` for example.
23+
:param force_proxy: Even if the module is in sys.modules, still return a proxy. This is a break glass
24+
control only relevant for hard cycle breaking.
25+
"""
26+
if not force_proxy and (module := sys.modules.get(target, None)) is not None:
27+
return module
1928
return DelayedInstantiation(types.ModuleType, importlib.import_module, target)
29+
30+
31+
# Convert this to a type guard when py3.14 is min.
32+
def is_delayed(obj: typing.Any) -> bool:
33+
cls = object.__getattribute__(obj, "__class__")
34+
return isinstance(cls, BaseDelayedObject)

src/snakeoil/deprecation/registry.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import typing
44
import warnings
55

6-
from ..delayed import import_module
6+
from ..delayed import import_module, is_delayed
77
from .util import suppress_deprecations
88

99
python_namespaces = import_module("snakeoil.python_namespaces")
@@ -137,6 +137,13 @@ def __call__(
137137
"""Decorate a callable with a deprecation notice, registering it in the internal list of deprecations"""
138138

139139
def f(functor):
140+
# Catch mistakes that force proxy objects to realize immediately
141+
if is_delayed(functor):
142+
raise ValueError(
143+
"deprecation of lazy instantiation objects (`snakeoil.obj.DelayedInstantation` for example) are not possible. `warnings.deprecated` will immediately reify them. "
144+
"You must interpose a *real* functor that internally invokes the delayed object when accessed; this is the only way to shield the delayed object `from warnings.deprecated` triggering it."
145+
)
146+
140147
if not self.is_enabled:
141148
return functor
142149

src/snakeoil/python_namespaces.py

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
11
__all__ = ("import_submodules_of", "get_submodules_of")
22

3-
import contextlib
43
import os
5-
import sys
64
import types
75
import typing
8-
from importlib import import_module, invalidate_caches, machinery
6+
from importlib import import_module, machinery
97
from importlib import util as import_util
108
from pathlib import Path
119

10+
from . import delayed
11+
from ._internals import deprecated
12+
1213
T_class_filter = typing.Callable[[str], bool]
1314

15+
# Delayed to avoid triggering all test crap- pytest for example- and cycle breaking
16+
# while the deprecation is being moved out. There is a cycle for all of this
17+
# that is due to klass needing the deprecated registry whilst having it's own deprecations.
18+
_test = delayed.import_module("snakeoil.test")
19+
20+
21+
@deprecated(
22+
"use `snakeoil.tests.protect_imports`; this should only be used for tests. Runtime usage should use `import_module_from_path`",
23+
removal_in=(0, 12, 0),
24+
qualname="snakeoil.python_namespaces.protect_imports",
25+
)
26+
def protect_imports():
27+
# isolate the access so only when this is invoked, does _test reify.
28+
return _test.protect_imports()
29+
1430

1531
def get_submodules_of(
1632
root: types.ModuleType | str,
@@ -111,33 +127,6 @@ def remove_py_extension(path: Path | str) -> str | None:
111127
return None
112128

113129

114-
@contextlib.contextmanager
115-
def protect_imports() -> typing.Generator[
116-
tuple[list[str], dict[str, types.ModuleType]], None, None
117-
]:
118-
"""
119-
Non threadsafe mock.patch of internal imports to allow revision
120-
121-
This should used in tests or very select scenarios. Assume that underlying
122-
c extensions that hold internal static state (curse module) will reimport, but
123-
will not be 'clean'. Any changes an import inflicts on the other modules in
124-
memory, etc, this cannot block that. Nor is this intended to do so; it's
125-
for controlled tests or very specific usages.
126-
"""
127-
orig_content = sys.path[:]
128-
orig_modules = sys.modules.copy()
129-
with contextlib.nullcontext():
130-
yield sys.path, sys.modules
131-
132-
sys.path[:] = orig_content
133-
# This is explicitly not thread safe, but manipulating sys.path fundamentally isn't thus this context
134-
# isn't thread safe. TL;dr: nuke it, and restore, it's the only way to be sure (to paraphrase)
135-
sys.modules.clear()
136-
sys.modules.update(orig_modules)
137-
# Out of paranoia, force loaders to reset their caches.
138-
invalidate_caches()
139-
140-
141130
def import_module_from_path(
142131
path: str | Path, module_name: str | None = None
143132
) -> types.ModuleType:

src/snakeoil/test/__init__.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,21 @@
77
"Modules",
88
"NamespaceCollector",
99
"protect_process",
10+
"protect_imports",
1011
"random_str",
1112
"Slots",
1213
)
1314

1415

16+
import contextlib
1517
import os
1618
import random
1719
import string
1820
import subprocess
1921
import sys
22+
import types
23+
import typing
24+
from importlib import invalidate_caches
2025
from unittest.mock import patch
2126

2227
from .abstract import AbstractTest
@@ -113,3 +118,31 @@ def mock_import(name, *args, **kwargs):
113118
return orig_import(name, *args, **kwargs)
114119

115120
return patch("builtins.__import__", side_effect=mock_import)
121+
122+
123+
@contextlib.contextmanager
124+
def protect_imports() -> typing.Generator[
125+
tuple[list[str], dict[str, types.ModuleType]], None, None
126+
]:
127+
"""
128+
Non threadsafe mock.patch of internal imports to allow revision
129+
130+
This should used in tests or very select scenarios. Assume that underlying
131+
c extensions that hold internal static state (curse module) will reimport, but
132+
will not be 'clean'. Any changes an import inflicts on the other modules in
133+
memory, etc, this cannot block that. Nor is this intended to do so; it's
134+
for controlled tests or very specific usages.
135+
"""
136+
# Do not change this code without changing python_namespaces.protect_imports. We have two implementations due to cycle issues.
137+
orig_content = sys.path[:]
138+
orig_modules = sys.modules.copy()
139+
with contextlib.nullcontext():
140+
yield sys.path, sys.modules
141+
142+
sys.path[:] = orig_content
143+
# This is explicitly not thread safe, but manipulating sys.path fundamentally isn't thus this context
144+
# isn't thread safe. TL;dr: nuke it, and restore, it's the only way to be sure (to paraphrase)
145+
sys.modules.clear()
146+
sys.modules.update(orig_modules)
147+
# Out of paranoia, force loaders to reset their caches.
148+
invalidate_caches()

tests/test_delayed.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,8 @@ def test_import_module(tmp_path):
2727
assert "blah" in modules
2828
assert 1 == f.x
2929
assert modules["blah"] is not f
30+
31+
shortcircuited = delayed.import_module("blah")
32+
assert modules["blah"] is shortcircuited, (
33+
"import_module must return the module if it already is in sys.modules rather than a proxy"
34+
)

0 commit comments

Comments
 (0)