From f6d0b9839a32bdf4e4e6094d4dceb0a14ef7a320 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 17:56:27 +0200 Subject: [PATCH] Add scoped singleton registries to SingletonConfigurable Introduce SingletonScope, an isolated singleton registry activated for a dynamic extent (current thread / async task) via a contextvars.ContextVar. While active, instance() on any subclass of the scope's base resolves within the scope -- creating fresh instances on first use -- without ever reading, creating, or mutating the process-global _instance. The classic (no-scope) instance()/initialized()/clear_instance() paths are left textually unchanged; scoped resolution is prepended as an early branch. - SingletonScope with covers()/get() (MRO-walk parity)/add() (write-through pre-seeding) and a re-enterable __call__ context manager - SingletonConfigurable.scope() factory and _current_scope() helper - Export SingletonScope from traitlets.config - Tests covering isolation, lazy creation by uncontrolled code, no global side effects, initialized() semantics, re-entry, pre-seeding, blast radius, nesting/LIFO restore, MRO sibling MultipleInstanceError parity, in-scope clear_instance, thread isolation, and asyncio task propagation - Docs: "Singleton scopes" section with usage/semantics and a per-thread singleton example --- .pre-commit-config.yaml | 2 +- docs/source/config-api.rst | 3 + docs/source/config.rst | 91 ++++++++++++++ tests/config/test_configurable.py | 198 +++++++++++++++++++++++++++++- traitlets/config/__init__.py | 1 + traitlets/config/configurable.py | 84 +++++++++++++ 6 files changed, 377 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a4a1fcf5..3aa09e90 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ ci: repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v6.0.0 hooks: - id: check-case-conflict - id: check-ast diff --git a/docs/source/config-api.rst b/docs/source/config-api.rst index bb956bb2..d30767c2 100644 --- a/docs/source/config-api.rst +++ b/docs/source/config-api.rst @@ -9,6 +9,9 @@ Traitlets config API reference .. autoclass:: SingletonConfigurable :members: +.. autoclass:: SingletonScope + :members: + .. autoclass:: LoggingConfigurable :members: diff --git a/docs/source/config.rst b/docs/source/config.rst index 210c42ee..de0a0240 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -73,6 +73,97 @@ Singletons: :class:`~traitlets.config.SingletonConfigurable` of a given singleton class, but the :meth:`instance` method will always return the same one. +Singleton scopes +---------------- + +By default :meth:`~traitlets.config.SingletonConfigurable.instance` resolves +against a single, process-global registry. A :class:`~traitlets.config.SingletonScope` +provides an isolated registry that is active only for a dynamic extent (the +current thread or :mod:`asyncio` task), leaving the global one untouched. This is +useful when you need to run code that internally calls ``.instance()`` — code you +do not control — against a fresh set of singletons, for example in a test or when +serving concurrent requests. + +Create a scope from the base class whose subtree it should cover with +:meth:`~traitlets.config.SingletonConfigurable.scope`, then activate it as a +context manager: + +.. sourcecode:: python + + a = MyApp.instance() # process-global instance + + scope = MyApp.scope() # covers MyApp and its subclasses + with scope(): + third_party_function() # its internal MyApp.instance() calls... + b = MyApp.instance() # ...resolve to this fresh instance + assert b is not a + assert MyApp.instance() is a # global untouched, exception-safe + + scope.get(MyApp) # -> b, retrieve what was created in-scope + with scope(): # re-entering resumes the same registry + assert MyApp.instance() is b + +Key semantics: + +- **Coverage is defined by the base class.** ``Foo.scope()`` only intercepts + ``.instance()`` for ``Foo`` and its subclasses; unrelated singletons keep + resolving against the global registry. Use ``SingletonConfigurable.scope()`` to + cover every singleton, or a narrower base to limit the blast radius. +- **No fallback to the global.** Inside a scope, the first ``.instance()`` call + creates a fresh instance registered in the scope; :meth:`instance` never reads, + creates, or mutates the process-global ``_instance``, and + :meth:`~traitlets.config.SingletonConfigurable.initialized` is ``False`` until + that first in-scope creation. +- **Pre-seeding.** :meth:`~traitlets.config.SingletonScope.add` registers an + existing instance so it is returned inside the scope (including via ancestor + singleton classes), letting you reuse an object across scope entries. +- **Nesting and propagation.** Scopes nest; the innermost active scope covering a + class wins, and blocks restore in LIFO order (even on exceptions). Because the + active scope lives in a :class:`~contextvars.ContextVar`, an :mod:`asyncio` task + created inside a scope inherits it, but a new :class:`threading.Thread` started + inside does not — it falls through to the global registry. +- **Direct construction is never intercepted.** ``MyApp()`` always builds a new, + unregistered object, in or out of a scope. + +Because a scope is bound to the thread (or task) that activates it, a freshly +started :class:`threading.Thread` does *not* inherit its parent's scope. This +makes it easy to give each worker thread its own isolated singleton: have every +thread activate its own scope. Any ``.instance()`` calls made by that thread — +directly or deep inside code it calls — then resolve to a per-thread instance, +while the process-global singleton is left untouched: + +.. sourcecode:: python + + import threading + from traitlets.config import SingletonConfigurable + + + class MyApp(SingletonConfigurable): + pass + + + seen = {} + + + def worker(name): + scope = MyApp.scope() # a fresh registry, private to this thread + with scope(): # activate it for this thread's extent + app = MyApp.instance() # created once, here... + assert MyApp.instance() is app # ...and reused within the thread + seen[name] = app + + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)] + for t in threads: + t.start() + for t in threads: + t.join() + + # each thread got its own distinct instance + assert len({id(app) for app in seen.values()}) == 3 + # and none of them is the process-global singleton + assert all(app is not MyApp.instance() for app in seen.values()) + Having described these main concepts, we can now state the main idea in our configuration system: *"configuration" allows the default values of class attributes to be controlled on a class by class basis*. Thus all instances of diff --git a/tests/config/test_configurable.py b/tests/config/test_configurable.py index 8d34354d..72aeaab4 100644 --- a/tests/config/test_configurable.py +++ b/tests/config/test_configurable.py @@ -4,14 +4,21 @@ # Distributed under the terms of the Modified BSD License. from __future__ import annotations +import asyncio import logging +import threading from unittest import TestCase import pytest from tests._warnings import expected_warnings from traitlets.config.application import Application -from traitlets.config.configurable import Configurable, LoggingConfigurable, SingletonConfigurable +from traitlets.config.configurable import ( + Configurable, + LoggingConfigurable, + MultipleInstanceError, + SingletonConfigurable, +) from traitlets.config.loader import Config from traitlets.log import get_logger from traitlets.traitlets import ( @@ -335,6 +342,195 @@ class Bam(Bar): self.assertEqual(SingletonConfigurable._instance, None) +class TestSingletonScope(TestCase): + def test_isolation(self): + class MyApp(SingletonConfigurable): + pass + + a = MyApp.instance() + scope = MyApp.scope() + with scope(): + b = MyApp.instance() + self.assertIsNot(b, a) + self.assertIs(MyApp.instance(), b) + + self.assertIs(MyApp.instance(), a) + self.assertIs(MyApp._instance, a) + + def test_lazy_creation_uncontrolled_code(self): + class MyApp(SingletonConfigurable): + pass + + def third_party(): + return MyApp.instance() + + scope = MyApp.scope() + with scope(): + b = third_party() + self.assertIs(scope.get(MyApp), b) + + def test_no_global_side_effects(self): + class MyApp(SingletonConfigurable): + pass + + scope = MyApp.scope() + with scope(): + MyApp.instance() + + self.assertIsNone(MyApp._instance) + self.assertIs(MyApp.initialized(), False) + + def test_initialized_false_before_creation_in_scope(self): + class MyApp(SingletonConfigurable): + pass + + MyApp.instance() + self.assertIs(MyApp.initialized(), True) + + scope = MyApp.scope() + with scope(): + self.assertIs(MyApp.initialized(), False) + MyApp.instance() + self.assertIs(MyApp.initialized(), True) + + def test_reentry(self): + class MyApp(SingletonConfigurable): + pass + + scope = MyApp.scope() + with scope(): + b = MyApp.instance() + + with scope(): + self.assertIs(MyApp.instance(), b) + + def test_preseeding(self): + class Bar(SingletonConfigurable): + pass + + class Bam(Bar): + pass + + existing = Bam() + scope = Bar.scope() + scope.add(existing) + with scope(): + self.assertIs(Bam.instance(), existing) + self.assertIs(Bar.instance(), existing) + + def test_blast_radius(self): + class Foo(SingletonConfigurable): + pass + + class Baz(SingletonConfigurable): + pass + + scope = Foo.scope() + with scope(): + Foo.instance() + self.assertIsNone(Foo._instance) + + baz = Baz.instance() + self.assertIs(Baz._instance, baz) + self.assertIsNone(scope.get(Baz)) + + def test_nesting(self): + class Outer(SingletonConfigurable): + pass + + outer_scope = Outer.scope() + inner_scope = Outer.scope() + + with outer_scope(): + o = Outer.instance() + + with inner_scope(): + i = Outer.instance() + self.assertIsNot(i, o) + + self.assertIs(Outer.instance(), o) + + try: + with inner_scope(): + raise RuntimeError("boom") + except RuntimeError: + pass + + self.assertIs(Outer.instance(), o) + + def test_mro_parity_in_scope(self): + class Bar(SingletonConfigurable): + pass + + class Bam(Bar): + pass + + scope1 = Bar.scope() + with scope1(): + bam = Bam.instance() + self.assertIs(Bar.instance(), bam) + + scope2 = Bar.scope() + with scope2(): + Bar.instance() + with self.assertRaises(MultipleInstanceError): + Bam.instance() + + def test_clear_instance_in_scope(self): + class MyApp(SingletonConfigurable): + pass + + a = MyApp.instance() + scope = MyApp.scope() + with scope(): + b = MyApp.instance() + MyApp.clear_instance() + self.assertIsNone(scope.get(MyApp)) + self.assertIs(MyApp._instance, a) + + c = MyApp.instance() + self.assertIsNot(c, b) + + def test_thread_isolation(self): + class MyApp(SingletonConfigurable): + pass + + a = MyApp.instance() + scope = MyApp.scope() + results = {} + with scope(): + b = MyApp.instance() + + def target(): + results["thread"] = MyApp.instance() + + thread = threading.Thread(target=target) + thread.start() + thread.join() + + self.assertIsNot(results["thread"], b) + self.assertIs(results["thread"], a) + self.assertIs(MyApp._instance, a) + + def test_async_propagation(self): + class MyApp(SingletonConfigurable): + pass + + scope = MyApp.scope() + + async def coro(): + return MyApp.instance() + + async def main(): + with scope(): + b = MyApp.instance() + task = asyncio.create_task(coro()) + result = await task + self.assertIs(result, b) + + asyncio.run(main()) + + class TestLoggingConfigurable(TestCase): def test_parent_logger(self): class Parent(LoggingConfigurable): diff --git a/traitlets/config/__init__.py b/traitlets/config/__init__.py index 2f7b7b0e..427643f6 100644 --- a/traitlets/config/__init__.py +++ b/traitlets/config/__init__.py @@ -16,5 +16,6 @@ "LoggingConfigurable", "MultipleInstanceError", "SingletonConfigurable", + "SingletonScope", "configurable", ] diff --git a/traitlets/config/configurable.py b/traitlets/config/configurable.py index d2bafef8..603265fa 100644 --- a/traitlets/config/configurable.py +++ b/traitlets/config/configurable.py @@ -4,8 +4,10 @@ # Distributed under the terms of the Modified BSD License. from __future__ import annotations +import contextlib import logging import typing as t +from contextvars import ContextVar from copy import deepcopy from textwrap import dedent @@ -514,6 +516,52 @@ def _get_log_handler(self) -> logging.Handler | None: return logger.handlers[0] +CT = t.TypeVar("CT", bound="SingletonConfigurable") + + +_active_scopes: ContextVar[tuple[SingletonScope, ...]] = ContextVar("singleton_scopes", default=()) + + +class SingletonScope: + """An isolated singleton registry, activated for a dynamic extent. + + While active (``with scope():``), :meth:`SingletonConfigurable.instance` + on any subclass of ``base`` resolves within this registry — creating + fresh instances on first use — in the current thread/async task only. + Re-enterable: the registry persists across activations. + """ + + def __init__(self, base: type[SingletonConfigurable]) -> None: + self.base = base + self._instances: dict[type, SingletonConfigurable] = {} + + def covers(self, cls: type) -> bool: + """Whether ``cls`` resolves within this scope.""" + return issubclass(cls, self.base) + + def get(self, cls: type[CT]) -> CT | None: + """Registered instance for ``cls``, emulating class-attribute + inheritance: walk ``cls.__mro__`` and return the first hit.""" + for klass in cls.__mro__: + if klass in self._instances: + return t.cast("CT", self._instances[klass]) + return None + + def add(self, inst: SingletonConfigurable) -> None: + """Pre-seed the registry with an existing instance (same + write-through to singleton parents as the classic path).""" + for subclass in type(inst)._walk_mro(): + self._instances[subclass] = inst + + @contextlib.contextmanager + def __call__(self) -> t.Generator[SingletonScope, None, None]: + token = _active_scopes.set((*_active_scopes.get(), self)) + try: + yield self + finally: + _active_scopes.reset(token) + + class SingletonConfigurable(LoggingConfigurable): """A configurable that only allows one instance. @@ -539,9 +587,28 @@ def _walk_mro(cls) -> t.Generator[type[SingletonConfigurable], None, None]: ): yield subclass + @classmethod + def scope(cls) -> SingletonScope: + """Create a scope covering this class and its subclasses.""" + return SingletonScope(cls) + + @classmethod + def _current_scope(cls) -> SingletonScope | None: + """The innermost active scope covering ``cls``, or None.""" + for scope in reversed(_active_scopes.get()): + if scope.covers(cls): + return scope + return None + @classmethod def clear_instance(cls) -> None: """unset _instance for this class and singleton parents.""" + scope = cls._current_scope() + if scope is not None: + for klass in list(scope._instances): + if isinstance(scope._instances[klass], cls): + del scope._instances[klass] + return if not cls.initialized(): return for subclass in cls._walk_mro(): @@ -578,6 +645,20 @@ def instance(cls, *args: t.Any, **kwargs: t.Any) -> Self: >>> bam == Bar.instance() True """ + scope = cls._current_scope() + if scope is not None: + if scope.get(cls) is None: + inst = cls(*args, **kwargs) + for subclass in cls._walk_mro(): + scope._instances[subclass] = inst + existing = scope.get(cls) + if isinstance(existing, cls): + return existing + raise MultipleInstanceError( + f"An incompatible sibling of '{cls.__name__}' is already instantiated" + f" as singleton: {type(existing).__name__}" + ) + # Create and save the instance if cls._instance is None: inst = cls(*args, **kwargs) @@ -597,4 +678,7 @@ def instance(cls, *args: t.Any, **kwargs: t.Any) -> Self: @classmethod def initialized(cls) -> bool: """Has an instance been created?""" + scope = cls._current_scope() + if scope is not None: + return scope.get(cls) is not None return hasattr(cls, "_instance") and cls._instance is not None