From f6d0b9839a32bdf4e4e6094d4dceb0a14ef7a320 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 17:56:27 +0200 Subject: [PATCH 1/4] 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 a4a1fcf52..3aa09e902 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 bb956bb21..d30767c2c 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 210c42ee0..de0a02407 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 8d34354d4..72aeaab4b 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 2f7b7b0e7..427643f6e 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 d2bafef88..603265fad 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 From a4e676519353d3b8f362fc4d80702862b05e1900 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 12:59:46 +0000 Subject: [PATCH 2/4] Skip test_thread_isolation when thread_inherit_context is enabled On the free-threaded build (e.g. 3.14t) sys.flags.thread_inherit_context defaults to true, so a new threading.Thread starts with a copy of the parent's contextvars.Context. The active SingletonScope therefore propagates into child threads and MyApp.instance() resolves within the scope instead of falling through to the process-global registry, which made test_thread_isolation fail on all 3.14t jobs. Skip the test whenever thread_inherit_context is enabled (the default on free-threaded builds, opt-in elsewhere) so CI stays green; the classic GIL-build behavior remains covered. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0159wyFjYsXoVKyUN9NkNxzw --- tests/config/test_configurable.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/config/test_configurable.py b/tests/config/test_configurable.py index 72aeaab4b..fe0860d70 100644 --- a/tests/config/test_configurable.py +++ b/tests/config/test_configurable.py @@ -6,6 +6,7 @@ import asyncio import logging +import sys import threading from unittest import TestCase @@ -491,6 +492,13 @@ class MyApp(SingletonConfigurable): c = MyApp.instance() self.assertIsNot(c, b) + @pytest.mark.skipif( + bool(getattr(sys.flags, "thread_inherit_context", 0)), + reason="When thread_inherit_context is enabled (the default on the " + "free-threaded build, e.g. 3.14t), a new threading.Thread starts with a " + "copy of the parent's contextvars.Context, so an active scope propagates " + "to child threads instead of falling through to the global registry.", + ) def test_thread_isolation(self): class MyApp(SingletonConfigurable): pass From 8a92e2904239c35fee60b11af014f871a66e6ae1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 13:07:04 +0000 Subject: [PATCH 3/4] Document free-threading caveat and warn on scope use under thread_inherit_context The "threads don't inherit the scope" guarantee only holds when sys.flags.thread_inherit_context is false (the default GIL build). On the free-threaded build (e.g. 3.14t) it defaults to true, so a child thread starts with a copy of the parent context and resolves .instance() within the scope. - Docs: add a warning admonition to the "Singleton scopes" section and the SingletonScope docstring spelling out the build-dependent behavior, and correct the propagation bullet; reframe the per-thread example as working on both builds. - Emit a RuntimeWarning when a scope is activated while thread_inherit_context is enabled, pointing users at per-thread scopes. - Test the warning fires when the flag is enabled and stays silent when not. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0159wyFjYsXoVKyUN9NkNxzw --- docs/source/config.rst | 33 +++++++++++++++++++++++-------- tests/config/test_configurable.py | 26 ++++++++++++++++++++++++ traitlets/config/configurable.py | 26 +++++++++++++++++++++++- 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/docs/source/config.rst b/docs/source/config.rst index de0a02407..dfa98c899 100644 --- a/docs/source/config.rst +++ b/docs/source/config.rst @@ -120,17 +120,34 @@ Key semantics: - **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. + created inside a scope inherits it. A new :class:`threading.Thread` started + inside a scope inherits it only when :data:`sys.flags.thread_inherit_context` + is set (see the caveat below); otherwise the thread 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: +.. warning:: + + **Free-threaded builds inherit the scope in child threads.** Whether a newly + started :class:`threading.Thread` inherits the parent's active scope is + governed by :data:`sys.flags.thread_inherit_context`. On the default + GIL-enabled build this flag is *false*, so a child thread starts with an empty + :class:`~contextvars.Context` and its ``.instance()`` calls fall through to the + process-global registry. On the free-threaded build (e.g. ``3.14t``) the flag + defaults to *true*, so a child thread starts with a copy of the parent's + context and therefore resolves ``.instance()`` **within** the parent's scope. + (The same is true on any build launched with ``-X thread_inherit_context=1``.) + + Do not rely on child threads escaping a scope. Activating a scope while this + flag is enabled emits a :class:`RuntimeWarning`. If you need per-thread + isolation, have each thread activate its *own* scope rather than depending on + non-inheritance — that pattern (below) works identically on both builds. + +Giving each worker thread its own isolated singleton works on every build: 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 diff --git a/tests/config/test_configurable.py b/tests/config/test_configurable.py index fe0860d70..b6d5a3b44 100644 --- a/tests/config/test_configurable.py +++ b/tests/config/test_configurable.py @@ -8,6 +8,7 @@ import logging import sys import threading +import warnings from unittest import TestCase import pytest @@ -538,6 +539,31 @@ async def main(): asyncio.run(main()) + def test_free_threading_warning(self): + from unittest import mock + + class MyApp(SingletonConfigurable): + pass + + scope = MyApp.scope() + + # When thread_inherit_context is enabled (the default on free-threaded + # builds) child threads inherit the scope, so activating one warns. + enabled = mock.Mock(thread_inherit_context=1) + with ( + mock.patch.object(sys, "flags", enabled), + pytest.warns(RuntimeWarning, match="thread_inherit_context"), + scope(), + ): + pass + + # When it is disabled, activating a scope is silent. + disabled = mock.Mock(thread_inherit_context=0) + with mock.patch.object(sys, "flags", disabled), warnings.catch_warnings(): + warnings.simplefilter("error") + with scope(): + pass + class TestLoggingConfigurable(TestCase): def test_parent_logger(self): diff --git a/traitlets/config/configurable.py b/traitlets/config/configurable.py index 603265fad..ab1c44e6e 100644 --- a/traitlets/config/configurable.py +++ b/traitlets/config/configurable.py @@ -6,6 +6,7 @@ import contextlib import logging +import sys import typing as t from contextvars import ContextVar from copy import deepcopy @@ -527,8 +528,19 @@ class SingletonScope: 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. + fresh instances on first use — in the current thread/async task. Re-enterable: the registry persists across activations. + + .. warning:: + + The scope lives in a :class:`~contextvars.ContextVar`. On builds where + :data:`sys.flags.thread_inherit_context` is enabled — the default on the + free-threaded build (e.g. ``3.14t``) — a :class:`threading.Thread` + started while the scope is active inherits a copy of the parent context + and resolves ``.instance()`` within the scope instead of the + process-global registry. Activating a scope under that flag emits a + :class:`RuntimeWarning`. For per-thread isolation, activate a fresh scope + inside each thread rather than relying on non-inheritance. """ def __init__(self, base: type[SingletonConfigurable]) -> None: @@ -555,6 +567,18 @@ def add(self, inst: SingletonConfigurable) -> None: @contextlib.contextmanager def __call__(self) -> t.Generator[SingletonScope, None, None]: + if getattr(sys.flags, "thread_inherit_context", 0): + warnings.warn( + "A SingletonScope is being activated while thread_inherit_context " + "is enabled (the default on the free-threaded build, e.g. 3.14t). " + "A threading.Thread started while this scope is active inherits a " + "copy of the parent context, so its .instance() calls resolve " + "within this scope instead of falling through to the process-global " + "registry. Activate a fresh scope inside each thread if you need " + "per-thread isolation.", + RuntimeWarning, + stacklevel=3, + ) token = _active_scopes.set((*_active_scopes.get(), self)) try: yield self From ffc2e52019f075d8e9a844c173c0a891d3634f38 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 19:33:07 +0000 Subject: [PATCH 4/4] Fix test_free_threading_warning breaking on pypy The test mocked the whole sys.flags structseq with a Mock, so other code that reads integer fields off sys.flags during the scope block (surfaced on pypy-3.11) failed with "expected integer, got Mock object". Read the flag through a small _threads_inherit_context() helper and patch that in the test instead of replacing sys.flags wholesale. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0159wyFjYsXoVKyUN9NkNxzw --- tests/config/test_configurable.py | 11 +++++++---- traitlets/config/configurable.py | 13 ++++++++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/config/test_configurable.py b/tests/config/test_configurable.py index b6d5a3b44..5a70fc658 100644 --- a/tests/config/test_configurable.py +++ b/tests/config/test_configurable.py @@ -542,6 +542,8 @@ async def main(): def test_free_threading_warning(self): from unittest import mock + from traitlets.config import configurable + class MyApp(SingletonConfigurable): pass @@ -549,17 +551,18 @@ class MyApp(SingletonConfigurable): # When thread_inherit_context is enabled (the default on free-threaded # builds) child threads inherit the scope, so activating one warns. - enabled = mock.Mock(thread_inherit_context=1) with ( - mock.patch.object(sys, "flags", enabled), + mock.patch.object(configurable, "_threads_inherit_context", return_value=True), pytest.warns(RuntimeWarning, match="thread_inherit_context"), scope(), ): pass # When it is disabled, activating a scope is silent. - disabled = mock.Mock(thread_inherit_context=0) - with mock.patch.object(sys, "flags", disabled), warnings.catch_warnings(): + with ( + mock.patch.object(configurable, "_threads_inherit_context", return_value=False), + warnings.catch_warnings(), + ): warnings.simplefilter("error") with scope(): pass diff --git a/traitlets/config/configurable.py b/traitlets/config/configurable.py index ab1c44e6e..4bcfd4323 100644 --- a/traitlets/config/configurable.py +++ b/traitlets/config/configurable.py @@ -523,6 +523,17 @@ def _get_log_handler(self) -> logging.Handler | None: _active_scopes: ContextVar[tuple[SingletonScope, ...]] = ContextVar("singleton_scopes", default=()) +def _threads_inherit_context() -> bool: + """Whether a new :class:`threading.Thread` inherits the caller's context. + + Governed by :data:`sys.flags.thread_inherit_context`: false by default on the + GIL build, true by default on the free-threaded build (e.g. ``3.14t``) and on + any build launched with ``-X thread_inherit_context=1``. When true, an active + :class:`SingletonScope` propagates into threads started while it is active. + """ + return bool(getattr(sys.flags, "thread_inherit_context", 0)) + + class SingletonScope: """An isolated singleton registry, activated for a dynamic extent. @@ -567,7 +578,7 @@ def add(self, inst: SingletonConfigurable) -> None: @contextlib.contextmanager def __call__(self) -> t.Generator[SingletonScope, None, None]: - if getattr(sys.flags, "thread_inherit_context", 0): + if _threads_inherit_context(): warnings.warn( "A SingletonScope is being activated while thread_inherit_context " "is enabled (the default on the free-threaded build, e.g. 3.14t). "