10 Commits
Author SHA1 Message Date
cristhian f29265a860 Merge pull request 'Rewrite as a dora 1.0 service node' (#2) from refactor/dora-1.0-services into main
validate / validate (push) Successful in 58m48s
validate / test (push) Successful in 58m53s
validate / markdownlint (push) Successful in 58m43s
Reviewed-on: #2
2026-09-15 16:37:58 +00:00
cristhianandClaude Opus 5 8cd4db93f1 docs: update the test count
validate / markdownlint (pull_request) Successful in 58m43s
validate / validate (pull_request) Successful in 58m48s
validate / test (pull_request) Successful in 58m55s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:32:43 -05:00
cristhianandClaude Opus 5 4427da9c34 fix requests queued while the arm is down never being answered
_run_control never drained the job queue while the arm had not been reached, so
every request that arrived during an outage waited for a connection that might
never come. That breaks the one-response-per-request rule the node states as
absolute, and it was circular: requests are queued before the arm is reached
because recover is what a caller sends then, yet recover is served by the
control thread and so was the one service that could never run while down.

Serve the queue during the wait between connection attempts. Non-motion services
are tried on the arm, which is what lets recover and clear reach a controller
that answers while refusing to serve. A motion is refused rather than held: the
arm is somewhere unknown and nobody is watching, so a held motion would fire
unattended the moment the link came back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:32:31 -05:00
cristhianandClaude Opus 5 608e22078e fix zero-velocity jog reporting a running motion as idle
start_jog restored READY for any zero velocity, without checking whether a
motion owned the arm. A teleop source streaming zero-velocity ticks during a
move published moving -> ready mid-motion, and the closing guard in _run_motion
then saw READY rather than MOVING and never republished, so a caller watching
robot_state concluded the motion had finished early.

Route every "the arm has stopped" transition through _idle_unless_busy, which
reports READY only when no motion is running, no jog is driving the arm, and the
state has not already left MOVING for unavailable or recovering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 12:32:17 -05:00
cristhian 412aeac955 docs: rewrite README and AGENTS for the service node
validate / validate (pull_request) Successful in 58m45s
validate / test (pull_request) Successful in 58m51s
validate / markdownlint (pull_request) Successful in 58m44s
Documents the eleven services, the wait flag, motion_state and
preemption, and the lifecycle guarantees around restart and shutdown.

AGENTS.md records the two rules that are easy to break by accident: the
stopping event must stay set-to-stop, because an event that is set while
running makes every wait() return at once and turns each cadence into a
busy loop; and every request gets exactly one response, since a caller
has no other way to know its request finished.
2026-09-12 11:47:06 -03:00
cristhian 4ff584e9b5 add Gitea CI running dora validate, ruff, pytest and markdownlint
Replaces the GitHub markdownlint workflow with a Gitea one covering all
four gates, matching dora-zed and dora-plot. None of them need a robot:
dora validate runs no node, and the tests need neither a dataflow nor a
daemon.
2026-09-12 11:47:06 -03:00
cristhian 6311f6daae update packaging for dora 1.0 and uv
dora-rs 1.0.1 requires Python 3.11+. Drops the numpy<2 pin, which the
littlehand lockfile shows is unnecessary with the current xArm SDK, and
moves to hatchling with a uv lockfile that CI installs with --locked so a
dependency change that was never locked cannot reach main.

Removes scripts/install.sh: uv sync is the whole install now.
2026-09-12 11:47:06 -03:00
cristhian a2bf59065d add dora-node.yml manifest and a service example dataflow
The manifest is the only contract a consumer has, since no source package
is shared between node repositories, and dora validate checks a dataflow
against it without running the node — so CI needs no robot.

request and response are deliberately untyped. They carry request_id, and
dora defines that as the marker for a pattern-correlated port where
several Arrow schemas legitimately flow, one per service; the runtime
skips type checks on those messages, so annotating them would claim a
contract neither side keeps.

examples/ carries its own manifest because dora resolves a node's
manifest by walking up from its path, and a manifest applies only when
<manifest dir>/<entrypoint> names the same file as the dataflow's path.
Without it the example caller would be matched against the arm node's
manifest and strict validation would fail on the entrypoint mismatch.
2026-09-12 11:47:06 -03:00
cristhian fdabf7e241 refactor node into services over the dora 1.0 contract
Replaces the command/status JSON pair with dora's own service pattern:
one request, one response, correlated by the request_id dora puts in the
message metadata. Motions take a wait flag, and their lifecycle is
published as the arm's state on motion_state rather than as a per-goal
stream — the arm is one resource, so a new motion preempts the one in
play and a state describes that exactly.

motion_state is keyed by request_id and publishes accepted before the
control thread reaches the motion. Both exist to close one race: a caller
polling a bare moving/idle flag right after its request would read "not
moving" and conclude the motion had already finished.

Three threads, because the SDK blocks. The control thread carries out one
job at a time and may block; the reporter thread only ever reads the
report cache, so telemetry keeps flowing through a ten-second move; the
stopper thread exists because a stop fits nowhere else — it must not
queue behind the motion it aborts, and emergency_stop re-commands the
controller for up to three seconds, which is far too long to stop routing
a dataflow.

The event loop never calls anything that can block, get_state included:
that one is allowed there only because it reads the cache. The node is
built before the arm threads start, so INIT_MODE cannot move a robot
before the process has joined the dataflow, and a restart leaves the arm
where it is rather than driving it home unasked. Shutdown halts the arm
first — a motion lives in the controller, and dropping the connection
does not abort it.
2026-09-12 11:46:59 -03:00
cristhian eccd9e3c24 add xArm driver layer ported from littlehand
Brings across the robot layer from monolitic-littlehand-python, which was
written against this same controller. The point of it is that a command
either moved the arm or raised: the SDK returns a code for a motion the
controller quietly refused, wait_move calls that refusal finished, and
arm.error_code is the report stream's stale copy.

So errors are read live, arrival is confirmed rather than assumed, and a
move whose straight-line path leaves the joint envelope is refused before
it is commanded. Failures are typed, because the distinctions decide the
response: an unreachable pose is not an unavailable arm, and a joint past
its limit needs a motion nobody should start without warning the room.

read_report is the deliberate exception to all of it: a report-cache read
that costs no round trip and cannot block, which is the only read that
works while a motion is in flight.
2026-09-12 11:46:54 -03:00
32 changed files with 4445 additions and 1516 deletions
+63
View File
@@ -0,0 +1,63 @@
# Two gates, neither of which needs a robot: the dataflow still matches the
# typed contract in dora-node.yml, and the node's own tests pass.
name: validate
# A pull request would otherwise run this twice on the same commit: once for the
# branch push and once for the PR.
on:
push:
branches: [main]
pull_request:
env:
DORA_VERSION: v1.0.1
DORA_TARGET: x86_64-unknown-linux-gnu
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install the dora CLI
run: |
curl -fsSL -o dora-cli.tar.gz \
"https://github.com/dora-rs/dora/releases/download/${DORA_VERSION}/dora-cli-${DORA_TARGET}.tar.gz"
tar xzf dora-cli.tar.gz
mkdir -p "$HOME/.local/bin"
install -m 755 "dora-cli-${DORA_TARGET}/dora" "$HOME/.local/bin/dora"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
rm -rf dora-cli.tar.gz "dora-cli-${DORA_TARGET}"
- run: dora --version
# strict_types: true in the dataflow turns a type warning into a non-zero
# exit, so a mismatched annotation fails here rather than at runtime.
- name: Validate the dataflow
run: dora validate dataflow.yml
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
# --locked fails if uv.lock is out of date with pyproject.toml, so a
# dependency change that was never locked cannot reach main.
- name: Install
run: uv sync --locked --group dev
- run: uv run ruff check .
- run: uv run pytest -q
markdownlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: DavidAnson/markdownlint-cli2-action@v16
with:
globs: "*.md"
-22
View File
@@ -1,22 +0,0 @@
name: markdownlint
on:
push:
paths:
- "**.md"
- ".markdownlint.json"
pull_request:
paths:
- "**.md"
- ".markdownlint.json"
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm install -g markdownlint-cli2
- run: markdownlint-cli2 "**/*.md"
+1
View File
@@ -11,3 +11,4 @@ build/
.coverage .coverage
.ruff_cache/ .ruff_cache/
out/ out/
.dora/
+171 -28
View File
@@ -4,56 +4,199 @@ This file provides guidance to AI coding assistants when working with code in th
## What this is ## What this is
A dora-rs Python node that interfaces with a UFactory Lite6 robot arm. On each tick it reads joint angles A dora-rs Python node that drives a UFactory Lite6 arm. Every operation is a **service**
and TCP pose from the robot and publishes them as dora outputs. It also accepts JSON commands — one request, one response — and the arm's joint angles, tool pose and connection state
(home, move_to, move_to_pose, vacuum_on/off) via a `command` input. A FastAPI REST server with a web UI are published as ordinary topics.
runs in a background thread for manual control. The robot control wrapper lives in `dora_ulite6/main.py`
as the `ULite6Helper` class, and the dora event loop is the `main()` function in the same file. There is deliberately **no action pattern** here: no goals, no feedback, no cancel. A
motion is a service that takes a `wait` flag, and its lifecycle is published as **the
arm's state** (`motion_state`) rather than as a per-goal stream. That is the right model
because the arm is one resource: there is never more than one motion, so a new one
preempts the old, and a state describes that exactly where an action pattern would be
modelling concurrency that cannot physically exist. Do not reintroduce
goal/feedback/result.
The service pattern is **dora's own**, not a ROS2 port. The correlation key `request_id`
is defined in `dora-message`, and dora's runtime treats a port carrying it as
pattern-correlated: several Arrow schemas may legitimately flow through it, one per
service, and runtime type checks are skipped for those messages. That is why `request`
and `response` in `dora-node.yml` are deliberately untyped, and annotating them would be
a lie.
dora's `ros2:` service bridge is one consumer of the same convention, so a ROS2 front end
is a dataflow change rather than a code change. Do not add a ROS2 dependency here.
It served a `command` JSON input and a `status` output through `v0.2.0`, and ran a
FastAPI REST server with a web UI through `v0.1.2`. Both remain reachable at those tags.
The driver layer is ported from the
[littlehand](https://gitea.liria.cl/Littlehand/monolitic-littlehand-python) robot layer
(`littlehand/robot/`), which was written against this same controller. Fixes made there
belong here, and vice versa.
## Build ## Build
```bash ```bash
uv pip install -e . uv sync # runtime deps
uv sync --group dev # plus pytest and ruff
``` ```
Requirements: Python >=3.8, uv, and network access to the UFactory Lite6 robot. `dora build --uv` / `dora run --uv` rewrites the manifest's `build: pip install .` to
`uv pip install .` and gives the node its own managed venv under
`.dora/python-envs/<node-id>/`, so what it imports at runtime is what was installed at
build time.
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and
network access to the arm's controller.
## Run ## Run
```bash ```bash
dora up dora run dataflow.yml --uv
dora start dataflow.yml
``` ```
Robot behavior is configured through environment variables (see README.md for the full list: Everything is configured through environment variables — see README.md for the full list.
ROBOT_IP, DEFAULT_SPEED, DEFAULT_UNITS, API_HOST, API_PORT, VACUUM_ENABLED, INIT_MODE, etc.).
## Test
```bash
uv run pytest # 86 tests, no robot and no dataflow needed
uv run ruff check .
```
## Architecture ## Architecture
- **`dora_ulite6/main.py`** -- Single-file implementation containing: Two things carry the design. **Who may touch the SDK**, and **why the arm gets threads of
- `ULite6Helper`: Wraps the xArm Python SDK for motion control (go_home, move_to_pose, its own**.
get_current_position, get_joint_angles_deg, get_pose_matrix, vacuum gripper, status, reset).
- `MoveToRequest` / `MoveToPoseRequest`: Pydantic models for API validation. The xArm SDK blocks: a move waits out the motion, and a read from a controller whose
- `create_api()`: FastAPI app factory with REST endpoints and embedded HTML/JS web UI for manual control. cable was pulled blocks until its own socket timeout. One thread would therefore have to
- `main()`: Dora event loop. Starts FastAPI in a daemon thread, then processes `tick` (publishes choose between carrying out a motion and reporting where the arm is, and since a motion
joint_pose and tcp_pose) and `command` (executes robot actions and publishes status) inputs. service does not answer until it finishes, telemetry is the only thing a caller can watch
- **`dora_ulite6/__init__.py`** -- Package marker. while it runs. So:
- **`pyproject.toml`** -- Package metadata, dependencies, and entry point.
- **`dataflow.yml`** -- Example dora dataflow using the git: source format. - **`dora_ulite6/main.py`** — The dora event loop, and nothing else. Routes `request`,
`jog` and `tick`; reads the `request_id` out of the event metadata; publishes what the
arm threads produced. **It never calls anything that can block**, `get_state` included —
that one is allowed here only because it reads the report cache. A request it cannot
parse is refused here, under its own id: a caller waiting on a request_id must get
exactly one response, never silence. It builds the `Node` before starting the arm
threads, so `INIT_MODE` cannot move a robot before this process has joined the
dataflow, and so the arm can be told whether this is a restart.
- **`dora_ulite6/control.py`** — `Control`: both arm threads and the mailboxes the loop
drains. The **control thread** runs one job at a time and is allowed to block: startup,
the `INIT_MODE` move, and every service including the motions that take seconds. Its
wait between connection attempts serves the queue (`_serve_while_down`) rather than
sleeping through it: a request is answered exactly once whatever the arm is doing, and
an arm that is down is an outcome to report, not a reason to go quiet. A motion is
refused there rather than held, since a held one would fire unattended the moment the
link came back. The
**reporter thread** never blocks: it reads the report cache at the telemetry cadence,
so `tcp_pose` keeps flowing right through a move. **Nothing outside this file may call
the driver**, except `stop` and `read_report`, both written for another thread. Same
lesson, same shape as `zed::Capture` in the ZED node.
The **stopper thread** exists because a stop fits nowhere else: it must not queue
behind the motion it aborts, and it must not run on the event loop either, since the
SDK's `emergency_stop` re-commands the controller for up to three seconds. Requests to
it coalesce.
`start_motion` and `start_jog` are the two preemption doors, both over
`_preempt_motions`: mark the running motion and any still waiting, ask the stopper to
halt the arm, publish `accepted`, and acknowledge the caller when `wait` is false.
`_stopped_for_preempt` is only ever *set* there and cleared by the motion that consumes
it — preempting a motion that had not started stops nothing, and must not swallow a
clear owed by an earlier preemption that did.
`shutdown` halts the arm **first** and then joins, returning whether the control thread
really came back. A motion lives in the controller, not in this process.
- **`dora_ulite6/driver.py`** — `ULite6`: the arm over the vendor SDK. Ported from
littlehand's `UFactoryRobot`. Its whole point is that a command either moved the arm or
raised: the SDK returns a code for a motion the controller quietly refused, `wait_move`
calls that refusal finished, and `arm.error_code` is the report stream's stale copy — so
errors are read live (`_live_error_code`), arrival is confirmed (`_check_arrived`) and a
move is refused before it is commanded if the straight-line path leaves the joint
envelope (`_check_path_reachable`). `read_report` is the deliberate exception: a cache
read, no round trip, the only read that works while a motion blocks.
- **`dora_ulite6/services.py`** — Every operation the node serves, and the two sets that
matter. `MOVES_THE_ARM` is what preempts, is preemptable, and reports `moving`.
`IMMEDIATE` is served by the event loop rather than the control thread, so it answers
*during* a motion — adding to it is a promise that the service never blocks and is safe
off the control thread, which is why `get_state` reads the report cache rather than the
wire.
- **`dora_ulite6/payload.py`** — Reading arguments out of the one-row Arrow struct a
request arrives as. Assumes no fixed schema, because there isn't one. Arguments are
read before the arm is touched, so a malformed request never half-moves it.
- **`dora_ulite6/errors.py`** — What the driver raises. The distinctions decide the
response: `UnreachablePoseError` is not a `RobotUnavailableError` because the arm is fine
and the target is not, and `ArmNeedsHomingError` is separate because nothing but a
motion of ours clears it and nobody should drive an arm home without warning the room.
- **`dora_ulite6/effectors.py`** — What is bolted to the flange: which driver method
switches it and how far past the flange its tip sits. A mismatch is refused when the arm
is built, not mid-motion.
- **`dora_ulite6/pose.py`** — `RobotPose` and `with_tool_offset`, which slides a pose along
its own Z axis. The offset inverts exactly, which is what lets goals and telemetry share
a frame.
- **`dora_ulite6/controller.py`** — Facts about the xArm controller: its modes, the codes
that mean the command never ran, and the Euler maths. Nothing tunable.
- **`dora_ulite6/config.py`** — Every environment variable, parsed once at startup. An
unusable `TOOL_TYPE` or `INIT_MODE` raises before the node joins the dataflow.
- **`dora-node.yml`** — Node manifest (`apiVersion: 1`). `dora validate` injects these
types into any dataflow that reaches the node by `path:`; a `git:`-sourced node does
**not** pick it up, which is why `dataflow.yml` builds from the checkout.
- **`examples/caller.py`** and **`examples/dora-node.yml`** — The convention in one file:
one instant request, one that drives the arm, and `tcp_pose` arriving while the latter
is still open.
The manifest next to it exists because dora resolves a node's manifest by walking up
from its path: without one, the caller would be matched against the arm node's manifest
and `dora validate --strict` would fail on the entrypoint mismatch.
- **`.gitea/workflows/validate.yml`** — `dora validate`, ruff, pytest and markdownlint.
None of them need a robot.
## Two manifest rules worth knowing
A manifest applies to a node iff `<manifest dir>/<entrypoint>` names the same file as
`<working dir>/<path>`. That is why `examples/dora-node.yml` says `entrypoint: caller.py`
and not `examples/caller.py`.
`dora validate --strict` injects a contract type for *every* typed manifest input and then
warns `input_types key "..." not found in inputs list` for any the dataflow does not wire.
A typed optional input therefore forces the example dataflow to wire it. `request` and
`response` dodge this only because they are untyped for the reason given above.
## Key APIs ## Key APIs
- **dora Python API** (`dora`): `Node()`, `node.send_output()`, event iteration via `for event in node`. - **dora Python API** (`dora`): `Node(name)`, `node.send_output(id, data, metadata)`,
- **xArm SDK** (`xarm.wrapper.XArmAPI`): `connect()`, `motion_enable()`, `set_mode()`, `set_state()`, event iteration via `for event in node`. The `request_id` rides in `event["metadata"]`
`set_position()`, `move_gohome()`, `get_position()`, `get_servo_angle()`, `set_vacuum_gripper()`. and in the `metadata` dict passed to `send_output`.
- Output data is sent as PyArrow arrays with metadata dicts. - **xArm SDK** (`xarm.wrapper.XArmAPI`): `connect()`, `motion_enable()`, `set_mode()`,
`set_state()`, `set_position()`, `move_gohome()`, `get_position()`, `get_servo_angle()`,
`get_err_warn_code()`, `get_inverse_kinematics()`, `is_joint_limit()`,
`set_vacuum_gripper()`, `open_lite6_gripper()`, `vc_set_cartesian_velocity()`, and the
cached `position` / `angles` properties the reporter thread reads.
## Tools ## Tools
- **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage issues, - **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage
pull requests, and other repository operations. issues, pull requests, and other repository operations.
## Rules ## Rules
- Do not mention AI tools in commit messages or documentation. - Do not mention AI tools in commit messages or documentation.
- This controls a real robot. Test changes carefully and preserve safety checks. - This controls a real robot. Test changes carefully and preserve the safety checks: the
reachability check before a move, the arrival confirmation after it, and the live error
reads that make both of them mean anything.
- `_stopping` is set to ask the threads to finish, and it is what they sleep on. Never
invert it back to a flag that is set *while running*: `Event.wait(timeout)` returns at
once on an event that is already set, which turns every cadence in this file into a
busy loop. That bug shipped once and cost a core and 1.4 million arm reads a second,
while behaving correctly in every visible way. `test_the_reporter_honours_the_telemetry_interval`
is there to catch it coming back.
- Every request gets exactly one `response`, under its own `request_id`. A path that can
drop one, or send two, is a bug however unlikely it looks — a caller has no other way
to know its request finished. A `wait: false` motion spends its response on the
acknowledgement, so its outcome goes on `motion_state` and nowhere else.
- `motion_state` is keyed by `request_id` and publishes `accepted` before the control
thread reaches the motion. Both exist to close one race: a caller polling a bare
moving/idle flag right after its request would read "not moving" and conclude the
motion had finished. Do not reduce it to a two-valued flag.
+250 -87
View File
@@ -1,117 +1,280 @@
# Dora ULite6 Node # dora-ulite6
Dora node for controlling a UFactory Lite6 robot via REST API, web UI, and publishing joint/TCP state. Dora node for a UFactory Lite6 arm. Every operation is a **service** — dora's own
request/response pattern — and the arm's joint angles, tool pose and connection state
are published as ordinary topics.
## Installation One request, one response, correlated by the `request_id` you put in the message
metadata. That is the node's whole control surface: there is no second protocol for the
slow operations. A motion takes a `wait` flag — hold me until the arm stops, or answer me
now and I will follow it on `motion_state`.
```bash Correlation lives in the message metadata because that is dora's convention, not a ROS2
uv pip install -e . transliteration: `request_id` is defined in dora's own message crate, and the runtime
treats a port carrying it as one where several Arrow schemas legitimately flow — one per
service.
The arm runs on threads of its own, so a move that takes ten seconds — or a controller
whose cable was pulled — never stalls the dora event loop, and `tcp_pose` keeps arriving
throughout.
The driver is ported from the
[littlehand](https://gitea.liria.cl/Littlehand/monolitic-littlehand-python) robot layer,
which was written against this same controller: typed failures, a reachability check
before a move is commanded, and arrival confirmed rather than assumed.
## The services
| Service | Arguments | Moves the arm | Description |
| ------- | --------- | ------------- | ----------- |
| `move_to` | `x`, `y`, `z`, optional `roll`, `pitch`, `yaw`, `speed`, `wait` | yes | Move the tool tip to a base-frame pose. Orientation defaults to tool-down (`180, 0, 0`). Refused before anything moves if the straight-line path leaves the joint envelope. |
| `move_joints` | `angles` (list, degrees), optional `speed`, `wait` | yes | Move the joints directly. |
| `home` | optional `wait` | yes | Joint-space home. |
| `home_and_clear` | optional `wait` | yes | Clear a latched error, then drive home. The answer to a joint-limit error, and only to be sent once the area is clear. |
| `stop` | — | no | Halt the arm. Served by a thread of its own, so it neither queues behind the motion it aborts nor holds up the dataflow; the response says the stop was commanded, and whether it took shows on `motion_state`. |
| `clear` | — | no | Clear a latched controller error. Never moves the arm. |
| `recover` | — | no | Reconnect if needed, clear, re-enable motion. Never moves the arm. |
| `freedrive` | `enabled` (bool, default `true`) | no | Enter or leave manual teach mode. |
| `grasp` | — | no | Take hold with whatever `TOOL_TYPE` names. |
| `release` | — | no | Let go. |
| `get_state` | — | no | Adds `connected`, `ready`, `tool_engaged`, `freedrive`, `pose`, `joints` to the response. Served on the event loop and read from the controller's report cache, so it answers *during* a motion. |
The four that move the arm report `moving` on `robot_state` while they run, and so does
a jog — an arm that is travelling never looks idle. `stop` interrupts whatever is moving;
the interrupted request comes back `ok: false` saying it was aborted.
## Calling a service
Send a one-row struct on `request`, with your own id in the metadata:
```python
node.send_output(
"request",
pa.array([{"service": "move_to", "x": 250.0, "y": 0.0, "z": 200.0, "speed": 40.0}]),
metadata={"request_id": "move-1"},
)
``` ```
## Dataflow The answer arrives on `response` under the same `request_id`:
```python
{"ok": True, "service": "move_to", "message": "the tool is at (250, 0, 200 mm)"}
```
Exactly one response per request, always — including for a request that was refused
before the arm was touched. Because the answer is matched by the id in the metadata and
not by arrival order, several requests can be in flight at once.
Poses are the **tool tip**, the same frame `tcp_pose` reports, so the arm can be sent
back to a pose that was read off the dataflow. The tool's reach is taken back off before
the flange is commanded.
## Blocking and non-blocking motions
`wait` decides only *when* the one response is sent.
```python
# wait: true (the default) — the response arrives when the arm has stopped.
{"service": "move_to", "x": 250.0, "y": 0.0, "z": 200.0}
# wait: false — the response arrives at once, as an acknowledgement.
{"service": "move_to", "x": 250.0, "y": 0.0, "z": 200.0, "wait": False}
```
Either way it is **one response per request**. With `wait: false` that response is the
acknowledgement, and how the motion actually ended is reported on `motion_state` — never
as a second response, which would break the id a caller correlates by.
### Following a motion: `motion_state`
Published on every change, as `{request_id, service, state, message}`:
| State | Meaning |
| ----- | ------- |
| `accepted` | Taken, not started. |
| `moving` | The arm is being driven. |
| `done` | Finished; the arm arrived. |
| `failed` | The arm refused it, or never got there. |
| `preempted` | A newer motion took its place. |
`accepted` is not decoration. Without it, a caller that sends a motion and then polls a
two-valued `moving`/`idle` state reads "not moving" in the window before the control
thread picks the request up, and concludes the motion it just asked for had already
finished. Keying the state by `request_id` and publishing `accepted` closes that window:
the caller can always tell *"mine has not started"* from *"mine has finished"*.
The `request_id` is in the fields rather than the metadata, because this is a broadcast of
the arm's state rather than an answer to a request — anyone may read it, and
`get_state` sees the same value.
`tcp_pose` also keeps arriving throughout, at `TELEMETRY_INTERVAL_MS`: the thread that
publishes it is not the thread inside the motion.
### A new motion preempts the one in play
The arm is one resource — there is never more than one motion. A new motion cuts the
running one short (through the same stop a caller could send) and takes its place; a
motion still waiting its turn is dropped without ever moving anything. Each one is still
answered exactly once: by its response if a caller is still waiting, and on
`motion_state` as `preempted` either way.
So a sequence of positions is driven by the caller, one at a time — send the next when
you see `done`. Sending them all at once does not queue them; the last one wins.
## Jogging
`jog` is a topic, not a service: a velocity command expires on the controller's own TTL,
so a sender keeps sending and nobody waits for an answer.
```python
node.send_output("jog", pa.array([{"velocity": [20.0, 0, 0, 0, 0, 0]}]))
```
It still drives the arm, so it behaves like a motion where that matters: the first
non-zero jog preempts a motion in play and `robot_state` turns to `moving`. A zero
velocity stops it, and so does simply going quiet — the node returns to `ready` once
`VELOCITY_COMMAND_TTL_S` has passed, because that is when the controller has expired the
command.
## Telemetry
| Output | Type | Description |
| ------ | ---- | ----------- |
| `joint_pose` | `Float32` | The six joint angles in degrees, base to wrist. |
| `tcp_pose` | `Float32` | `[x, y, z, roll, pitch, yaw]` in mm and degrees: the tool tip in the base frame. |
| `robot_state` | `String` | `connecting`, `ready`, `moving`, `recovering` or `unavailable`, on every change. |
| `motion_state` | struct | Where the current motion stands — see above. |
`tick` sets how often the newest reading is *published*; `TELEMETRY_INTERVAL_MS` sets how
often the arm is *read*. A tick with nothing new publishes nothing rather than repeating
a stale pose.
## Usage
Add to your dataflow:
```yaml ```yaml
nodes: nodes:
- id: ulite6 - id: ulite6
git: ssh://git@gitea.liria.cl:5001/liria/dora-ulite6.git git: ssh://git@gitea.liria.cl:5001/liria/dora-ulite6.git
git_checkout: v0.1.0 tag: v0.3.0
build: uv pip install -e . build: pip install .
path: dora_ulite6/main.py path: dora-ulite6
inputs: inputs:
tick: dora/timer/millis/10 tick: dora/timer/millis/100
outputs: [status, joint_pose, tcp_pose] request: planner/request
inputs: [command] outputs:
- response
- motion_state
- joint_pose
- tcp_pose
- robot_state
env: env:
ROBOT_IP: "192.168.1.192" ROBOT_IP: "192.168.1.192"
DEFAULT_SPEED: "30" TOOL_TYPE: "vacuum"
DEFAULT_UNITS: "mm"
API_HOST: "0.0.0.0"
API_PORT: "8080"
VACUUM_ENABLED: "false"
``` ```
Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own
managed environment.
### Runnable example
[`dataflow.yml`](dataflow.yml) builds the node from this checkout and wires
[`examples/caller.py`](examples/caller.py), which sends a non-blocking motion, follows it
on `motion_state`, and then preempts it with a second one:
```bash
dora run dataflow.yml --uv
```
Building from the checkout (`path:` + `build:`) rather than from a tag is what lets
`dora validate` pick up the manifest in [`dora-node.yml`](dora-node.yml).
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and
network access to the arm's controller.
## Lifecycle
The node connects to the dataflow **before** it touches the arm, so a startup that never
joins cannot have moved a robot first.
**On a restart** (`restart_policy` in your dataflow), `INIT_MODE` is skipped: the arm is
left wherever it was. Driving it home because the process came back is a motion nobody
asked for.
**On shutdown**, the arm is halted before the node exits. A motion lives in the
controller, not in this process — dropping the connection does not abort it — so exiting
without commanding a stop would leave an arm travelling with nobody left to stop it. If
the control thread is still inside a blocking SDK call afterwards, the node leaves
without it rather than hanging until dora loses patience.
## Configuration ## Configuration
| Env Variable | Default | Description | | Env Variable | Default | Description |
| ------------ | ------- | ----------- | | ------------ | ------- | ----------- |
| `ROBOT_IP` | `192.168.1.192` | Robot IP address | | `ROBOT_IP` | `192.168.1.192` | Controller address |
| `DEFAULT_SPEED` | `30` | Movement speed (mm/s) | | `TOOL_TYPE` | `none` | What is mounted: `none`, `vacuum`, `gripper` |
| `DEFAULT_UNITS` | `mm` | Position units (mm or m) | | `TOOL_OFFSET_MM` | `0.0` | Extra reach past the flange, on top of the tool's own |
| `API_HOST` | `0.0.0.0` | API server host | | `DEFAULT_SPEED` | `30` | Cartesian speed (mm/s) for a `move_to` with no speed |
| `API_PORT` | `8080` | API server port | | `DEFAULT_JOINT_SPEED` | `20` | Joint speed (deg/s) for a `move_joints` with no speed |
| `VACUUM_ENABLED` | `false` | Enable vacuum gripper controls | | `TELEMETRY_INTERVAL_MS` | `100` | How often the arm is read, and how often telemetry is produced |
| `TOOL_TYPE` | `none` | Mounted tool: `none`, `vacuum`, `gripper` | | `RECONNECT_INTERVAL_S` | `5.0` | How long to wait before retrying an unreachable arm |
| `TOOL_OFFSET_MM` | `0.0` | Additional Z-offset in mm added on top of the tool preset | | `INIT_MODE` | `none` | Startup positioning: `none`, `home`, `pose` |
| `INIT_MODE` | `none` | Startup positioning: `home`, `pose`, or `none` | | `INIT_X/Y/Z` | `300/0/250` | Startup pose position (mm, tool tip) |
| `INIT_X/Y/Z` | `300/0/250` | Initial pose coordinates (mm) | | `INIT_ROLL/PITCH/YAW` | `180/0/0` | Startup pose orientation (deg) |
| `INIT_ROLL/PITCH/YAW` | `180/0/0` | Initial pose orientation (deg) | | `INIT_SPEED` | `50` | Startup move speed (mm/s) |
| `INIT_SPEED` | `50` | Startup movement speed (mm/s) |
## Web UI The motion limits the driver enforces are tunable too, and the defaults are the ones
littlehand runs with. The arrival tolerances are wide on purpose: whether a motion
happened is decided by the controller's error, and these only catch an arm that never
left where it stood.
Access at `http://localhost:8080/` | Env Variable | Default | Description |
| ------------ | ------- | ----------- |
| `ARRIVAL_TOLERANCE_MM` | `25.0` | How far off the target still counts as arrived |
| `ARRIVAL_TOLERANCE_DEG` | `15.0` | The same for orientation, and for homing |
| `ARRIVAL_TIMEOUT_S` | `2.0` | How long to keep asking before calling a move interrupted |
| `JOINT_LIMIT_MARGIN_DEG` | `1.0` | How clear of its bound a joint must stay for a pose to be commanded |
| `PATH_SAMPLES` | `4` | How many points along the straight line are checked for reachability |
| `READY_TIMEOUT_S` | `3.0` | How long `recover` waits for a movable state |
| `HOME_TIMEOUT_S` | `20.0` | How long homing may take |
| `TOOL_SETTLE_S` | `2.0` | How long to wait for suction to build or fingers to close |
| `VELOCITY_COMMAND_TTL_S` | `0.5` | The controller's own expiry for one `jog` command |
- Live status, TCP pose, and joint angles ## Development
- Home and Reset buttons
- Move to position form
- Vacuum gripper controls (when enabled)
## REST API
Interactive docs at `http://localhost:8080/docs`
| Method | Endpoint | Description |
| ------ | -------- | ----------- |
| GET | `/` | Web control interface |
| GET | `/api/status` | Robot status (connected, errors) |
| GET | `/api/pose` | Current TCP pose |
| GET | `/api/joints` | Current joint angles |
| GET | `/api/config` | API configuration |
| POST | `/api/home` | Go to home position |
| POST | `/api/reset` | Clear errors and reset state |
| POST | `/api/move_to` | Move to position |
| POST | `/api/move_to_pose` | Move to full pose |
| POST | `/api/disconnect` | Disconnect from robot |
### Vacuum Gripper (when `VACUUM_ENABLED=true`)
| Method | Endpoint | Description |
| ------ | -------- | ----------- |
| GET | `/api/vacuum` | Get vacuum status |
| POST | `/api/vacuum/on` | Turn vacuum on |
| POST | `/api/vacuum/off` | Turn vacuum off |
## CLI Examples
```bash ```bash
# Get pose uv sync --group dev
curl http://localhost:8080/api/pose uv run pytest # 86 tests, no robot needed
uv run ruff check .
# Go home
curl -X POST http://localhost:8080/api/home
# Move to position
curl -X POST http://localhost:8080/api/move_to \
-H "Content-Type: application/json" \
-d '{"x": 200, "y": 0, "z": 300}'
# Move with orientation
curl -X POST http://localhost:8080/api/move_to_pose \
-H "Content-Type: application/json" \
-d '{"x": 200, "y": 0, "z": 300, "roll": 180, "pitch": 0, "yaw": 0}'
``` ```
## Dora Outputs ## Bridging to ROS2
- `status` - JSON: `{ok, action, message, timestamp_ns}` Nothing here depends on ROS2, and no ROS2 message definition is shipped. It is worth
- `joint_pose` - 6 joint angles in degrees knowing anyway that dora's `ros2:` service bridge speaks exactly this convention — it
- `tcp_pose` - `[x, y, z, roll, pitch, yaw]` in mm/deg — reflects tool tip when `TOOL_TYPE` is set emits `request` and takes a response under the same `request_id`. So exposing these
services to a ROS2 graph later is a matter of adding bridge nodes to a dataflow and
mapping the struct fields, with no change to this code.
## Dora Inputs ## Migrating from v0.2.x
- `command` - JSON string: `{id, action, payload}` - **The `command` input and the `status` output are gone**, replaced by `request` and
- `id`: unique command id (required) `response`. A command's `{id, action, payload}` JSON becomes a service request, and the
- `action`: `home`, `move_to`, or `move_to_pose` id moves from the payload into the message metadata, where dora's own pattern puts it.
- `payload`: fields for move (`x`, `y`, `z`, `roll`, `pitch`, `yaw`, `speed`) - **Payloads are Arrow structs, not JSON strings.** `pa.array([{...}])` instead of
`json.dumps`.
- **`action` is now `service`**, and `vacuum_on`/`vacuum_off` are `grasp`/`release`.
- **`jog` is its own input** rather than a command.
- **There is no `started` message.** A request produces exactly one response. Follow a
motion on `motion_state`, which reports `accepted`, `moving` and then how it ended.
- **Telemetry no longer stops while the arm is moving**, thanks to the reporter thread.
- **`stop` answers "commanded" rather than "done."** It is handed to a thread of its own
instead of being run inline, because the SDK spends up to three seconds confirming a
stop and a dataflow router cannot go away that long.
`status` will include `command_id` for command responses. ## Migrating from v0.1.x
See the v0.2.0 notes in the git history for the removal of the FastAPI REST server and
web UI (still reachable at tag `v0.1.2`), the `DEFAULT_UNITS` removal, and the move to
tool-tip poses.
+28 -10
View File
@@ -1,19 +1,37 @@
# The repository's own example: builds and runs the node from this checkout, so
# `dora validate` picks up the typed contract in dora-node.yml next to it.
# A consumer wires the node from a tag instead — see README.md.
#
# Run it with `--uv` so every Python node gets its own managed environment.
#
# `examples/caller.py` shows the whole convention: a non-blocking motion
# answered at once, its progress followed on `motion_state`, and a second motion
# preempting the first.
strict_types: true
nodes: nodes:
- id: ulite6 - id: ulite6
build: uv pip install -e . path: dora-ulite6
path: dora_ulite6/main.py build: pip install .
inputs: inputs:
tick: dora/timer/millis/10 tick: dora/timer/millis/100
request: caller/request
outputs: outputs:
- status - response
- motion_state
- joint_pose - joint_pose
- tcp_pose - tcp_pose
- robot_state
env: env:
ROBOT_IP: "192.168.1.192" ROBOT_IP: "192.168.1.192"
DEFAULT_SPEED: "30"
DEFAULT_UNITS: "mm"
API_HOST: "0.0.0.0"
API_PORT: "9000"
VACUUM_ENABLED: "true"
TOOL_TYPE: "vacuum" TOOL_TYPE: "vacuum"
TOOL_OFFSET_MM: "0.0" INIT_MODE: "none"
- id: caller
path: examples/caller.py
inputs:
tick: dora/timer/millis/500
response: ulite6/response
motion_state: ulite6/motion_state
outputs:
- request
+128
View File
@@ -0,0 +1,128 @@
# Node manifest: the typed contract a consumer wires against.
#
# No source package is shared between node repositories, so this file and its
# type URNs are the only contract a consumer has. `dora validate` checks a
# dataflow against it without running the node, so CI needs no robot.
#
# The `request` and `response` ports are deliberately **untyped**. They carry
# dora's pattern-correlation metadata (`request_id`), and dora defines that as
# the marker for a port where several Arrow schemas legitimately flow — one per
# service — distinguished by metadata rather than by a fixed Arrow type. The
# runtime skips type checks on such messages, so annotating them would claim a
# contract neither side keeps.
apiVersion: 1
name: dora-ulite6
namespace: liria
description: UFactory Lite6 arm node serving motion and control services, and publishing joint and tool pose
categories: [robot, actuator]
keywords: [ufactory, lite6, xarm, robot-arm, manipulator, service, vacuum, gripper]
runtime: python
entrypoint: dora-ulite6
# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and
# gives the node its own managed venv under `.dora/python-envs/`, so the
# dependencies it sees at runtime are the ones installed here.
build: pip install .
dora: ">=1.0"
inputs:
tick:
type: std/core/v1/UInt64
required: true
description: >-
Each tick publishes whatever the arm threads produced since the last one.
It does not drive the arm — the tick rate is the publication rate, and
TELEMETRY_INTERVAL_MS is how often the arm is actually read. Connect to
dora/timer/millis/N.
request:
required: true
description: >-
A service request: a one-row struct {service, ...arguments}, with the
caller's `request_id` in the metadata. Services: move_to, move_joints,
home, home_and_clear, clear, recover, stop, freedrive, grasp, release,
get_state. Answered on `response` under the same request_id, exactly once.
A motion takes `wait`: true (the default) holds the caller until the arm
stops; false answers at once with an acknowledgement and reports the rest
on `motion_state`.
jog:
required: false
description: >-
A continuous velocity command: a one-row struct {velocity} of 6 numbers
[vx, vy, vz, wx, wy, wz] in mm/s and deg/s. A topic rather than a service
because it expires on the controller's own TTL and a sender must keep
sending; nothing is published in answer. It drives the arm, so the first
non-zero one preempts a motion in play and robot_state turns to moving.
outputs:
response:
description: >-
The answer to one service request, under its `request_id`: a struct
{ok, service, message, ...extras}. get_state adds connected, ready,
tool_engaged, freedrive, pose and joints. Exactly one response per
request, including for one that was refused or preempted.
motion_state:
description: >-
Where the current motion stands, published on every change: a struct
{request_id, service, state, message} with state one of accepted, moving,
done, failed, preempted. The request_id is in the fields rather than the
metadata because this is the arm's state, not an answer to a request. A
new motion preempts the one in play, so there is only ever one.
joint_pose:
type: std/core/v1/Float32
description: The six joint angles in degrees, base to wrist.
tcp_pose:
type: std/core/v1/Float32
description: >-
The mounted tool's tip as [x, y, z, roll, pitch, yaw] in mm and degrees,
in the robot base frame. With TOOL_TYPE none this is the flange itself.
robot_state:
type: std/core/v1/String
description: >-
One of connecting, ready, moving, recovering, unavailable. Published on
every change, never repeated.
env:
ROBOT_IP:
type: string
description: Controller address. Defaults to 192.168.1.192.
TOOL_TYPE:
type: string
description: What is mounted at the flange - none, vacuum or gripper. Defaults to none.
TOOL_OFFSET_MM:
type: float
description: Extra reach past the flange, added to the mounted tool's own. Defaults to 0.
DEFAULT_SPEED:
type: float
description: Cartesian speed in mm/s for a move_to with no speed of its own. Defaults to 30.
DEFAULT_JOINT_SPEED:
type: float
description: Joint speed in deg/s for a move_joints with no speed of its own. Defaults to 20.
TELEMETRY_INTERVAL_MS:
type: int
description: >-
How often the reporter reads the arm, and so how often telemetry is
produced. Keeps running through a motion. Defaults to 100.
RECONNECT_INTERVAL_S:
type: float
description: How long to wait before trying an unreachable arm again. Defaults to 5.
INIT_MODE:
type: string
description: Where the arm goes on startup - none, home or pose. Defaults to none.
example: |
- id: ulite6
git: ssh://git@gitea.liria.cl:5001/liria/dora-ulite6.git
tag: v0.3.0
build: pip install .
path: dora-ulite6
inputs:
tick: dora/timer/millis/100
request: planner/request
outputs:
- response
- joint_pose
- tcp_pose
- robot_state
env:
ROBOT_IP: "192.168.1.192"
TOOL_TYPE: "vacuum"
+1 -1
View File
@@ -1 +1 @@
"""Dora ULite6 node package.""" """Dora node for a UFactory Lite6 arm: telemetry out, commands in."""
+130
View File
@@ -0,0 +1,130 @@
"""Everything the node reads from the environment, parsed once at startup.
Parsing happens here rather than where each value is used, so an unusable
setting stops the node before it joins the dataflow instead of surfacing on the
first motion — the same reason the ZED node parses its depth mode up front.
"""
import os
from dataclasses import dataclass, field
from dora_ulite6.effectors import EndEffector, get_effector
from dora_ulite6.pose import RobotPose
INIT_MODES = ("none", "home", "pose")
def env_str(name: str, default: str) -> str:
"""Return the variable, or ``default`` when it is unset."""
return os.getenv(name, default)
def env_float(name: str, default: float) -> float:
"""Return the variable as a float, naming it if it is not one."""
value = os.getenv(name)
if value is None or not value.strip():
return default
try:
return float(value)
except ValueError:
raise ValueError(f"{name} must be a number, not {value!r}") from None
def env_int(name: str, default: int) -> int:
"""Return the variable as an integer, naming it if it is not one."""
value = os.getenv(name)
if value is None or not value.strip():
return default
try:
return int(value)
except ValueError:
raise ValueError(f"{name} must be an integer, not {value!r}") from None
@dataclass
class MotionConfig:
"""Driver-level motion limits and timeouts.
The arrival tolerances are wide on purpose: whether a motion happened is
decided by the controller's error, and these only catch an arm that never
left where it stood. Tightening them starts refusing moves that worked.
"""
arrival_tolerance_mm: float = 25.0
arrival_tolerance_deg: float = 15.0
arrival_timeout_s: float = 2.0
# How clear of its own bound a joint solution must stay for a pose to be
# commanded, and how many points along the straight line are checked.
joint_limit_margin_deg: float = 1.0
path_samples: int = 4
ready_timeout_s: float = 3.0
home_timeout_s: float = 20.0
tool_settle_s: float = 2.0
# The controller's own expiry for one jog command: the arm keeps moving at
# the commanded velocity until this runs out, so a sender that stops sending
# stops the arm.
velocity_command_ttl_s: float = 0.5
@classmethod
def from_env(cls) -> "MotionConfig":
"""Read the motion limits and timeouts from the environment."""
return cls(
arrival_tolerance_mm=env_float("ARRIVAL_TOLERANCE_MM", 25.0),
arrival_tolerance_deg=env_float("ARRIVAL_TOLERANCE_DEG", 15.0),
arrival_timeout_s=env_float("ARRIVAL_TIMEOUT_S", 2.0),
joint_limit_margin_deg=env_float("JOINT_LIMIT_MARGIN_DEG", 1.0),
path_samples=env_int("PATH_SAMPLES", 4),
ready_timeout_s=env_float("READY_TIMEOUT_S", 3.0),
home_timeout_s=env_float("HOME_TIMEOUT_S", 20.0),
tool_settle_s=env_float("TOOL_SETTLE_S", 2.0),
velocity_command_ttl_s=env_float("VELOCITY_COMMAND_TTL_S", 0.5),
)
@dataclass
class Config:
"""The node's settings: where the arm is, what it carries, and how it starts."""
robot_ip: str = "192.168.1.192"
effector: EndEffector = field(default_factory=lambda: get_effector("none"))
# The mounted tool's own reach past the flange, plus TOOL_OFFSET_MM.
tool_offset_mm: float = 0.0
default_speed: float = 30.0
default_joint_speed: float = 20.0
# How often the control thread re-reads the arm. The dataflow tick only
# decides how often the newest reading is published.
telemetry_interval_s: float = 0.1
# How long to wait before trying an unreachable arm again.
reconnect_interval_s: float = 5.0
init_mode: str = "none"
init_pose: RobotPose = field(default_factory=lambda: RobotPose(300.0, 0.0, 250.0))
init_speed: float = 50.0
motion: MotionConfig = field(default_factory=MotionConfig)
@classmethod
def from_env(cls) -> "Config":
"""Read every setting from the environment, refusing one the node cannot use."""
effector = get_effector(env_str("TOOL_TYPE", "none").lower())
init_mode = env_str("INIT_MODE", "none").lower()
if init_mode not in INIT_MODES:
raise ValueError(f"INIT_MODE must be one of {', '.join(INIT_MODES)}, not {init_mode!r}")
return cls(
robot_ip=env_str("ROBOT_IP", "192.168.1.192"),
effector=effector,
tool_offset_mm=effector.offset_mm + env_float("TOOL_OFFSET_MM", 0.0),
default_speed=env_float("DEFAULT_SPEED", 30.0),
default_joint_speed=env_float("DEFAULT_JOINT_SPEED", 20.0),
telemetry_interval_s=env_int("TELEMETRY_INTERVAL_MS", 100) / 1000.0,
reconnect_interval_s=env_float("RECONNECT_INTERVAL_S", 5.0),
init_mode=init_mode,
init_pose=RobotPose(
x=env_float("INIT_X", 300.0),
y=env_float("INIT_Y", 0.0),
z=env_float("INIT_Z", 250.0),
roll=env_float("INIT_ROLL", 180.0),
pitch=env_float("INIT_PITCH", 0.0),
yaw=env_float("INIT_YAW", 0.0),
),
init_speed=env_float("INIT_SPEED", 50.0),
motion=MotionConfig.from_env(),
)
+609
View File
@@ -0,0 +1,609 @@
"""The threads that own the arm, and the mailboxes the event loop drains.
**Nothing outside this file may call into the driver**, with two exceptions
written to be safe from another thread: :meth:`ULite6.stop`, and
:meth:`ULite6.read_report`, which reads the controller's report cache rather
than the wire.
There are two threads because a motion must not stop the arm being watched. The
xArm SDK blocks: a move waits out the motion, and a read from a controller whose
cable was pulled blocks until its own socket timeout. So:
- The **control thread** runs one job at a time and is allowed to block. It is
where every service is carried out, including the motions that take seconds.
- The **reporter thread** never blocks. It reads the report cache at a fixed
cadence, so `tcp_pose` and `joint_pose` keep flowing right through a ten-second
move — which is the only way a caller can follow one, since the thread that
started it is inside it until it ends.
- The **stopper thread** exists only to call `stop`. It cannot be the control
thread (a stop must not queue behind the motion it aborts) and it must not be
the event loop: the SDK's `emergency_stop` re-commands the controller until it
confirms, for up to three seconds, and a dataflow router cannot go away for
three seconds. Requests to it coalesce — several stops in a row are one stop.
The dora event loop is a third party to both: it drains mailboxes and publishes,
and never waits for the arm.
"""
import queue
import threading
import time
from dataclasses import dataclass, field
from typing import Any
from dora_ulite6 import services
from dora_ulite6.config import Config
from dora_ulite6.driver import ULite6
from dora_ulite6.errors import ArmNeedsHomingError, RobotUnavailableError
from dora_ulite6.log import log
from dora_ulite6.pose import RobotPose, with_tool_offset
# The states the node reports on `robot_state`, in the order a healthy start
# walks through them.
CONNECTING = "connecting"
READY = "ready"
MOVING = "moving"
RECOVERING = "recovering"
UNAVAILABLE = "unavailable"
# The motion lifecycle published on `motion_state`. `accepted` exists precisely
# to close the race a bare moving/idle flag leaves open: between a caller being
# told its request was taken and the control thread reaching it, a two-valued
# state still reads "not moving", and a caller polling for completion would
# conclude the motion it just asked for had already finished.
ACCEPTED = "accepted"
DONE = "done"
FAILED = "failed"
PREEMPTED = "preempted"
@dataclass
class ServiceCall:
"""One service request waiting on the control thread."""
request_id: str
row: dict
# Whether the caller is held until the arm stops. False means the response
# was already sent as an acknowledgement, and completion is only reported on
# `motion_state` — which is what keeps it one response per request.
wait: bool = True
# Set when a newer motion took this one's place, before or during its run.
preempted_by: str | None = None
@property
def service(self) -> str:
"""The service this request names."""
return self.row.get(services.SERVICE, "")
@property
def moves_the_arm(self) -> bool:
"""Whether carrying this out drives the arm, and so preempts and is preemptable."""
return self.service in services.MOVES_THE_ARM
@dataclass
class Jog:
"""One velocity command. Not a service: nobody waits for an answer."""
velocity: list[float]
@dataclass
class Telemetry:
"""One reading of the arm: joint angles in degrees, tool-tip pose in mm and degrees."""
joints: list[float]
tip: RobotPose
timestamp_ns: int
@dataclass
class Message:
"""Something to publish: an output id, its fields, and its correlation metadata."""
output: str
fields: dict[str, Any]
metadata: dict[str, str] = field(default_factory=dict)
class Control:
"""Owns the arm and hands the event loop what there is to publish."""
def __init__(self, config: Config, restarted: bool = False):
self.config = config
# A restarted node inherits an arm that is wherever it was left. Driving
# it to INIT_MODE because the process came back is a motion nobody asked
# for, so a restart skips it.
self.restarted = restarted
self.robot: ULite6 | None = None
self._jobs: queue.Queue[ServiceCall | Jog] = queue.Queue()
self._lock = threading.Lock()
self._telemetry: Telemetry | None = None
self._outbox: list[Message] = []
self._state = CONNECTING
self._state_changes: list[str] = [CONNECTING]
# The motion the control thread is inside right now, and whether the
# last preemption left a software stop for the next motion to clear.
self._running_motion: ServiceCall | None = None
self._stopped_for_preempt = False
# Set by anyone who needs the arm halted; the stopper thread drains it.
self._stop_wanted = threading.Event()
# Whether a velocity command is currently keeping the arm moving, and
# when the last one arrived — the controller expires them on its own.
self._jogging = False
self._last_jog = 0.0
# Whether the arm has been reached once and put where INIT_MODE wants it.
self._started = False
# Set to ask the threads to finish. The sense matters: this is waited on
# as the threads' sleep, and an event that is *set* while running makes
# every wait() return at once — a sleep that never sleeps, which is a
# busy loop rather than a cadence.
self._stopping = threading.Event()
self._control = threading.Thread(target=self._run_control, name="ulite6-control", daemon=True)
self._reporter = threading.Thread(target=self._run_reporter, name="ulite6-reporter", daemon=True)
self._stopper = threading.Thread(target=self._run_stopper, name="ulite6-stopper", daemon=True)
def start(self) -> None:
"""Start the three threads; they reach the arm themselves, so this never blocks."""
self._control.start()
self._reporter.start()
self._stopper.start()
def shutdown(self) -> bool:
"""Halt the arm, stop the threads, and say whether they really finished.
Halting first is the point. A motion lives in the *controller*, not in
this process: dropping the connection does not abort it, so a node that
exits mid-move leaves an arm travelling with nobody left to stop it. The
stop is commanded here on the caller's thread and waited for, because by
now the event loop has finished routing and there is nothing left to
starve.
Returns False if the control thread is still inside the SDK afterwards,
which is the caller's cue to leave without waiting for it.
"""
if self.robot is not None:
self._attempt(self.robot.stop)
self._stopping.set()
self._stop_wanted.set()
self._stopper.join(timeout=1.0)
self._reporter.join(timeout=1.0)
self._control.join(timeout=self.config.motion.ready_timeout_s)
if self._control.is_alive():
log("the control thread is still inside the SDK; leaving it behind")
return False
return True
# --- what the event loop puts in ---
def submit(self, job: ServiceCall | Jog) -> None:
"""Queue work for the control thread. Returns immediately."""
self._jobs.put(job)
def request_stop(self, request_id: str = "") -> None:
"""Ask for the arm to be halted, and come straight back.
Answering before the arm has actually stopped is deliberate: the reply
says the stop was commanded, and whether it took is reported where every
other outcome is, on `motion_state` and `robot_state`. Waiting here
instead would hand the caller a better answer at the price of the whole
dataflow, for up to three seconds.
"""
self._stop_wanted.set()
if request_id:
self._respond(
ServiceCall(request_id=request_id, row={services.SERVICE: services.STOP}),
(True, {"message": "stop commanded"}),
)
def _preempt_motions(self, by: str) -> None:
"""Mark the running motion and any still waiting, and halt the arm if one was moving."""
with self._lock:
running = self._running_motion
waiting = [
job
for job in list(self._jobs.queue)
if isinstance(job, ServiceCall) and job.moves_the_arm and job.preempted_by is None
]
for job in [running, *waiting]:
if job is not None:
job.preempted_by = by
# The next motion to actually run clears the software stop this is
# about to cause. Only ever set here, never unset: preempting a
# motion that had not started yet stops nothing, and must not clear a
# flag left by an earlier preemption that nothing has consumed — that
# would send the next motion at an arm still in its stopped state,
# where it would be refused the moment it was commanded.
self._stopped_for_preempt = self._stopped_for_preempt or running is not None
if running is not None:
log(f"{running.service} ({running.request_id}) preempted by {by}")
self.request_stop()
def start_jog(self, velocity: list[float]) -> None:
"""Take one velocity command, preempting a motion the first time it moves the arm.
A jog arrives many times a second, so everything here happens on the
transitions only: a stream of them is one preemption and one state
change, not hundreds.
"""
moving = any(velocity)
if moving and not self._jogging:
self._preempt_motions("jog")
self._set_state(MOVING)
with self._lock:
was_jogging, self._jogging = self._jogging, moving
self._last_jog = time.monotonic()
# Only a jog that was in fact jogging ends one. A zero velocity from a
# teleop source that is merely idling says nothing about a motion that
# owns the arm, and must not answer for it.
if not moving and was_jogging:
self._idle_unless_busy()
self._jobs.put(Jog(velocity=velocity))
def start_motion(self, call: ServiceCall) -> None:
"""Take a motion, preempting whatever motion was already in play.
The arm is one resource: there is never more than one motion, so a new
one replaces the old rather than queueing behind it. The running motion
is cut short through the arm — the same stop a caller can send — and any
motion still waiting its turn is dropped without ever moving anything.
Each preempted request is answered exactly once, as every request is: if
its caller is still waiting, by its response; if it was a `wait: false`
request, it was acknowledged when it arrived, and the preemption shows
only on `motion_state`.
"""
self._preempt_motions(call.request_id)
self._motion_state(call, ACCEPTED, "queued")
if not call.wait:
self._respond(call, (True, {"message": "accepted", "accepted": True}))
self._jobs.put(call)
def post(self, message: Message) -> None:
"""Add a message for the event loop to publish on the next tick.
The loop uses this to answer a request it refused itself, so a malformed
message is still answered under its own correlation id without the arm
threads being troubled by it.
"""
self._post(message)
def call_now(self, request_id: str, row: dict) -> None:
"""Run a service on the calling thread, for the ones that must not queue.
Only `stop` takes this path: queued, it would sit behind the very motion
it exists to abort. It is safe here because the driver method it reaches
is the one written for another thread.
"""
call = ServiceCall(request_id=request_id, row=row)
if self.robot is None:
self._respond(call, (False, {"message": "the arm has not been reached yet"}))
return
self._respond(call, self._attempt(lambda: services.run(self.robot, row)))
# --- what the event loop drains ---
def take_telemetry(self) -> Telemetry | None:
"""Return the newest reading and clear it, so a tick never republishes a stale pose."""
with self._lock:
telemetry, self._telemetry = self._telemetry, None
return telemetry
def take_messages(self) -> list[Message]:
"""Return every response recorded since the last call."""
with self._lock:
outbox, self._outbox = self._outbox, []
return outbox
def take_state_changes(self) -> list[str]:
"""Return every state transition since the last call."""
with self._lock:
changes, self._state_changes = self._state_changes, []
return changes
def _post(self, message: Message) -> None:
with self._lock:
self._outbox.append(message)
# --- the control thread ---
def _run_control(self) -> None:
while not self._stopping.is_set():
if not self._started:
self._start_up()
continue
try:
job = self._jobs.get(timeout=0.1)
except queue.Empty:
continue
if isinstance(job, Jog):
self._run_jog(job)
else:
self._run_service(job)
def _start_up(self) -> None:
"""Reach an arm that answers, put it where INIT_MODE says, and start serving.
The driver is built once and then retried through its own ``connect()``,
which reuses the SDK handle: rebuilding it on every attempt would leak a
socket and its threads for every second an absent arm stays absent.
"""
self._set_state(CONNECTING)
if self.robot is None:
self.robot = ULite6(self.config)
try:
if not self.robot.is_connected() and not self.robot.connect():
raise RobotUnavailableError(f"robot at {self.config.robot_ip} is not reachable")
self._start_position()
except RobotUnavailableError as error:
log(f"{error}; retrying in {self.config.reconnect_interval_s:.0f}s")
self._set_state(UNAVAILABLE)
self._serve_while_down(self.config.reconnect_interval_s)
return
self._started = True
self._set_state(READY)
def _serve_while_down(self, seconds: float) -> None:
"""Answer what can be answered without a serving arm, until it is time to retry.
This is the wait between connection attempts, and it serves the queue
rather than sleeping through it. Sitting silent is the one thing a caller
must never get: an arm that is down is an outcome to report, not a reason
to stop answering, and a request left in the queue here would be answered
only if the arm ever came back.
"""
deadline = time.monotonic() + seconds
while not self._stopping.is_set():
remaining = deadline - time.monotonic()
if remaining <= 0:
return
try:
job = self._jobs.get(timeout=min(remaining, 0.1))
except queue.Empty:
continue
if isinstance(job, ServiceCall):
self._serve_without_arm(job)
def _serve_without_arm(self, call: ServiceCall) -> None:
"""Answer one request that arrived before the arm was serving.
A motion is refused outright. Holding it would be worse than silence:
the arm is somewhere unknown, nobody is watching, and the motion would
fire unattended the moment the link came back.
Everything else is tried on the arm, because a controller that answers
while refusing to serve is exactly what `recover` and `clear` are for,
and refusing them here would leave the one state they exist to escape
with no way out of it.
"""
if call.moves_the_arm:
self._finish_motion(call, FAILED, False, "the arm is not serving yet")
return
self._respond(call, self._attempt(lambda: services.run(self.robot, call.row)))
def _start_position(self) -> None:
"""Put the arm where INIT_MODE says it should start, if anywhere."""
if self.restarted and self.config.init_mode != "none":
log(f"restarted: leaving the arm where it is rather than running INIT_MODE={self.config.init_mode}")
return
if self.config.init_mode == "home":
log("moving to home")
self.robot.go_home()
elif self.config.init_mode == "pose":
log(f"moving to the initial pose {self.config.init_pose.as_list()}")
self.robot.move_to(
with_tool_offset(self.config.init_pose, -self.config.tool_offset_mm),
self.config.init_speed,
)
def _run_service(self, call: ServiceCall) -> None:
"""Carry out one service and post its response."""
if call.moves_the_arm:
self._run_motion(call)
return
self._respond(call, self._attempt(lambda: services.run(self.robot, call.row)))
def _run_motion(self, call: ServiceCall) -> None:
"""Carry out one motion, reporting its whole life on `motion_state`.
Blocks for as long as the arm takes. `robot_state` says `moving`
meanwhile, so a caller watching it can tell a slow service from a stalled
one, and `tcp_pose` keeps arriving because the reporter thread is not the
one in here.
"""
if call.preempted_by is not None:
# Replaced while it waited its turn; it never moved anything.
self._finish_motion(call, PREEMPTED, False, f"preempted by {call.preempted_by}")
return
with self._lock:
self._running_motion = call
clear_first = self._stopped_for_preempt
self._stopped_for_preempt = False
self._set_state(MOVING)
self._motion_state(call, MOVING, "")
if self._leave_jog():
# A pose command would switch the controller out of velocity mode
# behind the driver's back, leaving its own bookkeeping saying the
# arm is still jogging. Leaving through the front door keeps them
# agreed.
self._attempt(lambda: self.robot.set_cartesian_velocity([0.0] * 6))
if clear_first:
# Preempting leaves the controller in its software-stopped state.
# The driver's own set_state(0) would probably be enough; clearing
# first is cheap and does not depend on that being true.
self._attempt(self.robot.clear)
outcome = self._attempt(lambda: services.run(self.robot, call.row))
with self._lock:
self._running_motion = None
preempted_by = call.preempted_by
ok, fields = outcome
if preempted_by is not None:
# A preempted move fails through MotionInterruptedError like any
# aborted one; only the flag says who cut it short.
self._finish_motion(call, PREEMPTED, False, f"preempted by {preempted_by}")
else:
self._finish_motion(call, DONE if ok else FAILED, ok, fields.get("message", ""), fields)
self._idle_unless_busy()
def _leave_jog(self) -> bool:
"""Clear the jogging flag, reporting whether the arm was in fact jogging."""
with self._lock:
was_jogging, self._jogging = self._jogging, False
return was_jogging
def _finish_motion(self, call: ServiceCall, state: str, ok: bool, message: str, fields=None) -> None:
"""Publish a motion's end, and answer its caller if one is still waiting."""
self._motion_state(call, state, message)
if call.wait:
self._respond(call, (ok, dict(fields or {"message": message})))
def _motion_state(self, call: ServiceCall, state: str, message: str) -> None:
"""Publish where a motion stands.
The request_id rides in the *fields*, not the metadata: metadata carrying
a request_id means "this is the answer to that request", and this is a
broadcast of the arm's state, which anyone may read. Keeping it in the
fields is also what lets a caller polling `get_state` see the same value
as one watching the topic.
"""
self._post(
Message(
output="motion_state",
fields={
"request_id": call.request_id,
"service": call.service,
"state": state,
"message": message,
},
)
)
def _run_jog(self, jog: Jog) -> None:
"""Command one velocity. Nothing is published: a jog has no answer."""
def command() -> dict:
self.robot.set_cartesian_velocity(jog.velocity)
return {"message": "jogging"}
ok, fields = self._attempt(command)
if not ok:
log(f"jog refused: {fields['message']}")
def _respond(self, call: ServiceCall, outcome: tuple[bool, dict]) -> None:
"""Post the one response this request gets."""
ok, fields = outcome
fields.setdefault("message", "")
self._post(
Message(
output="response",
fields={"ok": ok, "service": call.service, **fields},
metadata={"request_id": call.request_id},
)
)
def _attempt(self, work) -> tuple[bool, dict]:
"""Run ``work``, turning whatever it raises into a failed response."""
try:
return True, work()
except ArmNeedsHomingError as error:
return False, {"message": f"{error}; call `home_and_clear` once the area is clear"}
except RobotUnavailableError as error:
self._go_unavailable(error)
return False, {"message": str(error)}
except Exception as error: # noqa: BLE001 — a failed request must never take the thread down
return False, {"message": str(error)}
def _go_unavailable(self, error: Exception) -> None:
"""Report the arm as unusable and try once to bring it back.
A single attempt, not a retry loop: the thread has jobs to serve, and the
reporter comes round again in milliseconds.
"""
self._set_state(RECOVERING)
log(f"the arm is unusable ({error}); trying to recover")
try:
self.robot.recover()
except RobotUnavailableError as recovery_error:
log(f"recovery failed: {recovery_error}")
self._set_state(UNAVAILABLE)
return
self._set_state(READY)
# --- the stopper thread ---
def _run_stopper(self) -> None:
"""Halt the arm whenever anyone asks, and never make them wait for it.
One stop per wake-up however many asked, which is what a stop means:
several callers wanting the arm halted want the same single thing.
"""
while not self._stopping.is_set():
if not self._stop_wanted.wait(timeout=0.1):
continue
self._stop_wanted.clear()
if self._stopping.is_set():
return
if self.robot is not None:
self._attempt(self.robot.stop)
def _set_state(self, state: str) -> None:
"""Record a state, queueing it for publication only when it is a change."""
with self._lock:
if state == self._state:
return
self._state = state
self._state_changes.append(state)
log(f"state: {state}")
def _idle_unless_busy(self) -> None:
"""Report the arm idle, but only when nothing is still driving it.
One thing ending is not the arm stopping. A motion may still be winding
down inside the SDK after the jog that preempted it went quiet; a jog may
have taken the arm over from the motion that just finished. Either way
`ready` published over it is the false idle the state exists to rule out.
The state also stops being ours to set once it has left `moving` — that
is `unavailable` or `recovering` reporting something this does not know
about, and it must not be overwritten with `ready`.
"""
with self._lock:
if self._running_motion is not None or self._jogging or self._state != MOVING:
return
self._set_state(READY)
# --- the reporter thread ---
def _expire_jog(self) -> None:
"""Stop calling the arm jogging once the controller has expired the command.
A jog lives on the controller's own TTL, so a sender that goes quiet
leaves an arm that stopped moving a moment later. Nothing tells us; the
clock does.
"""
with self._lock:
if not self._jogging or time.monotonic() - self._last_jog < self.config.motion.velocity_command_ttl_s:
return
self._jogging = False
self._idle_unless_busy()
def _run_reporter(self) -> None:
"""Publish telemetry, whatever the control thread is doing.
Never touches anything that can block, so it keeps running through a
ten-second move — which is what lets a caller follow a motion whose
response will not arrive until it ends.
"""
while not self._stopping.is_set():
self._stopping.wait(self.config.telemetry_interval_s)
if self.robot is None:
continue
self._expire_jog()
reading = self.robot.read_report()
if reading is None:
continue
joints, flange = reading
tip = with_tool_offset(flange, self.config.tool_offset_mm)
with self._lock:
self._telemetry = Telemetry(joints, tip, time.time_ns())
+71
View File
@@ -0,0 +1,71 @@
"""Facts about the xArm controller: its modes, its codes, and the pose maths.
Nothing tunable lives here — the limits and timeouts are configuration, read
from the environment in :mod:`dora_ulite6.config`. These are the constants the
SDK itself defines and the helpers that read its answers.
"""
from xarm.core.config.x_code import ControllerErrorCodeMap
from xarm.x3.code import APIState
MODE_POSITION = 0
MODE_TEACH = 2
MODE_CARTESIAN_VELOCITY = 5
# Controller states as the arm reports them. From 4 on the arm is stopped and
# refuses to move until enabled again — where the emergency stop leaves it.
STATE_STOPPED = 4
# Return codes meaning the command never ran because the arm was not usable. The
# SDK returns them instead of raising, so a driver that only logged them would
# have every caller believe a motion happened that never did.
UNAVAILABLE_CODES = frozenset(
{APIState.NOT_CONNECTED, APIState.NOT_READY, APIState.HAS_ERROR, APIState.STATE_NOT_READY}
)
# The one controller error a motion of ours can clear: C23, joints angle exceed
# limit, whose documented remedy is to send the arm to its initial position.
# Every other error is answered by a person — releasing a button, dragging the
# arm out of a collision — and homing one would move an arm somebody stopped.
HOMEABLE_ERROR = 23
# How often to re-ask the controller while waiting. Cadence, not a limit.
ARRIVAL_POLL = 0.1
READY_POLL = 0.2
def format_pose(pose) -> str:
"""Render a pose for a log line or an error message."""
x, y, z, roll, pitch, yaw = pose
return f"({x:.0f}, {y:.0f}, {z:.0f} mm; {roll:.0f}, {pitch:.0f}, {yaw:.0f}°)"
def shortest_turn(start: float, target: float) -> float:
"""Return the signed degrees from ``start`` to ``target``, the short way round.
Euler angles wrap: an arm reporting roll −178 and asked for roll 180 has two
degrees to turn, but subtracting gives 358 — a sweep through roll 0 with the
tool pointing sideways, which no arm passes through.
"""
return (target - start + 180) % 360 - 180
def interpolate(start: list, target: list, fraction: float) -> list:
"""Return the pose ``fraction`` of the way from ``start`` to ``target``.
Position moves in a straight line and each rotation turns the short way
round, which is what the controller does along a linear move.
"""
pose = [a + (b - a) * fraction for a, b in zip(start[:3], target[:3], strict=True)]
pose += [a + shortest_turn(a, b) * fraction for a, b in zip(start[3:], target[3:], strict=True)]
return pose
def describe_controller_error(code: int) -> str:
"""Name a controller error, so a log reader does not have to look the number up."""
if not code:
return "no controller error"
entry = ControllerErrorCodeMap.get(code)
if entry is None:
return f"controller error {code}"
return f"controller error {code} ({entry['en']['title']})"
+526
View File
@@ -0,0 +1,526 @@
"""The UFactory Lite6 driver, over the vendor xArm SDK.
Ported from the littlehand robot layer, which was written against this same
controller. The point of all of it is that a command either moved the arm or
raised: the SDK returns a code for a motion it quietly refused, and a driver that
only logged that code would have every caller believe in a motion that never
happened.
**Every method here blocks**, some of them for as long as the arm takes to move,
and on a disconnected controller some block unboundedly. Nothing but
:mod:`dora_ulite6.control` may call into this class — see the note there.
"""
import time
from xarm.wrapper import XArmAPI
from xarm.x3.code import APIState
from dora_ulite6.config import Config
from dora_ulite6.controller import (
ARRIVAL_POLL,
HOMEABLE_ERROR,
MODE_CARTESIAN_VELOCITY,
MODE_POSITION,
MODE_TEACH,
READY_POLL,
STATE_STOPPED,
UNAVAILABLE_CODES,
describe_controller_error,
format_pose,
interpolate,
shortest_turn,
)
from dora_ulite6.errors import (
ArmNeedsHomingError,
MotionInterruptedError,
RobotUnavailableError,
UnreachablePoseError,
)
from dora_ulite6.log import log
from dora_ulite6.pose import RobotPose
class ULite6:
"""A UFactory Lite6 arm and the tool bolted to it."""
def __init__(self, config: Config):
self.config = config
self.motion = config.motion
self.ip = config.robot_ip
self.effector = config.effector
self.effector.verify(self)
# Whether the arm is holding something with whatever tool it carries.
self.tool_engaged = False
self.freedrive_on = False
# Built without opening, so every connection goes through connect() and
# an arm that was absent at boot is no different from one unplugged later.
# enable_report is the SDK's default, but read_report's whole promise —
# that it costs no round trip and cannot block — rests on it, so it is
# stated here rather than inherited. Without it `arm.position` quietly
# falls back to a blocking get_position().
self.arm = XArmAPI(self.ip, do_not_open=True, enable_report=True)
self._velocity_mode = False
self.connect()
# --- connection ---
def connect(self) -> bool:
"""Open (or reopen) the connection and enable motion.
Never raises: an unreachable arm is a state to report and retry, not an
error that stops the node from starting. The handle is reused rather than
rebuilt, which is how the SDK reconnects.
"""
try:
self.arm.connect()
self.arm.motion_enable(enable=True)
self.arm.set_mode(MODE_POSITION)
self.arm.set_state(0)
self._velocity_mode = False
log(f"connected to the robot at {self.ip}")
return True
except Exception as error: # noqa: BLE001 — the SDK raises anything; an absent arm is a state, not a crash
log(f"failed to connect to the robot at {self.ip}: {error}")
return False
def disconnect(self) -> None:
"""Close the connection."""
self.arm.disconnect()
log("robot disconnected")
def is_connected(self) -> bool:
"""Return whether the arm controller is reachable."""
return bool(self.arm.connected)
def is_ready(self) -> bool:
"""Return whether the arm would accept a motion right now.
Read from the report stream, which is what makes it cheap enough for
telemetry: a pressed emergency stop leaves the controller answering every
query while refusing every move.
"""
return bool(self.arm.connected and self.arm.state < STATE_STOPPED and self.arm.error_code == 0)
def _require_connection(self) -> None:
"""Raise unless the controller link is up; every command needs one."""
if not self.arm.connected:
raise RobotUnavailableError(f"robot at {self.ip} is not connected")
# --- reads ---
def get_current_position(self) -> RobotPose:
"""Return the current flange pose."""
self._require_connection()
code, values = self.arm.get_position()
if code != 0 or len(values) < 6:
raise RobotUnavailableError(f"could not read the arm pose (code {code})")
return RobotPose.from_sequence(values)
def get_joint_angles(self) -> list:
"""Return the current joint angles in degrees."""
self._require_connection()
code, angles = self.arm.get_servo_angle()
if code != 0 or not len(angles):
raise RobotUnavailableError(f"could not read the joint angles (code {code})")
return list(angles)
def read_report(self) -> tuple[list, RobotPose] | None:
"""Return ``(joints, flange pose)`` from the controller's report stream, or None.
The one read that works **while a motion blocks**, and the only one the
reporter thread is allowed to make. The SDK keeps these values fresh from
a background stream of the controller's own, so this costs no round trip
and cannot block behind the move in flight — `get_position()` would sit
on the same socket the motion is using.
The cost is that it is a cache: it says what the arm last reported, with
no error code to say whether the report is current. That is the right
trade for the telemetry a caller follows a motion by, and the wrong one for
deciding whether a motion happened, which is why the driver's own checks
keep asking over the wire.
Returns None when the link is down, rather than handing back the last
pose the arm held before the cable was pulled.
"""
if not self.arm.connected:
return None
position = self.arm.position
angles = self.arm.angles
if not position or len(position) < 6 or not angles:
return None
return list(angles), RobotPose.from_sequence(position)
def _live_error_code(self) -> int:
"""Read the controller error over the wire, not from the report cache.
``arm.error_code`` is whatever the report stream last pushed, which is
stale exactly when it matters: right after an abort, and right after
clearing one.
"""
_, err_warn = self.arm.get_err_warn_code()
return err_warn[0]
# --- motion ---
def move_to(self, pose: RobotPose, speed: float | None = None) -> None:
"""Move the flange to ``pose``, refusing a move it cannot make without commanding it."""
self._require_connection()
target = pose.as_list()
self._check_path_reachable(target, "move_to")
self.arm.set_mode(MODE_POSITION)
self.arm.set_state(0)
code = self.arm.set_position(
pose.x,
pose.y,
pose.z,
roll=pose.roll,
pitch=pose.pitch,
yaw=pose.yaw,
speed=speed if speed is not None else self.config.default_speed,
wait=True,
)
self._check_motion_code(code, "move_to", pose=target)
self._check_arrived(target, "move_to")
log(f"moved to {format_pose(target)}")
def set_joint_angles(self, angles: list, speed: float | None = None) -> None:
"""Move the joints to ``angles`` in degrees."""
self._require_connection()
self.arm.set_mode(MODE_POSITION)
self.arm.set_state(0)
code = self.arm.set_servo_angle(
angle=angles,
speed=speed if speed is not None else self.config.default_joint_speed,
wait=True,
)
self._check_motion_code(code, "set_joint_angles")
log(f"joints moved to {angles}")
def go_home(self) -> None:
"""Send the arm to its joint-space home."""
self._require_connection()
self.arm.set_mode(MODE_POSITION)
self.arm.set_state(0)
self._go_home_blocking()
log("moved to home")
def _go_home_blocking(self) -> None:
"""Command the joint-space homing and confirm the arm really made it.
``XArmAPI.reset`` looks like it does this, but it throws ``move_gohome``'s
return code away: ask it to home an arm the controller refuses to move and
it reports nothing and leaves the arm where it was.
"""
code = self.arm.move_gohome(wait=True, timeout=self.motion.home_timeout_s)
self._check_motion_code(code, "move_gohome")
self._check_at_home()
def _check_at_home(self) -> None:
"""Raise unless the arm really is at the joint zero it was sent to.
Homing is waited on by the same ``wait_move`` that calls a refused motion
finished, so it can report success for an arm that never moved.
"""
try:
angles = self.get_joint_angles()
except RobotUnavailableError as error:
log(f"could not confirm the arm reached home ({error}); assuming it did")
return
off_by = max(abs(angle) for angle in angles)
if off_by > self.motion.arrival_tolerance_deg:
raise RobotUnavailableError(f"homing did not move the arm; it is still {off_by:.0f}° from home")
def home_and_clear(self) -> None:
"""Clear the latched error and drive the arm home in joint space; this **moves the arm**.
The answer to :class:`ArmNeedsHomingError`, and only ever run after
whoever asked for it has warned the room.
Order matters: the controller refuses a motion while an error is latched,
and a latched error is what this is here to escape.
"""
self.clear()
self.go_home()
def stop(self) -> None:
"""Abort the current motion immediately (software stop, state 4).
The one method safe to call from another thread while a blocking move is
in flight — that is the whole point of it — so the disconnected case
returns rather than sitting for seconds retrying down a dead socket. The
interrupted move raises :class:`MotionInterruptedError` on its own thread.
"""
if not self.arm.connected:
log("stop requested while disconnected")
return
self.arm.emergency_stop()
self._velocity_mode = False
log("motion stopped")
# --- arrival and reachability ---
def _check_arrived(self, target: list, operation: str) -> None:
"""Raise unless the arm actually made the move it was commanded.
A return code of 0 does not mean it arrived: ``wait_move`` calls a motion
finished once the arm has been idle for a few polls, so a command the
controller quietly refused comes back looking like one that ran. The
controller's error is the half that decides; the pose is a coarse
backstop for a refusal that latched nothing.
"""
error = self._live_error_code()
if error:
raise RobotUnavailableError(
f"{operation} to {format_pose(target)} left the arm in {describe_controller_error(error)}"
)
deadline = time.monotonic() + self.motion.arrival_timeout_s
while True:
code, current = self.arm.get_position()
if code != 0 or not len(current):
log(f"{operation}: could not confirm arrival (code {code}); assuming it landed")
return
off_by = max(abs(a - b) for a, b in zip(current[:3], target[:3], strict=True))
turned = max(abs(shortest_turn(a, b)) for a, b in zip(current[3:], target[3:], strict=True))
if off_by <= self.motion.arrival_tolerance_mm and turned <= self.motion.arrival_tolerance_deg:
return
if time.monotonic() >= deadline:
raise MotionInterruptedError(
f"{operation} to {format_pose(target)} never arrived; the arm is at {format_pose(current)} "
f"({describe_controller_error(self._live_error_code())})"
)
time.sleep(ARRIVAL_POLL)
def _check_path_reachable(self, target: list, operation: str) -> None:
"""Refuse a move whose straight-line path leaves the joint envelope.
The controller aborts partway through a line it cannot follow, with a
latched error that then refuses every later command. Asking it to solve
the path first turns that into a refusal nobody has to recover from.
Sampling matters as much as the target: a line from a turned wrist to a
downward one passes through configurations neither end visits.
"""
*waypoints, destination = self._path_to(target)
for pose in waypoints:
self._check_pose_holdable(pose, target, operation, margin=False)
self._check_pose_holdable(destination, target, operation, margin=True)
def _path_to(self, target: list) -> list:
"""Return the poses to check on the way to ``target``, ``target`` last.
A pose read the controller could not answer leaves nothing to interpolate
from, so the path collapses to the target alone rather than being sampled
from a stale start, which would refuse perfectly good moves.
"""
code, current = self.arm.get_position()
if code != 0 or not len(current):
log(f"could not read the pose to plan from (code {code}); checking the target only")
return [target]
samples = self.motion.path_samples
steps = [interpolate(current, target, step / samples) for step in range(1, samples)]
return steps + [target]
def _check_pose_holdable(self, pose: list, target: list, operation: str, margin: bool) -> None:
"""Raise unless the arm has a joint solution for ``pose``, clear of its limits.
``margin`` asks the dearer question — whether the solution merely fits —
and is worth its round trips only where the arm comes to rest.
"""
code, angles = self.arm.get_inverse_kinematics(pose)
if code != 0 or not len(angles):
raise UnreachablePoseError(
f"{operation} to {format_pose(target)}: no joint solution for {format_pose(pose)} (code {code})"
)
code, limited = self.arm.is_joint_limit(angles)
# A check the controller could not answer is not a refusal: failing shut
# would ground the arm on any transient, and it still guards the motion.
if code == 0 and limited:
raise UnreachablePoseError(
f"{operation} to {format_pose(target)}: {format_pose(pose)} needs joint angles out of range"
)
near = self._joints_near_limit(angles) if margin else []
if near:
joints = ", ".join(f"J{index}" for index in near)
raise UnreachablePoseError(
f"{operation} to {format_pose(target)}: {format_pose(pose)} leaves "
f"{joints} within {self.motion.joint_limit_margin_deg}° of a limit"
)
def _joints_near_limit(self, angles) -> list:
"""Return the 1-based joints whose solution sits within the margin of a bound.
Asked of the controller rather than read off a table: the SDK exposes only
the reduced-mode bounds, and the real ones differ per arm model.
"""
margin = self.motion.joint_limit_margin_deg
near = []
for index in range(len(angles)):
for delta in (margin, -margin):
probe = list(angles)
probe[index] += delta
code, limited = self.arm.is_joint_limit(probe)
if code == 0 and limited:
near.append(index + 1)
break
return near
# --- failure handling ---
def _check_motion_code(self, code: int, operation: str, pose: list | None = None) -> None:
"""Raise on a motion that was aborted or never ran, log anything else."""
if code == APIState.EMERGENCY_STOP:
# The SDK returns this for any aborted motion, a stop of ours and a
# controller fault alike; only the live error code tells them apart.
where = f" to {format_pose(pose)}" if pose else ""
error = self._live_error_code()
if error:
raise self._motion_fault(f"{operation}{where} aborted by {describe_controller_error(error)}", error)
raise MotionInterruptedError(f"{operation}{where} aborted by stop")
self._check_command_code(code, operation, pose)
def _check_command_code(self, code: int, operation: str, pose: list | None = None) -> None:
"""Raise on a command that never ran, log anything else.
For commands that are not motions — switching the vacuum, say. Nothing
here can raise :class:`MotionInterruptedError`, because there was no
motion to interrupt; a refusal means the arm was not usable.
"""
if code == 0:
return
where = f" to {format_pose(pose)}" if pose else ""
if code in UNAVAILABLE_CODES:
error = self._live_error_code()
raise self._motion_fault(
f"{operation}{where} was refused (code {code}, {describe_controller_error(error)}): "
"the arm is disconnected, stopped or not enabled",
error,
)
log(f"{operation} returned error code {code}")
def _motion_fault(self, detail: str, error: int) -> RobotUnavailableError:
"""Pick the failure that names what will actually get the arm moving again.
A joint past its limit clears whenever asked and comes straight back on
the next motion, so "clear it and try again" never converges; it needs a
joint-space move home, and that has to be announced first.
"""
if error == HOMEABLE_ERROR:
return ArmNeedsHomingError(detail)
return RobotUnavailableError(detail)
def recover(self) -> None:
"""Reconnect if the link dropped, clear errors, and re-enable motion. Never moves the arm.
Clearing is unconditional, because the cached error codes come from the
report stream, which is stale exactly in the case that dropped the link.
A joint past its own limit is the one thing clearing cannot fix — that
raises :class:`ArmNeedsHomingError`, whose answer is
:meth:`home_and_clear`.
"""
self._velocity_mode = False
if not self.arm.connected and not self.connect():
raise RobotUnavailableError(f"robot at {self.ip} is not reachable")
self.clear()
try:
self._wait_until_ready()
except RobotUnavailableError as error:
if self._live_error_code() == HOMEABLE_ERROR:
raise ArmNeedsHomingError(f"{error}; only homing the arm will clear it") from error
raise
log("recovered and motion re-enabled")
def clear(self) -> None:
"""Clear any latched error and warning, and put the arm back in position mode."""
self._require_connection()
self._velocity_mode = False
self.arm.clean_error()
self.arm.clean_warn()
self.arm.motion_enable(enable=True)
self.arm.set_mode(MODE_POSITION)
self.arm.set_state(0)
def _wait_until_ready(self) -> None:
"""Block until the controller reports a state it can move from.
Both the state and the error are asked over the wire: after a reconnect
the report stream may have delivered nothing yet, and after a clear it
still carries the error that was just cleared.
"""
deadline = time.monotonic() + self.motion.ready_timeout_s
while True:
code, state = self.arm.get_state()
error = self._live_error_code()
if code == 0 and state < STATE_STOPPED and error == 0:
return
if time.monotonic() >= deadline:
raise RobotUnavailableError(
f"robot did not become ready (state {state}, {describe_controller_error(error)}): "
"is the emergency stop still pressed?"
)
time.sleep(READY_POLL)
# --- the tool ---
def engage_tool(self) -> None:
"""Take hold with whatever tool is mounted."""
self.effector.attach(self)
self.tool_engaged = True
def release_tool(self) -> None:
"""Let go."""
self.effector.detach(self)
self.tool_engaged = False
def set_vacuum(self, on: bool) -> None:
"""Switch the vacuum gripper and wait for it to build or release."""
self._require_connection()
code = self.arm.set_vacuum_gripper(on, wait=False)
self._check_command_code(code, "set_vacuum")
time.sleep(self.motion.tool_settle_s)
log(f"vacuum set to {on}")
def set_gripper(self, closed: bool) -> None:
"""Close or open the Lite6 two-finger gripper and wait for the fingers to settle."""
self._require_connection()
code = self.arm.close_lite6_gripper() if closed else self.arm.open_lite6_gripper()
self._check_command_code(code, "set_gripper")
time.sleep(self.motion.tool_settle_s)
log(f"gripper {'closed' if closed else 'opened'}")
# --- control modes ---
def set_freedrive(self, enabled: bool) -> None:
"""Enter or leave manual teach mode, so the arm can be moved by hand."""
self._require_connection()
self.arm.set_mode(MODE_TEACH if enabled else MODE_POSITION)
self.arm.set_state(0)
self.freedrive_on = enabled
log(f"freedrive {'enabled' if enabled else 'disabled'}")
def set_cartesian_velocity(self, velocity: list) -> None:
"""Drive the arm at a constant base-frame velocity (xArm mode 5) and return.
``velocity`` is ``[vx, vy, vz, wx, wy, wz]`` in mm/s and deg/s. The arm
keeps moving until the controller's own TTL expires or a new velocity
arrives, so a sender must keep sending; an all-zero vector stops it and
leaves velocity mode.
"""
self._require_connection()
if any(velocity):
if not self._velocity_mode:
self.arm.set_mode(MODE_CARTESIAN_VELOCITY)
self.arm.set_state(0)
self._velocity_mode = True
code = self.arm.vc_set_cartesian_velocity(
velocity,
is_radian=False,
is_tool_coord=False,
duration=self.motion.velocity_command_ttl_s,
)
self._check_motion_code(code, "set_cartesian_velocity")
return
if self._velocity_mode:
self.arm.vc_set_cartesian_velocity([0.0] * 6, is_radian=False)
self.arm.set_mode(MODE_POSITION)
self.arm.set_state(0)
self._velocity_mode = False
+99
View File
@@ -0,0 +1,99 @@
"""End effectors: what the arm carries at its flange, and what that lets it do.
"Grasp" is the same symbolic command whatever is bolted on; what differs is how
it is realized (suction on, fingers closed). That difference lives here, so the
event loop never names a tool.
Each effector declares the driver method that switches it and how far past the
flange its tip sits, which is what `tcp_pose` is offset by. Adding a tool is a
method on the driver and a class here.
"""
from abc import ABC
from typing import ClassVar
from dora_ulite6.errors import EffectorUnsupportedError
class EndEffector(ABC):
"""Base class for whatever is mounted at the flange."""
# The driver method that switches this tool on and off; None for a bare
# flange, which asks nothing of the arm.
drives: ClassVar[str | None]
# Distance from the flange to the tool tip along the flange Z axis, in mm.
offset_mm: ClassVar[float]
def attach(self, robot) -> None:
"""Take hold."""
self._switch(robot, True)
def detach(self, robot) -> None:
"""Let go."""
self._switch(robot, False)
def _switch(self, robot, engaged: bool) -> None:
"""Call the driver method this effector drives.
Using the declared name rather than a literal is what keeps ``drives``
and the call from drifting apart.
"""
getattr(robot, self.drives)(engaged)
def verify(self, robot) -> None:
"""Raise unless this arm has the method this effector switches.
Checked when the arm is built rather than at first use: a configured
effector the driver has no method for is a setup mistake, and it should
stop the node joining the dataflow rather than surface mid-motion.
"""
if self.drives and not hasattr(robot, self.drives):
raise EffectorUnsupportedError(
f"{type(self).__name__} needs a driver with {self.drives}(); "
f"{type(robot).__name__} has none"
)
class VacuumEffector(EndEffector):
"""A suction cup: it comes down onto an object's top face and holds it by vacuum."""
drives = "set_vacuum"
offset_mm = 61.0
class GripperEffector(EndEffector):
"""The Lite6 two-finger gripper."""
drives = "set_gripper"
offset_mm = 65.0
class NoEffector(EndEffector):
"""A bare flange: the arm can reach and point, but cannot hold anything."""
drives = None
offset_mm = 0.0
def attach(self, robot) -> None:
"""Refuse: there is nothing mounted to grasp with."""
raise EffectorUnsupportedError("this arm has no effector to grasp with")
def detach(self, robot) -> None:
"""Refuse: there is nothing mounted to release with."""
raise EffectorUnsupportedError("this arm has no effector to release with")
EFFECTORS: dict[str, type[EndEffector]] = {
"vacuum": VacuumEffector,
"gripper": GripperEffector,
"none": NoEffector,
}
def get_effector(name: str) -> EndEffector:
"""Return the effector registered under ``name``, or raise listing which exist."""
try:
return EFFECTORS[name]()
except KeyError:
available = ", ".join(sorted(EFFECTORS))
raise ValueError(f"unknown effector {name!r}; available: {available}") from None
+43
View File
@@ -0,0 +1,43 @@
"""Everything the robot layer raises.
The distinctions matter: they say whether the arm moved, whether anything can be
done about it, and whether the answer needs a human first. Taken from the
littlehand robot layer, where they were arrived at against this same controller.
"""
class MotionInterruptedError(Exception):
"""The motion was aborted in flight, by :meth:`ULite6.stop` or a controller fault."""
class RobotUnavailableError(Exception):
"""The command never ran because the arm was not usable.
A pulled cable, a pressed emergency stop, a latched controller error: what a
caller must do is the same, and it is never to carry on as if the arm moved.
Recoverable by :meth:`ULite6.recover`.
"""
class UnreachablePoseError(Exception):
"""The pose was never commanded because the arm cannot hold it.
Deliberately not a :class:`RobotUnavailableError`: the arm is fine and the
target is not, so a retry would only ask the same impossible thing and latch
a joint limit error on the controller.
"""
class ArmNeedsHomingError(RobotUnavailableError):
"""Only a motion of ours can clear this error, so the caller must warn first.
A joint past its own limit stays past it however often the error is cleared.
Narrow on purpose: an arm that is stopped rather than stuck — emergency stop
held, collision to be dragged out of by hand — stays a plain
:class:`RobotUnavailableError`, because homing it would move an arm somebody
stopped deliberately.
"""
class EffectorUnsupportedError(Exception):
"""The configured effector is not one this arm can drive."""
+13
View File
@@ -0,0 +1,13 @@
"""Timestamped stderr logging.
stderr rather than stdout: dora reads a dynamic node's stdout, and a print that
lands there is noise in the wrong stream.
"""
import sys
import time
def log(message: str) -> None:
"""Print a timestamped line for the node."""
print(f"[ulite6 {time.strftime('%H:%M:%S')}] {message}", file=sys.stderr, flush=True)
+152 -1023
View File
File diff suppressed because it is too large Load Diff
+91
View File
@@ -0,0 +1,91 @@
"""Reading arguments out of the Arrow row that carries a request.
Every request arrives as a one-row Arrow struct, and each service
brings its own schema — dora allows that on an input carrying a correlation id,
so nothing here assumes a fixed set of fields. `as_py()` hands back a plain dict
and these helpers read it, naming what was missing or unusable rather than
letting a `None` reach the arm.
"""
from typing import Any
from dora_ulite6.pose import RobotPose
class PayloadError(Exception):
"""The request could not be understood, so nothing was asked of the arm."""
def read_row(value) -> dict[str, Any]:
"""Return the single struct row of ``value`` as a dict.
Raises
------
PayloadError
The message was empty, or was not a struct this node can read.
"""
if value is None or len(value) == 0:
raise PayloadError("the message carries no data")
row = value[0].as_py()
if not isinstance(row, dict):
raise PayloadError(f"expected a struct with named fields, got {type(row).__name__}")
return row
def name_of(row: dict, field: str, known) -> str:
"""Return the operation ``row`` names, or raise listing the ones that exist."""
name = row.get(field)
if not isinstance(name, str) or name not in known:
raise PayloadError(f"unknown {field} {name!r}; available: {', '.join(sorted(known))}")
return name
def number(row: dict, field: str) -> float:
"""Return a required numeric field."""
if field not in row or row[field] is None:
raise PayloadError(f"missing `{field}`")
try:
return float(row[field])
except (TypeError, ValueError):
raise PayloadError(f"`{field}` must be a number, not {row[field]!r}") from None
def optional_number(row: dict, field: str, default: float | None) -> float | None:
"""Return a numeric field, or ``default`` when it is absent or null."""
if field not in row or row[field] is None:
return default
try:
return float(row[field])
except (TypeError, ValueError):
raise PayloadError(f"`{field}` must be a number, not {row[field]!r}") from None
def flag(row: dict, field: str, default: bool) -> bool:
"""Return a boolean field, or ``default`` when it is absent or null."""
value = row.get(field)
return default if value is None else bool(value)
def numbers(row: dict, field: str, count: int | None = None) -> list[float]:
"""Return a required list of numbers, optionally of an exact length."""
values = row.get(field)
if not isinstance(values, list) or not values:
raise PayloadError(f"`{field}` must be a non-empty list of numbers")
if count is not None and len(values) != count:
raise PayloadError(f"`{field}` must hold {count} numbers, got {len(values)}")
try:
return [float(value) for value in values]
except (TypeError, ValueError):
raise PayloadError(f"`{field}` must hold only numbers") from None
def pose(row: dict) -> RobotPose:
"""Read a tool-tip pose, defaulting the orientation to tool-down."""
return RobotPose(
x=number(row, "x"),
y=number(row, "y"),
z=number(row, "z"),
roll=optional_number(row, "roll", 180.0),
pitch=optional_number(row, "pitch", 0.0),
yaw=optional_number(row, "yaw", 0.0),
)
+50
View File
@@ -0,0 +1,50 @@
"""The pose that crosses the driver boundary, and the tool-tip offset applied to it."""
from dataclasses import dataclass
import numpy as np
from scipy.spatial.transform import Rotation
@dataclass
class RobotPose:
"""A robot pose: position in mm, orientation in degrees, both in the base frame.
The controller reports and accepts the flange pose. ``tcp_pose`` on the
dataflow is this pose pushed out to the mounted tool's tip by
:func:`with_tool_offset`.
"""
x: float
y: float
z: float
roll: float = 180.0
pitch: float = 0.0
yaw: float = 0.0
@classmethod
def from_sequence(cls, values) -> "RobotPose":
"""Build a pose from ``[x, y, z, roll, pitch, yaw]``; a position alone is enough."""
return cls(**dict(zip(("x", "y", "z", "roll", "pitch", "yaw"), values, strict=False)))
def as_list(self) -> list[float]:
"""Return the pose as ``[x, y, z, roll, pitch, yaw]``, the order the SDK uses."""
return [self.x, self.y, self.z, self.roll, self.pitch, self.yaw]
def rotation_matrix(pose: RobotPose) -> np.ndarray:
"""Return the 3x3 base-frame rotation of ``pose``."""
return Rotation.from_euler("xyz", [pose.roll, pose.pitch, pose.yaw], degrees=True).as_matrix()
def with_tool_offset(pose: RobotPose, offset_mm: float) -> RobotPose:
"""Return ``pose`` moved ``offset_mm`` along its own Z axis.
The offset is along the *tool* Z, not the base Z, so a tilted flange puts the
tip where the tool actually reaches rather than straight down from it.
Orientation is unchanged: sliding along the axis does not turn anything.
"""
if offset_mm == 0.0:
return pose
tip = np.array([pose.x, pose.y, pose.z]) + rotation_matrix(pose) @ np.array([0.0, 0.0, offset_mm])
return RobotPose(tip[0], tip[1], tip[2], pose.roll, pose.pitch, pose.yaw)
+167
View File
@@ -0,0 +1,167 @@
"""Every operation the node serves, and what each one does to the arm.
A service is one request and one response, correlated by the `request_id` dora
puts in the message metadata. That is the node's whole control surface: there is
no second protocol for the slow ones.
Two groups behave differently enough to be worth naming here:
- The motions in :data:`MOVES_THE_ARM` take as long as the arm does. A caller
chooses with `wait` whether to be held until the arm stops or answered at
once; either way the motion's progress is on `motion_state`. A new motion
preempts one already running, so there is never more than one in play.
- The services in :data:`IMMEDIATE` are run by the event loop rather than queued,
because queued they would sit behind a motion — which is exactly when they are
wanted. Putting one there is a promise that it never blocks: `get_state` reads
the controller's report cache rather than the wire, which is what makes it
answerable *during* a motion.
`stop` is a service to a caller but has no handler here at all. It cannot queue
(it exists to abort what is at the head of the queue) and it cannot run on the
event loop either: the SDK's `emergency_stop` re-commands the controller for up
to three seconds, and three seconds is far too long to stop routing a dataflow.
It gets a thread of its own — see :mod:`dora_ulite6.control`.
"""
from typing import Any
from dora_ulite6 import payload
from dora_ulite6.driver import ULite6
from dora_ulite6.pose import with_tool_offset
# Field naming the service in a request row.
SERVICE = "service"
def run(robot: ULite6, row: dict) -> dict[str, Any]:
"""Carry out the service ``row`` names and return the response fields.
``row`` must name a service with a handler, which `stop` deliberately has
not; the event loop routes that one before it ever reaches here.
Blocks for as long as the service takes — seconds, for a motion.
Raises
------
PayloadError
The row named no known service, or its arguments were unusable.
RobotUnavailableError, UnreachablePoseError, EffectorUnsupportedError
The arm refused; the caller turns it into a failed response.
"""
return SERVICES[payload.name_of(row, SERVICE, SERVICES)](robot, row)
# --- motions: these block, and they move a real arm ---
def _move_to(robot: ULite6, row: dict) -> dict:
tip = payload.pose(row)
# The request names where the tool tip should end up; the controller only
# takes flange poses, so the tool's own reach comes back off here.
robot.move_to(
with_tool_offset(tip, -robot.config.tool_offset_mm),
payload.optional_number(row, "speed", None),
)
return {"message": f"the tool is at ({tip.x:.0f}, {tip.y:.0f}, {tip.z:.0f} mm)"}
def _move_joints(robot: ULite6, row: dict) -> dict:
angles = payload.numbers(row, "angles")
robot.set_joint_angles(angles, payload.optional_number(row, "speed", None))
return {"message": f"the joints are at {angles}"}
def _home(robot: ULite6, row: dict) -> dict:
robot.go_home()
return {"message": "the arm is home"}
def _home_and_clear(robot: ULite6, row: dict) -> dict:
robot.home_and_clear()
return {"message": "the arm was cleared and driven home"}
# --- everything that answers immediately ---
def _clear(robot: ULite6, row: dict) -> dict:
robot.clear()
return {"message": "the controller error is cleared"}
def _recover(robot: ULite6, row: dict) -> dict:
robot.recover()
return {"message": "the arm is usable again"}
def _freedrive(robot: ULite6, row: dict) -> dict:
enabled = payload.flag(row, "enabled", True)
robot.set_freedrive(enabled)
return {"message": f"freedrive {'on' if enabled else 'off'}"}
def _grasp(robot: ULite6, row: dict) -> dict:
robot.engage_tool()
return {"message": "the tool is holding"}
def _release(robot: ULite6, row: dict) -> dict:
robot.release_tool()
return {"message": "the tool has let go"}
def _get_state(robot: ULite6, row: dict) -> dict:
"""Read the arm and hand back everything a caller might otherwise poll for.
Reads the report cache, not the wire. That is what lets this run on the event
loop and answer *during* a motion — the point of polling at all — where a
`get_position()` round trip would block behind the move on the same socket.
An arm the report cache cannot speak for answers `connected: false` rather
than handing back the pose it held before the cable was pulled.
"""
reading = robot.read_report()
state = {
"connected": reading is not None,
"ready": robot.is_ready(),
"tool_engaged": robot.tool_engaged,
"freedrive": robot.freedrive_on,
"pose": [],
"joints": [],
}
if reading is None:
return {"message": "not connected", **state}
joints, flange = reading
state["pose"] = with_tool_offset(flange, robot.config.tool_offset_mm).as_list()
state["joints"] = joints
return {"message": "ready" if state["ready"] else "not ready", **state}
SERVICES = {
"move_to": _move_to,
"move_joints": _move_joints,
"home": _home,
"home_and_clear": _home_and_clear,
"clear": _clear,
"recover": _recover,
"freedrive": _freedrive,
"grasp": _grasp,
"release": _release,
"get_state": _get_state,
}
# The services that drive the arm across the table. They are what `robot_state`
# reports as `moving`, and what a `stop` is there to interrupt.
MOVES_THE_ARM = frozenset({"move_to", "move_joints", "home", "home_and_clear"})
# Served on the event loop rather than the control thread. See the module note.
# Adding to this set is a promise that the service never blocks and is safe from
# a thread other than the control one — check the driver method before you do.
IMMEDIATE = frozenset({"get_state"})
# Named by callers and accepted, but handled by neither of the above. See the
# module note for why it has a thread to itself.
STOP = "stop"
# Every name a request may carry.
NAMES = frozenset(SERVICES) | {STOP}
+64
View File
@@ -0,0 +1,64 @@
"""A minimal caller: what using the node's services looks like.
Run by `dataflow.yml`. It sends a motion with `wait: false`, follows it on
`motion_state`, and then sends a second one that preempts it. That is the whole
convention:
- a request carries a `request_id` you choose, and gets exactly one `response`
under that id — an acknowledgement when `wait` is false, the outcome when it
is true;
- a motion's progress is on `motion_state`, keyed by the same request_id, so a
non-blocking caller can tell "mine has not started" from "mine has finished"
without a race;
- a new motion replaces the one in play, because the arm is one resource.
"""
import pyarrow as pa
from dora import Node
def main() -> None:
"""Send a non-blocking motion, follow it, preempt it, and exit."""
node = Node("caller")
sent_first = False
sent_second = False
for event in node:
if event["type"] != "INPUT":
continue
if event["id"] == "tick" and not sent_first:
sent_first = True
_move(node, "move-1", z=200.0)
elif event["id"] == "response":
row = event["value"][0].as_py()
print(
f"response to {event['metadata'].get('request_id')}: "
f"ok={row['ok']} {row['message']}",
flush=True,
)
elif event["id"] == "motion_state":
row = event["value"][0].as_py()
print(f"{row['request_id']} is {row['state']}", flush=True)
# Once the first one is really under way, change our mind.
if row["request_id"] == "move-1" and row["state"] == "moving" and not sent_second:
sent_second = True
_move(node, "move-2", z=120.0)
elif row["request_id"] == "move-2" and row["state"] in ("done", "failed"):
break
def _move(node: Node, request_id: str, z: float) -> None:
"""Ask for a move and come straight back, rather than waiting it out."""
node.send_output(
"request",
pa.array([{"service": "move_to", "x": 250.0, "y": 0.0, "z": z, "wait": False}]),
metadata={"request_id": request_id},
)
if __name__ == "__main__":
main()
+30
View File
@@ -0,0 +1,30 @@
# Manifest for the example caller, so `dora validate` matches it against this
# node rather than against the arm node's manifest at the repository root —
# dora resolves a node's manifest from its path, and without this the caller
# would be checked against an entrypoint it does not have.
apiVersion: 1
name: caller
namespace: liria
description: Example caller showing a non-blocking motion followed on motion_state, then preempted
categories: [debug]
runtime: python
# Resolved against this manifest's own directory: <manifest dir>/<entrypoint>
# must name the same file as the dataflow's `path:`.
entrypoint: caller.py
inputs:
tick:
type: std/core/v1/UInt64
required: true
description: Sends the first motion on the first tick.
response:
required: true
description: The answer to a request, under its request_id.
motion_state:
required: true
description: Where the current motion stands, which is how a non-blocking caller follows it.
outputs:
request:
description: A service request, carrying a request_id in its metadata.
+31 -17
View File
@@ -1,28 +1,42 @@
[project] [project]
name = "dora-ulite6" name = "dora-ulite6"
version = "0.1.2" version = "0.3.0"
license = "MIT" description = "Dora node for a UFactory Lite6 arm: motion and control services, and joint and tool-pose telemetry"
authors = [ readme = "README.md"
{ name = "Dora" } license = { text = "MIT" }
] # dora-rs 1.0 requires 3.11; the driver uses zip(strict=) and PEP 604 unions.
description = "Dora node for controlling the UFactory Lite6 robot" requires-python = ">=3.11"
requires-python = ">=3.12"
dependencies = [ dependencies = [
"dora-rs == 0.5.0", "dora-rs >= 1.0.1",
"numpy < 2.0.0", "numpy >= 2.0",
"scipy >= 1.14.0", "pyarrow >= 17.0",
"scipy >= 1.14",
"xarm-python-sdk >= 1.17.3", "xarm-python-sdk >= 1.17.3",
"fastapi >= 0.109.0",
"uvicorn >= 0.27.0",
] ]
[dependency-groups]
dev = ["pytest >=8.1.1", "ruff >=0.9.1"]
[project.scripts] [project.scripts]
dora-ulite6 = "dora_ulite6.main:main" dora-ulite6 = "dora_ulite6.main:main"
[tool.setuptools] [dependency-groups]
dev = ["pytest >= 8.1", "ruff >= 0.9"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["dora_ulite6"] packages = ["dora_ulite6"]
[tool.ruff]
line-length = 110
[tool.ruff.lint]
extend-select = ["D", "I", "N", "RET", "UP"]
[tool.ruff.lint.pydocstyle]
convention = "numpy"
[tool.ruff.lint.per-file-ignores]
# Test names are the documentation here; a docstring would only repeat them.
"tests/*" = ["D1"]
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env bash
# Install dependencies for dora-ulite6.
# Requires: Python >=3.8
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="${SCRIPT_DIR}/.."
# Install uv if not present
if ! command -v uv &>/dev/null; then
echo ">>> Installing uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh
fi
# Create venv and install
echo ">>> Installing dora-ulite6 in editable mode..."
cd "$REPO_DIR"
uv venv --seed --allow-existing
uv pip install -e ".[dev]"
echo ">>> Done. Activate with: source .venv/bin/activate"
View File
+169
View File
@@ -0,0 +1,169 @@
"""Shared fakes. Nothing here needs a robot, a dataflow or a daemon."""
import time
import pytest
from dora_ulite6.pose import RobotPose
TIMEOUT = 2.0
class FakeArm:
"""Stands in for the driver: records what it was asked and answers instantly.
``block`` lets a test hold a motion open the way a real one does, which is
the only way to observe feedback and cancellation at all.
"""
def __init__(self, config):
self.config = config
self.calls = []
self.ready = True
self.tool_engaged = False
self.freedrive_on = False
self.pose = RobotPose(300, 0, 250)
self.joints = [0.0] * 6
self.read_error = None
self.move_error = None
self.command_error = None
self.recover_error = None
self.block = None
self.stopped = False
self.connected = True
# --- reads ---
def is_connected(self):
return self.connected
def connect(self):
self.calls.append("connect")
return self.connected
def is_ready(self):
return self.ready
def read_report(self):
# A report cache that cannot speak for the arm returns nothing, the way
# the real one does when the link is down.
if self.read_error:
return None
return list(self.joints), self.pose
def get_joint_angles(self):
if self.read_error:
raise self.read_error
return list(self.joints)
def get_current_position(self):
if self.read_error:
raise self.read_error
return self.pose
# --- motion ---
def _move(self, what):
self.calls.append(what)
if self.block is not None:
# Hold the "motion" open until the test releases it or stops the arm.
deadline = time.monotonic() + TIMEOUT
while not self.block.is_set() and not self.stopped and time.monotonic() < deadline:
time.sleep(0.005)
if self.stopped:
from dora_ulite6.errors import MotionInterruptedError
raise MotionInterruptedError("aborted by stop")
if self.move_error:
raise self.move_error
def go_home(self):
self._move("go_home")
def home_and_clear(self):
self._move("home_and_clear")
def move_to(self, pose, speed=None):
self._move(("move_to", pose.as_list(), speed))
def set_joint_angles(self, angles, speed=None):
self._move(("set_joint_angles", angles, speed))
def set_cartesian_velocity(self, velocity):
self.calls.append(("jog", velocity))
def stop(self):
self.calls.append("stop")
self.stopped = True
# --- the rest ---
def clear(self):
self.calls.append("clear")
self.stopped = False
def recover(self):
self.calls.append("recover")
if self.recover_error:
raise self.recover_error
self.read_error = None
def engage_tool(self):
self.calls.append("engage_tool")
self.tool_engaged = True
def release_tool(self):
self.calls.append("release_tool")
self.tool_engaged = False
def set_freedrive(self, enabled):
self.calls.append(("set_freedrive", enabled))
if self.command_error:
raise self.command_error
self.freedrive_on = enabled
class RecordingNode:
"""A dora node that records outputs *with* their metadata.
`dora.testing.MockNode` drops the metadata dict on `send_output`, and the
metadata is precisely what this node's service and action patterns are built
on — the request_id and goal_id a caller correlates by. So the tests record
it themselves.
"""
def __init__(self, events=()):
self._events = iter(list(events))
self.sent = []
def is_restart(self):
return False
def __iter__(self):
return self
def __next__(self):
return next(self._events)
def send_output(self, output_id, data, metadata=None):
self.sent.append((output_id, data, dict(metadata or {})))
def outputs(self, output_id):
"""Return every message sent on ``output_id`` as (row, metadata) pairs."""
return [(data[0].as_py(), metadata) for name, data, metadata in self.sent if name == output_id]
def input_event(event_id, value, metadata=None):
"""Build the event dict dora hands a node for one input."""
return {"type": "INPUT", "id": event_id, "value": value, "metadata": dict(metadata or {})}
def wait_for(predicate):
"""Poll until ``predicate`` holds, or fail the test rather than hang it."""
deadline = time.monotonic() + TIMEOUT
while time.monotonic() < deadline:
result = predicate()
if result:
return result
time.sleep(0.005)
pytest.fail("the control threads never reached the expected state")
+110
View File
@@ -0,0 +1,110 @@
"""Reading the environment, and refusing a setting the node cannot use."""
import pytest
from dora_ulite6.config import Config, env_float, env_int
from dora_ulite6.effectors import (
EFFECTORS,
GripperEffector,
NoEffector,
VacuumEffector,
get_effector,
)
from dora_ulite6.errors import EffectorUnsupportedError
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
"""Run every test against a bare environment, whatever the shell set."""
for name in ("ROBOT_IP", "TOOL_TYPE", "TOOL_OFFSET_MM", "INIT_MODE", "INIT_Z", "DEFAULT_SPEED"):
monkeypatch.delenv(name, raising=False)
def test_the_defaults_need_no_environment():
config = Config.from_env()
assert config.robot_ip == "192.168.1.192"
assert isinstance(config.effector, NoEffector)
assert config.tool_offset_mm == 0.0
assert config.init_mode == "none"
def test_the_tool_offset_is_the_tools_own_plus_the_extra(monkeypatch):
monkeypatch.setenv("TOOL_TYPE", "vacuum")
monkeypatch.setenv("TOOL_OFFSET_MM", "4.5")
config = Config.from_env()
assert isinstance(config.effector, VacuumEffector)
assert config.tool_offset_mm == pytest.approx(VacuumEffector.offset_mm + 4.5)
def test_an_unknown_tool_is_refused_at_startup(monkeypatch):
monkeypatch.setenv("TOOL_TYPE", "magnet")
with pytest.raises(ValueError, match="vacuum"):
Config.from_env()
def test_an_unknown_init_mode_is_refused_at_startup(monkeypatch):
# Caught here rather than at the first motion, so the node never joins the
# dataflow with a startup move nobody can name.
monkeypatch.setenv("INIT_MODE", "somewhere")
with pytest.raises(ValueError, match="INIT_MODE"):
Config.from_env()
def test_init_pose_components_are_read(monkeypatch):
monkeypatch.setenv("INIT_MODE", "pose")
monkeypatch.setenv("INIT_Z", "180")
assert Config.from_env().init_pose.z == 180.0
@pytest.mark.parametrize("value", ["", " "])
def test_a_blank_variable_falls_back_to_the_default(monkeypatch, value):
monkeypatch.setenv("DEFAULT_SPEED", value)
assert env_float("DEFAULT_SPEED", 30.0) == 30.0
def test_a_number_that_is_not_one_names_itself(monkeypatch):
monkeypatch.setenv("PATH_SAMPLES", "four")
with pytest.raises(ValueError, match="PATH_SAMPLES"):
env_int("PATH_SAMPLES", 4)
def test_a_bare_flange_refuses_to_grasp():
with pytest.raises(EffectorUnsupportedError):
NoEffector().attach(object())
def test_an_effector_is_refused_by_a_driver_that_cannot_switch_it():
class ArmWithNoTools:
pass
with pytest.raises(EffectorUnsupportedError, match="set_vacuum"):
VacuumEffector().verify(ArmWithNoTools())
def test_each_effector_switches_the_driver_method_it_declares():
switched = []
class Arm:
def set_gripper(self, closed):
switched.append(closed)
arm = Arm()
effector = get_effector("gripper")
effector.verify(arm)
effector.attach(arm)
effector.detach(arm)
assert isinstance(effector, GripperEffector)
assert switched == [True, False]
def test_every_registered_effector_declares_a_reach():
assert all(effector.offset_mm >= 0 for effector in EFFECTORS.values())
+315
View File
@@ -0,0 +1,315 @@
"""Starting up, jogging and shutting down: the node's behaviour as a dora node."""
import threading
import time
import pytest
from dora_ulite6.config import Config, MotionConfig
from dora_ulite6.control import MOVING, PREEMPTED, READY, UNAVAILABLE, Control, ServiceCall
from dora_ulite6.pose import RobotPose
from tests.conftest import FakeArm, wait_for
def build(monkeypatch, connected=True, **config):
"""Return a started Control over a fake arm, and the arms it built."""
arms = []
def make(cfg):
arm = FakeArm(cfg)
arm.connected = connected
arms.append(arm)
return arm
monkeypatch.setattr("dora_ulite6.control.ULite6", make)
restarted = config.pop("restarted", False)
control = Control(Config(telemetry_interval_s=0.01, **config), restarted=restarted)
control.arms = arms
control.start()
wait_for(lambda: control.robot is not None)
return control
# --- shutting down ---
def test_shutdown_halts_the_arm_before_leaving(monkeypatch):
# A motion lives in the controller, not in this process: exiting without
# commanding a stop leaves an arm travelling with nobody left to stop it.
control = build(monkeypatch)
control.arms[0].block = threading.Event()
control.submit(ServiceCall(request_id="m1", row={"service": "home"}))
wait_for(lambda: "go_home" in control.arms[0].calls)
assert control.shutdown() is True
assert "stop" in control.arms[0].calls
def test_shutdown_reports_a_control_thread_it_could_not_get_back(monkeypatch):
# The caller's cue to leave rather than hang until dora kills the process.
control = build(monkeypatch, motion=MotionConfig(ready_timeout_s=0.1))
control.arms[0].block = threading.Event()
# An arm that ignores the stop is one whose thread will not come back.
control.arms[0].stop = lambda: control.arms[0].calls.append("stop")
control.submit(ServiceCall(request_id="m1", row={"service": "home"}))
wait_for(lambda: "go_home" in control.arms[0].calls)
assert control.shutdown() is False
control.arms[0].block.set()
def test_shutdown_is_clean_when_the_arm_was_idle(monkeypatch):
control = build(monkeypatch)
assert control.shutdown() is True
# --- restarting ---
@pytest.mark.parametrize("init_mode", ["home", "pose"])
def test_a_restart_leaves_the_arm_where_it_is(monkeypatch, init_mode):
# Driving a real arm to INIT_MODE because the process came back is a motion
# nobody asked for.
control = build(monkeypatch, init_mode=init_mode, restarted=True)
try:
wait_for(lambda: READY in control.take_state_changes())
assert control.arms[0].calls == []
finally:
control.shutdown()
def test_a_first_start_still_runs_init_mode(monkeypatch):
control = build(monkeypatch, init_mode="home")
try:
wait_for(lambda: "go_home" in control.arms[0].calls)
finally:
control.shutdown()
# --- an arm that is not serving yet ---
def messages_on(control, output, seen):
"""Collect messages on one output, draining the outbox as the event loop would."""
seen.extend(control.take_messages())
return [message for message in seen if message.output == output]
def test_a_request_sent_while_the_arm_is_down_is_still_answered(monkeypatch):
# An arm that is down is an outcome to report, not a reason to stop
# answering. `recover` in particular has to reach a controller that answers
# while refusing to serve — that state is the one it exists to escape.
control = build(monkeypatch, connected=False, reconnect_interval_s=0.05)
seen = []
try:
wait_for(lambda: UNAVAILABLE in control.take_state_changes())
control.submit(ServiceCall(request_id="r1", row={"service": "recover"}))
responses = wait_for(lambda: messages_on(control, "response", seen))
assert responses[0].metadata["request_id"] == "r1"
assert responses[0].fields["ok"] is True
assert "recover" in control.arms[0].calls
finally:
control.shutdown()
def test_a_motion_sent_while_the_arm_is_down_is_refused_rather_than_held(monkeypatch):
# Holding it would be worse than silence: the arm is somewhere unknown and
# nobody is watching, so a held motion would fire unattended the moment the
# link came back.
control = build(monkeypatch, connected=False, reconnect_interval_s=0.05)
seen = []
try:
wait_for(lambda: UNAVAILABLE in control.take_state_changes())
control.start_motion(ServiceCall(request_id="m1", row={"service": "home"}))
responses = wait_for(lambda: messages_on(control, "response", seen))
assert responses[0].metadata["request_id"] == "m1"
assert responses[0].fields["ok"] is False
control.arms[0].connected = True
wait_for(lambda: READY in control.take_state_changes())
assert "go_home" not in control.arms[0].calls
finally:
control.shutdown()
# --- jogging ---
def test_a_jog_makes_the_arm_report_that_it_is_moving(monkeypatch):
# An arm that is travelling must never look idle.
control = build(monkeypatch)
try:
wait_for(lambda: READY in control.take_state_changes())
control.start_jog([20.0, 0, 0, 0, 0, 0])
assert MOVING in control.take_state_changes()
wait_for(lambda: any(c[0] == "jog" for c in control.arms[0].calls if isinstance(c, tuple)))
finally:
control.shutdown()
def test_a_stream_of_jogs_is_one_state_change_not_hundreds(monkeypatch):
control = build(monkeypatch)
try:
wait_for(lambda: READY in control.take_state_changes())
for _ in range(20):
control.start_jog([20.0, 0, 0, 0, 0, 0])
assert control.take_state_changes() == [MOVING]
finally:
control.shutdown()
def test_a_zero_velocity_jog_puts_the_arm_back_to_ready(monkeypatch):
control = build(monkeypatch)
try:
wait_for(lambda: READY in control.take_state_changes())
control.start_jog([20.0, 0, 0, 0, 0, 0])
control.take_state_changes()
control.start_jog([0.0] * 6)
assert READY in control.take_state_changes()
finally:
control.shutdown()
def test_a_zero_velocity_jog_does_not_make_a_running_motion_look_idle(monkeypatch):
# A teleop source streaming zero velocity says nothing about a motion that
# owns the arm, and must not answer for it: an arm that is travelling must
# never look idle, and a `ready` here would also stop the motion's own end
# from ever being published.
control = build(monkeypatch)
control.arms[0].block = threading.Event()
try:
wait_for(lambda: READY in control.take_state_changes())
control.submit(ServiceCall(request_id="m1", row={"service": "home"}))
wait_for(lambda: MOVING in control.take_state_changes())
for _ in range(5):
control.start_jog([0.0] * 6)
assert control.take_state_changes() == []
control.arms[0].block.set()
wait_for(lambda: READY in control.take_state_changes())
finally:
control.shutdown()
def test_a_sender_that_goes_quiet_stops_being_called_jogging(monkeypatch):
# The controller expires the command on its own; nothing tells us, so the
# clock has to.
control = build(monkeypatch, motion=MotionConfig(velocity_command_ttl_s=0.02))
try:
wait_for(lambda: READY in control.take_state_changes())
control.start_jog([20.0, 0, 0, 0, 0, 0])
control.take_state_changes()
wait_for(lambda: READY in control.take_state_changes())
finally:
control.shutdown()
def test_a_jog_preempts_a_motion_that_is_running(monkeypatch):
control = build(monkeypatch)
control.arms[0].block = threading.Event()
try:
control.start_motion(ServiceCall(request_id="m1", row={"service": "home"}, wait=False))
wait_for(lambda: "go_home" in control.arms[0].calls)
control.start_jog([20.0, 0, 0, 0, 0, 0])
wait_for(lambda: "stop" in control.arms[0].calls)
states = _motion_states(control, "m1")
wait_for(lambda: PREEMPTED in _motion_states(control, "m1", states))
finally:
control.arms[0].block.set()
control.shutdown()
def test_a_motion_leaves_velocity_mode_through_the_front_door(monkeypatch):
# A pose command switches the controller out of velocity mode behind the
# driver's back, leaving its own bookkeeping wrong.
control = build(monkeypatch)
try:
wait_for(lambda: READY in control.take_state_changes())
control.start_jog([20.0, 0, 0, 0, 0, 0])
wait_for(lambda: any(isinstance(c, tuple) and c[0] == "jog" for c in control.arms[0].calls))
control.start_motion(
ServiceCall(request_id="m1", row={"service": "move_to", "x": 1.0, "y": 2.0, "z": 3.0}, wait=False)
)
wait_for(lambda: any(isinstance(c, tuple) and c[0] == "move_to" for c in control.arms[0].calls))
jogs = [c for c in control.arms[0].calls if isinstance(c, tuple) and c[0] == "jog"]
assert jogs[-1] == ("jog", [0.0] * 6)
finally:
control.shutdown()
def _motion_states(control, request_id, into=None):
collected = into if into is not None else []
collected.extend(
message.fields["state"]
for message in control.take_messages()
if message.output == "motion_state" and message.fields["request_id"] == request_id
)
return collected
# --- the report invariant get_state rests on ---
def test_get_state_reads_the_cache_not_the_wire(monkeypatch):
control = build(monkeypatch)
try:
control.arms[0].pose = RobotPose(1, 2, 3)
control.call_now("s1", {"service": "get_state"})
fields = control.take_messages()[0].fields
assert fields["pose"][:3] == [1, 2, 3]
# Nothing that costs a round trip was called.
assert control.arms[0].calls == []
finally:
control.shutdown()
# --- the cadence itself ---
def test_the_reporter_honours_the_telemetry_interval(monkeypatch):
"""The interval is a real sleep, not a flag that makes every wait return at once.
Worth a test of its own because the failure is silent: the node behaves
correctly in every other respect while burning a core and reading the arm a
million times a second.
"""
reads = []
class Counting(FakeArm):
def read_report(self):
reads.append(1)
return super().read_report()
monkeypatch.setattr("dora_ulite6.control.ULite6", Counting)
control = Control(Config(telemetry_interval_s=0.05))
control.start()
try:
wait_for(lambda: reads)
reads.clear()
time.sleep(0.3)
finally:
control.shutdown()
# ~6 at a 50 ms cadence. A spinning loop reaches six figures.
assert len(reads) < 30, f"the reporter read the arm {len(reads)} times in 0.3 s"
assert reads, "the reporter stopped reading the arm altogether"
+215
View File
@@ -0,0 +1,215 @@
"""The dora event loop: routing, correlation, and refusing without touching the arm."""
import pyarrow as pa
import pytest
from dora_ulite6 import main
from dora_ulite6.config import Config
from dora_ulite6.control import Message, ServiceCall, Telemetry
from dora_ulite6.pose import RobotPose
from tests.conftest import RecordingNode, input_event
class SpyControl:
"""Records what the loop hands the arm threads, without starting any."""
def __init__(self):
self.submitted = []
self.motions = []
self.called_now = []
self.stops = []
self.jogs = []
self.shutdowns = []
self.posted = []
self._telemetry = None
self._states = []
def submit(self, job):
self.submitted.append(job)
def start_motion(self, call):
self.motions.append(call)
def call_now(self, request_id, row):
self.called_now.append((request_id, row))
def request_stop(self, request_id=""):
self.stops.append(request_id)
def start_jog(self, velocity):
self.jogs.append(velocity)
def shutdown(self):
self.shutdowns.append(True)
return True
def post(self, message):
self.posted.append(message)
def take_messages(self):
messages, self.posted = self.posted, []
return messages
def take_state_changes(self):
states, self._states = self._states, []
return states
def take_telemetry(self):
telemetry, self._telemetry = self._telemetry, None
return telemetry
def row(**fields):
return pa.array([fields])
def handle(event, control=None):
"""Run one event through the loop and return the node it wrote to."""
node = RecordingNode()
main._handle(node, control or SpyControl(), event)
return node
def test_a_service_request_is_queued_with_its_request_id():
control = SpyControl()
main._handle(RecordingNode(), control, input_event("request", row(service="clear"), {"request_id": "r1"}))
assert isinstance(control.submitted[0], ServiceCall)
assert control.submitted[0].request_id == "r1"
def test_stop_goes_to_the_stopper_rather_than_the_queue_or_the_loop():
# It must not queue behind the motion it aborts, and must not run here
# either: the SDK spends up to three seconds confirming a stop.
control = SpyControl()
main._handle(RecordingNode(), control, input_event("request", row(service="stop"), {"request_id": "r1"}))
assert control.stops == ["r1"]
assert control.submitted == []
assert control.called_now == []
def test_get_state_is_the_one_service_run_on_the_loop():
control = SpyControl()
main._handle(RecordingNode(), control, input_event("request", row(service="get_state"), {"request_id": "r1"}))
assert control.called_now == [("r1", {"service": "get_state"})]
def test_a_jog_goes_through_the_door_that_preempts():
# A jog drives the arm, so it takes the same road a motion does.
control = SpyControl()
main._handle(
RecordingNode(), control, input_event("jog", row(velocity=[1.0, 0, 0, 0, 0, 0]))
)
assert control.jogs == [[1.0, 0, 0, 0, 0, 0]]
assert control.submitted == []
def test_a_jog_of_the_wrong_length_is_dropped_not_commanded():
control = SpyControl()
main._handle(RecordingNode(), control, input_event("jog", row(velocity=[1.0, 0])))
assert control.jogs == []
def test_a_motion_goes_through_the_door_that_preempts():
# Still one protocol for the caller; the routing differs because the arm is
# one resource and a new motion replaces the old.
control = SpyControl()
main._handle(
RecordingNode(),
control,
input_event("request", row(service="move_to", x=1.0, y=2.0, z=3.0), {"request_id": "m1"}),
)
assert control.submitted == []
assert control.motions[0].request_id == "m1"
assert control.motions[0].wait is True
def test_wait_false_is_read_off_the_request():
control = SpyControl()
main._handle(
RecordingNode(),
control,
input_event("request", row(service="home", wait=False), {"request_id": "m1"}),
)
assert control.motions[0].wait is False
def test_a_malformed_request_is_refused_under_its_own_id_without_reaching_the_arm():
control = SpyControl()
main._handle(
RecordingNode(), control, input_event("request", row(service="teleport"), {"request_id": "r1"})
)
assert control.submitted == []
refusal = control.posted[0]
assert refusal.output == "response"
assert refusal.metadata == {"request_id": "r1"}
assert refusal.fields["ok"] is False
assert "teleport" in refusal.fields["message"]
def test_an_unexpected_input_is_ignored():
node = handle(input_event("weather", row(sunny=True)))
assert node.sent == []
def test_a_tick_publishes_a_response_under_its_request_id():
control = SpyControl()
control.post(Message("response", {"ok": True, "message": "done"}, {"request_id": "r1"}))
node = handle(input_event("tick", pa.array([0])), control)
assert node.outputs("response") == [({"ok": True, "message": "done"}, {"request_id": "r1"})]
def test_a_tick_publishes_telemetry_and_state_changes():
control = SpyControl()
control._states = ["ready"]
control._telemetry = Telemetry([1.0] * 6, RobotPose(1, 2, 3), 42)
node = handle(input_event("tick", pa.array([0])), control)
sent = {name: (data, metadata) for name, data, metadata in node.sent}
assert sent["robot_state"][0].to_pylist() == ["ready"]
assert sent["joint_pose"][0].to_pylist() == [1.0] * 6
assert sent["tcp_pose"][0].to_pylist() == pytest.approx([1, 2, 3, 180, 0, 0])
assert sent["tcp_pose"][1]["frame"] == "tool_tip"
def test_a_tick_with_nothing_new_publishes_nothing():
node = handle(input_event("tick", pa.array([0])))
assert node.sent == []
def test_the_loop_runs_until_dora_stops_it(monkeypatch):
# The loop must drain on tick and leave on STOP, shutting the threads down.
control = SpyControl()
control.post(Message("response", {"ok": True}, {"request_id": "r1"}))
node = RecordingNode([input_event("tick", pa.array([0])), {"type": "STOP"}])
monkeypatch.setattr(main, "Node", lambda name: node)
monkeypatch.setattr(main, "Control", lambda config, restarted: control)
monkeypatch.setattr(main, "_parse_args", lambda: type("Args", (), {"name": "ulite6"})())
monkeypatch.setattr(Config, "from_env", classmethod(lambda cls: Config()))
control.start = lambda: None
main.main()
assert node.outputs("response")[0][1] == {"request_id": "r1"}
assert control.shutdowns == [True]
+71
View File
@@ -0,0 +1,71 @@
"""Pose maths and the tool-tip offset. No robot needed."""
import numpy as np
import pytest
from dora_ulite6.controller import interpolate, shortest_turn
from dora_ulite6.pose import RobotPose, with_tool_offset
def test_from_sequence_accepts_a_position_alone():
pose = RobotPose.from_sequence([10, 20, 30])
assert pose.as_list() == [10, 20, 30, 180.0, 0.0, 0.0]
def test_from_sequence_round_trips_a_full_pose():
values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
assert RobotPose.from_sequence(values).as_list() == values
@pytest.mark.parametrize(
("start", "target", "expected"),
[(-178.0, 180.0, -2.0), (180.0, -178.0, 2.0), (0.0, 90.0, 90.0), (0.0, 270.0, -90.0)],
)
def test_shortest_turn_never_takes_the_long_way(start, target, expected):
assert shortest_turn(start, target) == pytest.approx(expected)
def test_interpolate_turns_the_short_way_across_the_wrap():
halfway = interpolate([0, 0, 0, -178, 0, 0], [0, 0, 0, 180, 0, 0], 0.5)
assert halfway[3] == pytest.approx(-179.0)
def test_interpolate_walks_the_straight_line_in_position():
halfway = interpolate([0, 0, 0, 180, 0, 0], [100, 200, 300, 180, 0, 0], 0.5)
assert halfway[:3] == pytest.approx([50, 100, 150])
def test_tool_down_offset_lowers_the_tip():
tip = with_tool_offset(RobotPose(300, 0, 250, roll=180, pitch=0, yaw=0), 61.0)
assert tip.z == pytest.approx(250 - 61.0)
assert (tip.x, tip.y) == pytest.approx((300, 0))
def test_offset_follows_the_tool_axis_not_the_base_axis():
# Pitched 90° from tool-down, the tool points along +x rather than -z.
tip = with_tool_offset(RobotPose(300, 0, 250, roll=0, pitch=90, yaw=0), 61.0)
assert tip.x == pytest.approx(300 + 61.0)
assert tip.z == pytest.approx(250)
def test_a_zero_offset_returns_the_pose_untouched():
pose = RobotPose(1, 2, 3, 4, 5, 6)
assert with_tool_offset(pose, 0.0) is pose
def test_the_offset_inverts_exactly():
# move_to relies on this: it takes the offset back off a tip pose to get the
# flange pose the controller wants.
pose = RobotPose(123, -45, 210, roll=170, pitch=12, yaw=-30)
there = with_tool_offset(pose, 61.0)
back = with_tool_offset(there, -61.0)
np.testing.assert_allclose(back.as_list(), pose.as_list(), atol=1e-9)
+231
View File
@@ -0,0 +1,231 @@
"""Non-blocking motions, and a new one taking the place of the old."""
import threading
import pytest
from dora_ulite6.config import Config
from dora_ulite6.control import (
ACCEPTED,
DONE,
MOVING,
PREEMPTED,
Control,
ServiceCall,
)
from tests.conftest import FakeArm, wait_for
@pytest.fixture
def control(monkeypatch):
"""Start a Control over a fake arm whose motions can be held open."""
arms = []
def build(config):
arm = FakeArm(config)
arm.block = threading.Event()
arms.append(arm)
return arm
monkeypatch.setattr("dora_ulite6.control.ULite6", build)
control = Control(Config(telemetry_interval_s=0.01, reconnect_interval_s=0.01))
control.arms = arms
control.start()
wait_for(lambda: control.robot is not None)
yield control
arms[0].block.set()
control.shutdown()
class Mail:
"""Accumulates what the control threads produced, so nothing is missed."""
def __init__(self, control):
self.control = control
self.messages = []
def drain(self):
self.messages.extend(self.control.take_messages())
return self.messages
def states(self, request_id):
"""Return the motion_state lifecycle published for one request, in order."""
self.drain()
return [
m.fields["state"]
for m in self.messages
if m.output == "motion_state" and m.fields["request_id"] == request_id
]
def responses(self, request_id):
self.drain()
return [
m.fields
for m in self.messages
if m.output == "response" and m.metadata.get("request_id") == request_id
]
def wait_for_state(self, request_id, state):
wait_for(lambda: state in self.states(request_id))
def motion(request_id, wait=False, service="home", **args):
return ServiceCall(request_id=request_id, row={"service": service, **args}, wait=wait)
def test_a_non_blocking_motion_is_answered_before_the_arm_stops(control):
mail = Mail(control)
control.start_motion(motion("m1", wait=False))
wait_for(lambda: mail.responses("m1"))
# The caller has its answer while the arm is still held open — the response
# was never waiting on the motion, so the motion has not even been reported
# as finished.
assert not control.arms[0].block.is_set()
assert DONE not in mail.states("m1")
assert mail.responses("m1")[0] == {
"ok": True,
"service": "home",
"message": "accepted",
"accepted": True,
}
def test_a_non_blocking_motion_reports_completion_only_on_the_state(control):
mail = Mail(control)
control.start_motion(motion("m1", wait=False))
mail.wait_for_state("m1", MOVING)
control.arms[0].block.set()
mail.wait_for_state("m1", DONE)
assert mail.states("m1") == [ACCEPTED, MOVING, DONE]
# Exactly one response, the acknowledgement. A second would break the
# one-response-per-request contract the caller correlates by.
assert len(mail.responses("m1")) == 1
def test_a_blocking_motion_is_answered_only_when_the_arm_stops(control):
mail = Mail(control)
control.start_motion(motion("m1", wait=True))
mail.wait_for_state("m1", MOVING)
assert mail.responses("m1") == []
control.arms[0].block.set()
wait_for(lambda: mail.responses("m1"))
assert mail.responses("m1")[0]["ok"] is True
def test_accepted_is_published_before_the_thread_reaches_the_motion(control):
# The race a bare moving/idle flag leaves open: a caller polling right after
# its request must not read a state that says nothing is moving.
mail = Mail(control)
control.arms[0].block = threading.Event()
control.start_motion(motion("m1", wait=False))
assert mail.states("m1")[0] == ACCEPTED
def test_a_new_motion_preempts_the_one_running(control):
mail = Mail(control)
control.start_motion(motion("m1", wait=False))
mail.wait_for_state("m1", MOVING)
control.start_motion(motion("m2", wait=False, service="move_to", x=1.0, y=2.0, z=3.0))
mail.wait_for_state("m1", PREEMPTED)
assert "stop" in control.arms[0].calls
assert mail.states("m1") == [ACCEPTED, MOVING, PREEMPTED]
def test_the_preempting_motion_then_runs(control):
mail = Mail(control)
control.start_motion(motion("m1", wait=False))
mail.wait_for_state("m1", MOVING)
control.start_motion(motion("m2", wait=False, service="move_to", x=1.0, y=2.0, z=3.0))
mail.wait_for_state("m1", PREEMPTED)
control.arms[0].stopped = False
control.arms[0].block.set()
mail.wait_for_state("m2", DONE)
# The software stop the preemption caused is cleared before the new move.
assert "clear" in control.arms[0].calls
assert any(isinstance(call, tuple) and call[0] == "move_to" for call in control.arms[0].calls)
def test_a_preempted_blocking_caller_is_released_with_a_failure(control):
mail = Mail(control)
control.start_motion(motion("m1", wait=True))
mail.wait_for_state("m1", MOVING)
control.start_motion(motion("m2", wait=False))
wait_for(lambda: mail.responses("m1"))
answer = mail.responses("m1")[0]
assert answer["ok"] is False
assert "preempted by m2" in answer["message"]
def test_a_motion_still_waiting_its_turn_is_dropped_without_moving_the_arm(control):
# Driven straight at the branch rather than through the threads: whether a
# preemption lands before or after the control thread picks the motion up is
# a race by nature, and only one side of it is this behaviour.
mail = Mail(control)
call = motion("m2", wait=True, service="move_to", x=1.0, y=2.0, z=3.0)
call.preempted_by = "m3"
control._run_motion(call)
assert mail.states("m2") == [PREEMPTED]
assert mail.responses("m2")[0]["ok"] is False
assert "preempted by m3" in mail.responses("m2")[0]["message"]
assert not any(isinstance(c, tuple) and c[0] == "move_to" for c in control.arms[0].calls)
def test_every_preempted_request_is_answered_exactly_once(control):
mail = Mail(control)
control.start_motion(motion("m1", wait=True))
mail.wait_for_state("m1", MOVING)
control.start_motion(motion("m2", wait=True))
mail.wait_for_state("m1", PREEMPTED)
# m2 may or may not have started by now; either way m3 takes its place, and
# what must hold is that nobody is left without an answer.
control.start_motion(motion("m3", wait=True))
control.arms[0].block.set()
for request_id in ("m1", "m2", "m3"):
wait_for(lambda rid=request_id: mail.responses(rid))
assert len(mail.responses(request_id)) == 1, request_id
def test_a_preemption_that_stopped_nothing_still_clears_for_the_one_that_did(control):
# A preemption of a motion still waiting its turn stops nothing, and must
# not swallow the pending clear owed by an earlier one that did.
mail = Mail(control)
control.start_motion(motion("m1", wait=False))
mail.wait_for_state("m1", MOVING)
control.start_motion(motion("m2", wait=False)) # stops m1
mail.wait_for_state("m1", PREEMPTED)
control.start_motion(motion("m3", wait=False)) # may stop nothing
control.arms[0].block.set()
mail.wait_for_state("m3", DONE)
assert "clear" in control.arms[0].calls
def test_a_service_that_does_not_move_the_arm_never_preempts(control):
mail = Mail(control)
control.start_motion(motion("m1", wait=False))
mail.wait_for_state("m1", MOVING)
control.submit(ServiceCall(request_id="s1", row={"service": "clear"}))
assert PREEMPTED not in mail.states("m1")
assert "stop" not in control.arms[0].calls
+275
View File
@@ -0,0 +1,275 @@
"""Services: one request, one response, correlated by request_id."""
import threading
import pytest
from dora_ulite6 import payload, services
from dora_ulite6.config import Config
from dora_ulite6.control import MOVING, READY, Control, ServiceCall
from dora_ulite6.errors import RobotUnavailableError, UnreachablePoseError
from tests.conftest import FakeArm, wait_for
@pytest.fixture
def control(monkeypatch):
"""Start a Control over a fake arm, and shut it down when the test ends."""
arms = []
def build(config):
arm = FakeArm(config)
arms.append(arm)
return arm
monkeypatch.setattr("dora_ulite6.control.ULite6", build)
control = Control(Config(telemetry_interval_s=0.01, reconnect_interval_s=0.01))
control.arms = arms
control.start()
wait_for(lambda: control.robot is not None)
yield control
control.shutdown()
def responses(control):
"""Wait for at least one response and return them as (row, metadata) pairs."""
found = []
def got_one():
found.extend(m for m in control.take_messages() if m.output == "response")
return found
wait_for(got_one)
return [(message.fields, message.metadata) for message in found]
def test_a_response_comes_back_under_the_requests_own_id(control):
control.submit(ServiceCall(request_id="req-7", row={"service": "clear"}))
fields, metadata = responses(control)[0]
assert metadata == {"request_id": "req-7"}
assert fields["ok"] is True
assert fields["service"] == "clear"
assert "clear" in control.arms[0].calls
def test_get_state_answers_with_everything_a_caller_would_otherwise_poll(control):
control.submit(ServiceCall(request_id="s1", row={"service": "get_state"}))
fields, _ = responses(control)[0]
assert fields["ok"] is True
assert fields["connected"] is True
assert fields["ready"] is True
assert fields["pose"] == [300, 0, 250, 180.0, 0.0, 0.0]
assert fields["joints"] == [0.0] * 6
def test_the_tool_offset_reaches_the_state_response(monkeypatch):
monkeypatch.setattr("dora_ulite6.control.ULite6", FakeArm)
control = Control(Config(telemetry_interval_s=0.01, tool_offset_mm=61.0))
control.start()
try:
wait_for(lambda: control.robot is not None)
control.submit(ServiceCall(request_id="s1", row={"service": "get_state"}))
fields, _ = responses(control)[0]
finally:
control.shutdown()
assert fields["pose"][2] == pytest.approx(250 - 61.0)
def test_freedrive_reads_its_argument(control):
control.submit(ServiceCall(request_id="s1", row={"service": "freedrive", "enabled": False}))
responses(control)
assert ("set_freedrive", False) in control.arms[0].calls
def test_grasp_and_release_go_through_the_mounted_tool(control):
control.submit(ServiceCall(request_id="s1", row={"service": "grasp"}))
responses(control)
control.submit(ServiceCall(request_id="s2", row={"service": "release"}))
responses(control)
assert "engage_tool" in control.arms[0].calls
assert "release_tool" in control.arms[0].calls
def test_a_refusing_arm_makes_a_failed_response_not_an_exception(control):
control.arms[0].recover_error = RobotUnavailableError("still unreachable")
control.arms[0].command_error = RobotUnavailableError("cable pulled")
control.submit(ServiceCall(request_id="s1", row={"service": "freedrive"}))
fields, metadata = responses(control)[0]
assert fields["ok"] is False
assert "cable pulled" in fields["message"]
assert metadata == {"request_id": "s1"}
def test_get_state_on_a_dead_link_says_so_rather_than_handing_back_a_stale_pose(control):
control.arms[0].read_error = RobotUnavailableError("cable pulled")
control.call_now("s1", {"service": "get_state"})
fields = control.take_messages()[0].fields
assert fields["connected"] is False
assert fields["pose"] == []
def test_get_state_answers_during_a_motion_rather_than_queueing_behind_it(control):
# The whole point of polling: it must work while the arm is moving.
control.arms[0].block = threading.Event()
control.start_motion(ServiceCall(request_id="m1", row={"service": "home"}, wait=False))
wait_for(lambda: "go_home" in control.arms[0].calls)
control.call_now("s1", {"service": "get_state"})
answers = [m for m in control.take_messages() if m.output == "response" and m.metadata == {"request_id": "s1"}]
assert answers and answers[0].fields["ok"] is True
control.arms[0].block.set()
def test_stop_does_not_queue_behind_the_motion_it_aborts(control):
# The motion service is held open the way a real one holds the thread.
control.arms[0].block = threading.Event()
control.submit(ServiceCall(request_id="m1", row={"service": "home"}))
wait_for(lambda: "go_home" in control.arms[0].calls)
# Queued, this would sit behind `home` until the motion ended.
control.request_stop("s1")
wait_for(lambda: "stop" in control.arms[0].calls)
assert any(metadata == {"request_id": "s1"} for _, metadata in responses(control))
def test_stop_answers_without_waiting_for_the_arm_to_confirm(control):
# The SDK spends up to three seconds confirming a stop. The caller is
# answered at once and learns the outcome from the state, like everything
# else — the alternative is a dataflow that stops routing for three seconds.
control.arms[0].block = threading.Event()
control.submit(ServiceCall(request_id="m1", row={"service": "home"}))
wait_for(lambda: "go_home" in control.arms[0].calls)
control.request_stop("s1")
answers = [m for m in control.take_messages() if m.metadata == {"request_id": "s1"}]
# Answered on this thread, before the stopper thread has run at all.
assert answers and answers[0].fields["message"] == "stop commanded"
def test_a_motion_service_answers_only_once_the_arm_has_stopped(control):
control.arms[0].block = threading.Event()
control.submit(ServiceCall(request_id="m1", row={"service": "home"}))
wait_for(lambda: "go_home" in control.arms[0].calls)
# The arm is still moving, so the caller has no answer yet.
assert [m for m in control.take_messages() if m.output == "response"] == []
control.arms[0].block.set()
fields, metadata = responses(control)[0]
assert metadata == {"request_id": "m1"}
assert fields["ok"] is True
assert fields["message"] == "the arm is home"
def test_telemetry_keeps_flowing_while_a_motion_blocks(control):
# The reporter thread is not the one inside the motion, which is what lets a
# caller follow a service whose response will not arrive for seconds.
control.arms[0].block = threading.Event()
control.submit(ServiceCall(request_id="m1", row={"service": "home"}))
wait_for(lambda: "go_home" in control.arms[0].calls)
assert wait_for(control.take_telemetry) is not None
control.arms[0].block.set()
def test_the_state_says_moving_while_a_motion_service_runs(control):
control.arms[0].block = threading.Event()
control.submit(ServiceCall(request_id="m1", row={"service": "home"}))
wait_for(lambda: MOVING in control.take_state_changes())
control.arms[0].block.set()
wait_for(lambda: READY in control.take_state_changes())
def test_a_move_request_is_commanded_in_the_flange_frame(monkeypatch):
monkeypatch.setattr("dora_ulite6.control.ULite6", FakeArm)
control = Control(Config(telemetry_interval_s=0.01, tool_offset_mm=61.0))
control.start()
try:
wait_for(lambda: control.robot is not None)
control.submit(
ServiceCall(request_id="m1", row={"service": "move_to", "x": 300, "y": 0, "z": 100})
)
responses(control)
move = next(call for call in control.robot.calls if call[0] == "move_to")
finally:
control.shutdown()
# The caller asked for the tool tip at z=100, so the flange goes to 161.
assert move[1][2] == pytest.approx(161.0)
def test_an_unreachable_pose_fails_the_request_and_leaves_the_arm_ready(control):
control.arms[0].move_error = UnreachablePoseError("no joint solution")
control.submit(
ServiceCall(request_id="m1", row={"service": "move_to", "x": 9000, "y": 0, "z": 0})
)
fields, metadata = responses(control)[0]
assert fields["ok"] is False
assert "no joint solution" in fields["message"]
assert metadata == {"request_id": "m1"}
# The target was wrong, not the arm, so nothing tried to recover it.
assert "recover" not in control.arms[0].calls
def test_a_motion_missing_an_argument_is_refused_without_moving_the_arm(control):
control.submit(ServiceCall(request_id="m1", row={"service": "move_to", "x": 1, "y": 2}))
fields, _ = responses(control)[0]
assert fields["ok"] is False
assert "missing `z`" in fields["message"]
assert control.arms[0].calls == []
def test_move_joints_reads_its_angles(control):
control.submit(
ServiceCall(request_id="m1", row={"service": "move_joints", "angles": [1, 2, 3, 4, 5, 6]})
)
responses(control)
assert ("set_joint_angles", [1.0, 2.0, 3.0, 4.0, 5.0, 6.0], None) in control.arms[0].calls
@pytest.mark.parametrize(
("row", "expected"),
[
({}, "unknown service"),
({"service": "teleport"}, "unknown service"),
({"service": 7}, "unknown service"),
],
)
def test_a_row_naming_no_known_service_is_refused(row, expected):
with pytest.raises(payload.PayloadError, match=expected):
payload.name_of(row, services.SERVICE, services.SERVICES)
def test_an_immediate_service_before_the_arm_is_reached_still_gets_an_answer(monkeypatch):
# `stop` runs on the event loop, so it can arrive before the control thread
# has built the driver. A caller waiting on a request_id must never hang.
monkeypatch.setattr("dora_ulite6.control.ULite6", FakeArm)
control = Control(Config())
control.call_now("s1", {"service": "stop"})
message = control.take_messages()[0]
assert message.metadata == {"request_id": "s1"}
assert message.fields["ok"] is False
assert "not been reached" in message.fields["message"]
Generated
+341 -307
View File
@@ -1,48 +1,9 @@
version = 1 version = 1
revision = 3 revision = 3
requires-python = ">=3.12" requires-python = ">=3.11"
resolution-markers = [
[[package]] "python_full_version >= '3.12'",
name = "annotated-doc" "python_full_version < '3.12'",
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
] ]
[[package]] [[package]]
@@ -56,36 +17,39 @@ wheels = [
[[package]] [[package]]
name = "dora-rs" name = "dora-rs"
version = "0.5.0" version = "1.0.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "pyarrow" }, { name = "pyarrow" },
{ name = "pyyaml" }, { name = "pyyaml" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/f6/67/3b65873c34a2f668dcc698e3ed9571cfefe58f33cf1b4a1a06a5581f5ea6/dora_rs-0.5.0.tar.gz", hash = "sha256:3e0e41d31821d5c647c84072efb8ea358d38abfb09ea96de7628859a7754bdcd", size = 427224, upload-time = "2026-03-25T19:26:08.881Z" } sdist = { url = "https://files.pythonhosted.org/packages/dd/fc/9dd2e1cc4da1caf46496353da6c12f7b7c90512186d828f1d587dc6deddc/dora_rs-1.0.1.tar.gz", hash = "sha256:8cef7fa133d93e7ca46581a9efef6b45b5768cecef5ee6b9e22632978b8fe8a2", size = 1690155, upload-time = "2026-09-03T12:21:54.217Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/70/705f0f7c13b7cc82665f98a5fd57057c078b0bb52afd4f6cd095c3451e18/dora_rs-0.5.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:80603b8a500dbe114bce5a333ef6858989b06064664c8c24ba3cb5d8b4fac2f2", size = 18258324, upload-time = "2026-03-25T19:25:55.26Z" }, { url = "https://files.pythonhosted.org/packages/28/8b/2040480f8393da850f544347a389dab6bf99c84131da9cb5a2273829a76f/dora_rs-1.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:56fb3daf26a978e6f535b0d6b04ac3abd362aff62de6771cd74c2dee06207cb8", size = 24759810, upload-time = "2026-09-03T12:21:42.346Z" },
{ url = "https://files.pythonhosted.org/packages/9d/be/91f42e924658b70bac526a6357627b6c37e3715621c357c5893e616e74a7/dora_rs-0.5.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:37c89d9266231c664d124a1f182e10421e3bfa36d938865e53c3380cc79aa76c", size = 17406326, upload-time = "2026-03-25T19:25:44.719Z" }, { url = "https://files.pythonhosted.org/packages/b9/4c/c563464b74cd31d801fe37c693751005717c80834210c1fa61cb44d7bd34/dora_rs-1.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:61fd33ba5a5cfa01ba4aed4c75bda490b5ad0e5d7180bde7b075d17e0c782486", size = 24061908, upload-time = "2026-09-03T12:21:29.714Z" },
{ url = "https://files.pythonhosted.org/packages/f3/6f/4eccc13c10c92226be5bf069da7c6ae60fbd88b8af4b25a9826fbf791ab2/dora_rs-0.5.0-cp37-abi3-manylinux_2_28_armv7l.whl", hash = "sha256:a7d080908eb9e6b3f00060652ceea068ca5f944f03e975065fec2291f50fbb57", size = 15835974, upload-time = "2026-03-25T19:25:47.388Z" }, { url = "https://files.pythonhosted.org/packages/da/20/125f14f222c2eab928b23d310eeed61c93e9805d30575d21f05df285fb84/dora_rs-1.0.1-cp311-abi3-manylinux_2_28_armv7l.whl", hash = "sha256:6b0075de47b8310bf3801f2ce70b13b6925ebe1c8a7b04c9084bcd11688506ea", size = 22804362, upload-time = "2026-09-03T12:21:32.351Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0b/13a843554e602617fdb7a9fb7861534e843c9d217d3e2313a9d7faa902d6/dora_rs-0.5.0-cp37-abi3-manylinux_2_28_i686.whl", hash = "sha256:1213a2e9a76f2a488cc89abebb08a0de0047f031dea83fed3e969c69c55026b2", size = 19131472, upload-time = "2026-03-25T19:25:50.045Z" }, { url = "https://files.pythonhosted.org/packages/cb/1b/98e0ce52006fbd6d91f33037c5a76ab12edf8c74a01062af39c4dab4c403/dora_rs-1.0.1-cp311-abi3-manylinux_2_28_i686.whl", hash = "sha256:7f1377dda91435ca9d69ecce88a621052c172c2eb07f9c0aa0c1634d6bbfc057", size = 25245130, upload-time = "2026-09-03T12:21:37.702Z" },
{ url = "https://files.pythonhosted.org/packages/38/f4/ada8cb79d7e535aaddc32fbc326557105e00902c75a272bc3ecddda903dd/dora_rs-0.5.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ab368789f72c2cfcd38f9871a0c7cafb8e9fb57d57f810672f71d9d43ffabe17", size = 17796791, upload-time = "2026-03-25T19:25:52.879Z" }, { url = "https://files.pythonhosted.org/packages/b8/80/f931008dec0806d3446de9d762cb2f16883530857e0941d36449b554b84b/dora_rs-1.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a28d634659a23dc8df9fbd40ad093f712e202821060f949f3dba5af8f7f36377", size = 24887127, upload-time = "2026-09-03T12:21:39.886Z" },
{ url = "https://files.pythonhosted.org/packages/b5/5b/546a15683d396674ba142e2ac0df19203468bce7082f958181ebe3458e1c/dora_rs-0.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:08f0617699aaa5d3a75d3f94f87dc2b3906dfec5b70ca24398f3b30d0b1c38eb", size = 21162981, upload-time = "2026-03-25T19:25:57.676Z" }, { url = "https://files.pythonhosted.org/packages/cf/43/01a610cb1f696410698ffb9a6e6d5f2dd3c6ae22ad63aed366c00136b3ae/dora_rs-1.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3255d040778b44542f3d03288268a22f85253db45099d60d33776b7ef92ac1c6", size = 29797596, upload-time = "2026-09-03T12:21:44.594Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e9/511ecd0274996cec4d412a1ac0dbbc7ecd72b46026d6a44f838b9bda3bbd/dora_rs-0.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:cb21bad41e4013845edd5910167fd234fabc3420be8a03254781222afdb5235e", size = 19917806, upload-time = "2026-03-25T19:26:00.644Z" }, { url = "https://files.pythonhosted.org/packages/b5/f5/6668702f8a107c2a701bc8368d14c18d1d36b620c8c7cbc5ac2d83c58cf7/dora_rs-1.0.1-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0febdd43c0c0b511c60def3691d83d69a54ed8543207af3bb238989f7a1f5c0b", size = 28151873, upload-time = "2026-09-03T12:21:46.781Z" },
{ url = "https://files.pythonhosted.org/packages/77/28/b24148d34c8bb642be83d4a0a17545dc9e9962c42003dd9a96c07f347968/dora_rs-0.5.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:bfdcce9e1c5d40836f4300136de004fbdc34981ed9e790b93723801eaa99e9bd", size = 21215205, upload-time = "2026-03-25T19:26:03.455Z" }, { url = "https://files.pythonhosted.org/packages/06/51/0141e6fe69a7444115d0e9e56bbed234cd86035d3d5588f61d56e0778a61/dora_rs-1.0.1-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:f774a046e6d869b4e40b9c9fea5d1a46175c3d83179b44d1f3310c788562b72e", size = 29301986, upload-time = "2026-09-03T12:21:49.576Z" },
{ url = "https://files.pythonhosted.org/packages/25/e1/1e629306ec502eb302e41ab4da718d11b9a528231ab27cce028591009711/dora_rs-0.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85c172e083cb300fcc3b95c14a675680633c533d62c0cae65ee73b0a73cb61a5", size = 20993466, upload-time = "2026-03-25T19:26:06.713Z" }, { url = "https://files.pythonhosted.org/packages/04/b2/5032b628e1e46ef76180860a6f14e32b940d2979971e931630c6e10e3316/dora_rs-1.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d8c8affc29e401558ac83136055750b9de10ac3d3a98855b59866b0c08260dfa", size = 29999168, upload-time = "2026-09-03T12:21:51.803Z" },
{ url = "https://files.pythonhosted.org/packages/b9/56/81d7da08648412f0dd5374d3331ddb69a0e2dd775952317709cabe8de123/dora_rs-0.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:b4353f50d3d1aca100057cd959474f30c3c39d4b3d2771adf6dc452eeec53d34", size = 15366445, upload-time = "2026-03-25T19:26:10.736Z" }, { url = "https://files.pythonhosted.org/packages/e9/3b/bb7c131dd2778909f4ec4d3e0151a7fa342a38ea8fa3ca64d2ef87d93f2f/dora_rs-1.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:fbca33bf87e77fb9532e3bbeff023adb0e9b8e713d6f9106f114e0b0408666f5", size = 22396575, upload-time = "2026-09-03T12:21:55.601Z" },
] ]
[[package]] [[package]]
name = "dora-ulite6" name = "dora-ulite6"
version = "0.1.1" version = "0.3.0"
source = { virtual = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "dora-rs" }, { name = "dora-rs" },
{ name = "fastapi" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "scipy" }, { name = "pyarrow" },
{ name = "uvicorn" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "xarm-python-sdk" }, { name = "xarm-python-sdk" },
] ]
@@ -97,52 +61,17 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "dora-rs", specifier = "==0.5.0" }, { name = "dora-rs", specifier = ">=1.0.1" },
{ name = "fastapi", specifier = ">=0.109.0" }, { name = "numpy", specifier = ">=2.0" },
{ name = "numpy", specifier = "<2.0.0" }, { name = "pyarrow", specifier = ">=17.0" },
{ name = "scipy", specifier = ">=1.14.0" }, { name = "scipy", specifier = ">=1.14" },
{ name = "uvicorn", specifier = ">=0.27.0" },
{ name = "xarm-python-sdk", specifier = ">=1.17.3" }, { name = "xarm-python-sdk", specifier = ">=1.17.3" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "pytest", specifier = ">=8.1.1" }, { name = "pytest", specifier = ">=8.1" },
{ name = "ruff", specifier = ">=0.9.1" }, { name = "ruff", specifier = ">=0.9" },
]
[[package]]
name = "fastapi"
version = "0.135.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
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 = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]] [[package]]
@@ -156,27 +85,169 @@ wheels = [
[[package]] [[package]]
name = "numpy" name = "numpy"
version = "1.26.4" version = "2.4.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } resolution-markers = [
"python_full_version < '3.12'",
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" }, { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
{ url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" }, { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
{ url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" }, { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
{ url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" }, { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
{ url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" }, { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
{ url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" }, { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
{ url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" }, { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
{ url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" }, { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
{ url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
{ url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
{ url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
{ url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
{ url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
{ url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
{ url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
{ url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
{ url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
{ url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
{ url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
{ url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
{ url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
{ url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
{ url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
{ url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
{ url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
{ url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
{ url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
{ url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
{ url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
{ url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
{ url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
{ url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
{ url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
{ url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
{ url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
{ url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
{ url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
{ url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
{ url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
{ url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
{ url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
{ url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
{ url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
{ url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
{ url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
{ url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
{ url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
{ url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
{ url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
{ url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
{ url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
]
[[package]]
name = "numpy"
version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12'",
]
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" },
{ url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" },
{ url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" },
{ url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" },
{ url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" },
{ url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" },
{ url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" },
{ url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" },
{ url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" },
{ url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" },
{ url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" },
{ url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" },
{ url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" },
{ url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" },
{ url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" },
{ url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" },
{ url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" },
{ url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" },
{ url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
{ url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
{ url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
{ url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
{ url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
{ url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
{ url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
{ url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
{ url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
{ url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
{ url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
{ url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
{ url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
{ url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
{ url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" },
{ url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" },
{ url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" },
{ url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" },
{ url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" },
{ url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" },
{ url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" },
{ url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" },
{ url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" },
{ url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" },
{ url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" },
{ url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" },
{ url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" },
{ url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" },
{ url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" },
{ url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" },
{ url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" },
] ]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.0" version = "26.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
] ]
[[package]] [[package]]
@@ -190,145 +261,59 @@ wheels = [
[[package]] [[package]]
name = "pyarrow" name = "pyarrow"
version = "23.0.1" version = "25.0.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" } sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/4b/4166bb5abbfe6f750fc60ad337c43ecf61340fa52ab386da6e8dbf9e63c4/pyarrow-23.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:f4b0dbfa124c0bb161f8b5ebb40f1a680b70279aa0c9901d44a2b5a20806039f", size = 34214575, upload-time = "2026-02-16T10:09:56.225Z" }, { url = "https://files.pythonhosted.org/packages/ee/8b/0d23b47702fcfe8b3618d5292035099675c5a1c48258932350c08020f7b5/pyarrow-25.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:51093dd9e10325fbdb3c10a2ae7c4806e5c822d94e74ae4938b26524a3323fee", size = 35946180, upload-time = "2026-08-10T12:37:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/e1/da/3f941e3734ac8088ea588b53e860baeddac8323ea40ce22e3d0baa865cc9/pyarrow-23.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:7707d2b6673f7de054e2e83d59f9e805939038eebe1763fe811ee8fa5c0cd1a7", size = 35832540, upload-time = "2026-02-16T10:10:03.428Z" }, { url = "https://files.pythonhosted.org/packages/d8/17/707d17a5476c55a9541fde0db8213ac30979a792864d72415f176ba50c45/pyarrow-25.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:eb6203482ff3746a5632303a7279ae0b5a304c46985b49ed1378cb350ea6728d", size = 37644787, upload-time = "2026-08-10T12:37:25.795Z" },
{ url = "https://files.pythonhosted.org/packages/88/7c/3d841c366620e906d54430817531b877ba646310296df42ef697308c2705/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86ff03fb9f1a320266e0de855dee4b17da6794c595d207f89bba40d16b5c78b9", size = 44470940, upload-time = "2026-02-16T10:10:10.704Z" }, { url = "https://files.pythonhosted.org/packages/c1/b2/cdc98ecf1a6408280bc3a6a07054cdd99a3f4670acc0545d383ce113e87d/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:880523be3d29efcf83d3998835d206118ccf35e3871dbd2fb60408cf6b007a80", size = 46834633, upload-time = "2026-08-10T12:37:33.604Z" },
{ url = "https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05", size = 47586063, upload-time = "2026-02-16T10:10:17.95Z" }, { url = "https://files.pythonhosted.org/packages/c8/6e/d3fafc41f378b2c65be43b827798c0fae42049a641c8526633ed3eb573e2/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:25f8720bf6387d5dc2ebd2622112de630760419e4b66134405dd24110d15f37e", size = 50065507, upload-time = "2026-08-10T12:37:40.565Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3c/b7d2ebcff47a514f47f9da1e74b7949138c58cfeb108cdd4ee62f43f0cf3/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bf5842f960cddd2ef757d486041d57c96483efc295a8c4a0e20e704cbbf39c67", size = 48173045, upload-time = "2026-02-16T10:10:25.363Z" }, { url = "https://files.pythonhosted.org/packages/d5/12/8d0698954b8c3001844a898e0a6900bebe83d7ee40c11195174c5122f324/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4facd65742a024a4a366328a1d2292062d72d6e023c1b7dda8d4c37544933a25", size = 49955690, upload-time = "2026-08-10T12:37:46.644Z" },
{ url = "https://files.pythonhosted.org/packages/43/b2/b40961262213beaba6acfc88698eb773dfce32ecdf34d19291db94c2bd73/pyarrow-23.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564baf97c858ecc03ec01a41062e8f4698abc3e6e2acd79c01c2e97880a19730", size = 50621741, upload-time = "2026-02-16T10:10:33.477Z" }, { url = "https://files.pythonhosted.org/packages/d3/0b/1ecb936ac6409e90a34d58eea1c7cec09a9ae6d2141b9e49ad01a2b1ea47/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa0559502e1cd6254d6814614085dd9c5a3dd0419362978a936a3f68a9e5c3df", size = 53128198, upload-time = "2026-08-10T12:37:52.531Z" },
{ url = "https://files.pythonhosted.org/packages/f6/70/1fdda42d65b28b078e93d75d371b2185a61da89dda4def8ba6ba41ebdeb4/pyarrow-23.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:07deae7783782ac7250989a7b2ecde9b3c343a643f82e8a4df03d93b633006f0", size = 27620678, upload-time = "2026-02-16T10:10:39.31Z" }, { url = "https://files.pythonhosted.org/packages/8e/1c/5236033550633c9b7377b2a53660b2bbb06cb06dc09c4356332d67643ca1/pyarrow-25.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:62cd0d785b8aa6675ee355f9fc02252a340f4441257c42674937826fd7594325", size = 27857263, upload-time = "2026-08-10T12:37:56.943Z" },
{ url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" }, { url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" },
{ url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" }, { url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" },
{ url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" }, { url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" },
{ url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" }, { url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" },
{ url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" }, { url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" }, { url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" },
{ url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" }, { url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" }, { url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" },
{ url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" }, { url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" },
{ url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" }, { url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" },
{ url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" }, { url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" },
{ url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" }, { url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" }, { url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" }, { url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" },
{ url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" }, { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" },
{ url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" }, { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" },
{ url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" }, { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" },
{ url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" }, { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" },
{ url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" }, { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" },
{ url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" }, { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" }, { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" },
{ url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" }, { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" },
{ url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" }, { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" },
{ url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" }, { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" },
{ url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" }, { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" },
{ url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" }, { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" }, { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" },
{ url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" }, { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
] ]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.20.0" version = "2.21.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
] ]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" version = "9.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -337,9 +322,9 @@ dependencies = [
{ name = "pluggy" }, { name = "pluggy" },
{ name = "pygments" }, { name = "pygments" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
] ]
[[package]] [[package]]
@@ -348,6 +333,15 @@ version = "6.0.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
@@ -390,38 +384,51 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.8" version = "0.16.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } sdist = { url = "https://files.pythonhosted.org/packages/82/bb/5a449b9162e49b139d72f61672bd3ac1d790221f796d3304e2241fff4c58/ruff-0.16.7.tar.gz", hash = "sha256:5f71d004ac1263b22fa39462ac5ae618a4b77d58981af2cc79bf79a29c12b1a6", size = 4924184, upload-time = "2026-09-10T18:04:06.336Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, { url = "https://files.pythonhosted.org/packages/e3/b2/c80aeeb7f9e469c0d63a85d2f1ab6e1ebfbe10ea7a8d2438b7e09e3ff09e/ruff-0.16.7-py3-none-linux_armv6l.whl", hash = "sha256:727307773e7c7f9181d3ed3a2484186e56c1fa1874255911c74585eb2c7c19f9", size = 10048917, upload-time = "2026-09-10T18:03:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, { url = "https://files.pythonhosted.org/packages/7b/96/20bb7bcae008004df52afcb7ac83432d4a467f2c17b672fe46d26be231c5/ruff-0.16.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9d61c258deabf58f34c67bd4bb4d939c7f2e6b5f0e59c1cdd1cf771b11cde929", size = 10242929, upload-time = "2026-09-10T18:03:32.706Z" },
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, { url = "https://files.pythonhosted.org/packages/90/b2/f184b0d5abec02db69cfd7e49b688ae0237554528ca777136c613bf36bee/ruff-0.16.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ab81118df8945e0193d0240712aa4496573595b75185c3636ed825592a0f728", size = 9847245, upload-time = "2026-09-10T18:03:34.509Z" },
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, { url = "https://files.pythonhosted.org/packages/eb/2d/db1633a641866ed801e34cc6b60ef236c5e16f9b2124ab1d49cc24a5fe4f/ruff-0.16.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c196c968874fc8019da8e7163de7a1a370f111e2309b4b7dfea0fce950198d0", size = 9961780, upload-time = "2026-09-10T18:03:36.618Z" },
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, { url = "https://files.pythonhosted.org/packages/4d/98/edea21e1a3e38dbbc3bf6bb068b863b3b06184cf8533a4c7dbbe208a89d5/ruff-0.16.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac8c3bd0a7e10ad31e6ce51e7a99f3cb772e69aecdd6b9ea7e99b362f62a62c0", size = 9866337, upload-time = "2026-09-10T18:03:38.805Z" },
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, { url = "https://files.pythonhosted.org/packages/0b/11/a15e60d4c87b214646f116ca9d204475bf993ee1047459bc9a360fd4d6d1/ruff-0.16.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:398d3988edde000b5c75dc1b3f584708da9bc990de069c18909142580fec1af9", size = 10562512, upload-time = "2026-09-10T18:03:40.71Z" },
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, { url = "https://files.pythonhosted.org/packages/29/42/eaff4c9b6d0c7cdf56df313a17e89ae854f5bbc0b0c8f9cce19be0ab7a8f/ruff-0.16.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce05b62b770a8217c4646a9c4139fca00efe8fe5d71f87df2b243ff20d4584d1", size = 11302938, upload-time = "2026-09-10T18:03:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, { url = "https://files.pythonhosted.org/packages/5d/43/c75aa59a4ec181fe2ec06cab30e198c1c6d107229a9f008ae3a7c16cabd8/ruff-0.16.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af1b576fddb9d9ef2ececfb5fadcd6a624b25070ed85e3cfcfe449fc3ff6a7b9", size = 10840857, upload-time = "2026-09-10T18:03:44.604Z" },
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, { url = "https://files.pythonhosted.org/packages/21/33/81f3da371942ea031105ba679d8d6e28ec1660ccd690a45f42d381161356/ruff-0.16.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ce7f8f22df67c93ed96c717f9128eadb797144ac2bad475cf536f31d6100c55", size = 10370001, upload-time = "2026-09-10T18:03:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, { url = "https://files.pythonhosted.org/packages/fa/0b/6345fb4dbf6dd0ed1cfe5d18391dc9c3f59cc81622a7b0a65b84b3e730ba/ruff-0.16.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:06d0e93d04f392996435ebd600c153f65b47d73fbec2415aa99c5ee5756b3a5f", size = 10548735, upload-time = "2026-09-10T18:03:48.658Z" },
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, { url = "https://files.pythonhosted.org/packages/3f/4d/c5576adf511f92a328e5569dda190ecdd430da51f1a649f3a4a2fd73e21e/ruff-0.16.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:142151a5e7b93c1b11111337142f89dd2fbfee92161225c99a97222f22e32656", size = 10108496, upload-time = "2026-09-10T18:03:50.563Z" },
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, { url = "https://files.pythonhosted.org/packages/ff/8c/667d83c16199a17a56adc6b0bd4c3beb5b767a2babcd16a56f76f9be7fd6/ruff-0.16.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e6651f97a342d8b35d54d8991544ca22169b86dc54111cb604666940c431b750", size = 9860136, upload-time = "2026-09-10T18:03:52.621Z" },
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, { url = "https://files.pythonhosted.org/packages/99/75/78d401106731999a1dd20cc5a6961e37e1eb9397a3b589f73f3a5ce146a3/ruff-0.16.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ef140c6eb935fa9a84c9c607dfb2cb1b85843c192e79265b0c54f35f557ea8e5", size = 10286290, upload-time = "2026-09-10T18:03:55.207Z" },
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, { url = "https://files.pythonhosted.org/packages/68/49/56f9c3a8b755df93a0ad318b2147bf4ef5dae9a7e5ec61c460109c67957f/ruff-0.16.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:53e39506a730fadeee0d998ed5946f30671f0db240c6c7c73bdabbe33604bb6f", size = 10745048, upload-time = "2026-09-10T18:03:57.299Z" },
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, { url = "https://files.pythonhosted.org/packages/5f/ea/7f9b938a63ece4bec677ad7f9f7fa02df3383db1949ed93a382441c09a87/ruff-0.16.7-py3-none-win32.whl", hash = "sha256:2ea3470fcebcbc5df2fb0c6f3b90333fa9084c534e0111c038fa4a6ab9f1c4b7", size = 10059082, upload-time = "2026-09-10T18:03:59.632Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, { url = "https://files.pythonhosted.org/packages/39/11/480a6973a927aa653e1cead6a6416008640e03a99d05b34c0434b8c6c366/ruff-0.16.7-py3-none-win_amd64.whl", hash = "sha256:7ac26aca826e9e21d0f1cb25b54ac660760a9fdd094d3e4df9848232be98cfc6", size = 10593368, upload-time = "2026-09-10T18:04:01.999Z" },
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
] ]
[[package]] [[package]]
name = "scipy" name = "scipy"
version = "1.17.1" version = "1.17.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.12'",
]
dependencies = [ dependencies = [
{ name = "numpy" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" },
{ url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" },
{ url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" },
{ url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" },
{ url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" },
{ url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" },
{ url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" },
{ url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" },
{ url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" },
{ url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" },
{ url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" },
@@ -475,57 +482,84 @@ wheels = [
] ]
[[package]] [[package]]
name = "starlette" name = "scipy"
version = "1.0.0" version = "1.18.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12'",
]
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" },
] { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" },
[[package]] { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" },
name = "typing-extensions" { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" },
version = "4.15.0" { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" },
source = { registry = "https://pypi.org/simple" } { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" },
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" },
wheels = [ { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" },
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" },
] { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" },
{ url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" },
[[package]] { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" },
name = "typing-inspection" { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" },
version = "0.4.2" { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" },
source = { registry = "https://pypi.org/simple" } { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" },
dependencies = [ { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" },
{ name = "typing-extensions" }, { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" },
] { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" },
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" },
wheels = [ { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" },
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" },
] { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" },
[[package]] { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" },
name = "uvicorn" { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" },
version = "0.42.0" { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" },
source = { registry = "https://pypi.org/simple" } { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" },
dependencies = [ { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" },
{ name = "click" }, { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" },
{ name = "h11" }, { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" },
] { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" },
sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" },
wheels = [ { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" },
{ url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" },
{ url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" },
{ url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" },
{ url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" },
{ url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" },
{ url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" },
{ url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" },
{ url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" },
{ url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" },
{ url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" },
{ url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" },
{ url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" },
{ url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" },
{ url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" },
{ url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" },
{ url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" },
{ url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" },
{ url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" },
{ url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" },
{ url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" },
{ url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" },
{ url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" },
{ url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" },
{ url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" },
{ url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" },
] ]
[[package]] [[package]]
name = "xarm-python-sdk" name = "xarm-python-sdk"
version = "1.17.3" version = "1.18.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/27/dd/073cf64fa9e74cfb97c9ded97750ed4652ada1b4921cd0e7d895ff242f7c/xarm_python_sdk-1.17.3.tar.gz", hash = "sha256:e61b988bc3be684c15f8e686958c00e619e14725130ee94148074b8ef5bd9ec3", size = 215842, upload-time = "2025-12-02T03:09:49.342Z" } sdist = { url = "https://files.pythonhosted.org/packages/e3/f1/5dcf5ed5bfd009b226a9656c1401df1782f7f86c7292696bb753366c9267/xarm_python_sdk-1.18.4.tar.gz", hash = "sha256:75acad469c106e1cb7074762015980372564e88a6d62b64ebaea1c7c580332d5", size = 230108, upload-time = "2026-05-21T04:08:48.871Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/0a/85b3df0fa6ddd4f1a9d23cd63948aa145797631c9e20d165ada8e13a058c/xarm_python_sdk-1.17.3-py3-none-any.whl", hash = "sha256:3dee2f9819d54f0ba476ea51ff63f2d1eb248e0658da1b1dcab8c519008955bb", size = 186790, upload-time = "2025-12-02T03:09:47.606Z" }, { url = "https://files.pythonhosted.org/packages/0f/25/ad2814469965e765dcf86c12276505a8aadd3d00c387aed82519f6c45434/xarm_python_sdk-1.18.4-py3-none-any.whl", hash = "sha256:943ce66308bc49619386d6eaf428a2783bcd202c1fbe3fe79de87d5f89647989", size = 193152, upload-time = "2026-05-21T04:08:47.281Z" },
] ]