diff --git a/.copier-answers.yml b/.copier-answers.yml index 982b4e99..ab06d7bd 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,9 +1,10 @@ # Changes here will be overwritten by Copier -_commit: 5.0.2 +_commit: 5.0.3 _src_path: https://github.com/DiamondLightSource/python-copier-template author_email: tom.cobb@diamond.ac.uk author_name: Tom Cobb -description: Specify step and flyscan paths in a serializable, efficient and Pythonic way +description: Specify step and flyscan paths in a serializable, efficient and Pythonic + way distribution_name: scanspec docker: true docker_debug: false diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6f67e7fd..72725604 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -24,4 +24,4 @@ It is recommended that developers use a [vscode devcontainer](https://code.visua This project was created using the [Diamond Light Source Copier Template](https://github.com/DiamondLightSource/python-copier-template) for Python projects. -For more information on common tasks like setting up a developer environment, running the tests, and setting a pre-commit hook, see the template's [How-to guides](https://diamondlightsource.github.io/python-copier-template/5.0.2/how-to.html). +For more information on common tasks like setting up a developer environment, running the tests, and setting a pre-commit hook, see the template's [How-to guides](https://diamondlightsource.github.io/python-copier-template/5.0.3/how-to.html). diff --git a/AGENTS.md b/AGENTS.md index dc51ae70..11994b1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,49 +1,70 @@ # Agent Guidelines -## Repository structure - -This repository contains two packages during the scanspec 2.0 development period: - -- `src/scanspec/` — the original 1.x package. **Do not modify this.** It is kept as a reference implementation. -- `src/scanspec2/` — the new 2.0 package under active development. All new work goes here. - -When `scanspec2` is feature-complete (all PRD requirements met and tests passing), the migration will be: -1. Delete `src/scanspec/`. -2. Rename `src/scanspec2/` → `src/scanspec/`. -3. Update `pyproject.toml` and any import references accordingly. - -## Where to work - -**Always make changes in `src/scanspec2/`**, never in `src/scanspec/`. +## Read first -All new tests go in `tests/scanspec2/`. Do not modify tests in `tests/` (those cover the 1.x package). +- **`PRD.md`** — requirements and current design intent. Authoritative; read + it before designing or implementing anything. +- **`API_SPEC.md`** — annotated consumption-API examples. Partially stale + (see PRD §10); where it disagrees with `src/scanspec2/` + PRD, the latter + win. +- **`docs/explanations/decisions/`** — ADRs. 0001–0005 accepted; 0006 + tentative; 0007 proposed with pending review corrections (PRD §9). Do not + treat 0006/0007 as settled. -The PRD is in `prd.md`. The design notes and user stories are in `thoughts.md`. - -## Testing conventions - -- Write pytest-style **functions**, not `unittest`-style classes. -- Keep tests simple: prefer a few direct instantiation / field-access assertions over elaborate setups. -- Test **public interfaces**; avoid mocks unless there is no other way. -- No serialisation tests for plain dataclasses — they carry no serialisation logic. -- **`tests/scanspec2/test_use_cases.py` is the user's file.** Never add, remove, or modify tests in it without explicit permission. Put agent-created tests in other test files (e.g. `test_compile.py`, `test_core.py`, `test_specs.py`). +## Repository structure -## Scratch / prototype files +Two packages coexist during 2.0 development: -- **Always write scratch or prototype files inside the workspace** (e.g. `/workspaces/scanspec/scratch/`) — never to `/tmp`. -- After verifying a prototype, delete the scratch file or incorporate it into the codebase. +- `src/scanspec/` — the 1.x package. **Do not modify.** Reference only; + don't load it into context unless porting a specific algorithm. +- `src/scanspec2/` — the 2.0 package. All new work goes here. -## Type annotations +Tests mirror this: `tests/` covers 1.x (do not modify); `tests/scanspec2/` +is where all new tests go. -- Do not add `# type: ignore` comments. If a type error cannot be fixed with code structure, leave it without a suppression comment and summarise the remaining pyright errors to the user at the end of the task. +Branch flow: feature branches → PRs against `bluesky/scanspec:v2-dev` → +`v2-dev` merges to `main` only at the final 2.0 migration (PRD §12). -## Type-checking +## Known churn — check before building on these -- Always run `python -m pyright src/scanspec2/ tests/scanspec2/` after running tests. -- Both must pass (0 errors) before marking a phase complete. +- `TriggerPattern`/`TriggerGroup` will merge into a `TriggerNode` tree and + `Window.trigger_groups` → `Window.trigger_nodes` when ADR 0007 is + accepted. Avoid new code that deepens coupling to the current split. +- Naming that the docs sometimes get wrong: the code uses + `Window.non_linear` (not `non_linear_move`), `Scan.has_moving_axes` / + `Scan.non_linear` (there is no `Scan.fly`), and + `Scan.with_start(window, trigger_index)` (not `time`). -## Linting +## Testing conventions -- Always run `ruff check src/scanspec2/ tests/scanspec2/` after running tests. -- Must report 0 errors before marking a phase complete. -- Use `# noqa: ` only when the violation is genuinely unfixable (e.g. `UP007` on a dynamic `Union[tuple(...)]`); never suppress fixable errors. +- pytest-style **functions**, not `unittest` classes. +- Simple, direct assertions; test **public interfaces**; avoid mocks unless + there is no other way. +- No serialisation tests for plain dataclasses (they carry none). +- **`tests/scanspec2/test_use_cases.py` is the maintainer's file.** Never + add, remove, or modify tests in it without explicit permission. Put your + tests in `test_compile.py`, `test_core.py`, `test_specs.py`, etc. + +## Quality gates — all three must pass after every change + +```bash +pytest tests/scanspec2/ -v +python -m pyright src/scanspec2/ tests/scanspec2/ # 0 errors +ruff check src/scanspec2/ tests/scanspec2/ # 0 errors +``` + +## Type annotations and lint + +- No `# type: ignore`. If a type error can't be fixed structurally, leave it + unsuppressed and report the remaining pyright errors at the end of the task. +- `# noqa: ` only for genuinely unfixable violations (e.g. `UP007` on + a dynamic `Union[tuple(...)]`). + +## Working style + +- Raise questions or errors rather than guessing on design ambiguity (e.g. + mismatched snake flags in `Zip` raise; they are not silently reconciled). +- Scratch/prototype files go in `/workspaces/scanspec/scratch/`, never + `/tmp`; delete or incorporate them after verification. +- `CONTEXT.*.md` files (if present locally) are private working notes — never + commit them or reference them in committed files. diff --git a/Dockerfile b/Dockerfile index ea002221..4a1b28d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ ENV UV_PYTHON_INSTALL_DIR=/python # Sync the project without its dev dependencies RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-editable --no-dev + uv sync --locked --no-editable --no-dev --managed-python # The runtime stage copies the built venv into a runtime container diff --git a/PRD.md b/PRD.md new file mode 100644 index 00000000..532e00e5 --- /dev/null +++ b/PRD.md @@ -0,0 +1,488 @@ +# scanspec 2.0 — Product Requirements Document + +This document is the single, current statement of design intent for scanspec +2.0. It is written to be read top-to-bottom by a developer joining the +project: it contains the requirements, the design that satisfies them, the +implementation status, and the open questions — without the design history. +Historical rationale and rejected alternatives are recorded in the ADRs under +[docs/explanations/decisions/](docs/explanations/decisions/). + +Relationship to other documents: + +- **PRD.md** (this file) — requirements and current design intent. Authoritative. +- **API_SPEC.md** — annotated code examples of the consumption API. Where the + two disagree, the *code* in `src/scanspec2/` plus this PRD reflect the most + recent decisions; API_SPEC.md is updated to match (see §10). +- **ADRs 0001–0005** — accepted decisions. **ADR 0006** is tentative, + **ADR 0007** is proposed; both are incorporated here with their open points + flagged (§9, §11). +- Working documents (PRD.md, API_SPEC.md) are deleted or folded into `docs/` + before the final 2.0 release; they exist for the development period. + +--- + +## 1. Background + +scanspec is a Python library for composably describing scan trajectories at +synchrotron beamlines. A scientist (or a UI) builds a serializable `Spec` +tree describing *what* the scan should do; the library compiles it into a +structure that instrument-control software (ophyd-async) consumes to move +motors and trigger detectors. + +scanspec 1.x (`src/scanspec/`) compiles specs into flat arrays of frame +positions (`Path`, `Midpoints`). Every consumer — PandA sequence-table +builders, motor-record flyscan drivers, PMAC trajectory loaders, step-scan +orchestrators — had to walk those internals to re-derive velocities, compute +trigger timing, and implement its own pause/resume. Three required use cases +could not be expressed at all (multi-rate triggering, per-phase detector +streams, servo-rate positions without materialising the whole scan). + +scanspec 2.0 is a **breaking release**: no backwards compatibility for JSON +specs or Python APIs. ophyd-async will be updated to the new API. JSON is the +only serialization format (GraphQL deferred). 2.0 is developed in parallel in +`src/scanspec2/`; on completion it replaces `src/scanspec/` (see §12). + +--- + +## 2. Required user stories + +All are in scope for 2.0; they may be delivered in stages. + +1. **Servo-cycle-rate motor positions.** Kinematics move up from the motion + controller into ophyd. Consumers must be able to ask for motor positions + every servo cycle (e.g. 0.2 ms) instead of per detector frame, in chunks + (~10 s of data), without scanspec ever materialising the full array. + scanspec does *not* do kinematic smoothing: it provides a continuous, + differentiable position function per contiguous stretch of motion; + discontinuities between stretches are bridged externally by ophyd-async. + +2. **Specified livetime and deadtime for detectors.** `duration = livetime + + deadtime`. Usually a per-spec scalar pair, but per-point patterns must be + supported (ptychography). When `deadtime` is omitted (`None`), + ophyd-async fills it in; scanspec does not mandate a value. + +3. **Continuously monitored detectors.** E.g. timestamped temperature from a + PV during a grid scan, or a camera at 10 Hz for the whole scan. These are + a separate top-level concept, associated with scan data by timestamp only + — never frame-indexed, never a node in the motion tree. + +4. **Multiple detectors at multiple rates.** E.g. SAXS/WAXS detectors taking + 1 frame per point while PandA encoders capture 10× faster. Detectors in + the same stream must trigger at integer ratios of each other (validated + at compile time). Detectors in different streams have no phase lock. + +5. **Gaps in the detector trigger pattern.** Ptychography wants variable + gaps between exposures while motion stays steady, e.g. "0.1 s livetime, + 0.01 s deadtime, 0.1 s livetime, 0.3 s deadtime" repeated per line. The + motion trajectory must cover an integer number of repetitions. + +6. **Different detector streams at different phases** (the flagship + multi-stream pattern). N iterations of: take a diffraction image at + static energy; flyscan energy up taking 1000 spectroscopy frames; flyscan + energy back down taking 1000 more. Diffraction data has shape `[N]`, + spectroscopy `[N, 2, 1000]` — different dimensionality, interleaved in + time, on the same motion axis. + +7. **Motor-controller dispatch by motion type.** A simple servo drive can do + static positioning and constant-velocity moves; a trajectory controller + can execute arbitrary curves. The orchestration layer must be able to + tell, per collection window and per whole scan, which kind of motion it + is — without re-deriving it from position arrays (§7). + +### Optional user stories (design must not preclude; not required for 2.0) + +- **Fast shutter in turnarounds** — acceptable to model the shutter as + another "axis" restricted to 0/1 if ever needed. +- **Waiting for a sample environment** mid-sequence (else: custom plan). +- **Relative positions** that survive JSON round-trip; resolved at execution + time in ophyd-async. +- **Ending a segment early** ("until detector value …"). Explicitly a + *future extension point*: scan shape becomes a maximum, and a completion- + condition mini-language would be needed. Not implemented in 2.0. + +### Non-functional requirements + +- **Memory**: deserialization and `compile()` are O(spec complexity), never + O(scan size). Position arrays are generated on demand, in chunks, from + numpy-enabled functions. +- **Step scans remain first-class**: the same compiled object drives + software step scans, not just flyscans. +- **Spec nodes are pydantic `BaseModel`s** (frozen) with positional-argument + support; compiled output is plain classes/dataclasses with no + serialization requirement (ADR 0003). +- **Streams have names** (default `"primary"`), naming the Bluesky event + stream each detector group writes to. + +--- + +## 3. The core model: `Spec` → `compile()` → `Scan` → `Window` + +``` +Spec (serializable description) → .compile() → Scan (compiled) → for window in scan → Window (pure data) +``` + +The fundamental unit of iteration is the **collection window**: one +contiguous stretch of motion during which detectors are triggered. Windows +are separated by turnarounds (the 1.x term "gap" is retired). A step scan +yields one window per point (e.g. 5000 windows); a flyscan yields one window +per sweep (e.g. 50 row windows for a 50×100 grid). + +### 3.1 Construction (`src/scanspec2/specs.py`) + +Motion is composed first, then `Acquire` attaches acquisition: + +```python +motion = Linspace("y", 0, 5, 50) * ~Linspace("x", 0, 10, 100) # snaked grid +spec = Acquire(motion, fly=True, detectors=[ + DetectorGroup(1, 1, 0.003, 0.001, ["saxs", "waxs"]), + DetectorGroup(10, 1, 0.0003, 8e-9, ["timestamp", "x_enc", "y_enc"]), +]) +scan = spec.compile() +``` + +- Motion primitives: `Linspace` (alias `Line`), `Static`, `Range`, `Spiral`, + `Ellipse`, `Polygon`. `Linspace.bounded` / `Range.bounded` construct from + extreme bounds. +- Combinators: `Product` (`a * b`, b fast), `Snake` (`~a`), `Zip` + (`a.zip(b)`), `Concat` (`a.concat(b)`), `Repeat(a, n)`. +- **`Acquire` is the only place `fly=True/False` appears.** It binds a + motion spec to one named windowed stream (`stream_name="primary"` by + default), plus `continuous_streams` and `monitors`. `fly=True` means the + innermost motion dimension sweeps continuously; all outer dimensions step. +- **Multi-stream scans are `Concat`s of `Acquire`s** with different + `stream_name`s, optionally wrapped in `Repeat` and an outer `Acquire` + carrying scan-wide monitors. This is how the flagship pattern (§2.6) is + expressed: + + ```python + diff = Acquire(Static("e", 7.0), detectors=[diff_det], stream_name="diff") + up = Acquire(Linspace("e", 7.0, 7.1, 1000), fly=True, detectors=[spec_det], stream_name="spec") + down = Acquire(Linspace("e", 7.1, 7.0, 1000), fly=True, detectors=[spec_det], stream_name="spec") + spec = Acquire(Repeat(diff.concat(up).concat(down), num=200), + monitors=[MonitorStream("temperature", "tc1")]) + ``` + +- `Concat`/`Product`/`Zip` **reject** specs carrying continuous streams or + monitors: those run in parallel to the *entire* scan, so they may only + appear on a top-level `Acquire`. +- `Snake` operates on a single-dimension spec (deviation from 1.x). `Zip` + supports exactly the cases 1.x supported. `Squash` is not needed and was + dropped — dimensions are never merged. +- Out-of-package `Spec` subclasses are supported: the serialization union is + rebuilt automatically whenever a `Spec` subclass is defined. + +### 3.2 The compiled `Scan` (`src/scanspec2/core.py`) + +`Scan` is the sole entry point for execution *and* analysis. Construction is +O(spec complexity). It holds: + +- `generators: list[WindowGenerator]` — internal motion engines, outer → + inner (ADR 0004). Only the innermost generator can fly. Sources are + `LinearSource` (uniform spacing, 1.x fence/post convention), + `FunctionSource` (arbitrary `fn(indexes) → dict[axis, array]`; spirals and + masked grids), or `ConcatSource` (sequential children; how concat-of- + acquires alternates trigger groups per window). +- `windowed_streams: list[WindowedStream]` — per stream: `name`, + `dimensions` (its own shape — streams can differ), `detector_groups`. + Used to **arm** detectors before the scan and to **reshape** data after. +- `continuous_streams: list[ContinuousStream]` and + `monitors: list[MonitorStream]` — whole-scan acquisition (§2.3). +- `has_moving_axes: bool` and `non_linear: bool` properties — scan-wide + capability dispatch: a step-only consumer asserts `not has_moving_axes`, a + linear-flyscan consumer asserts `not non_linear`, a trajectory consumer + takes anything. (There is deliberately *no* `Scan.fly` flag.) +- `with_start(window, trigger_index)` — pause/resume (§6). + +The result of `compile()` is **owned by the caller**, who may freely mutate +it; `compile()` is repeatable and never mutates the spec. + +### 3.3 `Window` + +Each iteration yields a `Window` — a pure data object with everything needed +to execute one collection phase: + +| Field | Meaning | +|---|---| +| `static_axes: dict[AxisT, float]` | Axes to position before the window starts. Contains **only axes that changed** since the previous window; the first window carries all axes. | +| `moving_axes: dict[AxisT, AxisMotion]` | Axes sweeping continuously, each with `start_position`, `start_velocity`, `end_position`, `end_velocity`. Empty for step windows. Disjoint from `static_axes`. | +| `non_linear: bool` | `True` → trajectory controller required (servo-rate positions). `False` → constant velocity per axis (or no motion). Computed analytically from the position functions, not by finite-differencing. | +| `duration: float` | Total window time in seconds, derived from trigger timing (§4). | +| `trigger_groups` | Detector triggering for this window (§4). | +| `previous: Window \| None` | One step back only — enough to compute the turnaround into this window. | + +`window.positions(dt, max_duration)` yields chunked +`dict[axis, np.ndarray]` for the moving axes: + +- `dt: float` — positions at a fixed interval (servo-cycle rate). +- `dt: TriggerPattern` — one position per trigger instant, centred on each + active window (the 1.x-equivalent "positions at my detector frames"). +- Raises `RuntimeError` on step windows (no continuous trajectory). + +Turnarounds are out of scope for scanspec: consumers call an external +`calculate_turnaround(from_pos, from_vel, to_pos, to_vel)` using the +boundary kinematics of adjacent windows. Position functions are therefore +required to be differentiable at window boundaries — a design constraint on +every motion node. + +### 3.4 Index convention + +The 1.x fence/post convention is kept: integer indexes `0 … N` are window +boundaries (posts); half-integer indexes `0.5 … N−0.5` are collection +midpoints (detector setpoints). `Spiral` starts half a point out from the +centre to avoid the velocity singularity at r=0. + +--- + +## 4. Trigger model + +### 4.1 Upfront description vs runtime instruction + +Two deliberately separate concepts: + +- **`DetectorGroup`** (on `Acquire.detectors`, surfaced via + `WindowedStream.detector_groups`) — *arming-time* description: + `exposures_per_collection`, `collections_per_event`, `livetime`, + `deadtime`, `detectors`. `exposures_per_event = exposures_per_collection × + collections_per_event`. Used with the stream's `dimensions` to call + `StandardDetector.prepare()` before any window is iterated. +- **`TriggerGroup`** (on `Window.trigger_groups`) — *runtime* instruction: + `detectors` + `trigger_patterns: list[TriggerPattern(repeats, livetime, + deadtime)]`. Baked from `DetectorGroup`s at compile time (ADR 0005): + flyscan windows get `repeats = inner_length × exposures_per_collection`; + step windows get `repeats = exposures_per_collection`. `livetime`/ + `deadtime` must be resolved (not `None`) before `compile()`. + Consumers find their group by matching `frozenset(group.detectors)` + (unique per window, enforced); they read, never compute. + +A stream's detector group may appear in the trigger groups of only a subset +of windows (flagship pattern: diffraction fires only in hold windows). + +Window `duration` is derived from the slowest group; an explicit +`Acquire(duration=...)` must be ≥ the derived value. Detector-less step +scans have `duration = 0`; detector-less fly scans require a supplied +duration. + +### 4.2 Centred livetime (ADR 0006 — tentative, agreed in substance) + +Execution order of each repeat is **`½·deadtime → livetime → ½·deadtime`**, +not `livetime → deadtime`. This centres the detector's active window on the +nominal scan position, which position-compare triggering requires; +leading-edge alignment would bias every position by `½·deadtime`. The struct +is unchanged — only the interpretation. + +`livetime = 0.0` is explicitly valid: a pure dead-gap spacer. Because +centred semantics already give symmetric gaps, spacers are only needed when +two bursts must be separated by a gap different from the intra-burst +deadtime — the ptychography pattern: + +```python +[TriggerPattern(N1, livetime1, deadtime), # first burst + TriggerPattern(1, 0.0, gap), # inter-burst spacer + TriggerPattern(N2, livetime2, deadtime)] # second burst +``` + +### 4.3 Planned unification: the trigger tree (ADR 0007 — proposed) + +Sibling `TriggerGroup`s at integer-multiple rates have no structural link — +nothing ties "500 SAXS frames" to "5000 encoder samples" during the scan. +ADR 0007 merges `TriggerPattern` + `TriggerGroup` into a single recursive +**`TriggerNode`** (`detectors`, `repeats`, `livetime`, `deadtime`, +`children`); rate ratios become parent/child nesting, so progress through +the parent structurally implies progress through the children: + +```python +TriggerNode(["saxs", "waxs"], 500, 0.003, 0.001, children=[ + TriggerNode(["x_enc", "y_enc"], 10, 0.0003, 8e-9) # 10× per SAXS frame +]) +``` + +`Window.trigger_groups` becomes `Window.trigger_nodes` — an **ordered +sequential list**; there are no parallel sibling streams within a window +(no zipping of unrelated trigger streams with no common checkpoint base). +Compiled specs always produce a length-1 root list; multi-entry lists can +only arise from manual `Window` construction. + +ADR 0007 also adds `Scan.active_stream_sets: list[frozenset[str]]` — every +combination of stream names simultaneously active in some window — so a +consumer can validate sequencer-table capacity **up front, without +iterating**: `Acquire` contributes its own singleton; `Concat` unions its +children's lists; everything else passes the inner value through. + +This ADR is **not yet accepted**; see §9 and §11 for its review status and +the corrections still to apply. When accepted it supersedes ADR 0005 and +the `positions(TriggerPattern)` signature of ADR 0006. + +--- + +## 5. Analysis — reshaping detector data + +Analysis is per stream, from static compiled geometry (never from windows): + +- `stream.dimensions: list[Dimension]`, ordered outer → inner; each + `Dimension` has `axes`, `length`, `snake`, and lazy + `setpoints(axis, chunk_size=None)` yielding forward-direction coordinate + arrays (chunked; never allocates more than a chunk). +- Base shape is `[dim.length for dim in stream.dimensions]`; groups with + `collections_per_event > 1` get an extra inner dimension. +- De-snaking is the caller's job (`dim.snake` tells it where); multiple axes + can share one dimension (a spiral is one `Dimension` with two axes). +- A `number_of_events` convenience (product of dimension lengths, for + `StandardDetector.prepare()`) is agreed but not yet implemented (§8). + +--- + +## 6. Pause and resume + +Principles (agreed, ADR 0007 context): + +- **Emitted data is final.** Re-emitting captured frames was judged too + disruptive to downstream pipelines. If data is bad, cancel and restart the + scan; resume is always forward-only — it truncates remaining work, never + completed work. +- **Progress is a count, not a time.** Hardware tracks completed trigger + repeats; elapsed time cannot reliably be mapped back to a repeat index + (variable-gap patterns). Hence `Scan.with_start(window, trigger_index)` + returns a new `Scan` whose first yielded window has the first + `trigger_index` repeats truncated off its patterns and its `duration` + reduced accordingly. There is no rewind method and no mutable iterator + state. +- Intra-window resume currently requires the window to have exactly one + `TriggerGroup` (raises otherwise); the `TriggerNode` unification removes + this restriction structurally (one root node). +- Within a window, safe pause points are **checkpoints** at root-level + repeat boundaries. ADR 0007 proposes gating each root repeat on a + Bluesky-held bit in the PandA sequencer table so that dropping the bit + stalls the hardware at the next checkpoint (max latency: one root repeat + period), after which Bluesky reads back progress and resumes via + `with_start`. Long blank sections must be constructed as many short + repeats to bound pause latency. The exact hardware sequence + (table-rewrite-while-stalled vs abort-and-restart, gate bit, status + readback) has open review corrections — see §11. + +--- + +## 7. Consumer capability dispatch + +Three consumer classes, dispatchable from the `Scan` without iterating: + +1. **Step-scan capable** — asserts `not scan.has_moving_axes`; moves + `window.static_axes`, fires `trigger_groups` per window. +2. **Linear-flyscan capable** (motor record) — asserts `not scan.non_linear`; + uses `AxisMotion` boundary kinematics to compute ramp distances; never + needs position arrays. +3. **Trajectory capable** (PMAC etc.) — consumes anything; streams + `window.positions(dt=0.0002, max_duration=10.0)` chunks and bridges + windows with `calculate_turnaround`. + +Worked examples of all of these plus the PandA sequence-table builder and +pause/resume are in [API_SPEC.md](API_SPEC.md) §Consumption use cases, and +exercised in `tests/scanspec2/test_use_cases.py`. + +--- + +## 8. Implementation status + +All 2.0 code is in `src/scanspec2/` (tests in `tests/scanspec2/`); 1.x in +`src/scanspec/` is frozen as a reference. Integration branch: `v2-dev`. + +**Implemented and passing**: all core data structures; all motion primitives +(`Linspace`+`bounded`, `Static`, `Range`+`bounded`, `Spiral`, `Ellipse`, +`Polygon`, `Line`); all combinators; `Acquire`; serialization via dynamic +discriminated union with out-of-package subclass support; centred-livetime +semantics and `positions(float | TriggerPattern)` (ADR 0006); +`with_start(window, trigger_index)` truncation resume. + +**Known gaps and defects**: + +1. `TriggerNode` rework + `active_stream_sets` + checkpoint pause/resume + (ADR 0007, once accepted) — supersedes parts of the trigger code above. +2. `Ellipse`/`Polygon` accept `snake=` but it has no effect on traversal + order (review finding) — implement or remove the parameter. +3. `window.positions(float dt)`: `max_duration < dt` yields a zero-size + chunk and loops forever — needs a guard (review finding). +4. `Scan.number_of_events` (or per-stream) property. +5. A use-case test mapping `DetectorGroup` + dimensions to an ophyd-async + `TriggerInfo` for `StandardDetector.prepare()`. +6. `scanspec2/__init__.py` exports (currently a bare docstring). +7. Serialization test coverage is thin (smoke-test level). +8. Auxiliary modules not ported (nice-to-have, in priority order): + `plot.py`, `cli.py` + `__main__.py`, `service.py`, `sphinxext.py`. + +**Intentionally dropped from 1.x** (rationale in ADR 0003): `Path`, +`Midpoints`, `Slice`, `Squash`, `Mask`/regions, `Fly`, `ConstantDuration`, +`step()`, `fly()`, `get_constant_duration()`, `VARIABLE_DURATION`. + +--- + +## 9. In-flight design changes + +- **ADR 0006** (centred livetime, `positions(TriggerPattern)`): status + *Tentative*. The substance is agreed and implemented; review wording + corrections have been applied. To be marked Accepted after maintainer + sign-off — and partially superseded by ADR 0007 when that lands (the + centred semantics persist; the `TriggerPattern` argument type becomes the + node type). +- **ADR 0007** (trigger tree + checkpoint pause/resume): status *Proposed*. + The structural decisions (TriggerNode, sequential root list, forward-only + resume, structural `active_stream_sets`) reflect maintainer direction. + Maintainer review has identified factual corrections not yet applied to + the ADR text: + - The checkpoint gate bit is **BITB** (BITA is already used for + motion-controller sync at window boundaries). + - Stall detection should poll the SEQ block **STATE** field, not + `TABLE_LINE`/`LINE_REPEAT`. + - **Two levels of trigger nesting fit in a single SEQ block** — the + "two chained tables for two levels" reasoning in assumption A3 is wrong. + - The assumption-A2 scenario (collapsing N repeats into one `REPEATS=N` + row) "will never occur in reality"; the ADR should instead present the + concrete worked sequence-table encoding. + When 0007 is accepted: mark **ADR 0005 as superseded by 0007**, and + annotate ADR 0006 accordingly. + +--- + +## 10. Documentation debt + +- **API_SPEC.md is stale** against the code in places: it still names + `Window.non_linear_move` (code: `non_linear`), `with_start(window, time)` + (code: `trigger_index`), a `Scan.fly` field (removed in favour of + `has_moving_axes`/`non_linear`), and `positions()` without the + `TriggerPattern` argument. It must be reconciled — and will need a second + pass when `TriggerNode` lands. +- `docs/` (user-facing Sphinx docs) still document 1.x only; they are + rewritten as part of the final migration, not before. + +--- + +## 11. Open questions + +1. **Pause hardware sequence**: stall-then-rewrite-table-then-resume (as + drafted in ADR 0007) vs the maintainer-suggested simpler + stall-then-**abort**-the-sequence (then re-arm via `with_start`). Needs a + decision with the maintainer before implementing. +2. **`Concat` of two same-named `Acquire`s**: maintainer comment "becomes + serial" on the deduplication test expectation needs clarification — does + `active_stream_sets` dedupe to one singleton, or is there additional + sequential-table semantics to capture? +3. **Trigger-node depth validation**: given two nesting levels fit in one + SEQ block, where should the depth limit (if any) be enforced — compile + time in scanspec, or consumer-side capacity check via + `active_stream_sets`? +4. **`Ellipse`/`Polygon` snake** (§8.2): support it or drop the parameter? +5. Does pause/resume ever need an *end* point as well as a start point? + (Raised during design; unresolved, currently assumed not.) + +--- + +## 12. End state (migration) + +When `src/scanspec2/` is feature-complete on `v2-dev` and all tests pass: + +1. Delete `src/scanspec/` (1.x) and its tests. +2. Rename `src/scanspec2/` → `src/scanspec/` (and `tests/scanspec2/`), + updating `pyproject.toml` and imports. +3. Rewrite `docs/` for the 2.0 API; verify it reads well end-to-end. +4. Delete the working documents (this PRD, API_SPEC.md) or fold their + remaining content into `docs/`. +5. Merge `v2-dev` to `main` and release scanspec 2.0; ophyd-async is then + pointed at it. diff --git a/README.md b/README.md index eee384fd..7e6b1d41 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,9 @@ can be produced and expanded Paths created to consume chunk by chunk. [cycler]: https://matplotlib.org/cycler/ -Source | +What | Where :---: | :---: +Source | PyPI | `pip install scanspec` Docker | `docker run ghcr.io/bluesky/scanspec:latest` Documentation | diff --git a/pyproject.toml b/pyproject.toml index a273cc7d..b5ebf8c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ dev = [ "tox-uv", "types-mock", "Pillow", + "ophyd-async", ] [project.scripts] diff --git a/src/scanspec/specs.py b/src/scanspec/specs.py index e2a16d47..b93a1bd1 100644 --- a/src/scanspec/specs.py +++ b/src/scanspec/specs.py @@ -136,8 +136,21 @@ def concat(self, other: Spec[Axis]) -> Concat[Axis]: return Concat(self, other) def serialize(self) -> Mapping[str, Any]: - """Serialize the Spec to a dictionary.""" - return TypeAdapter(Spec[Any]).dump_python(self) + """Serialize the Spec to a dictionary. + + Any value that pydantic cannot natively convert to a JSON-serializable + type (e.g. an ``ophyd_async`` device used as an axis) is serialized by + using its ``name`` attribute (if it has one), otherwise its ``repr()`` + string. + """ + + def _fallback(obj: Any) -> str: + name = getattr(obj, "name", None) + if isinstance(name, str): + return name + return repr(obj) + + return TypeAdapter(Spec[Any]).dump_python(self, mode="json", fallback=_fallback) @staticmethod def deserialize(obj: Any) -> Spec[Any]: diff --git a/src/scanspec2/specs.py b/src/scanspec2/specs.py index f95dbec1..c3d262a4 100644 --- a/src/scanspec2/specs.py +++ b/src/scanspec2/specs.py @@ -63,6 +63,7 @@ AxisT = TypeVar("AxisT") DetectorT = TypeVar("DetectorT") MonitorT = TypeVar("MonitorT") +_BoundedAxisT = TypeVar("_BoundedAxisT") def _discriminate_by_type(obj: Any) -> str | None: @@ -267,6 +268,28 @@ def compile(self) -> Scan[AxisT, Never, Never]: ) return Scan(generators=[gen]) + @classmethod + def bounded( + cls, + axis: _BoundedAxisT, + lower: float, + upper: float, + num: int = 1, + ) -> Linspace[_BoundedAxisT]: + """Construct a Linspace from extreme bounds rather than midpoints. + + ``lower`` and ``upper`` are the outer edges of the first and last + frames respectively. The midpoints are half a step inward from each + edge. + """ + half_step = (upper - lower) / num / 2 + start = lower + half_step + if num == 1: + stop = upper + half_step + else: + stop = upper - half_step + return cast(Linspace[_BoundedAxisT], cls(cast(Any, axis), start, stop, num)) + class Static(Spec[AxisT, Never, Never]): """Single static position for one axis.""" @@ -289,6 +312,75 @@ def compile(self) -> Scan[AxisT, Never, Never]: return Scan(generators=[gen]) +class Range(Spec[AxisT, Never, Never]): + """Evenly-spaced sweep defined by a step size rather than a point count. + + ``start`` and ``stop`` are the midpoints of the first and last frames. + ``step`` is the spacing between midpoints (must be > 0). The number of + points is derived from ``abs(stop - start) / step`` using the same + rounding rule as the 1.x implementation. + """ + + axis: AxisT = Field(description="Axis identifier.") + start: float = Field(description="Midpoint of the first frame.") + stop: float = Field(description="Midpoint of the last frame.") + step: float = Field(gt=0, description="Step size between midpoints.") + + def _num(self) -> int: + step = abs(self.step) + distance = abs(self.stop - self.start) + num = int(distance // step) + 1 + if np.isclose(step * num, distance): + num = num + 1 + return num + + def compile(self) -> Scan[AxisT, Never, Never]: + """Compile into a one-dimension Scan with a linear position function.""" + num = self._num() + # Use the computed last midpoint rather than self.stop: when stop is not + # an exact multiple of step away from start, LinearSource((start, stop), num) + # would produce the wrong step spacing. + sign = np.sign(self.stop - self.start) + actual_stop = self.start + (num - 1) * sign * self.step + gen = WindowGenerator( + axes=[self.axis], + length=num, + source=LinearSource({self.axis: (self.start, actual_stop)}, num), + ) + return Scan(generators=[gen]) + + @classmethod + def bounded( + cls, + axis: _BoundedAxisT, + lower: float, + upper: float, + step: float, + ) -> Range[_BoundedAxisT]: + """Construct a Range from extreme bounds rather than midpoints. + + ``lower`` and ``upper`` are the outer edges of the bounding box. + ``step`` is clamped to ``abs(upper - lower)`` so at least one frame + is always produced. When ``lower == upper`` the step is used as-is + and a single point at that position is returned. + """ + distance = abs(upper - lower) + if distance == 0.0: + # Degenerate case: single point, step unchanged + return cast( + Range[_BoundedAxisT], + cls(cast(Any, axis), lower, lower, abs(step)), + ) + direction = np.sign(upper - lower) + step = min(distance, abs(step)) + half_step = step / 2 * direction + start = lower + half_step + stop = upper - half_step + if stop == start: + stop = np.nextafter(start, np.inf * direction) + return cast(Range[_BoundedAxisT], cls(cast(Any, axis), start, stop, step)) + + class Spiral(Spec[AxisT, Never, Never]): """Archimedean spiral of *x_axis* and *y_axis*. @@ -807,6 +899,216 @@ def _compute_duration( return per_point +# --------------------------------------------------------------------------- +# Masked-grid helpers +# --------------------------------------------------------------------------- + + +def _eval_full_grid(scan: Scan[Any, Any, Any]) -> dict[Any, np.ndarray]: + """Return all midpoints of a compiled scan as flat arrays. + + Expands a multi-generator scan into a full Cartesian product, giving one + array per axis of length ``product(g.length for g in scan.generators)``. + Snake flags are ignored — the caller cares only about *which* points + exist, not their traversal order. + """ + per_axis: dict[Any, np.ndarray] = {} + outer_len = 1 + for gen in scan.generators: + inner_len = gen.length + midpt_idx = np.arange(inner_len, dtype=float) + 0.5 + pts = gen.setpoints(midpt_idx) + # Each already-collected outer axis repeats inner_len times. + for ax in list(per_axis.keys()): + per_axis[ax] = np.repeat(per_axis[ax], inner_len) + # Each new inner axis tiles outer_len times. + for ax, arr in pts.items(): + per_axis[ax] = np.tile(arr, outer_len) + outer_len *= inner_len + return per_axis + + +class Ellipse(Spec[AxisT, Never, Never]): + """Grid of points masked to an elliptical footprint. + + Builds a 2-D rectangular grid from ``Range`` objects spanning the + bounding box of the ellipse, then keeps only those midpoints that satisfy + the ellipse equation. ``snake`` and ``vertical`` control the fast/slow + axis selection (they do not affect *which* points are retained). + """ + + x_axis: AxisT = Field(description="Axis identifier for x.") + x_centre: float = Field(description="x centre of the ellipse.") + x_diameter: float = Field(description="x diameter of the ellipse.") + x_step: float = Field(gt=0, description="Grid spacing along x.") + y_axis: AxisT = Field(description="Axis identifier for y.") + y_centre: float = Field(description="y centre of the ellipse.") + y_diameter: float | None = Field( + default=None, + description="y diameter (defaults to abs(x_diameter)).", + ) + y_step: float | None = Field( + default=None, + description="Grid spacing along y (defaults to x_step).", + ) + snake: bool = Field(default=False, description="Snake the fast axis.") + vertical: bool = Field(default=False, description="If True, y is the fast axis.") + + @model_validator(mode="after") + def _fill_y_defaults(self) -> Self: + if self.x_diameter == 0.0: + raise ValueError("x_diameter must not be zero") + if self.y_diameter is None: + object.__setattr__(self, "y_diameter", abs(self.x_diameter)) + if self.y_step is None: + object.__setattr__(self, "y_step", self.x_step) + if self.y_diameter == 0.0: + raise ValueError("y_diameter must not be zero") + return self + + def compile(self) -> Scan[AxisT, Never, Never]: + """Compile into a flat single-generator Scan of masked midpoints.""" + x_radius = abs(self.x_diameter) / 2 + eff_y_diam = ( + self.y_diameter if self.y_diameter is not None else abs(self.x_diameter) + ) + y_radius = abs(eff_y_diam) / 2 + eff_y_step = self.y_step if self.y_step is not None else self.x_step + + x_range: Range[AxisT] = Range( + self.x_axis, + self.x_centre - x_radius, + self.x_centre + x_radius, + self.x_step, + ) + y_range: Range[AxisT] = Range( + self.y_axis, + self.y_centre - y_radius, + self.y_centre + y_radius, + eff_y_step, + ) + + # Build grid: slow * fast (snake/vertical determine fast axis but do + # not change the set of masked points). + if self.vertical: + grid: Spec[AxisT, Never, Never] = x_range * y_range + else: + grid = y_range * x_range + + all_pts = _eval_full_grid(grid.compile()) + + x = all_pts[self.x_axis] - self.x_centre + y = all_pts[self.y_axis] - self.y_centre + mask = (2 * x / self.x_diameter) ** 2 + (2 * y / eff_y_diam) ** 2 <= 1 + + x_masked = all_pts[self.x_axis][mask] + y_masked = all_pts[self.y_axis][mask] + num_masked = int(mask.sum()) + + x_ax: AxisT = self.x_axis + y_ax: AxisT = self.y_axis + + def _pos_fn(indexes: np.ndarray) -> dict[AxisT, np.ndarray]: + idx = (indexes - 0.5).astype(int) + return {y_ax: y_masked[idx], x_ax: x_masked[idx]} + + gen = WindowGenerator( + axes=[self.y_axis, self.x_axis], + length=num_masked, + source=FunctionSource(_pos_fn), + ) + return Scan(generators=[gen]) + + +class Polygon(Spec[AxisT, Never, Never]): + """Grid of points masked to a polygonal footprint. + + Uses an even-odd ray-casting rule to determine which grid midpoints are + inside the polygon defined by ``vertices``. + """ + + x_axis: AxisT = Field(description="Axis identifier for x.") + y_axis: AxisT = Field(description="Axis identifier for y.") + vertices: list[tuple[float, float]] = Field( + description="Ordered (x, y) vertices of the polygon." + ) + x_step: float = Field(gt=0, description="Grid spacing along x.") + y_step: float | None = Field( + default=None, + description="Grid spacing along y (defaults to x_step).", + ) + snake: bool = Field(default=False, description="Snake the fast axis.") + vertical: bool = Field(default=False, description="If True, y is the fast axis.") + + @model_validator(mode="after") + def _fill_y_defaults(self) -> Self: + if self.y_step is None: + object.__setattr__(self, "y_step", self.x_step) + return self + + def _eff_y_step(self) -> float: + return self.y_step if self.y_step is not None else self.x_step + + def _poly_mask(self, x: np.ndarray, y: np.ndarray) -> np.ndarray: + """Even-odd ray-casting mask (matches 1.x algorithm exactly).""" + v1x, v1y = self.vertices[-1] + mask = np.full(len(x), False, dtype=np.bool_) + for v2x, v2y in self.vertices: + if v2y != v1y: + vmask = np.full(len(x), False, dtype=np.bool_) + vmask |= (y < v2y) & (y >= v1y) + vmask |= (y < v1y) & (y >= v2y) + t = (y - v1y) / (v2y - v1y) + vmask &= x < v1x + t * (v2x - v1x) + mask ^= vmask + v1x, v1y = v2x, v2y + return mask + + def compile(self) -> Scan[AxisT, Never, Never]: + """Compile into a flat single-generator Scan of masked midpoints.""" + x_start = min(v[0] for v in self.vertices) + x_stop = max(v[0] for v in self.vertices) + y_start = min(v[1] for v in self.vertices) + y_stop = max(v[1] for v in self.vertices) + eff_y_step = self._eff_y_step() + + x_range: Range[AxisT] = Range(self.x_axis, x_start, x_stop, self.x_step) + y_range: Range[AxisT] = Range(self.y_axis, y_start, y_stop, eff_y_step) + + if self.vertical: + grid: Spec[AxisT, Never, Never] = x_range * y_range + else: + grid = y_range * x_range + + all_pts = _eval_full_grid(grid.compile()) + + x_arr = all_pts[self.x_axis] + y_arr = all_pts[self.y_axis] + mask = self._poly_mask(x_arr, y_arr) + + x_masked = x_arr[mask] + y_masked = y_arr[mask] + num_masked = int(mask.sum()) + + x_ax: AxisT = self.x_axis + y_ax: AxisT = self.y_axis + + def _pos_fn(indexes: np.ndarray) -> dict[AxisT, np.ndarray]: + idx = (indexes - 0.5).astype(int) + return {y_ax: y_masked[idx], x_ax: x_masked[idx]} + + gen = WindowGenerator( + axes=[self.y_axis, self.x_axis], + length=num_masked, + source=FunctionSource(_pos_fn), + ) + return Scan(generators=[gen]) + + +# Line is a trivial alias for Linspace (both names are supported). +Line = Linspace + + # --------------------------------------------------------------------------- # Runtime AnySpec class — defined after all subclasses so pydantic defers # annotation resolution until model_rebuild below. diff --git a/tests/scanspec2/test_compile.py b/tests/scanspec2/test_compile.py index f4323ad7..ddbbc22a 100644 --- a/tests/scanspec2/test_compile.py +++ b/tests/scanspec2/test_compile.py @@ -1012,3 +1012,284 @@ def test_zip_rejects_monitors(): ) with pytest.raises(ValueError, match="Zip does not accept.*monitors"): Linspace("y", 0, 1, 5).zip(a).compile() # type: ignore[reportArgumentType] + + +# --------------------------------------------------------------------------- +# Linspace.bounded — compile +# --------------------------------------------------------------------------- + + +def test_linspace_bounded_compile_one_point(): + sc = Linspace.bounded("x", 0.0, 1.0, 1).compile() + g = gens(sc) + assert len(g) == 1 + assert g[0].length == 1 + pts = g[0].setpoints(np.array([0.5]))["x"] + np.testing.assert_allclose(pts, [0.5]) + + +def test_linspace_bounded_compile_many_points(): + sc = Linspace.bounded("x", 0.0, 1.0, 4).compile() + g = gens(sc) + assert g[0].length == 4 + pts = g[0].setpoints(np.arange(4) + 0.5)["x"] + np.testing.assert_allclose(pts, [0.125, 0.375, 0.625, 0.875]) + + +def test_linspace_bounded_compile_symmetric(): + # bounded(x, 3, 7, 2) → Linspace(x, 4, 6, 2) → midpoints [4, 6] + sc = Linspace.bounded("x", 3.0, 7.0, 2).compile() + pts = gens(sc)[0].setpoints(np.arange(2) + 0.5)["x"] + np.testing.assert_allclose(pts, [4.0, 6.0]) + + +# --------------------------------------------------------------------------- +# Range — compile +# --------------------------------------------------------------------------- + + +def test_range_compile_dimensions(): + from scanspec2.specs import Range + + sc = Range("x", 0.0, 1.0, 0.25).compile() + g = gens(sc) + assert len(g) == 1 + assert g[0].axes == ["x"] + assert g[0].length == 5 # 0, 0.25, 0.5, 0.75, 1.0 + + +@pytest.mark.parametrize("step", [0.25, 0.25 + 1e-8]) +def test_range_setpoints_match_linspace(step: float) -> None: + from scanspec2.specs import Range + + sc_range = Range("x", 0.0, 1.0, step).compile() + sc_linspace = Linspace("x", 0.0, 1.0, 5).compile() + idx = np.arange(5) + 0.5 + pts_range = gens(sc_range)[0].setpoints(idx)["x"] + pts_linspace = gens(sc_linspace)[0].setpoints(idx)["x"] + np.testing.assert_allclose(pts_range, pts_linspace) + + +def test_range_one_point(): + from scanspec2.specs import Range + + # step > (stop - start) → only one midpoint at start + sc = Range("x", 0.0, 1.0, 2.0).compile() + g = gens(sc) + assert g[0].length == 1 + pts = g[0].setpoints(np.array([0.5]))["x"] + np.testing.assert_allclose(pts, [0.0]) + + +def test_range_stop_not_on_grid(): + """Last setpoint should be start + (num-1)*step even if stop is mid-interval.""" + from scanspec2.specs import Range + + # stop=1.1 is not a grid point; actual last midpoint is 0.0 + 2*0.5 = 1.0 + sc = Range("x", 0.0, 1.1, 0.5).compile() + g = gens(sc) + assert g[0].length == 3 + pts = g[0].setpoints(np.arange(3) + 0.5)["x"] + np.testing.assert_allclose(pts, [0.0, 0.5, 1.0]) + + +def test_range_descending_stop_not_on_grid(): + """Descending range: last point is start - (num-1)*step.""" + from scanspec2.specs import Range + + # start=5, stop=2.5, step=1 → 3 points at 5, 4, 3 (not 2.5) + sc = Range("x", 5.0, 2.5, 1.0).compile() + g = gens(sc) + assert g[0].length == 3 + pts = g[0].setpoints(np.arange(3) + 0.5)["x"] + np.testing.assert_allclose(pts, [5.0, 4.0, 3.0]) + + +@pytest.mark.parametrize("step", [1.0, 1.0 + 1e-8]) +def test_range_two_points(step: float) -> None: + from scanspec2.specs import Range + + sc = Range("x", 0.0, 1.0, step).compile() + assert gens(sc)[0].length == 2 + pts = gens(sc)[0].setpoints(np.arange(2) + 0.5)["x"] + np.testing.assert_allclose(pts, [0.0, 1.0]) + + +def test_range_snake_flag(): + from scanspec2.specs import Range + + sc = (~Range("x", 0.0, 1.0, 0.25)).compile() + assert gens(sc)[-1].snake is True + + +# --------------------------------------------------------------------------- +# Range.bounded — compile +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("step", [0.25, 0.25 + 1e-8]) +def test_range_bounded_setpoints(step: float) -> None: + from scanspec2.specs import Range + + sc = Range.bounded("x", 0.0, 1.0, step).compile() + g = gens(sc) + assert g[0].length == 4 + pts = g[0].setpoints(np.arange(4) + 0.5)["x"] + np.testing.assert_allclose(pts, [0.125, 0.375, 0.625, 0.875]) + + +@pytest.mark.parametrize( + "lower,upper,step,expected_mid", + [ + (0.0, 1.0, 0.8, [0.4]), # step < range → one frame + (0.0, 1.0, 1.0, [0.5]), # step == range → one frame + (0.0, 1.0, 1.2, [0.5]), # step > range → clamped to one frame + ], +) +def test_range_bounded_one_point_setpoints( + lower: float, upper: float, step: float, expected_mid: list[float] +) -> None: + from scanspec2.specs import Range + + sc = Range.bounded("x", lower, upper, step).compile() + g = gens(sc) + assert g[0].length == 1 + pts = g[0].setpoints(np.array([0.5]))["x"] + np.testing.assert_allclose(pts, expected_mid) + + +def test_range_bounded_lower_equals_upper_compile(): + """lower == upper must produce exactly one point at that position.""" + from scanspec2.specs import Range + + sc = Range.bounded("x", 7.0, 7.0, 0.5).compile() + g = gens(sc) + assert g[0].length == 1 + pts = g[0].setpoints(np.array([0.5]))["x"] + np.testing.assert_allclose(pts, [7.0]) + + +def test_line_compile_same_as_linspace(): + from scanspec2.specs import Line + + sc_line = Line("x", 0.0, 10.0, 5).compile() + sc_linspace = Linspace("x", 0.0, 10.0, 5).compile() + idx = np.arange(5) + 0.5 + pts_line = gens(sc_line)[0].setpoints(idx)["x"] + pts_linspace = gens(sc_linspace)[0].setpoints(idx)["x"] + np.testing.assert_allclose(pts_line, pts_linspace) + + +# --------------------------------------------------------------------------- +# Ellipse — compile +# --------------------------------------------------------------------------- + + +def test_ellipse_compile_returns_single_generator(): + from scanspec2.specs import Ellipse + + sc = Ellipse("x", 5.0, 1.0, 0.5, "y", 0.0).compile() + assert len(gens(sc)) == 1 + + +def test_ellipse_compile_point_count(): + from scanspec2.specs import Ellipse + + # x: [4.5, 5.0, 5.5], y: [-0.5, 0.0, 0.5] → 9-point grid, 5 inside ellipse + sc = Ellipse("x", 5.0, 1.0, 0.5, "y", 0.0).compile() + assert gens(sc)[0].length == 5 + + +def test_ellipse_compile_all_points_inside(): + from scanspec2.specs import Ellipse + + sc = Ellipse("x", 5.0, 1.0, 0.5, "y", 0.0).compile() + g = gens(sc)[0] + idx = np.arange(g.length) + 0.5 + pts = g.setpoints(idx) + x = pts["x"] - 5.0 + y = pts["y"] - 0.0 + # All points must satisfy the ellipse equation <= 1 + np.testing.assert_array_less( + (2 * x / 1.0) ** 2 + (2 * y / 1.0) ** 2, + np.ones(g.length) + 1e-9, + ) + + +def test_ellipse_compile_axes_present(): + from scanspec2.specs import Ellipse + + sc = Ellipse("x", 5.0, 1.0, 0.5, "y", 0.0).compile() + assert set(gens(sc)[0].axes) == {"x", "y"} + + +def test_ellipse_compile_snake_same_point_count(): + from scanspec2.specs import Ellipse + + sc_straight = Ellipse("x", 5.0, 1.0, 0.5, "y", 0.0, snake=False).compile() + sc_snake = Ellipse("x", 5.0, 1.0, 0.5, "y", 0.0, snake=True).compile() + assert gens(sc_straight)[0].length == gens(sc_snake)[0].length == 5 + + +def test_ellipse_compile_vertical_swaps_fast_slow(): + from scanspec2.specs import Ellipse + + # vertical=False: y is slow, x is fast → midpoints ordered by y first + # vertical=True: x is slow, y is fast → midpoints ordered by x first + # Both have same set of 5 points, just in different order + sc_h = Ellipse("x", 5.0, 1.0, 0.5, "y", 0.0, vertical=False).compile() + sc_v = Ellipse("x", 5.0, 1.0, 0.5, "y", 0.0, vertical=True).compile() + assert gens(sc_h)[0].length == gens(sc_v)[0].length == 5 + + +# --------------------------------------------------------------------------- +# Polygon — compile +# --------------------------------------------------------------------------- + + +def test_polygon_compile_returns_single_generator(): + from scanspec2.specs import Polygon + + sc = Polygon("x", "y", [(0, 0), (5, 0), (2.5, 4)], 1.0, 2.0).compile() + assert len(gens(sc)) == 1 + + +def test_polygon_compile_triangle_point_count(): + from scanspec2.specs import Polygon + + # Triangle (0,0),(5,0),(2.5,4), x_step=1, y_step=2 + # y rows: 0, 2, 4; x cols: 0..5 → 7 masked points + sc = Polygon("x", "y", [(0, 0), (5, 0), (2.5, 4)], 1.0, 2.0).compile() + assert gens(sc)[0].length == 7 + + +def test_polygon_compile_axes_present(): + from scanspec2.specs import Polygon + + sc = Polygon("x", "y", [(0, 0), (5, 0), (2.5, 4)], 1.0, 2.0).compile() + assert set(gens(sc)[0].axes) == {"x", "y"} + + +def test_polygon_compile_snake_same_point_count(): + from scanspec2.specs import Polygon + + sc_straight = Polygon( + "x", "y", [(0, 0), (5, 0), (2.5, 4)], 1.0, 2.0, snake=False + ).compile() + sc_snake = Polygon( + "x", "y", [(0, 0), (5, 0), (2.5, 4)], 1.0, 2.0, snake=True + ).compile() + assert gens(sc_straight)[0].length == gens(sc_snake)[0].length + + +def test_polygon_compile_square_all_inside(): + from scanspec2.specs import Polygon + + # Unit square [0,1]×[0,1], step=0.5 → 3×3=9 grid, all 9 are inside the square + vertices = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)] + sc = Polygon("x", "y", vertices, 0.5).compile() + g = gens(sc)[0] + idx = np.arange(g.length) + 0.5 + pts = g.setpoints(idx) + x, y = pts["x"], pts["y"] + assert np.all((x >= 0.0) & (x <= 1.0) & (y >= 0.0) & (y <= 1.0)) diff --git a/tests/scanspec2/test_specs.py b/tests/scanspec2/test_specs.py index 3467241f..318ce790 100644 --- a/tests/scanspec2/test_specs.py +++ b/tests/scanspec2/test_specs.py @@ -257,3 +257,250 @@ def test_acquire_frozen(): a: Acquire[str, Any, Any] = Acquire(Linspace("x", 0.0, 1.0, 10)) with pytest.raises(ValidationError): a.fly = True # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Linspace.bounded — construction +# --------------------------------------------------------------------------- + + +def test_linspace_bounded_one_point(): + inst = Linspace.bounded("x", 0.0, 1.0, 1) + assert isinstance(inst, Linspace) + assert inst.axis == "x" + assert inst.num == 1 + assert inst.start == 0.5 + # stop encodes the step size for num=1: stop = upper + half_step = 1.5 + assert inst.stop == 1.5 + + +def test_linspace_bounded_many_points(): + inst = Linspace.bounded("x", 0.0, 1.0, 4) + assert isinstance(inst, Linspace) + assert inst.start == 0.125 + assert inst.stop == 0.875 + assert inst.num == 4 + + +def test_linspace_bounded_symmetric(): + inst = Linspace.bounded("x", 3.0, 7.0, 2) + assert isinstance(inst, Linspace) + assert inst.start == 4.0 + assert inst.stop == 6.0 + assert inst.num == 2 + + +# --------------------------------------------------------------------------- +# Range — construction and validation +# --------------------------------------------------------------------------- + + +def test_range_positional(): + from scanspec2.specs import Range + + r = Range("x", 0.0, 1.0, 0.25) + assert r.axis == "x" + assert r.start == 0.0 + assert r.stop == 1.0 + assert r.step == 0.25 + + +def test_range_keyword(): + from scanspec2.specs import Range + + r = Range(axis="x", start=0.0, stop=10.0, step=2.0) + assert r.step == 2.0 + + +def test_range_zero_step_raises(): + from scanspec2.specs import Range + + with pytest.raises(ValueError): + Range("x", 0.0, 1.0, 0.0) + + +def test_range_negative_step_raises(): + from scanspec2.specs import Range + + with pytest.raises(ValueError): + Range("x", 0.0, 1.0, -0.5) + + +def test_range_type_field(): + from scanspec2.specs import Range + + r = Range("x", 0.0, 1.0, 0.5) + assert r.type == "Range" + + +# --------------------------------------------------------------------------- +# Range.bounded — construction +# --------------------------------------------------------------------------- + + +def test_range_bounded_many_points(): + from scanspec2.specs import Range + + inst = Range.bounded("x", 0.0, 1.0, 0.25) + assert isinstance(inst, Range) + assert inst.start == 0.125 + assert inst.stop == 0.875 + assert inst.step == 0.25 + + +@pytest.mark.parametrize( + "lower,upper,step,expected_start", + [ + (0.0, 1.0, 0.8, 0.4), # step smaller than range → one frame + (0.0, 1.0, 1.0, 0.5), # step equals range → one frame + (0.0, 1.0, 1.2, 0.5), # step larger than range → clamped to one frame + ], +) +def test_range_bounded_one_point( + lower: float, upper: float, step: float, expected_start: float +) -> None: + from scanspec2.specs import Range + + inst = Range.bounded("x", lower, upper, step) + assert isinstance(inst, Range) + assert inst.start == expected_start + + +def test_range_bounded_lower_equals_upper(): + """lower == upper must not crash and must produce a single point.""" + from scanspec2.specs import Range + + inst = Range.bounded("x", 5.0, 5.0, 0.5) + assert isinstance(inst, Range) + assert inst.start == 5.0 + assert inst.stop == 5.0 + assert inst.step == 0.5 # step kept as-is, not clamped to 0 + + +def test_line_is_linspace(): + from scanspec2.specs import Line + + assert Line is Linspace + + +def test_line_instantiation(): + from scanspec2.specs import Line + + ln = Line("x", 0.0, 10.0, 5) + assert isinstance(ln, Linspace) + assert ln.axis == "x" + assert ln.num == 5 + + +# --------------------------------------------------------------------------- +# Ellipse — construction +# --------------------------------------------------------------------------- + + +def test_ellipse_positional(): + from scanspec2.specs import Ellipse + + e = Ellipse("x", 5.0, 1.0, 0.5, "y", 0.0) + assert e.x_axis == "x" + assert e.x_centre == 5.0 + assert e.x_diameter == 1.0 + assert e.x_step == 0.5 + assert e.y_axis == "y" + assert e.y_centre == 0.0 + + +def test_ellipse_y_diameter_defaults_to_x_diameter(): + from scanspec2.specs import Ellipse + + e = Ellipse("x", 0.0, 2.0, 0.5, "y", 0.0) + assert e.y_diameter == 2.0 + + +def test_ellipse_y_step_defaults_to_x_step(): + from scanspec2.specs import Ellipse + + e = Ellipse("x", 0.0, 2.0, 0.5, "y", 0.0) + assert e.y_step == 0.5 + + +def test_ellipse_snake_and_vertical_defaults(): + from scanspec2.specs import Ellipse + + e = Ellipse("x", 0.0, 2.0, 0.5, "y", 0.0) + assert e.snake is False + assert e.vertical is False + + +def test_ellipse_explicit_y_diameter_and_y_step(): + from scanspec2.specs import Ellipse + + e = Ellipse("x", 0.0, 4.0, 1.0, "y", 0.0, y_diameter=2.0, y_step=0.5) + assert e.y_diameter == 2.0 + assert e.y_step == 0.5 + + +def test_ellipse_x_step_gt0_raises(): + from scanspec2.specs import Ellipse + + with pytest.raises(ValueError): + Ellipse("x", 0.0, 2.0, 0.0, "y", 0.0) + + +def test_ellipse_x_diameter_zero_raises(): + from scanspec2.specs import Ellipse + + with pytest.raises(ValueError): + Ellipse("x", 0.0, 0.0, 1.0, "y", 0.0) + + +def test_ellipse_y_diameter_zero_raises(): + from scanspec2.specs import Ellipse + + with pytest.raises(ValueError): + Ellipse("x", 0.0, 2.0, 1.0, "y", 0.0, y_diameter=0.0) + + +# --------------------------------------------------------------------------- +# Polygon — construction +# --------------------------------------------------------------------------- + + +def test_polygon_positional(): + from scanspec2.specs import Polygon + + vertices = [(0.0, 0.0), (5.0, 0.0), (2.5, 4.0)] + p = Polygon("x", "y", vertices, 1.0) + assert p.x_axis == "x" + assert p.y_axis == "y" + assert p.vertices == vertices + assert p.x_step == 1.0 + + +def test_polygon_y_step_defaults_to_x_step(): + from scanspec2.specs import Polygon + + p = Polygon("x", "y", [(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)], 0.25) + assert p.y_step == 0.25 + + +def test_polygon_explicit_y_step(): + from scanspec2.specs import Polygon + + p = Polygon("x", "y", [(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)], 0.5, 0.25) + assert p.x_step == 0.5 + assert p.y_step == 0.25 + + +def test_polygon_snake_and_vertical_defaults(): + from scanspec2.specs import Polygon + + p = Polygon("x", "y", [(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)], 0.25) + assert p.snake is False + assert p.vertical is False + + +def test_polygon_x_step_gt0_raises(): + from scanspec2.specs import Polygon + + with pytest.raises(ValueError): + Polygon("x", "y", [(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)], 0.0) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 80e0f63d..43cfd798 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -2,9 +2,10 @@ from typing import Any import pytest +from ophyd_async.core import Device from pydantic import TypeAdapter, ValidationError -from scanspec.specs import Ellipse, Linspace, Spec, Spiral +from scanspec.specs import Ellipse, Fly, Linspace, Spec, Spiral def test_line_serializes() -> None: @@ -146,3 +147,36 @@ def test_vanilla_serialization(): serialized = adapter.dump_json(ob) deserialized = adapter.validate_json(serialized) assert deserialized == ob + + +def test_spec_with_ophyd_async_device_axis_serializes(): + motor = Device(name="motor") + spec = Fly(Linspace(motor, 1, 2, 5)) + serialized = spec.serialize() + assert serialized["type"] == "Fly" + assert serialized["spec"]["type"] == "Linspace" + assert serialized["spec"]["axis"] == motor.name + assert serialized["spec"]["start"] == 1.0 + assert serialized["spec"]["stop"] == 2.0 + assert serialized["spec"]["num"] == 5 + + +def test_spec_with_nameless_axis_falls_back_to_repr(): + class NamelessAxis: + pass + + axis = NamelessAxis() + spec = Fly(Linspace(axis, 1, 2, 5)) + serialized = spec.serialize() + assert serialized["spec"]["axis"] == repr(axis) + + +def test_spec_with_ophyd_async_device_axis_is_json_serializable(): + import json + + motor = Device(name="motor") + spec = Fly(Linspace(motor, 1, 2, 5)) + serialized = spec.serialize() + assert serialized["spec"]["axis"] == motor.name + # Should not raise + json.dumps(serialized) diff --git a/uv.lock b/uv.lock index afbea3e4..d3ca77e0 100644 --- a/uv.lock +++ b/uv.lock @@ -87,6 +87,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, ] +[[package]] +name = "bluesky" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cycler" }, + { name = "event-model" }, + { name = "historydict" }, + { name = "msgpack" }, + { name = "msgpack-numpy" }, + { name = "numpy" }, + { name = "opentelemetry-api" }, + { name = "toolz" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/51/8fca065db911517add90c408760e3f209762d656f4d1d114172287874619/bluesky-1.15.0.tar.gz", hash = "sha256:71d35f3514e616e7fed0430327cc64d2d40e4fcef9d6764be4db8f621057f572", size = 507145, upload-time = "2026-04-15T15:39:23.557Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/bf/86fa0ca9d3ecf02ff9941dfa74d1038ccc34c2081ac780f7fe10cdb6ab39/bluesky-1.15.0-py3-none-any.whl", hash = "sha256:3fa8bbe0d069decbabaafb522db60418a2de0fb7450ad460dd3d6bcc7aed6d0e", size = 365347, upload-time = "2026-04-15T15:39:21.926Z" }, +] + [[package]] name = "cachetools" version = "6.2.0" @@ -197,6 +218,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorlog" +version = "6.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, +] + +[[package]] +name = "compress-pickle" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/23/a448abd4e98b64ad5b99537a2b4df3f6a829e6fac749afbaf921f89c0941/compress_pickle-2.1.0.tar.gz", hash = "sha256:3e944ce0eeab5b6331324d62351c957d41c9327c8417d439843e88fe69b77991", size = 16360, upload-time = "2021-09-21T18:13:58.57Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/4f/f94ac1b84d2169cf2ebf64353ce98fd743f85d30678059c514d9b3d6644c/compress_pickle-2.1.0-py3-none-any.whl", hash = "sha256:598650da4686d9bd97bee185b61e74d7fe1872bb0c23909d5ed2d8793b4a8818", size = 24694, upload-time = "2021-09-21T18:13:57.047Z" }, +] + +[package.optional-dependencies] +lz4 = [ + { name = "lz4" }, +] + [[package]] name = "contourpy" version = "1.3.3" @@ -443,6 +490,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/41/04e2a649058b0713b00d6c9bd22da35618bb157289e05d068e51fddf8d7e/dunamai-1.25.0-py3-none-any.whl", hash = "sha256:7f9dc687dd3256e613b6cc978d9daabfd2bb5deb8adc541fc135ee423ffa98ab", size = 27022, upload-time = "2025-07-04T19:25:54.863Z" }, ] +[[package]] +name = "event-model" +version = "1.23.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-resources" }, + { name = "jsonschema" }, + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/5a/de03f05fdbc4377db89fa6daf243e89370f9bcf2d21ad96b54d4549a74ed/event_model-1.23.1.tar.gz", hash = "sha256:5bb70fd8c7f345aa32afe561aff5a306b2c8a19cbdc3066b736643c8092ddaab", size = 185271, upload-time = "2025-08-28T13:26:38.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/32/e31e3363bf48ad2ba80b644b01ad9676ce154f1b755950de81eb4ed5b6bd/event_model-1.23.1-py3-none-any.whl", hash = "sha256:e0b951b829cebcf3879beff238bb370fd997d315856bc5d5ac2a66202b854958", size = 77057, upload-time = "2025-08-28T13:26:37.228Z" }, +] + [[package]] name = "fastapi" version = "0.118.0" @@ -533,6 +595,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "historydict" +version = "1.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/eb/91c2bd3684147ba8deef546ff2b6d091f32ece81ceb153831bab9f2ea6a5/historydict-1.2.6.tar.gz", hash = "sha256:a800ae05d28b618fe0c913ff0d64e4aebe05d76934fa610539f70ead37dc6fb5", size = 4011, upload-time = "2023-08-05T20:42:20.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/47/deb64c73aec25af7699247e021153a6bfe9a08452f7f7337dcee4aa07a2b/historydict-1.2.6-py3-none-any.whl", hash = "sha256:b4b00a170f05502aa682caba62435da5fe1f73037e884707581fe84f8d7b43f5", size = 4501, upload-time = "2023-08-05T20:42:19.244Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -588,6 +659,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "importlib-resources" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -739,6 +831,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, ] +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -919,6 +1059,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481, upload-time = "2025-08-29T07:20:42.218Z" }, ] +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/97/560d11202bcd537abca693fd85d81cebe2107ba17301de42b01ac1677b69/msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c", size = 82271, upload-time = "2025-10-08T09:14:49.967Z" }, + { url = "https://files.pythonhosted.org/packages/83/04/28a41024ccbd67467380b6fb440ae916c1e4f25e2cd4c63abe6835ac566e/msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0", size = 84914, upload-time = "2025-10-08T09:14:50.958Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/b817349db6886d79e57a966346cf0902a426375aadc1e8e7a86a75e22f19/msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296", size = 416962, upload-time = "2025-10-08T09:14:51.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/6cc2e852837cd6086fe7d8406af4294e66827a60a4cf60b86575a4a65ca8/msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef", size = 426183, upload-time = "2025-10-08T09:14:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/6a19f030b3d2ea906696cedd1eb251708e50a5891d0978b012cb6107234c/msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c", size = 411454, upload-time = "2025-10-08T09:14:54.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/cd/9098fcb6adb32187a70b7ecaabf6339da50553351558f37600e53a4a2a23/msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e", size = 422341, upload-time = "2025-10-08T09:14:56.328Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ae/270cecbcf36c1dc85ec086b33a51a4d7d08fc4f404bdbc15b582255d05ff/msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e", size = 64747, upload-time = "2025-10-08T09:14:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/2a/79/309d0e637f6f37e83c711f547308b91af02b72d2326ddd860b966080ef29/msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68", size = 71633, upload-time = "2025-10-08T09:14:59.177Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/7c4e2b3d9b1106cd0aa6cb56cc57c6267f59fa8bfab7d91df5adc802c847/msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406", size = 64755, upload-time = "2025-10-08T09:15:00.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "msgpack-numpy" +version = "0.4.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/94/61e8aee142733ebfdc400a05bdac6e1763c4514bba3b42743d223f388450/msgpack-numpy-0.4.8.tar.gz", hash = "sha256:c667d3180513422f9c7545be5eec5d296dcbb357e06f72ed39cc683797556e69", size = 10923, upload-time = "2022-06-09T03:43:08.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/5d/f25ac7d4fb77cbd53ddc6d05d833c6bf52b12770a44fa9a447eed470ca9a/msgpack_numpy-0.4.8-py2.py3-none-any.whl", hash = "sha256:773c19d4dfbae1b3c7b791083e2caf66983bb19b40901646f61d8731554ae3da", size = 6919, upload-time = "2022-06-09T03:43:06.82Z" }, +] + [[package]] name = "myst-parser" version = "4.0.1" @@ -1026,6 +1232,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/11/0cc63f9f321ccf63886ac203336777140011fb669e739da36d8db3c53b98/numpy-2.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2e267c7da5bf7309670523896df97f93f6e469fb931161f483cd6882b3b1a5dc", size = 12971844, upload-time = "2025-09-09T15:58:57.359Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/8e/3778a7e87801d994869a9396b9fc2a289e5f9be91ff54a27d41eace494b0/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09", size = 71416, upload-time = "2026-04-09T14:38:34.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" }, +] + +[[package]] +name = "ophyd-async" +version = "0.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bluesky" }, + { name = "colorlog" }, + { name = "event-model" }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "pydantic-numpy" }, + { name = "pyyaml" }, + { name = "scanspec" }, + { name = "stamina" }, + { name = "velocity-profile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/2d/cd178f31c4efb7f2a1b2787900d62b70b478111c5db51a625307f5fb9b15/ophyd_async-0.16.tar.gz", hash = "sha256:c8a3671c704da77c7a7b7c5343b972230f743b1029a100f6c5780123fb0df33d", size = 545367, upload-time = "2026-02-17T16:39:37.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/ab/0c92e9824c9e54df5a06b5759957fc23c30489c2aabef5653dd9057b3f61/ophyd_async-0.16-py3-none-any.whl", hash = "sha256:017d837767b63cdc20af1851275495b6bb0db195a887e0bd989dc7a17e0f0c79", size = 208499, upload-time = "2026-02-17T16:39:36.542Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -1240,6 +1480,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, ] +[[package]] +name = "pydantic-numpy" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "compress-pickle", extra = ["lz4"] }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "ruamel-yaml" }, + { name = "semver" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/b8/8d03bc7bb02a6b2ed2f9da4868cd44261bc0656f9fa797140b0e4f354738/pydantic_numpy-8.0.1.tar.gz", hash = "sha256:85ae382ff4ebd23902791f6710d26f039a6215670d8c128ac30d5b79171fde13", size = 14983, upload-time = "2025-02-22T17:18:19.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/cb/c13d8a74419dde9590ed6fab293b516f68316ac87d06569b79b5f446d519/pydantic_numpy-8.0.1-py3-none-any.whl", hash = "sha256:bf4cd84f4f864074197e9cfeafddca76bfbd1c2ef48f88be7322cc75838de4ae", size = 20224, upload-time = "2025-02-22T17:18:17.206Z" }, +] + [[package]] name = "pydantic-settings" version = "2.11.0" @@ -1598,6 +1854,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/08/4349bdd5c64d9d193c360aa9db89adeee6f6682ab8825dca0a3f535f434f/rpds_py-0.27.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:dc23e6820e3b40847e2f4a7726462ba0cf53089512abe9ee16318c366494c17a", size = 556523, upload-time = "2025-08-27T12:16:12.188Z" }, ] +[[package]] +name = "ruamel-yaml" +version = "0.18.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.15' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/2b/7a1f1ebcd6b3f14febdc003e658778d81e76b40df2267904ee6b13f0c5c6/ruamel_yaml-0.18.17.tar.gz", hash = "sha256:9091cd6e2d93a3a4b157ddb8fabf348c3de7f1fb1381346d985b6b247dcd8d3c", size = 149602, upload-time = "2025-12-17T20:02:55.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/fe/b6045c782f1fd1ae317d2a6ca1884857ce5c20f59befe6ab25a8603c43a7/ruamel_yaml-0.18.17-py3-none-any.whl", hash = "sha256:9c8ba9eb3e793efdf924b60d521820869d5bf0cb9c6f1b82d82de8295e290b9d", size = 121594, upload-time = "2025-12-17T20:02:07.657Z" }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/80/8ce7b9af532aa94dd83360f01ce4716264db73de6bc8efd22c32341f6658/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c583229f336682b7212a43d2fa32c30e643d3076178fb9f7a6a14dde85a2d8bd", size = 147998, upload-time = "2025-11-16T16:13:13.241Z" }, + { url = "https://files.pythonhosted.org/packages/53/09/de9d3f6b6701ced5f276d082ad0f980edf08ca67114523d1b9264cd5e2e0/ruamel_yaml_clib-0.2.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:56ea19c157ed8c74b6be51b5fa1c3aff6e289a041575f0556f66e5fb848bb137", size = 132743, upload-time = "2025-11-16T16:13:14.265Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f7/73a9b517571e214fe5c246698ff3ed232f1ef863c8ae1667486625ec688a/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5fea0932358e18293407feb921d4f4457db837b67ec1837f87074667449f9401", size = 731459, upload-time = "2025-11-16T20:22:44.338Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a2/0dc0013169800f1c331a6f55b1282c1f4492a6d32660a0cf7b89e6684919/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71831bd61fbdb7aa0399d5c4da06bea37107ab5c79ff884cc07f2450910262", size = 749289, upload-time = "2025-11-16T16:13:15.633Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ed/3fb20a1a96b8dc645d88c4072df481fe06e0289e4d528ebbdcc044ebc8b3/ruamel_yaml_clib-0.2.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:617d35dc765715fa86f8c3ccdae1e4229055832c452d4ec20856136acc75053f", size = 777630, upload-time = "2025-11-16T16:13:16.898Z" }, + { url = "https://files.pythonhosted.org/packages/60/50/6842f4628bc98b7aa4733ab2378346e1441e150935ad3b9f3c3c429d9408/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b45498cc81a4724a2d42273d6cfc243c0547ad7c6b87b4f774cb7bcc131c98d", size = 744368, upload-time = "2025-11-16T16:13:18.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/128ae8e19a7d794c2e36130a72b3bb650ce1dd13fb7def6cf10656437dcf/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:def5663361f6771b18646620fca12968aae730132e104688766cf8a3b1d65922", size = 745233, upload-time = "2025-11-16T20:22:45.833Z" }, + { url = "https://files.pythonhosted.org/packages/75/05/91130633602d6ba7ce3e07f8fc865b40d2a09efd4751c740df89eed5caf9/ruamel_yaml_clib-0.2.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:014181cdec565c8745b7cbc4de3bf2cc8ced05183d986e6d1200168e5bb59490", size = 770963, upload-time = "2025-11-16T16:13:19.344Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4b/fd4542e7f33d7d1bc64cc9ac9ba574ce8cf145569d21f5f20133336cdc8c/ruamel_yaml_clib-0.2.15-cp311-cp311-win32.whl", hash = "sha256:d290eda8f6ada19e1771b54e5706b8f9807e6bb08e873900d5ba114ced13e02c", size = 102640, upload-time = "2025-11-16T16:13:20.498Z" }, + { url = "https://files.pythonhosted.org/packages/bb/eb/00ff6032c19c7537371e3119287999570867a0eafb0154fccc80e74bf57a/ruamel_yaml_clib-0.2.15-cp311-cp311-win_amd64.whl", hash = "sha256:bdc06ad71173b915167702f55d0f3f027fc61abd975bd308a0968c02db4a4c3e", size = 121996, upload-time = "2025-11-16T16:13:21.855Z" }, + { url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" }, + { url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" }, + { url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" }, + { url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" }, + { url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" }, + { url = "https://files.pythonhosted.org/packages/17/5e/2f970ce4c573dc30c2f95825f2691c96d55560268ddc67603dc6ea2dd08e/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb", size = 147450, upload-time = "2025-11-16T16:13:33.542Z" }, + { url = "https://files.pythonhosted.org/packages/d6/03/a1baa5b94f71383913f21b96172fb3a2eb5576a4637729adbf7cd9f797f8/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471", size = 133139, upload-time = "2025-11-16T16:13:34.587Z" }, + { url = "https://files.pythonhosted.org/packages/dc/19/40d676802390f85784235a05788fd28940923382e3f8b943d25febbb98b7/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25", size = 731474, upload-time = "2025-11-16T20:22:49.934Z" }, + { url = "https://files.pythonhosted.org/packages/ce/bb/6ef5abfa43b48dd55c30d53e997f8f978722f02add61efba31380d73e42e/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a", size = 748047, upload-time = "2025-11-16T16:13:35.633Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf", size = 782129, upload-time = "2025-11-16T16:13:36.781Z" }, + { url = "https://files.pythonhosted.org/packages/de/4b/e98086e88f76c00c88a6bcf15eae27a1454f661a9eb72b111e6bbb69024d/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d", size = 736848, upload-time = "2025-11-16T16:13:37.952Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5c/5964fcd1fd9acc53b7a3a5d9a05ea4f95ead9495d980003a557deb9769c7/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf", size = 741630, upload-time = "2025-11-16T20:22:51.718Z" }, + { url = "https://files.pythonhosted.org/packages/07/1e/99660f5a30fceb58494598e7d15df883a07292346ef5696f0c0ae5dee8c6/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51", size = 766619, upload-time = "2025-11-16T16:13:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/fa0344a9327b58b54970e56a27b32416ffbcfe4dcc0700605516708579b2/ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec", size = 100171, upload-time = "2025-11-16T16:13:40.456Z" }, + { url = "https://files.pythonhosted.org/packages/06/c4/c124fbcef0684fcf3c9b72374c2a8c35c94464d8694c50f37eef27f5a145/ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6", size = 118845, upload-time = "2025-11-16T16:13:41.481Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bd/ab8459c8bb759c14a146990bf07f632c1cbec0910d4853feeee4be2ab8bb/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef", size = 147248, upload-time = "2025-11-16T16:13:42.872Z" }, + { url = "https://files.pythonhosted.org/packages/69/f2/c4cec0a30f1955510fde498aac451d2e52b24afdbcb00204d3a951b772c3/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf", size = 133764, upload-time = "2025-11-16T16:13:43.932Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/2480d062281385a2ea4f7cc9476712446e0c548cd74090bff92b4b49e898/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000", size = 730537, upload-time = "2025-11-16T20:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/e365ee305367559f57ba6179d836ecc3d31c7d3fdff2a40ebf6c32823a1f/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4", size = 746944, upload-time = "2025-11-16T16:13:45.338Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/8b56b08db91e569d0a4fbfa3e492ed2026081bdd7e892f63ba1c88a2f548/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c", size = 778249, upload-time = "2025-11-16T16:13:46.871Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1d/70dbda370bd0e1a92942754c873bd28f513da6198127d1736fa98bb2a16f/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043", size = 737140, upload-time = "2025-11-16T16:13:48.349Z" }, + { url = "https://files.pythonhosted.org/packages/5b/87/822d95874216922e1120afb9d3fafa795a18fdd0c444f5c4c382f6dac761/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524", size = 741070, upload-time = "2025-11-16T20:22:54.151Z" }, + { url = "https://files.pythonhosted.org/packages/b9/17/4e01a602693b572149f92c983c1f25bd608df02c3f5cf50fd1f94e124a59/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e", size = 765882, upload-time = "2025-11-16T16:13:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/9f/17/7999399081d39ebb79e807314de6b611e1d1374458924eb2a489c01fc5ad/ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa", size = 102567, upload-time = "2025-11-16T16:13:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/67/be582a7370fdc9e6846c5be4888a530dcadd055eef5b932e0e85c33c7d73/ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467", size = 122847, upload-time = "2025-11-16T16:13:51.807Z" }, +] + [[package]] name = "ruff" version = "0.13.3" @@ -1649,6 +1965,7 @@ dev = [ { name = "copier" }, { name = "httpx" }, { name = "myst-parser" }, + { name = "ophyd-async" }, { name = "pillow" }, { name = "pre-commit" }, { name = "pydata-sphinx-theme" }, @@ -1683,6 +2000,7 @@ dev = [ { name = "copier" }, { name = "httpx" }, { name = "myst-parser" }, + { name = "ophyd-async" }, { name = "pillow" }, { name = "pre-commit" }, { name = "pydata-sphinx-theme", specifier = ">=0.12" }, @@ -1771,6 +2089,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/30/2f9a5243008f76dfc5dee9a53dfb939d9b31e16ce4bd4f2e628bfc5d89d2/scipy-1.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d2a4472c231328d4de38d5f1f68fdd6d28a615138f842580a8a321b5845cf779", size = 26448374, upload-time = "2025-09-11T17:45:03.45Z" }, ] +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1976,6 +2303,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "stamina" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/bd/b2f71ae14368a066f103d182f25bbc6c3bf4aa695889f3ed3cba026d6f36/stamina-26.1.0.tar.gz", hash = "sha256:0214d05fdf5102c518194a4aac7520ce53cf660550ae3b940701aad88cf50c17", size = 568171, upload-time = "2026-04-13T17:44:31.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/f0/1ff90a1d1dd02de23feafdf9dffaecef3958348be5c192df56670ccb4f86/stamina-26.1.0-py3-none-any.whl", hash = "sha256:62e06829bec87c06d4cafde520b32a6097d1017c378a9eb63253c5bf5ebbbb88", size = 18508, upload-time = "2026-04-13T17:44:29.545Z" }, +] + [[package]] name = "starlette" version = "0.48.0" @@ -1989,6 +2328,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" }, ] +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "tomli" version = "2.2.1" @@ -2028,6 +2376,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +] + [[package]] name = "tox" version = "4.30.3" @@ -2062,6 +2419,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/ac/b32555d190c4440b8d2779d4a19439e5fbd5a3950f7e5a17ead7c7d30cad/tox_uv-1.28.0-py3-none-any.whl", hash = "sha256:3fbe13fa6eb6961df5512e63fc4a5cc0c8d264872674ee09164649f441839053", size = 17225, upload-time = "2025-08-14T17:53:06.299Z" }, ] +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + [[package]] name = "types-mock" version = "5.2.0.20250924" @@ -2140,6 +2509,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, ] +[[package]] +name = "velocity-profile" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/a0/edb76fc2b71a3cd0717a40184f512f8249a64443c472541939d915fab70d/velocity_profile-1.0.0.tar.gz", hash = "sha256:520a4dbc69519744c89438571ffe542f512ae528232f51e73cde9dbaaee2e086", size = 29716, upload-time = "2024-06-13T10:49:54.951Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/89/0265b2b79424ed05b8d1e9c8fca71e1b150478e5b0c19aa50b0ae397326e/velocity_profile-1.0.0-py3-none-any.whl", hash = "sha256:b9082aedb2863748e1e6e56e7a794cd5742addd571f6ba2e13f4f5b8a09422d9", size = 16858, upload-time = "2024-06-13T10:49:53.555Z" }, +] + [[package]] name = "virtualenv" version = "20.34.0" @@ -2288,3 +2669,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +]