diff --git a/docs/source/trait_types.rst b/docs/source/trait_types.rst index 9a892fd0..fb3a6463 100644 --- a/docs/source/trait_types.rst +++ b/docs/source/trait_types.rst @@ -93,6 +93,9 @@ Classes and instances .. autoclass:: Type :members: __init__ +.. autoclass:: LazyType + :members: __init__ + .. autoclass:: This .. autoclass:: ForwardDeclaredInstance diff --git a/tests/test_traitlets.py b/tests/test_traitlets.py index 2e7b8459..dd0ab021 100644 --- a/tests/test_traitlets.py +++ b/tests/test_traitlets.py @@ -11,7 +11,9 @@ import pathlib import pickle import re +import sys import typing as t +from contextlib import contextmanager from unittest import TestCase import pytest @@ -41,6 +43,7 @@ Instance, Int, Integer, + LazyType, List, Long, MetaHasTraits, @@ -1115,6 +1118,197 @@ class A(HasTraits): self.assertEqual(a.klass, Config) +@contextmanager +def _importable_probe_module(tmp_path, name): + """Make a throwaway module importable, and make sure it is not imported yet.""" + (tmp_path / f"{name}.py").write_text( + "class Klass:\n pass\n\n\nclass Sub(Klass):\n pass\n" + ) + sys.path.insert(0, str(tmp_path)) + try: + assert name not in sys.modules + yield + finally: + sys.path.remove(str(tmp_path)) + sys.modules.pop(name, None) + + +class TestLazyType: + def test_klass_is_not_imported_until_accessed(self, tmp_path): + name = "lazy_type_probe_klass" + with _importable_probe_module(tmp_path, name): + + class A(HasTraits): + klass = LazyType(f"{name}.Klass") + + a = A() + # neither declaring the class nor instantiating it triggers the import + assert name not in sys.modules + + # but reading the value does + value = a.klass + assert name in sys.modules + assert value is sys.modules[name].Klass + + def test_klass_is_not_imported_by_eager_traits_on_the_same_class(self, tmp_path): + name = "lazy_type_probe_mixed" + with _importable_probe_module(tmp_path, name): + + class A(HasTraits): + lazy = LazyType(f"{name}.Klass") + eager = Int(0) + + A() + assert name not in sys.modules + + def test_resolution_is_cached(self, tmp_path): + name = "lazy_type_probe_cache" + with _importable_probe_module(tmp_path, name): + + class A(HasTraits): + klass = LazyType(f"{name}.Klass") + + # unresolved while nothing has needed the class + A() + assert A.klass._klass == f"{name}.Klass" + assert name not in sys.modules + + # reading .klass off the trait resolves it, and caches the result + resolved = A.klass.klass + assert resolved is sys.modules[name].Klass + assert A.klass.klass is resolved + assert A.klass.default_value is resolved + # the name is replaced in place by the class it resolved to + assert A.klass._klass is resolved + + def test_observer_sees_a_resolved_old_value(self, tmp_path): + name = "lazy_type_probe_observe" + with _importable_probe_module(tmp_path, name): + + class A(HasTraits): + klass = LazyType(f"{name}.Klass", allow_none=True) + + a = A() + seen = [] + a.observe(lambda change: seen.append(change.old), "klass") + a.klass = None + assert seen == [sys.modules[name].Klass] + + def test_reassigning_klass(self, tmp_path): + name = "lazy_type_probe_rename" + with _importable_probe_module(tmp_path, name): + + class A(HasTraits): + klass = LazyType(f"{name}.Klass") + + assert A.klass.klass is sys.modules[name].Klass + + # a new name is honoured, and resolved on the next read + A.klass.klass = f"{name}.Sub" + assert A.klass.klass is sys.modules[name].Sub + + # a class object is stored as is -- it is already imported + A.klass.klass = dict + assert A.klass.klass is dict + + def test_resolve_classes_forces_the_import(self, tmp_path): + name = "lazy_type_probe_force" + with _importable_probe_module(tmp_path, name): + + class A(HasTraits): + klass = LazyType(f"{name}.Klass") + + A() + assert name not in sys.modules + + A.klass._resolve_classes() + assert name in sys.modules + assert A.klass._klass is sys.modules[name].Klass + assert A.klass._default_value is sys.modules[name].Klass + + def test_an_already_imported_class_is_accepted(self): + # nothing left to defer, so it just behaves like Type + class Base: + pass + + class Sub(Base): + pass + + class A(HasTraits): + klass = LazyType(klass=Base) + + a = A() + assert a.klass is Base + a.klass = Sub + assert a.klass is Sub + with pytest.raises(TraitError): + a.klass = int + + # and with no arguments at all, Type's own default applies + assert LazyType().klass is object + + def test_allow_none(self, tmp_path): + name = "lazy_type_probe_none" + with _importable_probe_module(tmp_path, name): + + class A(HasTraits): + klass = LazyType(None, f"{name}.Klass", allow_none=True) + + a = A() + assert a.klass is None + assert name not in sys.modules + + a.klass = f"{name}.Sub" + assert a.klass is sys.modules[name].Sub + + def test_bad_string_raises_on_use_not_on_init(self, tmp_path): + class A(HasTraits): + klass = LazyType("no_such_module_xyz.Klass") + + a = A() # constructing the owner is fine + with pytest.raises(ImportError): + a.klass + + def test_validation(self, tmp_path): + name = "lazy_type_probe_validate" + with _importable_probe_module(tmp_path, name): + + class A(HasTraits): + klass = LazyType(klass=f"{name}.Klass") + + a = A() + assert name not in sys.modules + import lazy_type_probe_validate as probe # type:ignore[import-not-found] + + a.klass = probe.Sub + assert a.klass is probe.Sub + with pytest.raises(TraitError): + a.klass = int + + def test_separate_default_value(self, tmp_path): + name = "lazy_type_probe_default" + with _importable_probe_module(tmp_path, name): + + class A(HasTraits): + klass = LazyType(f"{name}.Sub", f"{name}.Klass") + + a = A() + assert name not in sys.modules + assert a.klass is sys.modules[name].Sub + + def test_type_still_resolves_eagerly(self, tmp_path): + name = "lazy_type_probe_eager" + with _importable_probe_module(tmp_path, name): + + class A(HasTraits): + klass = Type(f"{name}.Klass") + + assert name not in sys.modules + A() + # Type resolves at instance_init, without the value being read + assert name in sys.modules + + class TestInstance(TestCase): def test_basic(self): class Foo: diff --git a/traitlets/traitlets.py b/traitlets/traitlets.py index 0989ea98..130a0253 100644 --- a/traitlets/traitlets.py +++ b/traitlets/traitlets.py @@ -102,6 +102,7 @@ "Instance", "Int", "Integer", + "LazyType", "List", "Long", "MetaHasDescriptors", @@ -524,6 +525,9 @@ class TraitType(BaseDescriptor, t.Generic[G, S]): read_only: bool = False info_text: str = "any value" default_value: t.Any = Undefined + #: Set by trait types whose ``default_value`` is only computed on access + #: (see :class:`LazyType`), so that class setup does not force it. + _resolve_lazily: bool = False def __init__( self: TraitType[G, S], @@ -1060,42 +1064,46 @@ def setup_class(cls: MetaHasTraits, classdict: dict[str, t.Any]) -> None: # of initial values to speed up instance creation. # This is a very specific optimization, but a very common scenario in # for instance ipywidgets. - none_ok = trait.default_value is None and trait.allow_none - if ( - type(trait) in [CInt, Int, Long, CLong] - and trait.min is None # type: ignore[attr-defined] - and trait.max is None # type: ignore[attr-defined] - and (isinstance(trait.default_value, int) or none_ok) - ): - cls._static_immutable_initial_values[name] = trait.default_value - elif ( - type(trait) in [CFloat, Float] - and trait.min is None # type: ignore[attr-defined] - and trait.max is None # type: ignore[attr-defined] - and (isinstance(trait.default_value, float) or none_ok) - ): - cls._static_immutable_initial_values[name] = trait.default_value - elif type(trait) in [CBool, Bool] and ( - isinstance(trait.default_value, bool) or none_ok - ): - cls._static_immutable_initial_values[name] = trait.default_value - elif type(trait) in [CUnicode, Unicode] and ( - isinstance(trait.default_value, str) or none_ok - ): - cls._static_immutable_initial_values[name] = trait.default_value - elif type(trait) is Any and ( - isinstance(trait.default_value, (str, int, float, bool)) or none_ok - ): - cls._static_immutable_initial_values[name] = trait.default_value - elif type(trait) is Union and trait.default_value is None: - cls._static_immutable_initial_values[name] = None - elif ( - isinstance(trait, Instance) - and trait.default_args is None - and trait.default_kwargs is None - and trait.allow_none - ): - cls._static_immutable_initial_values[name] = None + # Traits that resolve their default lazily (see LazyType) are + # skipped: reading ``default_value`` would trigger the import + # they defer, and none of the cases below ever match them. + if not trait._resolve_lazily: + none_ok = trait.default_value is None and trait.allow_none + if ( + type(trait) in [CInt, Int, Long, CLong] + and trait.min is None # type: ignore[attr-defined] + and trait.max is None # type: ignore[attr-defined] + and (isinstance(trait.default_value, int) or none_ok) + ): + cls._static_immutable_initial_values[name] = trait.default_value + elif ( + type(trait) in [CFloat, Float] + and trait.min is None # type: ignore[attr-defined] + and trait.max is None # type: ignore[attr-defined] + and (isinstance(trait.default_value, float) or none_ok) + ): + cls._static_immutable_initial_values[name] = trait.default_value + elif type(trait) in [CBool, Bool] and ( + isinstance(trait.default_value, bool) or none_ok + ): + cls._static_immutable_initial_values[name] = trait.default_value + elif type(trait) in [CUnicode, Unicode] and ( + isinstance(trait.default_value, str) or none_ok + ): + cls._static_immutable_initial_values[name] = trait.default_value + elif type(trait) is Any and ( + isinstance(trait.default_value, (str, int, float, bool)) or none_ok + ): + cls._static_immutable_initial_values[name] = trait.default_value + elif type(trait) is Union and trait.default_value is None: + cls._static_immutable_initial_values[name] = None + elif ( + isinstance(trait, Instance) + and trait.default_args is None + and trait.default_kwargs is None + and trait.allow_none + ): + cls._static_immutable_initial_values[name] = None # we always add it, because a class may change when we call add_trait # and then the instance may not have all the _static_immutable_initial_values @@ -2178,6 +2186,87 @@ def default_value_repr(self) -> str: return repr(f"{value.__module__}.{value.__name__}") +class LazyType(Type[G, S]): + """A :class:`Type` trait that does not import its class until it is used. + + :class:`Type` resolves a string ``klass`` -- that is, imports it -- as soon + as the owning :class:`HasTraits` object is created. For a default like + ``"ipykernel.debugger.Debugger"`` that means paying for the ``debugpy`` + import on every kernel startup, even though most sessions never debug. + + ``LazyType`` waits until the class is actually needed: reading or writing + the trait, or reading ``klass`` / ``default_value`` / ``info()`` off the + trait itself. Generating help therefore does resolve it, like :class:`Type`. + + Give ``klass`` and ``default_value`` as strings, like 'foo.bar.Bah' -- + that is the whole point. A class object is accepted and simply stored as + is, since importing it is what the caller already did; there is nothing + left to defer, so such a trait behaves exactly like :class:`Type`. + + A bad class name is reported at first use rather than when the owning + object is constructed. + """ + + #: Tells :class:`MetaHasTraits` not to read ``default_value`` while setting + #: up a class, as that would trigger the import this trait defers. + _resolve_lazily = True + + #: The dotted name, replaced by the imported class once one is needed. + _klass: t.Any = None + _default_value: t.Any = Undefined + + if t.TYPE_CHECKING: + # Type's overloads bind G/S from a class argument; strings carry no + # such information, so these two pin them and let **kwargs absorb the + # arguments that do not affect inference. + + @t.overload + def __init__( + self: LazyType[type, type], + default_value: str | Sentinel = ..., + klass: str | None = ..., + allow_none: Literal[False] = ..., + **kwargs: t.Any, + ) -> None: ... + + @t.overload + def __init__( + self: LazyType[type | None, type | None], + default_value: str | Sentinel | None = ..., + klass: str | None = ..., + allow_none: Literal[True] = ..., + **kwargs: t.Any, + ) -> None: ... + + def __init__(self, *args: t.Any, **kwargs: t.Any) -> None: ... + + def instance_init(self, obj: t.Any) -> None: + """Deliberately does not resolve the class; see the class docstring.""" + pass # noqa: PIE790 -- explicit: this override exists to do nothing + + @property + def klass(self) -> t.Any: + """The class values must be a subclass of; imported on first read.""" + if isinstance(self._klass, str): + self._klass = self._resolve_string(self._klass) + return self._klass + + @klass.setter + def klass(self, value: t.Any) -> None: + self._klass = value + + @property + def default_value(self) -> t.Any: + """The default value of this trait; imported on first read.""" + if isinstance(self._default_value, str): + self._default_value = self._resolve_string(self._default_value) + return self._default_value + + @default_value.setter + def default_value(self, value: t.Any) -> None: + self._default_value = value + + class Instance(ClassBasedTraitType[T, T]): """A trait whose value must be an instance of a specified class.