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 @@ +
+
+ + + Proxy Instrument lifecycle + A Client requests a Blueprint for a Server-owned QCoDeS instrument and uses it to build a local Proxy Instrument. The real driver begins with a zero-valued FieldVector. A later FieldVector set replaces it, and a get reconstructs the current value in the script. + + + + Your script or notebook + + + + InstrumentServer + + + + ZMQ · serialized messages + + + + + + + cli + Client + + + + magnet + Proxy Instrument · field + + + + Server + dispatches calls + + + + magnet + real QCoDeS driver + + + + target · FieldVector + x=.01 · y=.02 · z=.03 + + + + returned · FieldVector + x=.01 · y=.02 · z=.03 + + + + FieldVector + x=0 · y=0 · z=0 + + + + FieldVector + x=.01 · y=.02 · z=.03 + + + + + magnet Blueprint + parameters · methods + submodules + + + + magnet Blueprint + parameters · methods + submodules + + + + same value · newly reconstructed object + + + +
+ +
    +
  1. + Action 1 + Create the Proxy Instrument +
  2. +
  3. 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.

  4. +
  5. 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.

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

  8. +
  9. 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.

  10. +
  11. + Action 2 + Set the value +
  12. +
  13. 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.

  14. +
  15. 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.

  16. +
  17. 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.

  18. +
  19. 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.

  20. +
  21. + Action 3 + Read the value +
  22. +
  23. Step 09

    Read through the Proxy

    returned = magnet.field() sends a get request to the Server. The Proxy does not answer from local state.

  24. +
  25. 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.

  26. +
  27. 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.

  28. +
+
+ + + diff --git a/docs/_static/animations/request_flow.html b/docs/_static/animations/request_flow.html index 3d04a66..82d0470 100644 --- a/docs/_static/animations/request_flow.html +++ b/docs/_static/animations/request_flow.html @@ -811,6 +811,15 @@

Subscribers react

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"