Add LazyType trait for deferred class imports - #972
Open
Carreau wants to merge 1 commit into
Open
Conversation
Codecov Report❌ Patch coverage is
☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Carreau
commented
Aug 9, 2026
Comment on lines
+1198
to
+1213
| def test_generating_help_resolves_the_class(self, tmp_path): | ||
| # a deliberate trade for the compact form: unlike the trait's value, | ||
| # help output is not lazy. info() resolves, like Type's does, and then | ||
| # reports the class's canonical module rather than the name as written, | ||
| # so --help-all pays for every LazyType it documents. | ||
| name = "lazy_type_probe_help" | ||
| with _importable_probe_module(tmp_path, name): | ||
| (tmp_path / f"{name}_re.py").write_text(f"from {name} import Klass, Sub\n") | ||
|
|
||
| class Svc(Configurable): | ||
| klass = LazyType(f"{name}_re.Klass", help="a class").tag(config=True) | ||
|
|
||
| assert name not in sys.modules | ||
| assert Svc.klass.info() == f"a subclass of '{name}.Klass'" | ||
| assert name in sys.modules | ||
| sys.modules.pop(f"{name}_re", None) |
Member
Author
There was a problem hiding this comment.
Remove this test, this is not a requirement this is just something we are fine happening, we don't want to constrain future evolution
Comment on lines
+2243
to
+2246
| # NOTE: no runtime __init__. Type's constructor already applies every | ||
| # defaulting rule and assigns through ``self.klass`` / ``self.default_value``, | ||
| # which the properties below intercept, validate and store. | ||
|
|
`Type` resolves a string `klass` -- that is, imports it -- as soon as the owning `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` subclasses `Type` and waits until the class is actually needed: reading or writing the trait, or reading `klass` / `default_value` off the trait itself. `Type` is untouched. It overrides `instance_init` to skip the eager resolution, and makes `klass` and `default_value` properties that import on first read and store the result in place. Resolving via properties rather than by intercepting `validate()`/`default()` matters: leaving the raw strings in those attributes makes `issubclass(x, trait.klass)` raise TypeError, hands `@observe` a string as `change.old`, and leaves the class permanently unresolved when a `@default` returns None under `allow_none`. `TraitType._resolve_lazily` tells `MetaHasTraits.setup_class` not to read `default_value` while building `_static_immutable_initial_values`. Without it the import fires at class-creation time and laziness is dead; none of that block's cases ever matched a `Type` subclass, so skipping it changes nothing else. Strings are what make this useful, and the type hints say so, but a class object is accepted and stored as is -- it is already imported, so there is nothing left to defer and such a trait simply behaves like `Type`. Two consequences worth knowing: a bad class name surfaces at first use rather than at construction, and generating help resolves the class, so `--help-all` costs the same as it does for `Type`. Co-Authored-By: M Bussonnier <bussonniermatthias@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LdLx7kk7uK14t7TprtUiMX
Carreau
force-pushed
the
claude/type-trait-lazy-refactor-xojfj2
branch
from
August 9, 2026 12:56
870778b to
172457d
Compare
Carreau
marked this pull request as ready for review
August 9, 2026 13:07
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Typeresolves a stringklass— that is, imports it — as soon as the owningHasTraitsobject is created. For a default like"ipykernel.debugger.Debugger"that means paying for thedebugpyimport on every kernel startup, even though most sessions never debug.LazyTypesubclassesTypeand waits until the class is actually needed: reading or writing the trait, or readingklass/default_valueoff the trait itself.Typeitself is untouched.Key changes
LazyType(Type)— overridesinstance_initto skip the eager resolution, and makesklass/default_valueproperties that import on first read and store the result in place.TraitType._resolve_lazily— tellsMetaHasTraits.setup_classnot to readdefault_valuewhile building_static_immutable_initial_values. Without it the import fires at class-creation time and laziness is dead. None of that block's cases ever matched aTypesubclass, so skipping it changes nothing else.Design notes
Why properties rather than intercepting
validate()/default(). The smaller approach — resolve inside those two methods, leave the attributes as raw strings — is not equivalent. It makesissubclass(x, trait.klass)raiseTypeError, hands@observea string aschange.old, and leaves the class permanently unresolved when a@defaultreturnsNoneunderallow_none. The properties close all three.Strings, but not enforced. Strings are what make this useful and the type hints say so, but a class object is accepted and stored as is — it is already imported, so there is nothing left to defer and such a trait simply behaves like
Type.Consequences worth knowing
--help-allcosts the same as it does forType.Testing
12 tests covering deferral through class definition and instantiation, resolution on read and on assignment, caching,
allow_nonewith aNonedefault staying lazy, observers receiving a resolvedchange.old,@default-returns-Nonenot stranding the trait, forcing resolution via_resolve_classes, accepting an already-imported class, and thatTypestill resolves eagerly.Full suite green,
mypy traitletsclean, ruff and formatting unchanged frommain.