diff --git a/docs/README.md b/docs/README.md
index c9dcac8..8d1532f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -69,7 +69,7 @@ docs/
│ │ └── gui_components.md
│ └── client/ # Client development guide
│ ├── index.md
-│ ├── basic_usage.md
+│ ├── client.md
│ ├── parameters_and_methods.md
│ └── advanced_patterns.md
│
@@ -92,6 +92,7 @@ The documentation uses the **pydata-sphinx-theme**, which provides:
- Light/dark mode support
- Mobile-friendly navigation
- Integrated search
+- Copy buttons on code examples
## Contributing to Documentation
@@ -145,4 +146,4 @@ Commits to the main branch trigger automatic builds.
- [Sphinx Documentation](https://www.sphinx-doc.org/)
- [MyST Parser](https://myst-parser.readthedocs.io/)
-- [PyData Theme](https://pydata-sphinx-theme.readthedocs.io/)
\ No newline at end of file
+- [PyData Theme](https://pydata-sphinx-theme.readthedocs.io/)
diff --git a/docs/_static/animations/proxy_lifecycle.html b/docs/_static/animations/proxy_lifecycle.html
new file mode 100644
index 0000000..c95c3b2
--- /dev/null
+++ b/docs/_static/animations/proxy_lifecycle.html
@@ -0,0 +1,410 @@
+
+
+
+ Action 1 · Create the Proxy Instrument
+ Step 01
+ Ask for a Proxy Instrument
+
+
+
+ Blueprint
+ remote call
+ Python value
+
+
+
+
+
+ Action 1
+ Create the Proxy Instrument
+
+
Step 01
Ask for a Proxy Instrument
magnet = cli.get_instrument("magnet") asks the Client for a local representation of the Server-owned instrument. The Client sends a Blueprint request; it does not copy the real driver.
+
Step 02
The Server creates a Blueprint
The Server creates a Blueprint from the real magnet driver. It describes the remote interface: parameters, methods, and submodules.
+
Step 03
The Blueprint returns
The Blueprint crosses the ZMQ connection as a serialized message. The Client reconstructs it as a new Python object in your script or notebook.
+
Step 04
The Client builds the Proxy
The Client uses the Blueprint to create magnet, a local Proxy Instrument with a Proxy Parameter named field. The Proxy remains available for later calls.
+
+ Action 2
+ Set the value
+
+
Step 05
Create the target value
target = FieldVector(x=0.01, y=0.02, z=0.03) creates the first FieldVector in your script. It has not crossed the connection yet.
+
Step 06
Set through the Proxy
magnet.field(target) passes the value into the Proxy Parameter. The request is serialized, sent to the Server, and reconstructed there as a second FieldVector.
+
Step 07
The real driver stores the value
The Server calls the real magnet.field(...) parameter with its reconstructed FieldVector. The new object replaces the driver's previous FieldVector(x=0, y=0, z=0) as its current field value.
+
Step 08
The set call completes
After the driver finishes the set, the Server returns a successful response to the Proxy. Only then does magnet.field(target) return to your code.
+
+ Action 3
+ Read the value
+
+
Step 09
Read through the Proxy
returned = magnet.field() sends a get request to the Server. The Proxy does not answer from local state.
+
Step 10
The Server reads the real driver
The Server calls the real magnet.field() parameter. The driver returns its current FieldVector to the Server.
+
Step 11
Your code receives a new object
The result crosses back as a serialized message and becomes returned. It has the same coordinates as the driver's current value, but it is a newly reconstructed Python object. The original target remains a third, distinct object in your script.
applyStep(index);
}
+ function selectStep(index) {
+ var step = stepEls[index];
+ if (!step) return;
+ setActive(index);
+ var rect = step.getBoundingClientRect();
+ var target = window.scrollY + rect.top + rect.height / 2 - window.innerHeight * 0.57;
+ window.scrollTo({ top: Math.max(0, target), behavior: reduced ? "auto" : "smooth" });
+ }
+
root.classList.add("is-live");
/* Cards are direct controls as well as scroll markers. Keep their activation
@@ -826,11 +835,11 @@
Subscribers react
"aria-label",
"Show " + STEPS[index].no + ": " + (title ? title.textContent : STEPS[index].title)
);
- card.addEventListener("click", function () { setActive(index); });
+ card.addEventListener("click", function () { selectStep(index); });
card.addEventListener("keydown", function (event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
- setActive(index);
+ selectStep(index);
}
});
});
diff --git a/docs/conf.py b/docs/conf.py
index 22d2a3f..f3c0b9a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -28,6 +28,8 @@
'nbsphinx', # Jupyter notebook support
'sphinx.ext.intersphinx', # Link to other project docs
'sphinx_design', # Tabs, cards, grids
+ 'sphinx-prompt', # Copy-safe shell and interpreter prompts
+ 'sphinx_copybutton', # Copy buttons on code blocks
]
# MyST Parser configuration
@@ -67,6 +69,14 @@
nbsphinx_allow_errors = True # Continue building if notebook has errors
nbsphinx_kernel_name = 'python3'
+# Match scikit-learn's copy behavior: remove Python REPL prompts and the inline
+# styles used to draw unselectable sphinx-prompt prefixes. Keep copy buttons off
+# notebook prompts and line-number gutters.
+copybutton_prompt_text = r">>> |\.\.\. "
+copybutton_prompt_is_regexp = True
+copybutton_exclude = "style"
+copybutton_selector = ":not(.prompt) > div.highlight pre"
+
# Intersphinx configuration (link to other docs)
intersphinx_mapping = {
'python': ('https://docs.python.org/3', None),
@@ -127,4 +137,4 @@
# HTML context for custom variables
html_context = {
"default_mode": "auto" # Light/dark theme
-}
\ No newline at end of file
+}
diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md
index b1e4b89..ad9ed68 100644
--- a/docs/getting_started/quickstart.md
+++ b/docs/getting_started/quickstart.md
@@ -177,7 +177,7 @@ This single entry only scratches the surface of what the config file controls; t
You've seen the whole loop: a Server owning instruments, clients reaching them through
Proxy Instruments, and every change broadcast to anyone watching. From here:
-- [Basic usage](../user_guide/basic_usage.md): the Python client in depth, the
+- [Python Client](../user_guide/client.md): the Python client in depth, the
interface you'll use the most.
- [The server](../user_guide/server.md): launch options, headless operation, and the
Detached GUI.
diff --git a/docs/user_guide/basic_usage.md b/docs/user_guide/basic_usage.md
deleted file mode 100644
index 14daac1..0000000
--- a/docs/user_guide/basic_usage.md
+++ /dev/null
@@ -1,14 +0,0 @@
-# Basic Usage
-
-:::{admonition} 🚧 This page is planned, not yet written
-:class: warning
-It will be replaced with verified content as the documentation refactor progresses.
-:::
-
-The Python client, the most-used interface. This page will cover:
-
-- Client basics: connect, list, get instrument
-- Proxy Instruments: parameters, methods, submodules
-- Batch parameter operations: `getParamDict` / `setParameters` / to and from file
-- Subscribing to Broadcasts (SubClient)
-- Error handling, timeouts, reconnection behavior
diff --git a/docs/user_guide/client.md b/docs/user_guide/client.md
new file mode 100644
index 0000000..64d6b5a
--- /dev/null
+++ b/docs/user_guide/client.md
@@ -0,0 +1,598 @@
+# Python client
+
+The Python Client is the interface to instruments owned by a Server. It represents a
+remote instrument as a local Proxy Instrument with the same parameters and methods as
+its real QCoDeS driver. The [Quickstart](../getting_started/quickstart.md) gives a shorter first
+look at instrumentserver. For a conceptual overview, see
+[How it works](../getting_started/how_it_works.md).
+
+Each section is self-contained and assumes the same Server remains running.
+
+## Connections and instrument discovery
+
+The examples use a local Server started with:
+
+```{prompt} bash
+instrumentserver -p 5555 -a 127.0.0.1
+```
+
+This opens the Server GUI, where instruments appear as the examples create them. The
+Client examples work in a terminal, a Jupyter notebook, or anywhere you can run python.
+
+:::{note}
+Port `5555` and address `127.0.0.1` are the defaults, so plain `instrumentserver` starts
+the same local Server. The `-p` option selects the request port, and `-a` adds a
+listening address. The Server always includes the loopback address.
+[The Server](server.md) covers addresses, ports, and remote connections.
+:::
+
+Clients can stay open for an entire measurement or be created only when needed, though
+we usually keep one for the duration of a measurement. In either case, closing the
+Client releases the network resources used by its connection to the Server.
+
+The Client is created with the Server's host and request port (if using defaults, you can just use `Client()`):
+
+```pycon
+>>> from instrumentserver.client import Client
+>>> cli = Client(host="localhost", port=5555)
+```
+
+Once the Client exists, `list_instruments()` reports the instruments that the Server
+already owns:
+
+```pycon
+>>> cli.list_instruments()
+[]
+```
+
+`find_or_create_instrument` adds the dummy RF generator used throughout this page:
+
+```pycon
+>>> generator = cli.find_or_create_instrument(
+... "generator",
+... "instrumentserver.testing.dummy_instruments.rf.Generator",
+... )
+>>> cli.list_instruments()
+['generator']
+```
+
+`find_or_create_instrument` returns a Proxy Instrument. If the Server doesn't have the
+requested name, it imports the class and creates the real instrument. If the name
+already exists, the Server returns a Proxy for that instrument. The method mirrors
+QCoDeS' [`find_or_create_instrument`](https://microsoft.github.io/Qcodes/api/instrument/index.html#qcodes.instrument.find_or_create_instrument).
+Instrumentserver, however, matches only by name. For an existing name, it does not check
+the class path supplied for creation.
+
+The Server GUI shows the generator as soon as the Server creates it:
+
+```{image} ../_static/getting_started/quickstart/server_generator_light.png
+:class: only-light
+:alt: The Server GUI showing the newly created generator
+```
+
+```{image} ../_static/getting_started/quickstart/server_generator_dark.png
+:class: only-dark
+:alt: The Server GUI showing the newly created generator
+```
+
+:::{note}
+`cli.get_instrument("generator")` covers the case where an instrument already exists and
+no creation class is needed. It returns a Proxy without creating an instrument.
+:::
+
+A Client remains connected until `disconnect()` closes its ZMQ connection:
+
+```pycon
+>>> cli.disconnect()
+```
+
+A context manager gives small scripts the same lifecycle. It disconnects the Client when
+the block exits:
+
+```pycon
+>>> with Client(host="localhost", port=5555) as cli:
+... generator = cli.get_instrument("generator")
+... print(generator.frequency())
+10000000000.0
+```
+
+:::{note}
+Constructing `Client` opens its ZMQ connection but does not perform a handshake with the
+Server. The first request, such as `list_instruments()`, is what confirms that the Server
+can reply. [Errors and timeouts](#errors-and-timeouts) describes what happens when it
+cannot.
+:::
+
+## Proxy instruments
+
+A Proxy Instrument is the local Python object that represents one instrument owned by
+the Server. Measurement code works with the Proxy, while the Server keeps the real
+QCoDeS driver and its hardware connection. The two objects share a name and a public
+interface, but they have different jobs.
+
+| In the Client process | In the Server process |
+| --- | --- |
+| The Proxy Instrument reproduces the driver's parameters, methods, and submodules. | The real instrument contains the driver logic, state, and hardware connection. |
+| A Proxy turns each parameter or method call into a request. | The Server runs that request on the real instrument and returns the result. |
+
+The Client builds a Proxy from a Blueprint supplied by the Server. That Blueprint
+describes the part of the driver available remotely, including parameter units, method
+signatures, docstrings, and the hierarchy of submodules. The Client turns the
+description into a real local QCoDeS object with parameters and bound methods. Ordinary
+driver attributes and driver code stay in the Server.
+
+`find_or_create_instrument` creates or finds the real generator in the Server, then
+returns the local object that represents it:
+
+```pycon
+>>> from instrumentserver.client import Client
+>>> cli = Client()
+>>> generator = cli.find_or_create_instrument(
+... "generator",
+... "instrumentserver.testing.dummy_instruments.rf.Generator",
+... )
+```
+
+Several Clients can hold Proxies for the same instrument. All of those Proxies refer to
+the one Server-owned driver, so a parameter read always asks the Server for its current
+value. A Proxy does not maintain a separate local value that can drift away from the
+hardware.
+
+Most interaction with a Proxy happens through its parameters and driver methods.
+`generator.frequency` is a local Proxy Parameter, while a proxied driver method is a
+local bound method. Both forward calls to the corresponding object in the Server.
+
+### Parameters and driver methods
+
+`generator.frequency` is a local QCoDeS
+[`Parameter`](https://microsoft.github.io/Qcodes/api/parameters/#qcodes.parameters.Parameter)
+whose get and set commands call the real parameter in the Server. The normal QCoDeS
+callable form therefore reads and writes the remote instrument:
+
+```pycon
+>>> print(generator.frequency())
+10000000000.0
+>>> generator.frequency(5e9)
+>>> generator.frequency()
+5000000000.0
+```
+
+The explicit QCoDeS methods make the same calls:
+
+```pycon
+>>> generator.frequency.set(6e9)
+>>> generator.frequency.get()
+6000000000.0
+```
+
+The Blueprint also records whether a parameter supports get and set operations, along
+with its unit and docstring. Parameter validators remain on the Server. An invalid value
+is rejected by the real parameter and the Client receives the resulting error.
+
+Driver methods follow the same model. The Client creates a local method with the
+signature and docstring reported by the Server. Calling it sends the target path,
+positional arguments, and keyword arguments to the real driver. The return value comes
+back as the result of the local call.
+
+This dummy resonator has a method that changes its simulated resonance frequency:
+
+```pycon
+>>> resonator = cli.find_or_create_instrument(
+... "resonator",
+... "instrumentserver.testing.dummy_instruments.rf.ResonatorResponse",
+... )
+>>> resonator.modulate_frequency(delta=1e6)
+```
+
+`modulate_frequency` looks like a local bound method, but its body runs on the real
+`resonator` in the Server. The call completes only after the Server returns a response.
+
+### Submodules keep their structure
+
+Blueprints describe submodules recursively. A nested QCoDeS module or instrument
+channel becomes another Proxy Instrument, with its own parameters and methods at the
+same attribute path as the real driver.
+
+The dummy instrument below has three submodules named `A`, `B`, and `C`. Parameter access
+through `multi_channel.A` keeps the same shape on both sides of the connection:
+
+```pycon
+>>> multi_channel = cli.find_or_create_instrument(
+... "multi_channel",
+... (
+... "instrumentserver.testing.dummy_instruments.generic."
+... "DummyInstrumentWithSubmodule"
+... ),
+... )
+```
+
+The nested parameter has the same callable interface as a parameter on the top-level
+instrument:
+
+```pycon
+>>> multi_channel.A.ch0()
+0
+>>> multi_channel.A.ch0(0.5)
+>>> multi_channel.A.ch0()
+0.5
+>>> multi_channel.A.dummy_function("calibrate", source="client")
+True
+```
+
+The Proxy can refresh its Blueprint when the Server-side interface changes. Calling
+`multi_channel.update()` fetches a fresh Blueprint, synchronizes parameters and
+submodules, and adds newly reported methods. It does not remove method objects already
+installed on the Proxy if the Server stops reporting them.
+
+### Values that cross the connection
+
+Parameter values, method arguments, and method return values travel between processes,
+so the Client and Server cannot share the same in-memory Python object. Instrumentserver
+serializes each value for transport and reconstructs it at the other end. This happens
+in both directions and applies equally to Proxy Parameters and driver methods.
+
+The built-in serialization handles `None`, booleans, numbers, strings, nested lists and
+dictionaries, complex numbers, and NumPy arrays. It also supports richer value objects.
+QCoDeS' `FieldVector`, for example, can pass through a Proxy Parameter without losing
+its type:
+
+```pycon
+>>> from qcodes.math_utils.field_vector import FieldVector
+>>> magnet = cli.find_or_create_instrument(
+... "magnet",
+... "instrumentserver.testing.dummy_instruments.generic.FieldVectorIns",
+... )
+>>> target = FieldVector(x=0.01, y=0.02, z=0.03)
+>>> magnet.field(target)
+>>> returned = magnet.field()
+>>> print(isinstance(returned, FieldVector))
+True
+>>> returned.is_equal(target)
+True
+```
+
+`target` and `returned` are different Python objects. The Client serializes `target`,
+the Server reconstructs a `FieldVector` for the real parameter, and the return trip
+creates another `FieldVector` in the Client process. Values survive the round trip, but
+object identity does not.
+
+```pycon
+>>> id(target)
+4385419344
+>>> id(returned)
+4385419824
+```
+
+### Making custom classes serializable
+
+Custom classes used in parameters and methods use an `attributes` class attribute to
+list the values instrumentserver needs to reconstruct an instance. Once a class provides
+that list, its instances can cross the connection as parameter values, method arguments,
+or method return values.
+
+For example, a sweep range can preserve its Python type instead of arriving as a plain
+dictionary:
+
+```python
+class SweepWindow:
+ attributes = ["start", "stop"]
+
+ def __init__(self, start: float, stop: float):
+ self.start = start
+ self.stop = stop
+```
+
+Instrumentserver serializes a `SweepWindow` as its listed values plus the class's import
+path. The receiving process imports the class and reconstructs it with
+`SweepWindow(start=..., stop=...)`. This places three requirements on a custom value
+class:
+
+- The class lives in an importable module available to both the Client and Server.
+- Every name in `attributes` is accepted by the constructor as a keyword argument.
+- The listed attribute values are JSON-compatible data.
+
+A class defined only in a notebook or in `__main__` cannot be imported by the other
+process. Both environments also need a compatible version of the module.
+
+Dataclasses fit this model well because their generated constructors already accept
+fields by name. Request and result types can live together in a small module installed
+in both environments. Declaring `attributes` as a `ClassVar` keeps it out of the
+dataclass fields and constructor:
+
+```python
+# lab_models.py
+from dataclasses import dataclass, field
+from typing import ClassVar
+
+
+@dataclass
+class SweepRequest:
+ center_hz: float
+ span_hz: float
+ metadata: dict[str, str] = field(default_factory=dict)
+
+ attributes: ClassVar[tuple[str, ...]] = (
+ "center_hz",
+ "span_hz",
+ "metadata",
+ )
+
+
+@dataclass
+class SweepResult:
+ frequency_hz: list[float]
+ magnitude_db: list[float]
+
+ attributes: ClassVar[tuple[str, ...]] = (
+ "frequency_hz",
+ "magnitude_db",
+ )
+```
+
+A Server-owned analyzer could accept `SweepRequest` in a driver method and return
+`SweepResult`:
+
+```python
+# lab_drivers.py
+from qcodes import Instrument
+
+from lab_models import SweepRequest, SweepResult
+
+
+class Analyzer(Instrument):
+ def run_sweep(self, request: SweepRequest) -> SweepResult:
+ half_span = request.span_hz / 2
+ return SweepResult(
+ frequency_hz=[
+ request.center_hz - half_span,
+ request.center_hz,
+ request.center_hz + half_span,
+ ],
+ magnitude_db=[-50.0, -20.0, -49.0],
+ )
+```
+
+The user and the driver work with Python objects. The transport code in the Client and
+Server handles the serialized form between them:
+
+- Request: user code passes a `SweepRequest`; the Client serializes it to JSON; the
+ Server deserializes it back into a `SweepRequest`; the driver receives that object.
+- Result: the driver returns a `SweepResult`; the Server serializes it to JSON; the
+ Client deserializes it back into a `SweepResult`; user code receives that object.
+
+The call therefore looks like any other Proxy method call:
+
+```pycon
+>>> from lab_models import SweepRequest, SweepResult
+>>> analyzer = cli.find_or_create_instrument(
+... "analyzer",
+... "lab_drivers.Analyzer",
+... )
+>>> request = SweepRequest(
+... center_hz=5e9,
+... span_hz=20e6,
+... metadata={"sample": "A"},
+... )
+>>> result = analyzer.run_sweep(request)
+>>> isinstance(result, SweepResult)
+True
+>>> result.frequency_hz
+[4990000000.0, 5000000000.0, 5010000000.0]
+>>> result.magnitude_db
+[-50.0, -20.0, -49.0]
+```
+
+The values listed in `attributes` must already be JSON-compatible. A list or dictionary
+containing scalar values works, but the serializer does not recursively apply the
+`attributes` protocol to a dataclass stored inside another custom object. A NumPy array
+stored as a dataclass field has the same limitation, even though NumPy arrays are
+supported when passed directly. Such fields need a JSON-compatible representation,
+such as a list, or their own handling before they cross the connection.
+
+Type annotations describe the dataclass but do not control deserialization.
+Instrumentserver passes the decoded values directly to the constructor. A class that
+requires exact field types can normalize them in `__post_init__()`.
+
+:::{note}
+Serialization preserves values, not every container's exact Python type. Tuples and
+sets arrive as lists. A top-level `Enum` return value keeps its enum type when the enum
+class is importable on the receiving side, while enums nested inside lists or
+dictionaries become their underlying values.
+:::
+
+:::{warning}
+The current decoder infers types from scalar text. A string such as `"123"` arrives as
+the integer `123`. Method and parameter APIs that require numeric-looking text need an
+unambiguous encoding, such as a nonnumeric prefix.
+:::
+
+### Following the full lifecycle
+
+The diagram ties the pieces in this section together. It follows a Proxy Instrument
+from its Blueprint through a `FieldVector` parameter set and get, including each point
+where instrumentserver serializes or deserializes a value.
+
+```{raw} html
+:file: ../_static/animations/proxy_lifecycle.html
+```
+
+The serialization lanes leave out their internal fields. The
+[Blueprints and Proxies](../technical_guide/blueprints_and_proxies.md) page covers the
+wire format and reconstruction machinery.
+
+
+## Parameter snapshots
+
+The Client can collect parameter values, apply a group of values, and save or restore
+experiment state. These methods work with any existing Server-owned instrument.
+
+This example starts from fixed generator values so the output is reproducible:
+
+```pycon
+>>> from instrumentserver.client import Client
+>>> cli = Client()
+>>> generator = cli.find_or_create_instrument(
+... "generator",
+... "instrumentserver.testing.dummy_instruments.rf.Generator",
+... )
+>>> generator.frequency(5e9)
+>>> generator.power(-42)
+>>> generator.rf_on(True)
+```
+
+`getParamDict` returns a flat dictionary. Each key is the dotted path to a parameter:
+
+```pycon
+>>> values = cli.getParamDict("generator", get=True)
+>>> values
+{'generator.frequency': 5000000000.0, 'generator.power': -42, 'generator.rf_on': True}
+```
+
+`setParameters` accepts that same flat shape:
+
+```pycon
+>>> cli.setParameters(
+... {
+... "generator.frequency": 6e9,
+... "generator.power": -30,
+... }
+... )
+```
+
+`paramsToFile` writes the generator's current values to a JSON file:
+
+```pycon
+>>> cli.paramsToFile(
+... "generator-parameters.json",
+... instruments=["generator"],
+... get=True,
+... )
+```
+
+The file groups parameter names under each instrument:
+
+```json
+{
+ "generator": {
+ "frequency": 6000000000.0,
+ "power": -30,
+ "rf_on": true
+ }
+}
+```
+
+After the values change, `paramsFromFile` restores the saved state:
+
+```pycon
+>>> generator.frequency(7e9)
+>>> generator.power(-20)
+>>> generator.rf_on(False)
+>>> cli.paramsFromFile(
+... "generator-parameters.json",
+... instruments=["generator"],
+... )
+>>> generator.frequency()
+6000000000.0
+>>> generator.power()
+-30
+```
+
+Both file methods run in the Client process, so relative paths refer to the Client's
+working directory, not the Server's. Without `instruments`, `paramsToFile` saves every
+Server instrument and `paramsFromFile` restores every matching entry in the file.
+
+:::{warning}
+`setParameters` and `paramsFromFile` cannot currently restore Boolean values.
+Deserialization converts `True` and `False` to `1.0` and `0.0`, which fail QCoDeS Boolean
+validation. Numeric values in the same operation still restore correctly. See
+[issue #152](https://github.com/toolsforexperiments/instrumentserver/issues/152).
+:::
+
+:::{note}
+`setParameters` does not accept the nested JSON shown above. It expects flat dotted keys
+and currently ignores the nested shape after logging a Server-side error.
+`paramsFromFile` flattens the saved JSON before sending it.
+:::
+
+:::{note}
+The [Parameter Manager](parameter_manager.md) has a separate profile workflow. Its
+`toFile` and `fromFile` methods run on the Server, preserve units, and can create or
+remove hierarchical parameters. Managed profiles use this workflow rather than a
+snapshot of existing instruments.
+:::
+
+`disconnect()` closes the Client used for the snapshot example:
+
+```pycon
+>>> cli.disconnect()
+```
+
+## Errors and timeouts
+
+By default, the Client turns Server-side failures into local exceptions. The dummy
+generator, for example, accepts frequencies only up to 20 GHz:
+
+```pycon
+>>> from instrumentserver.client import Client
+>>> cli = Client(timeout=0.2)
+>>> generator = cli.find_or_create_instrument(
+... "generator",
+... "instrumentserver.testing.dummy_instruments.rf.Generator",
+... )
+>>> try:
+... generator.frequency(100e9)
+... except Exception as exc:
+... rejected = exc
+>>> type(rejected) is Exception
+True
+>>> "generator_frequency" in str(rejected)
+True
+```
+
+The Client currently raises a generic `Exception` containing the original Server-side
+message. With the default `raise_exceptions=True`, a failed operation cannot look like a
+successful call that returned `None`.
+
+A timeout is different from a Server-side error. This Dummy Instrument has a method that
+takes longer than the Client's deadline:
+
+```pycon
+>>> import time
+>>> slow = cli.find_or_create_instrument(
+... "slow",
+... "instrumentserver.testing.dummy_instruments.generic.DummyInstrumentTimeout",
+... )
+>>> expected_random = slow.get_random()
+>>> try:
+... slow.get_random_timeout(wait_time=0.5)
+... except RuntimeError as exc:
+... print(exc)
+Server did not reply before timeout.
+>>> time.sleep(0.4)
+>>> slow.get_random() == expected_random
+True
+```
+
+The Client does not retry a timed-out request. It discards the old ZMQ socket and
+connects a replacement so later requests can work. The replacement connection does not
+rerun the timed-out operation.
+
+:::{warning}
+A timeout means that no reply arrived before the deadline. It does not mean the Server
+cancelled the operation. The Server worker continues and may still change the hardware.
+For a non-idempotent operation, the instrument state is the only reliable indication of
+whether the original call completed.
+:::
+
+After `disconnect()`, the Client cannot be reused. Another session requires a new Client:
+
+```pycon
+>>> cli.disconnect()
+```
+
+:::{note}
+`Client(raise_exceptions=False)` logs failures and usually returns `None`. This behavior
+fits long-running UI infrastructure with its own error reporting. In measurement code,
+`None` is ambiguous because it can also be a valid method result. The default
+`raise_exceptions=True` keeps those cases distinct.
+:::
diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md
index c66f6b7..c6cf7de 100644
--- a/docs/user_guide/index.md
+++ b/docs/user_guide/index.md
@@ -3,7 +3,7 @@
This user guide is organized by different topics, each having their own guides. Use the left menu to navigate through them.
```{toctree}
-basic_usage
+client
server
gui_features
parameter_manager
@@ -13,4 +13,4 @@ configuration
instrumentmonitoring
advanced/virtual_instruments
advanced/chaining_servers
-```
\ No newline at end of file
+```
diff --git a/pyproject.toml b/pyproject.toml
index 43bde0c..1750243 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -119,4 +119,6 @@ docs = [
"nbsphinx",
"sphinx-design",
"linkify-it-py",
+ "sphinx-copybutton>=0.5.2",
+ "sphinx-prompt>=1.10.2",
]
diff --git a/src/instrumentserver/testing/dummy_instruments/generic.py b/src/instrumentserver/testing/dummy_instruments/generic.py
index bcb7696..aaecad6 100644
--- a/src/instrumentserver/testing/dummy_instruments/generic.py
+++ b/src/instrumentserver/testing/dummy_instruments/generic.py
@@ -2,8 +2,10 @@
# No need to mypy check dummy testing instruments.
import time
+from dataclasses import dataclass, field
from enum import IntFlag
-from typing import List
+from types import MethodType
+from typing import ClassVar, List
import numpy as np
from qcodes import Instrument, validators
@@ -23,6 +25,34 @@ class StatusFlag(IntFlag):
ESB = 1 << 5
+@dataclass
+class SweepRequest:
+ """Serializable request used by Client transport tests and examples."""
+
+ center_hz: float
+ span_hz: float
+ metadata: dict[str, str] = field(default_factory=dict)
+
+ attributes: ClassVar[tuple[str, ...]] = (
+ "center_hz",
+ "span_hz",
+ "metadata",
+ )
+
+
+@dataclass
+class SweepResult:
+ """Serializable result used by Client transport tests and examples."""
+
+ frequency_hz: list[float]
+ magnitude_db: list[float]
+
+ attributes: ClassVar[tuple[str, ...]] = (
+ "frequency_hz",
+ "magnitude_db",
+ )
+
+
class DummyChannel(Instrument):
def __init__(self, name: str, *args, **kwargs):
super().__init__(name, *args, **kwargs)
@@ -38,6 +68,12 @@ def __init__(self, name: str, *args, **kwargs):
initial_value=1,
)
+ def ask_raw(self, cmd: str) -> str:
+ """Return a minimal response to identification queries."""
+ if cmd.strip().upper().startswith("*IDN"):
+ return f"dummy,{self.name},0,0"
+ return ""
+
def dummy_function(self, *args, **kwargs):
"""Dummy function for specific channels used for testing"""
print(f"the dummy chanel: {self.name} has been activated with:")
@@ -117,6 +153,95 @@ def dummy_function(self, *args, **kwargs):
return self.address, self.first_arg, self.second_arg
+def _dynamic_method(self, value, *, scale=1):
+ """Return a value multiplied by a keyword-only scale."""
+ return value * scale
+
+
+class MutableInterfaceInstrument(Instrument):
+ """Dummy whose parameters, methods, and submodules can change at runtime."""
+
+ def __init__(self, name: str, *args, **kwargs):
+ super().__init__(name, *args, **kwargs)
+ self.add_parameter("stable", set_cmd=None, initial_value=1)
+
+ def ask_raw(self, cmd: str) -> str:
+ """Return a minimal response to identification queries."""
+ if cmd.strip().upper().startswith("*IDN"):
+ return f"dummy,{self.name},0,0"
+ return ""
+
+ def add_dynamic_interface(self):
+ """Add one parameter, method, and submodule for Proxy update tests."""
+ if "dynamic_parameter" not in self.parameters:
+ self.add_parameter("dynamic_parameter", set_cmd=None, initial_value=2)
+ if "dynamic_module" not in self.submodules:
+ self.add_submodule(
+ "dynamic_module", DummyChannel(f"{self.name}_dynamic_module")
+ )
+ if not hasattr(self, "dynamic_method"):
+ self.dynamic_method = MethodType(_dynamic_method, self)
+
+ def remove_dynamic_interface(self):
+ """Remove the runtime interface added by :meth:`add_dynamic_interface`."""
+ if "dynamic_parameter" in self.parameters:
+ self.remove_parameter("dynamic_parameter")
+ dynamic_module = self.submodules.pop("dynamic_module", None)
+ if dynamic_module is not None:
+ dynamic_module.close()
+ if hasattr(self, "dynamic_method"):
+ del self.dynamic_method
+
+
+class SerializationInstrument(Instrument):
+ """Dummy that returns representative values across the Client transport."""
+
+ def __init__(self, name: str, *args, **kwargs):
+ super().__init__(name, *args, **kwargs)
+
+ def ask_raw(self, cmd: str) -> str:
+ """Return a minimal response to identification queries."""
+ if cmd.strip().upper().startswith("*IDN"):
+ return f"dummy,{self.name},0,0"
+ return ""
+
+ def echo(self, value):
+ """Return ``value`` unchanged."""
+ return value
+
+ def run_sweep(self, request):
+ """Return a three-point sweep for a :class:`SweepRequest`."""
+ half_span = request.span_hz / 2
+ return SweepResult(
+ frequency_hz=[
+ request.center_hz - half_span,
+ request.center_hz,
+ request.center_hz + half_span,
+ ],
+ magnitude_db=[-50.0, -20.0, -49.0],
+ )
+
+ def get_status(self):
+ """Return an importable top-level enum member."""
+ return StatusFlag.EAV
+
+ def get_nested_status(self):
+ """Return an enum nested in a list."""
+ return [StatusFlag.EAV]
+
+ def get_tuple(self):
+ """Return a tuple so transport container conversion can be tested."""
+ return (1, 2)
+
+ def get_set(self):
+ """Return a set so transport container conversion can be tested."""
+ return {1, 2}
+
+ def get_numeric_text(self):
+ """Return numeric-looking text so decoder coercion can be tested."""
+ return "123"
+
+
class DummyInstrumentTimeout(Instrument):
"""A dummy instrument to test timeout situations."""
@@ -140,6 +265,12 @@ def __init__(self, name: str, *args, **kwargs):
set_cmd=lambda p: setattr(self, "_param2", p),
)
+ def ask_raw(self, cmd: str) -> str:
+ """Answer identification queries without touching timeout behavior."""
+ if cmd.strip().upper().startswith("*IDN"):
+ return f"dummy,{self.name},0,0"
+ return ""
+
def _get_param1(self):
# for testing potentially redundant/duplicate get calls
print(
@@ -237,6 +368,12 @@ def __init__(self, name, starting_parameter=22, *args, **kwargs):
set_cmd=self.set_complex_list,
)
+ def ask_raw(self, cmd: str) -> str:
+ """Return a minimal response to identification queries."""
+ if cmd.strip().upper().startswith("*IDN"):
+ return f"dummy,{self.name},0,0"
+ return ""
+
def get_starting_parameter(self):
return self.starting_parameter
diff --git a/test/pytest/test_apps.py b/test/pytest/test_apps.py
index c2bc744..4469cf9 100644
--- a/test/pytest/test_apps.py
+++ b/test/pytest/test_apps.py
@@ -113,6 +113,8 @@ def test_server_script_gui_default_no_config():
mock_lc.assert_not_called()
kwargs = mock_gui.call_args.kwargs
+ assert kwargs["port"] == 5555
+ assert kwargs["addresses"] is None
assert kwargs["serverConfig"] is None
assert kwargs["stationConfig"] is None
diff --git a/test/pytest/test_basic_functionality.py b/test/pytest/test_basic_functionality.py
index e5be6e9..f0fabac 100644
--- a/test/pytest/test_basic_functionality.py
+++ b/test/pytest/test_basic_functionality.py
@@ -41,7 +41,7 @@ def test_closing_instruments(dummy_instrument):
assert "dummy" not in cli.list_instruments()
-def test_sending_and_receiving_arbitrary_objects(cli):
+def test_sending_and_receiving_arbitrary_objects(cli, start_server):
magnet = cli.find_or_create_instrument(
name="magnet",
instrument_class="instrumentserver.testing.dummy_instruments.generic.FieldVectorIns",
@@ -61,8 +61,12 @@ def test_sending_and_receiving_arbitrary_objects(cli):
# Setting parameter directly.
new_field_vector = FieldVector(11, 22, 33)
magnet.field(new_field_vector)
- assert isinstance(magnet.field(), FieldVector)
- assert magnet.field().is_equal(new_field_vector)
+ server_vector = start_server.station.components["magnet"].field_vector
+ returned_vector = magnet.field()
+ assert isinstance(returned_vector, FieldVector)
+ assert server_vector is not new_field_vector
+ assert returned_vector is not server_vector
+ assert returned_vector.is_equal(new_field_vector)
new_field_vector = FieldVector(101, 102, 103)
magnet.set_field(new_field_vector)
diff --git a/test/pytest/test_json_serializable.py b/test/pytest/test_json_serializable.py
index 1916b57..74be57b 100644
--- a/test/pytest/test_json_serializable.py
+++ b/test/pytest/test_json_serializable.py
@@ -1,9 +1,17 @@
+import json
+
import numpy as np
+import pytest
import qcodes as qc
from qcodes.math_utils.field_vector import FieldVector
+from instrumentserver.base import decode, encode
from instrumentserver.blueprints import (
+ CallSpec,
+ Operation,
ParameterBroadcastBluePrint,
+ ServerInstruction,
+ ServerResponse,
bluePrintFromInstrumentModule,
bluePrintFromMethod,
bluePrintFromParameter,
@@ -13,7 +21,11 @@
iterable_to_serialized_dict,
)
from instrumentserver.testing.dummy_instruments.generic import (
+ DummyInstrumentTimeout,
DummyInstrumentWithSubmodule,
+ FieldVectorIns,
+ SweepRequest,
+ SweepResult,
)
from instrumentserver.testing.dummy_instruments.rf import ResonatorResponse
@@ -47,6 +59,25 @@ def customFunction(self, x: int, y: int) -> int:
return x * y
+class NestedPayload:
+ attributes = ("request",)
+
+ def __init__(self, request):
+ self.request = request
+
+
+class ArrayPayload:
+ attributes = ("values",)
+
+ def __init__(self, values):
+ self.values = values
+
+
+class ConstructorRejectsFields:
+ def __init__(self):
+ pass
+
+
def test_basic_param_dictionary():
my_param = CustomParameter(name="my_param", unit="M")
param_bp = bluePrintFromParameter("", my_param)
@@ -77,6 +108,98 @@ def test_basic_instrument_dictionary():
assert dummy_bp == reconstructed_dummy_bp
+def test_timeout_dummy_responds_to_idn():
+ instrument = DummyInstrumentTimeout("timeout_dummy")
+ try:
+ assert instrument.get_idn() == {
+ "vendor": "dummy",
+ "model": "timeout_dummy",
+ "serial": "0",
+ "firmware": "0",
+ }
+ finally:
+ instrument.close()
+
+
+def test_field_vector_dummy_responds_to_idn_without_error_log(caplog):
+ instrument = FieldVectorIns("field_vector_idn")
+ try:
+ assert instrument.get_idn() == {
+ "vendor": "dummy",
+ "model": "field_vector_idn",
+ "serial": "0",
+ "firmware": "0",
+ }
+ assert "NotImplementedError" not in caplog.text
+ finally:
+ instrument.close()
+
+
+def test_custom_dataclass_request_and_result_codec():
+ request = SweepRequest(5e9, 20e6, {"sample": "A"})
+ instruction = ServerInstruction(
+ operation=Operation.call,
+ call_spec=CallSpec(target="analyzer.run_sweep", args=(request,)),
+ )
+ decoded_instruction = decode(encode(instruction))
+ decoded_request = decoded_instruction.call_spec.args[0]
+ assert isinstance(decoded_request, SweepRequest)
+ assert decoded_request == request
+ assert decoded_request is not request
+
+ result = SweepResult([4.99e9, 5e9, 5.01e9], [-50.0, -20.0, -49.0])
+ decoded_response = decode(encode(ServerResponse(message=result)))
+ assert isinstance(decoded_response.message, SweepResult)
+ assert decoded_response.message == result
+ assert decoded_response.message is not result
+
+
+def test_custom_serialization_requirements_and_non_recursive_fields():
+ request = SweepRequest(5e9, 20e6)
+ nested = ServerInstruction(
+ operation=Operation.call,
+ call_spec=CallSpec(target="echo", args=(NestedPayload(request),)),
+ )
+ with pytest.raises(TypeError, match="SweepRequest"):
+ encode(nested)
+
+ array = ServerInstruction(
+ operation=Operation.call,
+ call_spec=CallSpec(target="echo", args=(ArrayPayload(np.array([1])),)),
+ )
+ with pytest.raises(TypeError, match="ndarray"):
+ encode(array)
+
+ with pytest.raises(ModuleNotFoundError):
+ decode(
+ json.dumps(
+ {
+ "value": 1,
+ "_class_type": "missing_package.models.Value",
+ }
+ )
+ )
+
+ constructor_path = (
+ f"{ConstructorRejectsFields.__module__}.{ConstructorRejectsFields.__name__}"
+ )
+ with pytest.raises(TypeError, match="unexpected keyword argument"):
+ decode(json.dumps({"value": 1, "_class_type": constructor_path}))
+
+
+@pytest.mark.parametrize(
+ ("encoded", "expected"),
+ [
+ ("123", 123),
+ ("1.5", 1.5),
+ ("True", True),
+ ("plain text", "plain text"),
+ ],
+)
+def test_scalar_text_coercion(encoded, expected):
+ assert deserialize_obj(encoded) == expected
+
+
def test_basic_broadcast_parameter_dictionary():
broadcast_bp = ParameterBroadcastBluePrint(
name="my_param", action="an_action", value=-56, unit="M"
diff --git a/uv.lock b/uv.lock
index fba690a..76afb19 100644
--- a/uv.lock
+++ b/uv.lock
@@ -182,7 +182,7 @@ name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+ { name = "pycparser" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
@@ -872,7 +872,9 @@ docs = [
{ name = "pydata-sphinx-theme" },
{ name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+ { name = "sphinx-copybutton" },
{ name = "sphinx-design" },
+ { name = "sphinx-prompt" },
]
[package.metadata]
@@ -905,7 +907,9 @@ docs = [
{ name = "nbsphinx" },
{ name = "pydata-sphinx-theme" },
{ name = "sphinx" },
+ { name = "sphinx-copybutton", specifier = ">=0.5.2" },
{ name = "sphinx-design" },
+ { name = "sphinx-prompt", specifier = ">=1.10.2" },
]
[[package]]
@@ -1848,7 +1852,7 @@ name = "pexpect"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "ptyprocess" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
wheels = [
@@ -2705,23 +2709,23 @@ resolution-markers = [
"python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
- { name = "alabaster", marker = "python_full_version < '3.12'" },
- { name = "babel", marker = "python_full_version < '3.12'" },
- { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" },
- { name = "docutils", marker = "python_full_version < '3.12'" },
- { name = "imagesize", marker = "python_full_version < '3.12'" },
- { name = "jinja2", marker = "python_full_version < '3.12'" },
- { name = "packaging", marker = "python_full_version < '3.12'" },
- { name = "pygments", marker = "python_full_version < '3.12'" },
- { name = "requests", marker = "python_full_version < '3.12'" },
- { name = "roman-numerals", marker = "python_full_version < '3.12'" },
- { name = "snowballstemmer", marker = "python_full_version < '3.12'" },
- { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.12'" },
- { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.12'" },
- { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.12'" },
- { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.12'" },
- { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.12'" },
- { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.12'" },
+ { name = "alabaster" },
+ { name = "babel" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "docutils" },
+ { name = "imagesize" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "pygments" },
+ { name = "requests" },
+ { name = "roman-numerals" },
+ { name = "snowballstemmer" },
+ { name = "sphinxcontrib-applehelp" },
+ { name = "sphinxcontrib-devhelp" },
+ { name = "sphinxcontrib-htmlhelp" },
+ { name = "sphinxcontrib-jsmath" },
+ { name = "sphinxcontrib-qthelp" },
+ { name = "sphinxcontrib-serializinghtml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" }
wheels = [
@@ -2744,29 +2748,42 @@ resolution-markers = [
"python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
]
dependencies = [
- { name = "alabaster", marker = "python_full_version >= '3.12'" },
- { name = "babel", marker = "python_full_version >= '3.12'" },
- { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" },
- { name = "docutils", marker = "python_full_version >= '3.12'" },
- { name = "imagesize", marker = "python_full_version >= '3.12'" },
- { name = "jinja2", marker = "python_full_version >= '3.12'" },
- { name = "packaging", marker = "python_full_version >= '3.12'" },
- { name = "pygments", marker = "python_full_version >= '3.12'" },
- { name = "requests", marker = "python_full_version >= '3.12'" },
- { name = "roman-numerals", marker = "python_full_version >= '3.12'" },
- { name = "snowballstemmer", marker = "python_full_version >= '3.12'" },
- { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" },
- { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" },
- { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" },
- { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" },
- { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" },
- { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" },
+ { name = "alabaster" },
+ { name = "babel" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "docutils" },
+ { name = "imagesize" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "pygments" },
+ { name = "requests" },
+ { name = "roman-numerals" },
+ { name = "snowballstemmer" },
+ { name = "sphinxcontrib-applehelp" },
+ { name = "sphinxcontrib-devhelp" },
+ { name = "sphinxcontrib-htmlhelp" },
+ { name = "sphinxcontrib-jsmath" },
+ { name = "sphinxcontrib-qthelp" },
+ { name = "sphinxcontrib-serializinghtml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" },
]
+[[package]]
+name = "sphinx-copybutton"
+version = "0.5.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
+ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" },
+]
+
[[package]]
name = "sphinx-design"
version = "0.7.0"
@@ -2780,6 +2797,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl", hash = "sha256:f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282", size = 2220350, upload-time = "2026-01-19T13:12:51.077Z" },
]
+[[package]]
+name = "sphinx-prompt"
+version = "1.10.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "docutils" },
+ { name = "idna" },
+ { name = "jinja2" },
+ { name = "pygments" },
+ { name = "requests" },
+ { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
+ { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d0/a3/91293c0e0f0b76d0697ba7a41541929ca3f5457671d008bd84a9bde17e21/sphinx_prompt-1.10.2.tar.gz", hash = "sha256:47b592ba75caebd044b0eddf7a5a1b6e0aef6df587b034377cd101a999b686ba", size = 5566, upload-time = "2025-11-28T09:23:18.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/f4/44ce4d0179fb4e9cfe181a8aa281bba23e40158a609fb3680774529acaaa/sphinx_prompt-1.10.2-py3-none-any.whl", hash = "sha256:6594337962c4b1498602e6984634bed4a0dc7955852e3cfc255eb0af766ed859", size = 7474, upload-time = "2025-11-28T09:23:17.154Z" },
+]
+
[[package]]
name = "sphinxcontrib-applehelp"
version = "2.0.0"