diff --git a/docs/changes/newsfragments/8523.new b/docs/changes/newsfragments/8523.new new file mode 100644 index 000000000000..f0e94cb2e5e9 --- /dev/null +++ b/docs/changes/newsfragments/8523.new @@ -0,0 +1,22 @@ +Instruments can now opt in to a logger of their own, rather than sharing a +single logger with every other instrument in the process. Setting the class +attribute ``default_logger_scope`` to ``"instrument"`` makes ``Instrument.log`` +(and ``VisaInstrument.visa_log``) use a logger named after the driver class and +the instrument's ``name_parts``, so that levels, handlers and ``propagate`` can +be configured per instrument:: + + class MyDriver(VisaInstrument): + default_logger_scope = "instrument" + +The scoped loggers mirror the instrument hierarchy, for example +``qcodes.instrument.instrument_base.MyDriver`` for the driver, +``qcodes.instrument.instrument_base.MyDriver.myinst`` for one of its +instruments and ``qcodes.instrument.instrument_base.MyDriver.myinst.ChanA`` for +one of that instrument's channels. A level can therefore be set for a whole +driver class, a single instrument or a single channel, and is inherited by +everything below it. Setting the level for a whole driver also works before any +of its instruments exist, so it can be configured via ``logger_levels`` in +``qcodesrc.json``. + +The default is unchanged: without opting in, all instruments keep sharing one +logger exactly as before. diff --git a/docs/examples/logging/logging_example.ipynb b/docs/examples/logging/logging_example.ipynb index 819d5fb5a51e..b8afef017ec3 100644 --- a/docs/examples/logging/logging_example.ipynb +++ b/docs/examples/logging/logging_example.ipynb @@ -333,6 +333,108 @@ " driver.cartesian((0, 0, 1))" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## One logger per instrument\n", + "By default all instruments share a single logger, named `qcodes.instrument.instrument_base` (and `...instrument_base.com.visa` for the VISA communication). This means that calling `setLevel`, `addHandler` or setting `propagate` on `instrument.log.logger` affects every other instrument in the process as well.\n", + "\n", + "A driver can opt in to a logger of its own by setting `default_logger_scope` to `\"instrument\"`. This can be done on the driver class, which also works for drivers you do not control:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "class ScopedAMIModel430(AMIModel430):\n", + " default_logger_scope = \"instrument\"\n", + "\n", + "\n", + "mag_w = ScopedAMIModel430(\n", + " \"w\", address=\"GPIB::4::INSTR\", pyvisa_sim_file=\"AMI430.yaml\", terminator=\"\\n\"\n", + ")\n", + "\n", + "print(mag_w.log.logger.name)\n", + "print(mag_w.switch_heater.log.logger.name)\n", + "print(mag_x.log.logger.name)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The logger name is built from the driver class followed by the instrument's `name_parts`, so scoped loggers mirror the instrument hierarchy: a submodule logger is a child of its instrument's logger, which is a child of a per-driver-class logger, which is a child of the shared logger. Any level configured on `qcodes.instrument.instrument_base`, for instance via `logger_levels` in `qcodesrc.json`, is therefore still inherited.\n", + "\n", + "The scope is looked up on the root instrument when the instrument is created, so it has to be set before instantiating the driver and applies to the whole instrument.\n", + "\n", + "The level of a single instrument can now be changed without affecting any other instrument:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mag_w.log.logger.setLevel(logging.DEBUG)\n", + "\n", + "print(logging.getLevelName(mag_w.log.logger.getEffectiveLevel()))\n", + "print(logging.getLevelName(mag_x.log.logger.getEffectiveLevel()))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Because there is a node per driver class, you can also raise the level for *every* instrument of a driver at once, which is typically what you want while developing or debugging that driver. This works for instruments that do not exist yet, so it can equally well be put in the `logger_levels` section of `qcodesrc.json`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "logging.getLogger(\"qcodes.instrument.instrument_base.ScopedAMIModel430\").setLevel(\n", + " logging.DEBUG\n", + ")\n", + "\n", + "mag_v = ScopedAMIModel430(\n", + " \"v\", address=\"GPIB::2::INSTR\", pyvisa_sim_file=\"AMI430.yaml\", terminator=\"\\n\"\n", + ")\n", + "\n", + "# both instruments of this driver are now at DEBUG, mag_x is untouched\n", + "for inst in (mag_w, mag_v):\n", + " print(inst.name, logging.getLevelName(inst.log.logger.getEffectiveLevel()))\n", + "print(mag_x.name, logging.getLevelName(mag_x.log.logger.getEffectiveLevel()))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A few things are worth keeping in mind:\n", + "\n", + "* QCoDeS attaches its console and file handlers to the **root** logger and those handlers have their own levels. Lowering the level of an instrument logger to `DEBUG` only becomes visible if the handler level lets the record through, so combine it with `console_level` or `handler_level` as shown above.\n", + "* Setting `instrument.log.logger.propagate = False` only stops records from reaching *ancestor* handlers, including QCoDeS' root handlers — it is not a general mute. Handlers attached directly to that logger still run, and VISA records travel through the separate `instrument.visa_log`, which propagates independently. Submodule loggers are descendants, so their records are stopped at the instrument as well.\n", + "* Python's logging registry keeps every logger it creates for the lifetime of the process, and closing an instrument does not release its logger. Instruments with stable names reuse their existing logger, so a normal station is unaffected, but a workload that repeatedly creates instruments under *dynamically generated* names accumulates one registry entry per distinct name while this scope is enabled.\n", + "\n", + "Note also that parameters log through the logger of their *root* instrument, so messages from a channel parameter appear on the root instrument's logger rather than on the channel's own logger." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "mag_w.close()\n", + "mag_v.close()" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/src/qcodes/instrument/instrument_base.py b/src/qcodes/instrument/instrument_base.py index 733bfa0ecb6c..9c32df4f0adf 100644 --- a/src/qcodes/instrument/instrument_base.py +++ b/src/qcodes/instrument/instrument_base.py @@ -5,7 +5,7 @@ import collections.abc import logging import warnings -from typing import TYPE_CHECKING, Any, ClassVar, cast +from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast import numpy as np from typing_extensions import TypedDict, TypeVar, deprecated @@ -37,6 +37,21 @@ "TSubmodule", bound="InstrumentModule | ChannelTuple", default="InstrumentModule" ) +LoggerScope = Literal["shared", "instrument"] +""" +Naming scope used for the logger behind :attr:`InstrumentBase.log` (and +:attr:`~qcodes.instrument.VisaInstrument.visa_log`). + +``"shared"`` + All instruments share a single logger. +``"instrument"`` + Each instrument gets its own logger, named after the class of its + ``root_instrument`` followed by its ``name_parts``. This creates one logger + per driver class, one per instrument and one per submodule, each a child of + the previous one, so a level configured on any of them applies to + everything below it. +""" + class InstrumentBaseKWArgs(TypedDict): """ @@ -71,6 +86,30 @@ class InstrumentBase(MetadatableWithName, DelegateAttributes): """ + default_logger_scope: ClassVar[LoggerScope] = "shared" + """ + Logger naming scope used for :attr:`log` (and + :attr:`~qcodes.instrument.VisaInstrument.visa_log`). + + By default all instruments share a single logger. Set this to + ``"instrument"`` in the definition of a driver class to give each of its + instruments a logger of its own, so that log levels and handlers can be + configured per instrument:: + + class MyDriver(VisaInstrument): + default_logger_scope = "instrument" + + The resulting loggers are named + ``qcodes.instrument.instrument_base..``, so a + level can be set for a whole driver class, a single instrument, or a single + submodule. + + The scope is resolved once, when the :meth:`root_instrument` is created, + and every submodule uses the scope of its root. It therefore applies to the + whole instrument, including submodules added later, and changing it + afterwards has no effect on existing instruments. + """ + def __init__( self, name: str, @@ -118,9 +157,38 @@ def __init__( # This is needed for snapshot method to work self._meta_attrs = ["name", "label"] - self.log: InstrumentLoggerAdapter = get_instrument_logger(self, __name__) + root = self.root_instrument + self._logger_scope: LoggerScope = ( + type(self).default_logger_scope if root is self else root._logger_scope + ) + + self.log: InstrumentLoggerAdapter = get_instrument_logger( + self, self._logger_name(__name__) + ) self.log.debug("Created instrument: %s", self.full_name) + def _logger_name(self, base: str) -> str: + """ + Name of the logger to use, derived from ``base`` according to the + logger scope resolved when the :meth:`root_instrument` was created. + + Under the ``"instrument"`` scope the name is built from the class of + the :meth:`root_instrument` followed by :meth:`name_parts`, e.g. + ``.MyDriver.myinst.ChanA``. That gives one node per driver class, + one per instrument and one per submodule, so a level can be configured + for a whole driver, a single instrument, or a single channel, and each + is inherited by everything below it. + + Args: + base: Name of the shared logger that this instrument would use if + no scope was configured. + + """ + if self._logger_scope == "instrument": + root = self.root_instrument + return ".".join((base, type(root).__name__, *self.name_parts)) + return base + @property def label(self) -> str: """ diff --git a/src/qcodes/instrument/ip_to_visa.py b/src/qcodes/instrument/ip_to_visa.py index 67d61acb421b..47ef12da7695 100644 --- a/src/qcodes/instrument/ip_to_visa.py +++ b/src/qcodes/instrument/ip_to_visa.py @@ -57,7 +57,7 @@ def __init__( newkwargs = {kw: val for (kw, val) in kwargs.items() if kw not in ipkwargs} Instrument.__init__(self, name, **newkwargs) - self.visa_log = get_instrument_logger(self, VISA_LOGGER) + self.visa_log = get_instrument_logger(self, self._logger_name(VISA_LOGGER)) ################################################## # __init__ of VisaInstrument diff --git a/src/qcodes/instrument/visa.py b/src/qcodes/instrument/visa.py index e14b43b173be..7f7169bd9d0e 100644 --- a/src/qcodes/instrument/visa.py +++ b/src/qcodes/instrument/visa.py @@ -167,7 +167,7 @@ def __init__( timeout = self.default_timeout super().__init__(name, **kwargs) - self.visa_log = get_instrument_logger(self, VISA_LOGGER) + self.visa_log = get_instrument_logger(self, self._logger_name(VISA_LOGGER)) self.timeout: Parameter[float | None, Self] = self.add_parameter( "timeout", diff --git a/tests/test_logger.py b/tests/test_logger.py index cacfdc5d594c..4c59546faf2a 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -5,7 +5,7 @@ import logging import os from copy import copy -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, ClassVar import numpy as np import pytest @@ -14,7 +14,13 @@ import qcodes as qc from qcodes import logger from qcodes.instrument import Instrument +from qcodes.instrument.visa import VISA_LOGGER from qcodes.instrument_drivers.american_magnetics import AMIModel430, AMIModel4303D +from qcodes.instrument_drivers.mock_instruments import ( + DummyChannel, + DummyChannelInstrument, + DummyInstrument, +) from qcodes.instrument_drivers.tektronix import TektronixAWG5208 from qcodes.logger.log_analysis import capture_dataframe from tests.drivers.test_lakeshore_372 import LakeshoreModel372Mock @@ -22,10 +28,15 @@ if TYPE_CHECKING: from collections.abc import Callable, Generator + from qcodes.instrument.instrument_base import LoggerScope + TEST_LOG_MESSAGE = "test log message" NUM_PYTEST_LOGGERS = 4 +SHARED_INSTRUMENT_LOGGER_NAME = "qcodes.instrument.instrument_base" +SHARED_VISA_LOGGER_NAME = VISA_LOGGER + _LOGGER = logging.getLogger(__name__) @@ -351,3 +362,246 @@ def test_get_level_code_with_invalid_type() -> None: """Only str and int can be converted to a logging level code.""" with pytest.raises(RuntimeError, match="get_level_code: Cannot to convert level"): logger.get_level_code(1.5) # type: ignore[arg-type] + + +class ScopedDummyInstrument(DummyInstrument): + """Dummy instrument that opts in to a per instrument logger.""" + + default_logger_scope: ClassVar["LoggerScope"] = "instrument" + + +class ScopedDummyChannelInstrument(DummyChannelInstrument): + """Instrument with channels that opts in to a per instrument logger.""" + + default_logger_scope: ClassVar["LoggerScope"] = "instrument" + + +class ScopedAMIModel430(AMIModel430): + """VISA instrument that opts in to a per instrument logger.""" + + default_logger_scope: ClassVar["LoggerScope"] = "instrument" + + +@pytest.fixture(name="restore_shared_logger_levels", autouse=True) +def _restore_shared_logger_levels() -> "Generator[None, None, None]": + """ + Restore the level of the shared loggers and of every logger below them. + + Scoped loggers stay in the logging registry for the lifetime of the + process, so a level set by one test would otherwise leak into the next. + """ + roots = (SHARED_INSTRUMENT_LOGGER_NAME, SHARED_VISA_LOGGER_NAME) + + def scoped_loggers() -> list[logging.Logger]: + return [ + logging.getLogger(name) + for name in (*roots, *logging.Logger.manager.loggerDict) + if any(name == root or name.startswith(f"{root}.") for root in roots) + ] + + levels = {logger_.name: logger_.level for logger_ in scoped_loggers()} + yield + for logger_ in scoped_loggers(): + logger_.setLevel(levels.get(logger_.name, logging.NOTSET)) + + +def test_default_logger_scope_is_shared() -> None: + """By default all instruments keep sharing a single logger.""" + inst_a = DummyInstrument("shared_scope_a") + inst_b = DummyInstrument("shared_scope_b") + + assert inst_a.log.logger.name == SHARED_INSTRUMENT_LOGGER_NAME + assert inst_a.log.logger is inst_b.log.logger + + +def test_instrument_scope_gives_one_logger_per_instrument() -> None: + inst_a = ScopedDummyInstrument("instrument_scope_a") + inst_b = ScopedDummyInstrument("instrument_scope_b") + + assert ( + inst_a.log.logger.name + == f"{SHARED_INSTRUMENT_LOGGER_NAME}.ScopedDummyInstrument.instrument_scope_a" + ) + assert ( + inst_b.log.logger.name + == f"{SHARED_INSTRUMENT_LOGGER_NAME}.ScopedDummyInstrument.instrument_scope_b" + ) + assert inst_a.log.logger is not inst_b.log.logger + + +def test_instrument_scope_isolates_logger_level() -> None: + """Changing the level of one scoped logger must not affect any other.""" + shared_logger = logging.getLogger(SHARED_INSTRUMENT_LOGGER_NAME) + shared_level = shared_logger.level + + inst_a = ScopedDummyInstrument("level_isolation_a") + inst_b = ScopedDummyInstrument("level_isolation_b") + inst_a.log.logger.setLevel(logging.DEBUG) + + assert inst_a.log.logger.level == logging.DEBUG + assert inst_b.log.logger.level == logging.NOTSET + assert shared_logger.level == shared_level + + +def test_scoped_logger_inherits_level_from_shared_logger() -> None: + """Opting in must not disconnect an instrument from existing configuration.""" + logging.getLogger(SHARED_INSTRUMENT_LOGGER_NAME).setLevel(logging.ERROR) + + inst = ScopedDummyInstrument("level_inheritance") + + assert inst.log.logger.level == logging.NOTSET + assert inst.log.logger.getEffectiveLevel() == logging.ERROR + + +def test_level_can_be_set_for_a_whole_driver_class() -> None: + """ + A level configured on the driver class node applies to every instrument of + that driver, including ones created afterwards, and not to other drivers. + """ + # pin the shared level so the result does not depend on ambient logging config + logging.getLogger(SHARED_INSTRUMENT_LOGGER_NAME).setLevel(logging.WARNING) + class_logger = logging.getLogger( + f"{SHARED_INSTRUMENT_LOGGER_NAME}.ScopedDummyInstrument" + ) + class_logger.setLevel(logging.DEBUG) + + # created only after the level was configured + inst_a = ScopedDummyInstrument("class_level_a") + inst_b = ScopedDummyInstrument("class_level_b") + other = ScopedDummyChannelInstrument("class_level_other") + + assert inst_a.log.logger.getEffectiveLevel() == logging.DEBUG + assert inst_b.log.logger.getEffectiveLevel() == logging.DEBUG + assert other.log.logger.getEffectiveLevel() == logging.WARNING + + +def test_driver_class_level_applies_to_submodules() -> None: + class_logger = logging.getLogger( + f"{SHARED_INSTRUMENT_LOGGER_NAME}.ScopedDummyChannelInstrument" + ) + class_logger.setLevel(logging.DEBUG) + + inst = ScopedDummyChannelInstrument("class_level_channels") + channel = inst.submodules["A"] + + assert channel.log.logger.getEffectiveLevel() == logging.DEBUG # type: ignore[union-attr] + + +def test_scope_is_fixed_when_root_is_created( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Changing the scope after the root was created must not split the hierarchy + for submodules that are added later. + """ + inst = ScopedDummyChannelInstrument("fixed_scope") + monkeypatch.setattr(ScopedDummyChannelInstrument, "default_logger_scope", "shared") + + channel = DummyChannel(inst, "late_channel", "Z") + inst.add_submodule("late_channel", channel) + + assert channel.log.logger.name == f"{inst.log.logger.name}.late_channel" + assert channel.log.logger.parent is inst.log.logger + + +def test_instrument_level_overrides_driver_class_level() -> None: + """The per instrument node must stay more specific than the class node.""" + logging.getLogger( + f"{SHARED_INSTRUMENT_LOGGER_NAME}.ScopedDummyInstrument" + ).setLevel(logging.ERROR) + + inst_a = ScopedDummyInstrument("override_a") + inst_b = ScopedDummyInstrument("override_b") + inst_a.log.logger.setLevel(logging.DEBUG) + + assert inst_a.log.logger.getEffectiveLevel() == logging.DEBUG + assert inst_b.log.logger.getEffectiveLevel() == logging.ERROR + + +def test_scoped_logger_keeps_instrument_extra_info(caplog: LogCaptureFixture) -> None: + """Records must keep the info that ``filter_instrument`` relies on.""" + inst = ScopedDummyInstrument("extra_info") + + with caplog.at_level(logging.INFO): + inst.log.info(TEST_LOG_MESSAGE) + + record = caplog.records[-1] + assert record.name == inst.log.logger.name + assert record.__dict__["instrument_name"] == "extra_info" + assert record.__dict__["instrument_type"] == "ScopedDummyInstrument" + + +def test_scoped_logger_is_filterable_by_instrument() -> None: + inst = ScopedDummyInstrument("filterable") + other = ScopedDummyInstrument("not_filterable") + + with ( + logger.LogCapture(level=logging.DEBUG) as logs, + logger.filter_instrument(inst, handler=logs.string_handler), + ): + inst.log.info(TEST_LOG_MESSAGE) + other.log.info(TEST_LOG_MESSAGE) + + assert "[filterable(ScopedDummyInstrument)]" in logs.value + assert "[not_filterable(ScopedDummyInstrument)]" not in logs.value + + +def test_submodule_logger_is_child_of_instrument_logger() -> None: + """A submodule must sit below its parent in the logger hierarchy.""" + inst = ScopedDummyChannelInstrument("scoped_channels") + channel = inst.submodules["A"] + + assert ( + channel.log.logger.name # type: ignore[union-attr] + == f"{SHARED_INSTRUMENT_LOGGER_NAME}.ScopedDummyChannelInstrument" + ".scoped_channels.ChanA" + ) + assert channel.log.logger is not inst.log.logger # type: ignore[union-attr] + + +def test_instrument_level_applies_to_its_submodules() -> None: + """Setting the level on an instrument must also cover its channels.""" + inst = ScopedDummyChannelInstrument("level_to_channels") + channel = inst.submodules["A"] + + inst.log.logger.setLevel(logging.DEBUG) + + assert channel.log.logger.getEffectiveLevel() == logging.DEBUG # type: ignore[union-attr] + + +def test_submodule_of_unscoped_instrument_uses_shared_logger() -> None: + inst = DummyChannelInstrument("unscoped_channels") + channel = inst.submodules["A"] + + assert channel.log.logger.name == SHARED_INSTRUMENT_LOGGER_NAME # type: ignore[union-attr] + + +def test_visa_log_is_shared_by_default() -> None: + inst = AMIModel430( + "shared_visa_log", + address="GPIB::1::INSTR", + pyvisa_sim_file="AMI430.yaml", + terminator="\n", + ) + + assert inst.visa_log.logger.name == SHARED_VISA_LOGGER_NAME + assert inst.log.logger.name == SHARED_INSTRUMENT_LOGGER_NAME + + +def test_visa_log_follows_instrument_scope() -> None: + inst = ScopedAMIModel430( + "scoped_visa_log", + address="GPIB::1::INSTR", + pyvisa_sim_file="AMI430.yaml", + terminator="\n", + ) + + assert ( + inst.visa_log.logger.name + == f"{SHARED_VISA_LOGGER_NAME}.ScopedAMIModel430.scoped_visa_log" + ) + assert ( + inst.log.logger.name + == f"{SHARED_INSTRUMENT_LOGGER_NAME}.ScopedAMIModel430.scoped_visa_log" + ) + assert inst.visa_log.logger is not inst.log.logger