Skip to content

Add LazyType trait for deferred class imports - #972

Open
Carreau wants to merge 1 commit into
mainfrom
claude/type-trait-lazy-refactor-xojfj2
Open

Add LazyType trait for deferred class imports#972
Carreau wants to merge 1 commit into
mainfrom
claude/type-trait-lazy-refactor-xojfj2

Conversation

@Carreau

@Carreau Carreau commented Aug 9, 2026

Copy link
Copy Markdown
Member

Summary

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.

class A(HasTraits):
    encoder = LazyType("json.JSONEncoder")

a = A()      # json not imported
a.encoder    # now it is

Type itself is untouched.

Key changes

  • New LazyType(Type) — overrides instance_init to skip the eager resolution, and makes klass / default_value properties that import on first read and store the result in place.
  • 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.

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 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. 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

  • A bad class name surfaces at first use rather than at construction.
  • Generating help resolves the class, so --help-all costs the same as it does for Type.

Testing

12 tests covering deferral through class definition and instantiation, resolution on read and on assignment, caching, allow_none with a None default staying lazy, observers receiving a resolved change.old, @default-returns-None not stranding the trait, forcing resolution via _resolve_classes, accepting an already-imported class, and that Type still resolves eagerly.

Full suite green, mypy traitlets clean, ruff and formatting unchanged from main.

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.35028% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.20%. Comparing base (1dae189) to head (172457d).

Files with missing lines Patch % Lines
traitlets/traitlets.py 77.77% 7 Missing and 3 partials ⚠️
Components Coverage Δ
traitlets 85.45% <77.77%> (-0.05%) ⬇️
tests 99.09% <100.00%> (+0.02%) ⬆️

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread tests/test_traitlets.py Outdated
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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude

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 thread traitlets/traitlets.py Outdated
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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude delete this comment

Comment thread traitlets/traitlets.py
`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
Carreau force-pushed the claude/type-trait-lazy-refactor-xojfj2 branch from 870778b to 172457d Compare August 9, 2026 12:56
@Carreau
Carreau marked this pull request as ready for review August 9, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants