commit: bc1955a8e5505c0a1c5172395c052b70d32bb35e
Author: Brian Harring <ferringb <AT> gmail <DOT> com>
AuthorDate: Sat Jan 10 10:13:32 2026 +0000
Commit: Brian Harring <ferringb <AT> gmail <DOT> com>
CommitDate: Sat Jan 10 10:15:43 2026 +0000
URL:
https://gitweb.gentoo.org/proj/pkgcore/snakeoil.git/commit/?id=bc1955a8
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 <AT> gmail.com>
src/snakeoil/delayed/__init__.py | 23 ++++++++++++++---
src/snakeoil/deprecation/registry.py | 9 ++++++-
src/snakeoil/python_namespaces.py | 49 ++++++++++++++----------------------
src/snakeoil/test/__init__.py | 33 ++++++++++++++++++++++++
tests/test_delayed.py | 5 ++++
5 files changed, 84 insertions(+), 35 deletions(-)
diff --git a/src/snakeoil/delayed/__init__.py b/src/snakeoil/delayed/__init__.py
index 8feb83c..1599bb4 100644
--- a/src/snakeoil/delayed/__init__.py
+++ b/src/snakeoil/delayed/__init__.py
@@ -1,11 +1,13 @@
-__all__ = ("regexp",)
+__all__ = ("regexp", "import_module", "is_delayed")
import functools
import importlib
import re
+import sys
import types
+import typing
-from ..obj import DelayedInstantiation
+from ..obj import BaseDelayedObject, DelayedInstantiation
@functools.wraps(re.compile)
@@ -14,6 +16,19 @@ def regexp(pattern: str, flags: int = 0):
return DelayedInstantiation(re.Pattern, re.compile, pattern, flags)
-def import_module(target: str) -> types.ModuleType:
- """Import a module at time of access. This is a shim for python's lazy
import in 3.15"""
+def import_module(target: str, force_proxy=False) -> types.ModuleType:
+ """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
+
+ :param target: the python namespace path of what to import.
`snakeoil.klass` for example.
+ :param force_proxy: Even if the module is in sys.modules, still return a
proxy. This is a break glass
+ control only relevant for hard cycle breaking.
+ """
+ if not force_proxy and (module := sys.modules.get(target, None)) is not
None:
+ return module
return DelayedInstantiation(types.ModuleType, importlib.import_module,
target)
+
+
+# Convert this to a type guard when py3.14 is min.
+def is_delayed(obj: typing.Any) -> bool:
+ cls = object.__getattribute__(obj, "__class__")
+ return isinstance(cls, BaseDelayedObject)
diff --git a/src/snakeoil/deprecation/registry.py
b/src/snakeoil/deprecation/registry.py
index 26c461c..5628636 100644
--- a/src/snakeoil/deprecation/registry.py
+++ b/src/snakeoil/deprecation/registry.py
@@ -3,7 +3,7 @@ import sys
import typing
import warnings
-from ..delayed import import_module
+from ..delayed import import_module, is_delayed
from .util import suppress_deprecations
python_namespaces = import_module("snakeoil.python_namespaces")
@@ -137,6 +137,13 @@ class Registry:
"""Decorate a callable with a deprecation notice, registering it in
the internal list of deprecations"""
def f(functor):
+ # Catch mistakes that force proxy objects to realize immediately
+ if is_delayed(functor):
+ raise ValueError(
+ "deprecation of lazy instantiation objects
(`snakeoil.obj.DelayedInstantation` for example) are not possible.
`warnings.deprecated` will immediately reify them. "
+ "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."
+ )
+
if not self.is_enabled:
return functor
diff --git a/src/snakeoil/python_namespaces.py
b/src/snakeoil/python_namespaces.py
index 7f0e7dc..745e407 100644
--- a/src/snakeoil/python_namespaces.py
+++ b/src/snakeoil/python_namespaces.py
@@ -1,16 +1,32 @@
__all__ = ("import_submodules_of", "get_submodules_of")
-import contextlib
import os
-import sys
import types
import typing
-from importlib import import_module, invalidate_caches, machinery
+from importlib import import_module, machinery
from importlib import util as import_util
from pathlib import Path
+from . import delayed
+from ._internals import deprecated
+
T_class_filter = typing.Callable[[str], bool]
+# Delayed to avoid triggering all test crap- pytest for example- and cycle
breaking
+# while the deprecation is being moved out. There is a cycle for all of this
+# that is due to klass needing the deprecated registry whilst having it's own
deprecations.
+_test = delayed.import_module("snakeoil.test")
+
+
+@deprecated(
+ "use `snakeoil.tests.protect_imports`; this should only be used for tests.
Runtime usage should use `import_module_from_path`",
+ removal_in=(0, 12, 0),
+ qualname="snakeoil.python_namespaces.protect_imports",
+)
+def protect_imports():
+ # isolate the access so only when this is invoked, does _test reify.
+ return _test.protect_imports()
+
def get_submodules_of(
root: types.ModuleType | str,
@@ -111,33 +127,6 @@ def remove_py_extension(path: Path | str) -> str | None:
return None
[email protected]
-def protect_imports() -> typing.Generator[
- tuple[list[str], dict[str, types.ModuleType]], None, None
-]:
- """
- Non threadsafe mock.patch of internal imports to allow revision
-
- This should used in tests or very select scenarios. Assume that underlying
- c extensions that hold internal static state (curse module) will reimport,
but
- will not be 'clean'. Any changes an import inflicts on the other modules
in
- memory, etc, this cannot block that. Nor is this intended to do so; it's
- for controlled tests or very specific usages.
- """
- orig_content = sys.path[:]
- orig_modules = sys.modules.copy()
- with contextlib.nullcontext():
- yield sys.path, sys.modules
-
- sys.path[:] = orig_content
- # This is explicitly not thread safe, but manipulating sys.path
fundamentally isn't thus this context
- # isn't thread safe. TL;dr: nuke it, and restore, it's the only way to be
sure (to paraphrase)
- sys.modules.clear()
- sys.modules.update(orig_modules)
- # Out of paranoia, force loaders to reset their caches.
- invalidate_caches()
-
-
def import_module_from_path(
path: str | Path, module_name: str | None = None
) -> types.ModuleType:
diff --git a/src/snakeoil/test/__init__.py b/src/snakeoil/test/__init__.py
index 1539552..e97510c 100644
--- a/src/snakeoil/test/__init__.py
+++ b/src/snakeoil/test/__init__.py
@@ -7,16 +7,21 @@ __all__ = (
"Modules",
"NamespaceCollector",
"protect_process",
+ "protect_imports",
"random_str",
"Slots",
)
+import contextlib
import os
import random
import string
import subprocess
import sys
+import types
+import typing
+from importlib import invalidate_caches
from unittest.mock import patch
from .abstract import AbstractTest
@@ -113,3 +118,31 @@ def hide_imports(*import_names: str):
return orig_import(name, *args, **kwargs)
return patch("builtins.__import__", side_effect=mock_import)
+
+
[email protected]
+def protect_imports() -> typing.Generator[
+ tuple[list[str], dict[str, types.ModuleType]], None, None
+]:
+ """
+ Non threadsafe mock.patch of internal imports to allow revision
+
+ This should used in tests or very select scenarios. Assume that underlying
+ c extensions that hold internal static state (curse module) will reimport,
but
+ will not be 'clean'. Any changes an import inflicts on the other modules
in
+ memory, etc, this cannot block that. Nor is this intended to do so; it's
+ for controlled tests or very specific usages.
+ """
+ # Do not change this code without changing
python_namespaces.protect_imports. We have two implementations due to cycle
issues.
+ orig_content = sys.path[:]
+ orig_modules = sys.modules.copy()
+ with contextlib.nullcontext():
+ yield sys.path, sys.modules
+
+ sys.path[:] = orig_content
+ # This is explicitly not thread safe, but manipulating sys.path
fundamentally isn't thus this context
+ # isn't thread safe. TL;dr: nuke it, and restore, it's the only way to be
sure (to paraphrase)
+ sys.modules.clear()
+ sys.modules.update(orig_modules)
+ # Out of paranoia, force loaders to reset their caches.
+ invalidate_caches()
diff --git a/tests/test_delayed.py b/tests/test_delayed.py
index 82e4fd7..5f57399 100644
--- a/tests/test_delayed.py
+++ b/tests/test_delayed.py
@@ -27,3 +27,8 @@ def test_import_module(tmp_path):
assert "blah" in modules
assert 1 == f.x
assert modules["blah"] is not f
+
+ shortcircuited = delayed.import_module("blah")
+ assert modules["blah"] is shortcircuited, (
+ "import_module must return the module if it already is in
sys.modules rather than a proxy"
+ )