From e1be6afb3d6ea0eb5100751e71b673f392083727 Mon Sep 17 00:00:00 2001 From: remimd Date: Sun, 17 May 2026 17:22:33 +0200 Subject: [PATCH] fix: Prevent silent misuse of exited scopes --- injection/_core/scope.py | 28 ++++++++++++++++++++++------ tests/core/test_scope.py | 10 +++++++++- uv.lock | 12 ++++++------ 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/injection/_core/scope.py b/injection/_core/scope.py index a12ee09..7d33b85 100644 --- a/injection/_core/scope.py +++ b/injection/_core/scope.py @@ -212,6 +212,21 @@ def _bind_scope( stack.close() +class _SealedScopeCache(MutableMapping[Any, Any]): + __slots__ = () + + @staticmethod + def guard(*args: Any, **kwargs: Any) -> NoReturn: + raise ScopeError("Can't access cache of an exited scope.") + + __delitem__ = __getitem__ = __iter__ = __len__ = __setitem__ = guard + + +_sealed_cache = _SealedScopeCache() + +del _SealedScopeCache + + @runtime_checkable class Scope(Protocol): __slots__ = () @@ -227,14 +242,13 @@ def enter[T](self, context_manager: ContextManager[T]) -> T: raise NotImplementedError -@dataclass(repr=False, frozen=True, slots=True) +@dataclass(repr=False, eq=False, slots=True) class BaseScope[T](Scope, ABC): delegate: T - cache: MutableMapping[SlotKey[Any], Any] = field( - default_factory=dict, - init=False, - hash=False, - ) + cache: MutableMapping[SlotKey[Any], Any] = field(default_factory=dict, init=False) + + def close(self) -> None: + self.cache = _sealed_cache class AsyncScope(BaseScope[AsyncExitStack]): @@ -253,6 +267,7 @@ async def __aexit__( exc_value: BaseException | None, traceback: TracebackType | None, ) -> Any: + self.close() return await self.delegate.__aexit__(exc_type, exc_value, traceback) async def aenter[T](self, context_manager: AsyncContextManager[T]) -> T: @@ -278,6 +293,7 @@ def __exit__( exc_value: BaseException | None, traceback: TracebackType | None, ) -> Any: + self.close() return self.delegate.__exit__(exc_type, exc_value, traceback) async def aenter[T](self, context_manager: AsyncContextManager[T]) -> NoReturn: diff --git a/tests/core/test_scope.py b/tests/core/test_scope.py index 574783a..8643319 100644 --- a/tests/core/test_scope.py +++ b/tests/core/test_scope.py @@ -3,7 +3,7 @@ import pytest -from injection import define_scope, find_instance, scoped +from injection import SlotKey, define_scope, find_instance, scoped from injection.exceptions import ScopeAlreadyDefinedError, ScopeError @@ -35,3 +35,11 @@ def assertion() -> None: with ThreadPoolExecutor() as executor: with define_scope("test"): executor.submit(assertion) + + +def test_define_scope_with_sealed_raise_scope_error(): + with define_scope("test") as scope: + ... + + with pytest.raises(ScopeError): + scope.set_slot(SlotKey(), object()) diff --git a/uv.lock b/uv.lock index fa3d732..dc270f0 100644 --- a/uv.lock +++ b/uv.lock @@ -261,14 +261,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.3" +version = "8.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, ] [[package]] @@ -588,14 +588,14 @@ wheels = [ [[package]] name = "jaraco-functools" -version = "4.4.0" +version = "4.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, + { url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" }, ] [[package]]