Skip to content

Fix QuamRoot.load() NameError with TYPE_CHECKING forward references#200

Merged
nulinspiratie merged 8 commits into
mainfrom
fix/forward-references-v2
Apr 27, 2026
Merged

Fix QuamRoot.load() NameError with TYPE_CHECKING forward references#200
nulinspiratie merged 8 commits into
mainfrom
fix/forward-references-v2

Conversation

@nulinspiratie
Copy link
Copy Markdown
Contributor

Root cause

When a quam_dataclass component class uses from __future__ import annotations together with TYPE_CHECKING-only imports to avoid circular dependencies, QuamRoot.load() raises a NameError:

# qubit.py
from __future__ import annotations
from typing import TYPE_CHECKING
from quam.core import QuamComponent, quam_dataclass

if TYPE_CHECKING:
    from resonator import Resonator  # only imported by the type-checker, not at runtime

@quam_dataclass
class Qubit(QuamComponent):
    resonator: Resonator  # stored as the string "Resonator" at runtime

During loading, instantiate_quam_class(Qubit, ...) calls get_dataclass_attr_annotations(Qubit), which calls typing.get_type_hints(Qubit). That function tries to resolve the string annotation "Resonator" against qubit.py's global namespace — but since the import only happened under TYPE_CHECKING, Resonator is absent at runtime and a NameError is raised.

Saving (to_dict()) already worked because it never needs to resolve annotations.

How it's fixed

The fix introduces a fallback path in get_dataclass_attr_annotations() (quam/utils/dataclass.py):

get_type_hints(cls)
  → succeeds   → use as before (no change for existing code)
  → NameError  → _get_type_hints_with_fallback(cls)
                   collect raw __annotations__ from full MRO
                   eval each annotation string against the module's globals
                   substitute typing.Any for any that still can't be resolved

The key insight is that every serialised QuAM object already carries a __class__ key in its dict representation. instantiate_attrs() already reads that key to look up the concrete class via get_class_from_path() — completely independently of the annotation. So substituting Any for an unresolvable annotation is safe: the concrete type is recovered from __class__ at runtime instead.

The same NameError guard is added to _get_value_annotation() in quam_classes.py (returns None, meaning no element-type annotation for QuamDict/QuamList — already the safe default).

Changes

  • quam/utils/dataclass.py: add _get_type_hints_with_fallback(), catch NameError in get_dataclass_attr_annotations()
  • quam/core/quam_classes.py: catch NameError in _get_value_annotation()
  • quam/components/helper_files/: Qubit and Resonator test fixtures with circular TYPE_CHECKING imports
  • tests/serialisation/test_forward_references.py: 6 TDD tests covering save, load, and full roundtrip
  • pyproject.toml: add [tool.pytest.ini_options] pythonpath = ["."] so the local source tree is used during test runs
  • CHANGELOG.md: entry under ### Fixed

Test plan

  • TestForwardRefSavingto_dict() works and produces correct __class__ keys (baseline, was already passing)
  • TestForwardRefLoadingRoot.load(d) instantiates the correct component types and preserves reference strings
  • TestForwardRefRoundtrip — save → load produces identical instances and identical dicts
  • Full test suite: 633 passed, 0 failures

Closes #90

When a quam_dataclass uses `from __future__ import annotations` together with
TYPE_CHECKING-only imports to avoid circular dependencies, get_type_hints() raises
NameError because the referenced type isn't in the module's global namespace at
runtime.

Fix: in get_dataclass_attr_annotations(), catch NameError from get_type_hints() and
fall back to resolving each annotation individually against the module's globals.
Annotations that still can't be resolved are replaced with typing.Any — loading then
relies on the __class__ key already present in every serialised QuAM dict to
determine the concrete type, so no information is lost.

Also catches NameError in _get_value_annotation() (returns None, meaning no
QuamDict/QuamList element-type annotation — safe default).

Adds [tool.pytest.ini_options] pythonpath=["."] so the worktree's quam package is
used during tests rather than the installed editable version.
@nulinspiratie
Copy link
Copy Markdown
Contributor Author

@JacobHast could you verify whether this resolves your circular naming issue?

- Move forward_ref_helpers from quam/components/ to tests/serialisation/
  so test-only classes aren't shipped with the library
- Fix loading of Dict[str, ForwardRef] attributes: when annotation
  resolves to Any, process dict values through instantiate_attrs_from_dict
  so __class__ keys on individual values are still honoured
- Add tests for Dict and List collection roundtrips with forward refs
- Remove unnecessary pythonpath setting from pyproject.toml
tests/ and tests/serialisation/ need __init__.py so that
from tests.serialisation.forward_ref_helpers.* imports work.

Also fix __class__ strings in test_save_with_defaults_config.py
to use the full package path (tests.serialisation.*) now that
tests is a proper package.
@nulinspiratie nulinspiratie force-pushed the fix/forward-references-v2 branch from 52674b7 to e92d8b7 Compare April 22, 2026 20:00
… entry

Document the TYPE_CHECKING pattern for components in separate files that
reference each other's types. Simplify the changelog entry to focus on
the user-facing fix rather than implementation details.
@nulinspiratie nulinspiratie merged commit 6e49d12 into main Apr 27, 2026
4 checks passed
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.

Forward references in saving

1 participant