From bc2d40f2c6604df082298b514d32604cf6cba571 Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Tue, 14 Jul 2026 09:13:35 -0700 Subject: [PATCH 1/8] Break import cycle by lazily importing top-level qcodes shorthands The eager imports of the dataset, instrument, parameters, monitor and station submodules in ``qcodes/__init__.py`` glued 83 modules into a single import cycle. Combined with a generic ``ParameterBase`` whose TypeVar default forward-references ``InstrumentBase`` and the ``ParamMeasT`` union alias used in a ``TypedDict``, this triggered an internal error in mypy 2.2 ("Must not defer during final iteration"). Expose the discouraged top-level shorthands (``qcodes.Parameter``, ``qcodes.Measurement``, etc.) lazily via a module level ``__getattr__`` instead. This breaks the cycle so ``mypy src`` succeeds again. The shorthands remain available at runtime but are intentionally no longer statically typed; import from the respective submodules for type info. The ``atexit`` handler that closes all instruments is moved into ``qcodes.instrument`` so importing the top level package no longer eagerly imports the instrument submodule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 05b20136-29a2-4bfe-834c-894d3efa9d4c --- docs/changes/newsfragments/8252.underthehood | 8 ++ src/qcodes/__init__.py | 127 +++++++++++-------- src/qcodes/instrument/__init__.py | 7 + 3 files changed, 91 insertions(+), 51 deletions(-) create mode 100644 docs/changes/newsfragments/8252.underthehood diff --git a/docs/changes/newsfragments/8252.underthehood b/docs/changes/newsfragments/8252.underthehood new file mode 100644 index 00000000000..61f9119d3e5 --- /dev/null +++ b/docs/changes/newsfragments/8252.underthehood @@ -0,0 +1,8 @@ +Importing the top level ``qcodes`` namespace no longer eagerly imports the +``dataset``, ``instrument``, ``parameters``, ``monitor`` and ``station`` +submodules. The discouraged short hand aliases (such as ``qcodes.Parameter`` +and ``qcodes.Measurement``) are still available at runtime via a lazy module +level ``__getattr__`` but are intentionally no longer seen by static type +checkers; import the names from their respective submodules to retain type +information. This breaks a large import cycle spanning the parameters, dataset +and instrument packages that triggered an internal error in ``mypy`` 2.2. diff --git a/src/qcodes/__init__.py b/src/qcodes/__init__.py index e6f0b8d4674..03127e63709 100644 --- a/src/qcodes/__init__.py +++ b/src/qcodes/__init__.py @@ -1,12 +1,13 @@ """Set up the main qcodes namespace.""" -# ruff: noqa: F401, E402 +# ruff: noqa: E402 # This module still contains a lot of short hand imports # since these imports are discouraged and they are officially # added elsewhere under their respective submodules we cannot add # them to __all__ here so silence the warning. # config +import importlib import warnings from typing import Any @@ -22,57 +23,7 @@ conditionally_start_all_logging() -import atexit - import qcodes.validators -from qcodes.dataset import ( - Measurement, - ParamSpec, - SQLiteSettings, - experiments, - get_guids_by_run_spec, - initialise_database, - initialise_or_create_database_at, - initialised_database_at, - load_by_counter, - load_by_guid, - load_by_id, - load_by_run_spec, - load_experiment, - load_experiment_by_name, - load_last_experiment, - load_or_create_experiment, - new_data_set, - new_experiment, -) -from qcodes.instrument import ( - ChannelList, - ChannelTuple, - Instrument, - InstrumentChannel, - IPInstrument, - VisaInstrument, - find_or_create_instrument, -) -from qcodes.monitor import Monitor -from qcodes.parameters import ( - ArrayParameter, - CombinedParameter, - DelegateParameter, - Function, - ManualParameter, - MultiParameter, - Parameter, - ParameterWithSetpoints, - ScaledParameter, - SweepFixedValues, - SweepValues, - combine, -) -from qcodes.station import Station - -# ensure to close all instruments when interpreter is closed -atexit.register(Instrument.close_all) if config.core.import_legacy_api: warnings.warn( @@ -81,3 +32,77 @@ "Please avoid setting this in your `qcodesrc.json` config file.", QCoDeSDeprecationWarning, ) + +# The following names are re-exported for backwards compatibility as short hand +# for the objects in their respective submodules. Importing them eagerly here +# would pull ``qcodes.dataset``, ``qcodes.instrument``, ``qcodes.parameters``, +# ``qcodes.monitor`` and ``qcodes.station`` into a single large import cycle at +# type-check time (which also triggers an internal error in mypy >= 2.2). These +# short hands are discouraged anyway, so they are provided lazily via a module +# level ``__getattr__`` and are intentionally not statically typed. Import the +# names from their respective submodules to get proper type information. +_LAZY_NAME_TO_MODULE = ( + { + name: "qcodes.dataset" + for name in ( + "Measurement", + "ParamSpec", + "SQLiteSettings", + "experiments", + "get_guids_by_run_spec", + "initialise_database", + "initialise_or_create_database_at", + "initialised_database_at", + "load_by_counter", + "load_by_guid", + "load_by_id", + "load_by_run_spec", + "load_experiment", + "load_experiment_by_name", + "load_last_experiment", + "load_or_create_experiment", + "new_data_set", + "new_experiment", + ) + } + | { + name: "qcodes.instrument" + for name in ( + "ChannelList", + "ChannelTuple", + "Instrument", + "InstrumentChannel", + "IPInstrument", + "VisaInstrument", + "find_or_create_instrument", + ) + } + | { + name: "qcodes.parameters" + for name in ( + "ArrayParameter", + "CombinedParameter", + "DelegateParameter", + "Function", + "ManualParameter", + "MultiParameter", + "Parameter", + "ParameterWithSetpoints", + "ScaledParameter", + "SweepFixedValues", + "SweepValues", + "combine", + ) + } + | { + "Monitor": "qcodes.monitor", + "Station": "qcodes.station", + } +) + + +def __getattr__(name: str) -> Any: + module_name = _LAZY_NAME_TO_MODULE.get(name) + if module_name is not None: + return getattr(importlib.import_module(module_name), name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/qcodes/instrument/__init__.py b/src/qcodes/instrument/__init__.py index 5713b58b592..d53da6425f3 100644 --- a/src/qcodes/instrument/__init__.py +++ b/src/qcodes/instrument/__init__.py @@ -1,5 +1,7 @@ # left here for backwards compatibility # but not part of the api officially +import atexit + from qcodes.parameters import ( # noqa: F401 ArrayParameter, CombinedParameter, @@ -21,6 +23,11 @@ from .ip import IPInstrument from .visa import VisaInstrument, VisaInstrumentKWArgs +# ensure that all instruments are closed when the interpreter is shut down. +# this is registered here rather than in ``qcodes.__init__`` so that importing +# the top level ``qcodes`` package does not eagerly import ``qcodes.instrument``. +atexit.register(Instrument.close_all) + __all__ = [ "ChannelList", "ChannelTuple", From 0523431cc9de00e6bfffb53c74a172e61f9f62f8 Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Tue, 14 Jul 2026 09:22:47 -0700 Subject: [PATCH 2/8] Deprecate parameter re-exports from qcodes.instrument The parameter classes (Parameter, ManualParameter, DelegateParameter, etc.) were re-exported from qcodes.instrument for backwards compatibility. These now live in qcodes.parameters. Access them via a module level __getattr__ that emits a QCoDeSDeprecationWarning, and update the Legacy_Parameters example to import ArrayParameter from qcodes.parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 05b20136-29a2-4bfe-834c-894d3efa9d4c --- docs/changes/newsfragments/8252.improved | 5 ++ .../Parameters/Legacy_Parameters.ipynb | 2 +- src/qcodes/instrument/__init__.py | 54 +++++++++++++------ 3 files changed, 44 insertions(+), 17 deletions(-) create mode 100644 docs/changes/newsfragments/8252.improved diff --git a/docs/changes/newsfragments/8252.improved b/docs/changes/newsfragments/8252.improved new file mode 100644 index 00000000000..b51d2d35e3a --- /dev/null +++ b/docs/changes/newsfragments/8252.improved @@ -0,0 +1,5 @@ +Importing the parameter classes (such as ``Parameter``, ``ManualParameter`` and +``DelegateParameter``) from ``qcodes.instrument`` is now deprecated and emits a +``QCoDeSDeprecationWarning``. Import these names from ``qcodes.parameters`` +instead. The names remain available from ``qcodes.instrument`` at runtime for +backwards compatibility. diff --git a/docs/examples/Parameters/Legacy_Parameters.ipynb b/docs/examples/Parameters/Legacy_Parameters.ipynb index e0f078b70f8..27929fa737f 100644 --- a/docs/examples/Parameters/Legacy_Parameters.ipynb +++ b/docs/examples/Parameters/Legacy_Parameters.ipynb @@ -32,7 +32,7 @@ } ], "source": [ - "from qcodes.instrument import ArrayParameter\n", + "from qcodes.parameters import ArrayParameter\n", "\n", "\n", "class ArrayCounter(ArrayParameter):\n", diff --git a/src/qcodes/instrument/__init__.py b/src/qcodes/instrument/__init__.py index d53da6425f3..651dc3662a7 100644 --- a/src/qcodes/instrument/__init__.py +++ b/src/qcodes/instrument/__init__.py @@ -1,21 +1,9 @@ -# left here for backwards compatibility -# but not part of the api officially import atexit +import importlib +import warnings +from typing import Any -from qcodes.parameters import ( # noqa: F401 - ArrayParameter, - CombinedParameter, - DelegateParameter, - Function, - ManualParameter, - MultiParameter, - Parameter, - ParameterWithSetpoints, - ScaledParameter, - SweepFixedValues, - SweepValues, - combine, -) +from qcodes.utils import QCoDeSDeprecationWarning from .channel import ChannelList, ChannelTuple, InstrumentChannel, InstrumentModule from .instrument import Instrument, find_or_create_instrument @@ -41,3 +29,37 @@ "VisaInstrumentKWArgs", "find_or_create_instrument", ] + +# The following parameter classes used to be re-exported from +# ``qcodes.instrument`` for backwards compatibility. They now live in +# ``qcodes.parameters`` and importing them from here is deprecated. They are +# provided lazily via a module level ``__getattr__`` that emits a deprecation +# warning; import them from ``qcodes.parameters`` instead. +_DEPRECATED_PARAMETER_NAMES = frozenset( + { + "ArrayParameter", + "CombinedParameter", + "DelegateParameter", + "Function", + "ManualParameter", + "MultiParameter", + "Parameter", + "ParameterWithSetpoints", + "ScaledParameter", + "SweepFixedValues", + "SweepValues", + "combine", + } +) + + +def __getattr__(name: str) -> Any: + if name in _DEPRECATED_PARAMETER_NAMES: + warnings.warn( + f"Importing {name!r} from {__name__!r} is deprecated. " + f"Import it from 'qcodes.parameters' instead.", + QCoDeSDeprecationWarning, + stacklevel=2, + ) + return getattr(importlib.import_module("qcodes.parameters"), name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") From f1068aca64146a3fa3b8ffd8f5e932f2c5289575 Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Tue, 14 Jul 2026 09:25:47 -0700 Subject: [PATCH 3/8] Make top level qcodes.validators import lazy Expose qcodes.validators via the module level __getattr__ instead of importing it eagerly on 'import qcodes', keeping the top level import cheap. Also tidy the now-stale module comment and update the newsfragment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 05b20136-29a2-4bfe-834c-894d3efa9d4c --- docs/changes/newsfragments/8252.underthehood | 15 ++++++++------- src/qcodes/__init__.py | 17 ++++++++--------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/changes/newsfragments/8252.underthehood b/docs/changes/newsfragments/8252.underthehood index 61f9119d3e5..83d46138215 100644 --- a/docs/changes/newsfragments/8252.underthehood +++ b/docs/changes/newsfragments/8252.underthehood @@ -1,8 +1,9 @@ Importing the top level ``qcodes`` namespace no longer eagerly imports the -``dataset``, ``instrument``, ``parameters``, ``monitor`` and ``station`` -submodules. The discouraged short hand aliases (such as ``qcodes.Parameter`` -and ``qcodes.Measurement``) are still available at runtime via a lazy module -level ``__getattr__`` but are intentionally no longer seen by static type -checkers; import the names from their respective submodules to retain type -information. This breaks a large import cycle spanning the parameters, dataset -and instrument packages that triggered an internal error in ``mypy`` 2.2. +``dataset``, ``instrument``, ``parameters``, ``monitor``, ``station`` and +``validators`` submodules. The discouraged short hand aliases (such as +``qcodes.Parameter`` and ``qcodes.Measurement``) as well as ``qcodes.validators`` +are still available at runtime via a lazy module level ``__getattr__`` but the +short hand aliases are intentionally no longer seen by static type checkers; +import the names from their respective submodules to retain type information. +This breaks a large import cycle spanning the parameters, dataset and instrument +packages that triggered an internal error in ``mypy`` 2.2. diff --git a/src/qcodes/__init__.py b/src/qcodes/__init__.py index 03127e63709..64b57a45213 100644 --- a/src/qcodes/__init__.py +++ b/src/qcodes/__init__.py @@ -1,12 +1,5 @@ """Set up the main qcodes namespace.""" -# ruff: noqa: E402 -# This module still contains a lot of short hand imports -# since these imports are discouraged and they are officially -# added elsewhere under their respective submodules we cannot add -# them to __all__ here so silence the warning. - -# config import importlib import warnings from typing import Any @@ -23,8 +16,6 @@ conditionally_start_all_logging() -import qcodes.validators - if config.core.import_legacy_api: warnings.warn( "`core.import_legacy_api` and `gui.plotlib` config option has no effect " @@ -101,7 +92,15 @@ ) +# ``qcodes.validators`` is a public submodule but importing it eagerly here is +# not necessary; it is exposed lazily so that ``import qcodes`` stays cheap and +# does not force the submodule (and its dependencies) to be imported. +_LAZY_SUBMODULES = frozenset({"validators"}) + + def __getattr__(name: str) -> Any: + if name in _LAZY_SUBMODULES: + return importlib.import_module(f"{__name__}.{name}") module_name = _LAZY_NAME_TO_MODULE.get(name) if module_name is not None: return getattr(importlib.import_module(module_name), name) From b061996ca4127f13f153405489b9287e3e95a218 Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Tue, 14 Jul 2026 09:32:59 -0700 Subject: [PATCH 4/8] Hide lazy __getattr__ from type checkers Guard the module level __getattr__ in qcodes and qcodes.instrument behind 'if not TYPE_CHECKING' so the lazily provided short hands and deprecated parameter re-exports are reported as unknown attributes by static type checkers instead of silently resolving to Any. Runtime behaviour is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 05b20136-29a2-4bfe-834c-894d3efa9d4c --- docs/changes/newsfragments/8252.improved | 3 ++- docs/changes/newsfragments/8252.underthehood | 11 ++++---- src/qcodes/__init__.py | 22 +++++++++------ src/qcodes/instrument/__init__.py | 28 ++++++++++++-------- 4 files changed, 39 insertions(+), 25 deletions(-) diff --git a/docs/changes/newsfragments/8252.improved b/docs/changes/newsfragments/8252.improved index b51d2d35e3a..3feea630120 100644 --- a/docs/changes/newsfragments/8252.improved +++ b/docs/changes/newsfragments/8252.improved @@ -2,4 +2,5 @@ Importing the parameter classes (such as ``Parameter``, ``ManualParameter`` and ``DelegateParameter``) from ``qcodes.instrument`` is now deprecated and emits a ``QCoDeSDeprecationWarning``. Import these names from ``qcodes.parameters`` instead. The names remain available from ``qcodes.instrument`` at runtime for -backwards compatibility. +backwards compatibility but are hidden from static type checkers, so importing +them from ``qcodes.instrument`` is reported as an unknown attribute. diff --git a/docs/changes/newsfragments/8252.underthehood b/docs/changes/newsfragments/8252.underthehood index 83d46138215..458eefbc90f 100644 --- a/docs/changes/newsfragments/8252.underthehood +++ b/docs/changes/newsfragments/8252.underthehood @@ -2,8 +2,9 @@ Importing the top level ``qcodes`` namespace no longer eagerly imports the ``dataset``, ``instrument``, ``parameters``, ``monitor``, ``station`` and ``validators`` submodules. The discouraged short hand aliases (such as ``qcodes.Parameter`` and ``qcodes.Measurement``) as well as ``qcodes.validators`` -are still available at runtime via a lazy module level ``__getattr__`` but the -short hand aliases are intentionally no longer seen by static type checkers; -import the names from their respective submodules to retain type information. -This breaks a large import cycle spanning the parameters, dataset and instrument -packages that triggered an internal error in ``mypy`` 2.2. +are still available at runtime via a lazy module level ``__getattr__``, but they +are intentionally hidden from static type checkers and are now reported as +unknown attributes rather than typed as ``Any``; import the names from their +respective submodules to retain type information. This breaks a large import +cycle spanning the parameters, dataset and instrument packages that triggered an +internal error in ``mypy`` 2.2. diff --git a/src/qcodes/__init__.py b/src/qcodes/__init__.py index 64b57a45213..928dbfe103e 100644 --- a/src/qcodes/__init__.py +++ b/src/qcodes/__init__.py @@ -2,7 +2,7 @@ import importlib import warnings -from typing import Any +from typing import TYPE_CHECKING, Any import qcodes._version import qcodes.configuration as qcconfig @@ -98,10 +98,16 @@ _LAZY_SUBMODULES = frozenset({"validators"}) -def __getattr__(name: str) -> Any: - if name in _LAZY_SUBMODULES: - return importlib.import_module(f"{__name__}.{name}") - module_name = _LAZY_NAME_TO_MODULE.get(name) - if module_name is not None: - return getattr(importlib.import_module(module_name), name) - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +# The lazy ``__getattr__`` is intentionally hidden from static type checkers via +# ``if not TYPE_CHECKING`` so that these discouraged short hands are reported as +# unknown attributes (rather than typed as ``Any``); import the names from their +# respective submodules to get proper type information. +if not TYPE_CHECKING: + + def __getattr__(name: str) -> Any: + if name in _LAZY_SUBMODULES: + return importlib.import_module(f"{__name__}.{name}") + module_name = _LAZY_NAME_TO_MODULE.get(name) + if module_name is not None: + return getattr(importlib.import_module(module_name), name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/qcodes/instrument/__init__.py b/src/qcodes/instrument/__init__.py index 651dc3662a7..90e88e97dca 100644 --- a/src/qcodes/instrument/__init__.py +++ b/src/qcodes/instrument/__init__.py @@ -1,7 +1,7 @@ import atexit import importlib import warnings -from typing import Any +from typing import TYPE_CHECKING, Any from qcodes.utils import QCoDeSDeprecationWarning @@ -53,13 +53,19 @@ ) -def __getattr__(name: str) -> Any: - if name in _DEPRECATED_PARAMETER_NAMES: - warnings.warn( - f"Importing {name!r} from {__name__!r} is deprecated. " - f"Import it from 'qcodes.parameters' instead.", - QCoDeSDeprecationWarning, - stacklevel=2, - ) - return getattr(importlib.import_module("qcodes.parameters"), name) - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +# The lazy ``__getattr__`` is intentionally hidden from static type checkers via +# ``if not TYPE_CHECKING`` so that importing these deprecated names from +# ``qcodes.instrument`` is reported as an unknown attribute; import them from +# ``qcodes.parameters`` instead. +if not TYPE_CHECKING: + + def __getattr__(name: str) -> Any: + if name in _DEPRECATED_PARAMETER_NAMES: + warnings.warn( + f"Importing {name!r} from {__name__!r} is deprecated. " + f"Import it from 'qcodes.parameters' instead.", + QCoDeSDeprecationWarning, + stacklevel=2, + ) + return getattr(importlib.import_module("qcodes.parameters"), name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") From 5ef17a3f6029de28c34188de636077881cd3f4cb Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Tue, 14 Jul 2026 09:51:00 -0700 Subject: [PATCH 5/8] Deprecate top level qcodes short hand aliases Accessing the short hand aliases (qcodes.Parameter, qcodes.Measurement, qcodes.Monitor, qcodes.Station, etc.) from the top level qcodes namespace now emits a QCoDeSDeprecationWarning; import them from their respective submodules instead. The submodules these names live in (dataset, instrument, parameters, monitor, station) are added to the lazily imported submodules so that qcodes.dataset and friends keep working once the deprecated short hands are removed. validators is unchanged and not deprecated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 05b20136-29a2-4bfe-834c-894d3efa9d4c --- docs/changes/newsfragments/8252.improved | 10 ++++++- src/qcodes/__init__.py | 38 +++++++++++++++++------- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/docs/changes/newsfragments/8252.improved b/docs/changes/newsfragments/8252.improved index 3feea630120..c102b9d4061 100644 --- a/docs/changes/newsfragments/8252.improved +++ b/docs/changes/newsfragments/8252.improved @@ -1,4 +1,12 @@ -Importing the parameter classes (such as ``Parameter``, ``ManualParameter`` and +Importing the short hand aliases (such as ``Parameter``, ``Measurement``, +``Instrument``, ``Monitor`` and ``Station``) from the top level ``qcodes`` +namespace is now deprecated and emits a ``QCoDeSDeprecationWarning``. Import +these names from their respective submodules (for example ``qcodes.parameters``, +``qcodes.dataset`` or ``qcodes.station``) instead. The submodules themselves +(such as ``qcodes.dataset`` and ``qcodes.instrument``) remain accessible from the +top level ``qcodes`` namespace and are not deprecated. + +Similarly, importing the parameter classes (such as ``Parameter``, ``ManualParameter`` and ``DelegateParameter``) from ``qcodes.instrument`` is now deprecated and emits a ``QCoDeSDeprecationWarning``. Import these names from ``qcodes.parameters`` instead. The names remain available from ``qcodes.instrument`` at runtime for diff --git a/src/qcodes/__init__.py b/src/qcodes/__init__.py index 928dbfe103e..fd2abac5b89 100644 --- a/src/qcodes/__init__.py +++ b/src/qcodes/__init__.py @@ -25,13 +25,13 @@ ) # The following names are re-exported for backwards compatibility as short hand -# for the objects in their respective submodules. Importing them eagerly here -# would pull ``qcodes.dataset``, ``qcodes.instrument``, ``qcodes.parameters``, +# for the objects in their respective submodules. Importing them from the top +# level ``qcodes`` namespace is deprecated (import them from the submodule +# instead); they are provided lazily via a module level ``__getattr__`` that +# emits a ``QCoDeSDeprecationWarning``. Importing them eagerly here would also +# pull ``qcodes.dataset``, ``qcodes.instrument``, ``qcodes.parameters``, # ``qcodes.monitor`` and ``qcodes.station`` into a single large import cycle at -# type-check time (which also triggers an internal error in mypy >= 2.2). These -# short hands are discouraged anyway, so they are provided lazily via a module -# level ``__getattr__`` and are intentionally not statically typed. Import the -# names from their respective submodules to get proper type information. +# type-check time (which triggers an internal error in mypy >= 2.2). _LAZY_NAME_TO_MODULE = ( { name: "qcodes.dataset" @@ -92,10 +92,22 @@ ) -# ``qcodes.validators`` is a public submodule but importing it eagerly here is -# not necessary; it is exposed lazily so that ``import qcodes`` stays cheap and -# does not force the submodule (and its dependencies) to be imported. -_LAZY_SUBMODULES = frozenset({"validators"}) +# These public submodules are exposed lazily so that ``import qcodes`` stays +# cheap and does not force the submodules (and their dependencies) to be +# imported. Accessing them is not deprecated. The submodules that the deprecated +# short hands above live in are included here so that ``qcodes.dataset`` and +# friends keep working as submodule accessors once the deprecated short hands are +# eventually removed. +_LAZY_SUBMODULES = frozenset( + { + "validators", + "dataset", + "instrument", + "parameters", + "monitor", + "station", + } +) # The lazy ``__getattr__`` is intentionally hidden from static type checkers via @@ -109,5 +121,11 @@ def __getattr__(name: str) -> Any: return importlib.import_module(f"{__name__}.{name}") module_name = _LAZY_NAME_TO_MODULE.get(name) if module_name is not None: + warnings.warn( + f"Importing {name!r} from the top level {__name__!r} namespace " + f"is deprecated. Import it from {module_name!r} instead.", + QCoDeSDeprecationWarning, + stacklevel=2, + ) return getattr(importlib.import_module(module_name), name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") From 4ed0fdc2379c61c601ff95805ba3c88271b2254d Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Tue, 14 Jul 2026 10:00:57 -0700 Subject: [PATCH 6/8] Add tests for deprecated lazy qcodes imports Verify that the deprecated short hand aliases on the top level qcodes namespace and the parameter classes re-exported from qcodes.instrument still resolve to the correct objects while emitting a QCoDeSDeprecationWarning. The direct attribute accesses are also intentionally type errors, silenced with '# type: ignore[attr-defined]' pragmas to document that behaviour. Also check that non-deprecated submodule access is warning free and that unknown attributes raise AttributeError. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 05b20136-29a2-4bfe-834c-894d3efa9d4c --- tests/test_import.py | 130 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 tests/test_import.py diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 00000000000..d3e553ff278 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,130 @@ +"""Tests for the lazily provided attributes on the top level ``qcodes`` +namespace and on ``qcodes.instrument``. + +The short hand aliases on ``qcodes`` (such as ``qcodes.Parameter``) and the +parameter classes re-exported from ``qcodes.instrument`` are provided lazily via +module level ``__getattr__`` hooks that are hidden from static type checkers. +Accessing them still works at runtime for backwards compatibility but is +deprecated: it emits a ``QCoDeSDeprecationWarning`` at runtime *and* is reported +as an unknown attribute by static type checkers. The ``# type: ignore`` pragmas +below document (and, via the pre-commit type checkers, assert) that these lines +are indeed type errors. Importing the names from their respective submodules is +the supported, statically typed alternative. +""" + +from __future__ import annotations + +import warnings + +import pytest + +import qcodes +import qcodes.dataset +import qcodes.instrument +import qcodes.monitor +import qcodes.parameters +import qcodes.station +import qcodes.validators +from qcodes.utils import QCoDeSDeprecationWarning + + +def test_top_level_parameter_shorthand_deprecated() -> None: + with pytest.warns( + QCoDeSDeprecationWarning, match=r"top level.*'qcodes\.parameters'" + ): + obj = qcodes.Parameter # type: ignore[attr-defined] + assert obj is qcodes.parameters.Parameter + + +def test_top_level_combine_shorthand_deprecated() -> None: + with pytest.warns( + QCoDeSDeprecationWarning, match=r"top level.*'qcodes\.parameters'" + ): + obj = qcodes.combine # type: ignore[attr-defined] + assert obj is qcodes.parameters.combine + + +def test_top_level_measurement_shorthand_deprecated() -> None: + with pytest.warns(QCoDeSDeprecationWarning, match=r"top level.*'qcodes\.dataset'"): + obj = qcodes.Measurement # type: ignore[attr-defined] + assert obj is qcodes.dataset.Measurement + + +def test_top_level_instrument_shorthand_deprecated() -> None: + with pytest.warns( + QCoDeSDeprecationWarning, match=r"top level.*'qcodes\.instrument'" + ): + obj = qcodes.Instrument # type: ignore[attr-defined] + assert obj is qcodes.instrument.Instrument + + +def test_top_level_monitor_shorthand_deprecated() -> None: + with pytest.warns(QCoDeSDeprecationWarning, match=r"top level.*'qcodes\.monitor'"): + obj = qcodes.Monitor # type: ignore[attr-defined] + assert obj is qcodes.monitor.Monitor + + +def test_top_level_station_shorthand_deprecated() -> None: + with pytest.warns(QCoDeSDeprecationWarning, match=r"top level.*'qcodes\.station'"): + obj = qcodes.Station # type: ignore[attr-defined] + assert obj is qcodes.station.Station + + +def test_top_level_submodule_access_not_deprecated() -> None: + """Accessing the (imported) submodules from ``qcodes`` is statically typed + and does not emit a deprecation warning.""" + with warnings.catch_warnings(): + warnings.simplefilter("error", QCoDeSDeprecationWarning) + assert qcodes.dataset is not None + assert qcodes.instrument is not None + assert qcodes.parameters is not None + assert qcodes.monitor is not None + assert qcodes.station is not None + assert qcodes.validators is not None + + +def test_top_level_unknown_attribute_raises() -> None: + with pytest.raises(AttributeError, match="definitely_not_a_qcodes_attribute"): + qcodes.definitely_not_a_qcodes_attribute # type: ignore[attr-defined] + + +def test_instrument_parameter_reexport_deprecated() -> None: + """Document that a parameter class accessed from ``qcodes.instrument`` is + both a runtime deprecation warning and a static type error.""" + with pytest.warns( + QCoDeSDeprecationWarning, match=r"'ManualParameter'.*'qcodes\.parameters'" + ): + obj = qcodes.instrument.ManualParameter # type: ignore[attr-defined] + assert obj is qcodes.parameters.ManualParameter + + +@pytest.mark.parametrize( + "name", + [ + "ArrayParameter", + "CombinedParameter", + "DelegateParameter", + "Function", + "ManualParameter", + "MultiParameter", + "Parameter", + "ParameterWithSetpoints", + "ScaledParameter", + "SweepFixedValues", + "SweepValues", + "combine", + ], +) +def test_instrument_parameter_reexport_deprecated_all(name: str) -> None: + """Every parameter class re-exported from ``qcodes.instrument`` still + resolves to the object in ``qcodes.parameters`` and emits a warning.""" + with pytest.warns( + QCoDeSDeprecationWarning, match=rf"{name!r}.*'qcodes\.parameters'" + ): + obj = getattr(qcodes.instrument, name) + assert obj is getattr(qcodes.parameters, name) + + +def test_instrument_unknown_attribute_raises() -> None: + with pytest.raises(AttributeError, match="definitely_not_a_qcodes_attribute"): + qcodes.instrument.definitely_not_a_qcodes_attribute # type: ignore[attr-defined] From 71f1ccbd5cb109e3d337ca23d7dad9a1afe77cc0 Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Tue, 14 Jul 2026 10:05:30 -0700 Subject: [PATCH 7/8] Mark qcodes import deprecations as a breaking change newsfragment The deprecation of the top level qcodes short hands and the qcodes.instrument parameter re-exports is a change to the public API, so classify the newsfragment as breaking rather than improved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 05b20136-29a2-4bfe-834c-894d3efa9d4c --- docs/changes/newsfragments/{8252.improved => 8252.breaking} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changes/newsfragments/{8252.improved => 8252.breaking} (100%) diff --git a/docs/changes/newsfragments/8252.improved b/docs/changes/newsfragments/8252.breaking similarity index 100% rename from docs/changes/newsfragments/8252.improved rename to docs/changes/newsfragments/8252.breaking From 96db6db31e91fe421e6f98860fe8bceaec04162a Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Thu, 16 Jul 2026 09:46:26 -0700 Subject: [PATCH 8/8] Add option to disable logging in close_all --- src/qcodes/instrument/instrument.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/qcodes/instrument/instrument.py b/src/qcodes/instrument/instrument.py index 70ea6d203c3..2f52e6670df 100644 --- a/src/qcodes/instrument/instrument.py +++ b/src/qcodes/instrument/instrument.py @@ -180,7 +180,10 @@ def close(self) -> None: self.remove_instance(self) @classmethod - def close_all(cls) -> None: + def close_all( + cls, + log_status: bool = False, + ) -> None: """ Try to close all instruments registered in ``_all_instruments`` This is handy for use with atexit to @@ -190,15 +193,22 @@ def close_all(cls) -> None: Examples: >>> atexit.register(qc.Instrument.close_all()) + Args: + log_status: If True, log the status of closing each instrument. Set this to False + if you want to avoid logging during interpreter shutdown, which can cause errors. + """ - log.info("Closing all registered instruments") + if log_status: + log.info("Closing all registered instruments") for inststr in list(cls._all_instruments): try: inst: Instrument = cls.find_instrument(inststr) - log.info("Closing %s", inststr) + if log_status: + log.info("Closing %s", inststr) inst.close() except Exception: - log.exception("Failed to close %s, ignored", inststr) + if log_status: + log.exception("Failed to close %s, ignored", inststr) @classmethod def record_instance(cls, instance: Instrument) -> None: