Author SHA1 Message Date
cristhian e0f5e332ca docs: rewrite the README as a technical reference
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m43s
validate / markdownlint (pull_request) Successful in 58m35s
2026-09-17 17:03:25 -03:00
cristhian b237f4cc9a update requires-python to 3.12 2026-09-17 17:03:25 -03:00
cristhian e3fc538e5d docs: simplify the README and record the pad layout
validate / validate (pull_request) Successful in 58m44s
validate / test (pull_request) Successful in 58m45s
validate / markdownlint (pull_request) Successful in 58m40s
The README carried a lot of rationale that AGENTS.md already holds; what is
left is the tables a consumer wires against. It gains the swept axis and
button indices, and says plainly that the axes check themselves at runtime
and the buttons cannot.
2026-09-16 18:06:12 -03:00
cristhian eede2e5774 update dataflow-gamepad to drive the arm instead of the bench
It now runs joy, teleop and dora-ulite6 with the probe declared and the
console shut, so a pad can be checked against the arm that will actually
move. ROBOT_IP in both arm dataflows points at the bench arm.

The run recipe drops dora up + dora start: dora 1.0.1 refuses to start a
dataflow holding git: nodes from a coordinator, reporting that no build was
run straight after one that succeeded. That costs the file dora topic, which
needs a coordinator to read the wire.
2026-09-16 18:06:12 -03:00
cristhian 7a0847893c fix gamepad layout and refuse a pad whose axes disagree
An Xbox One S pad over Bluetooth numbers its controls differently from the
indices this node shipped with: the right stick is on axes 2 and 3, the
triggers on 4 and 5, and the buttons carry gaps at 2 and 5. Against the old
layout an untouched pad reported a resting trigger as full right-stick
deflection, which asked for 30 deg/s of pitch while lying on the desk, held
the lease for ever so GAMEPAD_IDLE_RELEASE_S never fired, and moved a real
arm into a controller error. X toggled freedrive and LB was the E-STOP, so
the resulting latch could not be released from the pad at all.

Layout now carries the swept indices. Each pad is also measured the first
time it is seen at rest: a stick must rest near centre and a trigger near
the bottom of its travel, and a pad that does not fit reads as absent and is
named in the log rather than driving an arm from a numbering nobody checked.
The measured rest becomes each axis's zero, so a worn stick's tilt is
subtracted rather than deadzoned away and a trigger that reads 0 until it
first moves stops reading as half pressed.

Rest is never re-measured while the pad is in use, because doing that under
a held stick would record the deflection as centre and the arm would move
when the operator let go. The probe drives from the same calibration, so
what it prints is still what the arm would get, and it shows the mismatch.

Tests name their controls through axes_for and buttons_for instead of
numbering them; the positional lists are why a wrong index passed a green
suite.
2026-09-16 18:06:06 -03:00
cristhian 7c1c33f5b1 Merge pull request 'Read the gamepad from dora-joy, and speak a robot-agnostic contract' (#2) from feat/robot-agnostic-teleop into main
validate / validate (push) Successful in 58m41s
validate / test (push) Successful in 58m44s
validate / markdownlint (push) Successful in 58m40s
Reviewed-on: #2
2026-09-16 17:52:06 +00:00
cristhian da1b079b7b serve the web console from the node and drop the websocket bridge
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m44s
validate / markdownlint (pull_request) Successful in 58m40s
The browser reached this node through `dora-websocket-transceiver`: a second
node, a second port, and a round trip through the dataflow for every button
press, to carry a UI only this node can interpret. The node now serves the
page and holds the browsers' sockets itself on one port, so the console is
one URL and a consumer wires nothing for it.

`dora_teleop/server.py` runs aiohttp on a thread of its own and meets the
control loop at two non-blocking points: a bounded queue the tick drains, and
a snapshot handed back. Both queues are bounded deliberately — a flood is
dropped rather than grown, and a slow browser loses stale snapshots rather
than the newest — because nothing a browser does may stall the thread that
stops the arm. A port already in use stops the node rather than leaving it
announcing a console nobody can open.

The web deadman moved but did not change: it was a `clients` message from the
bridge and is now the open-socket count read each tick. One hop fewer, same
meaning.

Contract, so 0.2.0 -> 0.3.0: `ui_command` and `clients` are gone. `ui_state`
stays published for `dora topic echo` and the bench, with no browser reading
it. New WEB_HOST and WEB_PORT (7000; 0 opens no port).

The console itself is laid out like the arm's own pendant — two rings
quartered into arc sectors you hold, each with its out-of-plane axis as pills
above. Directions and signs match xArm Studio on the controller, `Y+` on the
left included: two consoles driving one arm that disagree about which way is
+Y is a trap, not a preference.

Also sends a velocity only when it changes, repeating an unchanged one on
JOG_KEEPALIVE_S (0.2 s) purely to beat the consumer's own expiry. Repeating
every tick made `dora-ulite6` re-plan the same motion twenty times a second.
That turned out not to be the whole cause of the stepping an operator felt —
the rest was fixed in dora-ulite6 v0.3.1, pinned here — but sending 18
messages where 83 went before is worth keeping on its own.

`dora-joy` moves to v0.1.1: v0.1 is an annotated tag, and dora 1.0.1 records
the tag object's id as the commit hash and then rejects its own checkout for
sitting on the peeled commit. Neither dataflow could be built before this.
2026-09-16 14:50:21 -03:00
cristhian ffbba0f4d1 update version to 0.2.0 for the new output contract
validate / validate (pull_request) Successful in 58m40s
validate / test (pull_request) Successful in 58m44s
validate / markdownlint (pull_request) Successful in 58m40s
`twist` and `command` are the ports a consumer should wire from now on, and
`jog` and `request` are deprecated. Nothing breaks yet — both still publish —
but the version should say the contract moved.
2026-09-16 12:24:49 -05:00
cristhian ebd3b3e493 docs: describe the standard ports and the console
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m44s
validate / markdownlint (pull_request) Successful in 58m41s
2026-09-16 12:19:05 -05:00
cristhian 1b63ce97c9 validate the gamepad and robot dataflows in CI
Both pull sibling nodes by `git:`, but dora resolves the wiring without
fetching them, so this stays offline. It is what catches a drift between the
robot dataflow's output_types block and the tag it pins.

Also ignore `git/`, where dora build leaves those clones.
2026-09-16 12:18:57 -05:00
cristhian 177511af1f add the console's jog pad, gripper and freedrive controls
The page was a status readout: it sent three of the nine commands the node
serves, so the browser could take the lease and stop the arm but not move it.

It now holds a direction to move and releases to stop, with pads for
translation and rotation, gripper, freedrive, home and a speed slider. It sends
a fraction of full deflection rather than a speed, so it never learns the
robot's units — the same vector a stick produces.

Plain HTML and no build step: the bridge serves the directory as it stands, and
a console that cannot be fixed without a toolchain is the wrong thing to have
between a person and a robot.

Details that matter on a machine that moves: the pointer is captured so sliding
off a button still releases it; releasing sends jog_stop rather than relying on
the deadman; blur, tab-hide and a closed socket release everything; and the pad
is disabled unless the console holds the lease, so a button that would be
refused looks refused.

test_console.py checks the page against ui.py both ways — every message type it
sends is one the node serves, every telemetry key it reads is one the node
sends. Neither language catches a rename.
2026-09-16 12:18:57 -05:00
cristhian d336f8d499 read the gamepad from dora-joy and publish twist and command
The pad moves to dora-joy, so this node keeps no hardware dependency and the
pad becomes reusable. It arrives on `joy` as a plain reading; `JoyInput.read`
treats silence past JOY_TTL_S as absence, because that node dying looks like
silence rather than like an unplugged pad and the arm cannot tell them apart.

Both control sources are now normalised to a [-1, 1] six-vector before anything
is scaled, and the scaling happens in one function. The web path used to pass a
velocity straight through without going near mapping, which is why the console
had to speak the arm's units. It no longer does.

`twist` (SI) and `command` (robot-agnostic verbs) are the standard ports. `jog`
and `request` still carry the same intent in dora-ulite6's units and service
names so the two repositories can move independently; LEGACY_REQUEST is the
only place that knows what that node calls a verb. Wire one or the other, never
both. `joint_jog` is specified in the manifest and deliberately not built.

Schemas are declared rather than inferred: pyarrow reads a null `value` as its
own type, so an inferred `command` would change its port's type between one
verb and the next.

Three bugs found while moving this code, each with a test:

- `Sample.active` compared raw axes against a threshold, but SDL rests a
  trigger at -1.0, so every connected pad read as in use for ever: the pad took
  the lease on plug-in and GAMEPAD_IDLE_RELEASE_S never fired.
- The e-stop latch was never consulted when deciding a velocity, so Back
  stopped the arm for one tick and the next stick nudge drove it again. Nothing
  on the pad could clear it either. It is now checked above the lease, and X
  releases it and clears the controller's error, as the console's RESET does.
- Buttons were read before the arbiter granted the lease, so the first press
  after the pad had been idle was swallowed: not owned yet when read, and no
  longer a rising edge by the next tick.
2026-09-16 12:18:51 -05:00
cristhian 885b7a569f Merge pull request 'Hand the arm to a human, from a gamepad or a browser' (#1) from feat/gamepad-and-web-teleop into main
validate / test (push) Successful in 58m49s
validate / markdownlint (push) Successful in 58m43s
validate / validate (push) Successful in 58m43s
Reviewed-on: #1
2026-09-15 21:27:51 +00:00
cristhian ac2540629e docs: explain what debug inspection is for
validate / validate (pull_request) Successful in 58m43s
validate / test (pull_request) Successful in 58m50s
validate / markdownlint (pull_request) Successful in 58m42s
The dataflow turned on `enable_debug_inspection` without the README saying why
anyone would want it, which made it a line of configuration nobody could act on.

Documents the three commands it buys — topic list, topic echo, topic hz — and
that they need a coordinator rather than `dora run`. hz is the one that checks
the timings this node promises, so it names the measurement to expect.
2026-09-15 17:21:49 -05:00
cristhian fb14545850 enable debug inspection on the example dataflow
validate / validate (pull_request) Successful in 58m48s
validate / test (pull_request) Successful in 58m47s
validate / markdownlint (pull_request) Successful in 58m43s
`dora topic echo` and `dora topic hz` only read the wire when the dataflow asks
for it. Turning it on here means the velocities this node sends can be watched
live, and their cadence measured, without the bench printing anything and
without a robot — which is most of what testing this node with only a gamepad
needs.

Measured with it: the get_state poll runs at 1.88 Hz against a configured
STATE_POLL_S of 0.5.
2026-09-15 17:08:31 -05:00
cristhian e9fa4bf36b fix the node manifest and make the example dataflow self-contained
validate / validate (pull_request) Successful in 58m46s
validate / test (pull_request) Successful in 58m47s
validate / markdownlint (pull_request) Successful in 58m43s
Two problems, both found by the validate job once it could finally run.

categories named `teleop` and `input`, neither of which dora defines. The set is
fixed — sensor, actuator, robot, transform, filter, ml-inference, llm, speech,
communication, recorder, visualization, simulator, debug — and an unknown
variant makes the whole manifest unparseable, so every type annotation in it was
being ignored.

dataflow.yml spanned three repositories through sibling paths, which meant dora
could not resolve the other nodes' manifests and reported their typed outputs as
unannotated. It also could not be validated in CI, which clones one repo. The
house pattern is a self-contained dataflow, and this now follows it: teleop plus
a bench stand-in for the arm and the browser, both from this checkout. The real
three-node wiring moves to README.md, where a consumer would look for it.

The bench answers every service, publishes plausible telemetry, and prints the
velocities it is sent, so the node can be driven from a real gamepad with no
robot in the room. It is not a simulator and must not become one: it never
integrates a velocity or moves a pose.
2026-09-15 16:51:30 -05:00
cristhian 8a7bfb02e0 add a probe that shows what the gamepad is doing
validate / validate (pull_request) Failing after 58m47s
validate / test (pull_request) Successful in 58m48s
validate / markdownlint (pull_request) Successful in 58m43s
The axis and button indices in mapping.py are SDL's documented layout rather
than an observed one, and until now the only way to find a pad that numbers
things differently was to watch a real arm move the wrong way.

`dora-teleop-probe` prints the raw axes, the buttons it sees and the velocity
that would be sent, live, with no dataflow and no arm — it cannot move anything.
It goes through the same Gamepad and velocity() the node runs, so what it prints
is what the arm would get; that is the whole reason it is worth having, and the
reason it must not grow its own copy of the mapping.

Every axis is listed, including ones the layout does not use, because an axis
marked unused that moves when a stick is pushed is exactly the finding this is
for. The centred bar makes a worn stick that does not return to zero obvious.
2026-09-15 15:47:36 -05:00
cristhian 7250396a1f add the node manifest, dataflow, CI and docs
validate / validate (pull_request) Failing after 58m48s
validate / test (pull_request) Successful in 58m48s
validate / markdownlint (pull_request) Successful in 58m43s
dora-node.yml is the typed contract a consumer wires against, and the only one
they get: no source package is shared between node repositories. The jog,
request and ui ports stay untyped, because dora treats a port carrying
request_id as pattern-correlated and several Arrow schemas legitimately flow
over each — one per service, one per console message.

dataflow.yml runs the whole operator stack from checkouts: this node, the
WebSocket bridge and the arm.

web/ holds a placeholder page, not the real console. It exists so the dataflow
has something to serve and so the node can be checked end to end before the
Control section is ported across from the monolithic repo. It reads telemetry
and can take, release and stop; nothing else.
2026-09-15 15:43:12 -05:00
cristhian 56b86699bd add the event loop and the deadman
The loop owns the one decision this node exists to make: whether a velocity
reaches the arm this tick, and from whom.

An arm under a velocity command keeps moving until told otherwise, so the
question is never whether to start but whether anything has happened that means
it must stop. Every path through _wanted_velocity that is not an owner actively
asking for motion returns a stop, so a case nobody thought of fails by halting
rather than by carrying on. The default has to be still.

Three ways a source goes away, all ending in the same place. It goes quiet, and
the watchdog fires after JOG_TTL_S. It goes away outright — the pad unplugged,
the last browser gone — and the arm stops at once, without waiting out the
timer, because an unplugged pad is not a centred pad. Or the process itself
goes away, and one all-zero velocity is sent before it does: a motion lives in
the controller, not here.

The stop is sent once, not every tick. A stream of zeroes would keep the arm
node believing a jog is live and hold robot_state at moving forever.

E-STOP is not gated on the lease, from either source. Somebody reaching for the
stop is the one moment when asking who owns the arm is the wrong question.
2026-09-15 15:43:06 -05:00
cristhian 9f56b2b334 add the translation to and from the web console
The console's telemetry keys are a contract: app.js reads arm_state, owner,
estop, vacuum, freedrive, position and joints by name, so renaming one breaks
the page silently. They are reproduced rather than improved for that reason,
vacuum included, which is the console's name for whatever tool is bolted on.

The two vocabularies do not line up and this is where they are reconciled. The
arm node reports a driver's lifecycle; the console draws an operator's screen.
The ordering in arm_state is the ordering of what an operator most needs to
know: a latched e-stop outranks everything, because the arm may well read ready
and saying so would invite somebody to push it.

A message this node does not recognise is dropped rather than refused. The
console and the node version independently, and a page from a newer build
saying something we have not learned yet must not take the arm down.
2026-09-15 15:43:00 -05:00
cristhian 04f0d57068 add the lease that decides who drives
Two sources want one arm and the arm cannot take both. Putting the decision in
one place means there is exactly one answer to "why did it not move" rather than
one answer per input path.

The model is ported from the monolithic repo's RobotService lease rather than
replaced with last-writer-wins, because the console is already built on it: its
jog pad is gated on owning the arm and it draws an owner chip. A simpler scheme
would mean rewriting the page to match the node instead of the other way round.

The gamepad takes the arm implicitly, on the first nudge of a stick, so nobody
has to reach for a browser to use the thing in their hands — and gives it back
after sitting untouched, so a pad left on the bench cannot lock the browser out
forever. The web takes and releases explicitly, and may preempt: an operator who
can see the arm is better placed than this code to decide they should have it.
2026-09-15 15:43:00 -05:00
cristhian c9cb4b8c9e add the axis mapping
Pure functions, no state and no hardware, because this is the part that has to
be right and the cheapest to check.

Analog and blended: how fast the arm moves depends on how far the stick is
pushed, and several axes move at once. That is the whole reason a pad beats the
web console's four-sector dial pad, which can only ask for one axis at a time at
one fixed speed.

The deadzone rescales what is left of the travel rather than just clipping it,
so the arm starts from zero just outside the band instead of jumping to a
fraction of full speed the instant the stick leaves it. Expo bends the response
because fine positioning happens near centre, where a linear stick gives almost
no resolution.

Up on the stick sends the arm along +X, matching the console's dial, so the two
agree about which way is which.
2026-09-15 15:42:53 -05:00
cristhian bde8582de3 add the gamepad reader and the node's settings
The reader owns the pad on a thread of its own, and nothing outside that file
may touch pygame: everything else sees a plain immutable Sample. That is what
lets the rest of the node be tested with no hardware, and what keeps SDL's
quirks out of the arm logic.

Sample.present is the field that matters. A pad that has been unplugged reads as
absent rather than as centred, so "the operator let go" and "the pad fell off
the desk" stay distinguishable — they call for the same stop, but only one of
them is worth logging.

Only the display and joystick subsystems are initialised. pygame.init() also
opens audio, which on a headless robot fills stderr with ALSA errors for a
device this node never uses; the display is needed despite there being no
window, because SDL refreshes joystick state during the event pump.

Settings are parsed once at startup so a deadzone that would silently make the
pad inert stops the node before it joins the dataflow.
2026-09-15 15:42:48 -05:00
35 changed files with 6063 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
# A pull request would otherwise run this twice on the same commit: once for the
# branch push and once for the PR.
name: validate
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
# The gamepad dataflow pulls dora-joy by `git:`, but validation resolves
# the wiring without fetching it, so this stays offline and still catches
# a mistyped port.
- name: Validate the gamepad dataflow
run: dora validate dataflow-gamepad.yml
# Same again for the real-arm stack. Its `output_types:` block is what
# lets this pass without an arm or a clone of the arm node, so a drift
# between it and the tag it pins fails here.
- name: Validate the robot dataflow
run: dora validate dataflow-robot.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.
- run: uv sync --locked --group dev
- run: uv run ruff check .
- run: uv run ruff format --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"
+3
View File
@@ -12,3 +12,6 @@ build/
.ruff_cache/
out/
.dora/
# Where `dora build` clones a `git:` node source, which
# dataflow-robot.yml and dataflow-gamepad.yml both have.
git/
+251
View File
@@ -0,0 +1,251 @@
# AGENTS.md
This file provides guidance to AI coding assistants when working with code in this repository.
## What this is
A dora-rs Python node that gives a human control of a robot arm, from a gamepad or from a
web browser, and decides which of the two is driving.
It is **the piece in the middle**, and it **touches no hardware at all**. The arm is
[`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that node's
ordinary `jog` and `request` ports. The gamepad is
[`dora-joy`](https://gitea.liria.cl/liria/dora-joy), which publishes raw axes and buttons
on `joy` and knows nothing about arms. Do not add either here: a node whose job is policy
should not carry a hardware dependency, and a pad nobody else can reach is a pad that gets
reimplemented.
**The web console is the exception, and it is deliberate.** This node serves the page and
holds the browsers' sockets itself (`dora_teleop/server.py`), rather than going through
`dora-websocket-transceiver` as it did up to v0.2.0. A bridge node for one page meant a
second node, a second port and a round trip through the dataflow for every button press,
to carry a UI that only this node can interpret anyway. That is not the monolith
`dora-ulite6` was rewritten to escape — the monolith was one process owning the robot
driver, perception and the UI at once. What is forbidden is the hardware, and that has not
moved.
**What stays here is everything about what a reading *means***: the deadzone, the response
curve, the layout (which button is grasp), and the mapping to base-frame mm/s and deg/s.
`dora-joy` publishes raw SDL and nothing else, so there is exactly one place shaping each
number.
The web console it serves is ported from `Littlehand/monolitic-littlehand-python`
(`littlehand/console/static/`), and **only its Control section**. Perception, calibration,
tasks, chat, ROI, settings and health belong to other nodes; porting them here would
rebuild the monolith inside a node. That boundary is the one that matters, and the
built-in server does not move it: the console can ask for motion and nothing else.
## Build
```bash
uv sync # runtime deps
uv sync --group dev # plus pytest and ruff
```
## Run
```bash
dora run dataflow.yml --uv # this node plus a bench stand-in, no pad
dora run dataflow-robot.yml # the pad and a real arm, both pulled in by git:
dora run dataflow-gamepad.yml # the same, with the probe declared and no console
```
`dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which
is what lets CI validate it. Keep it that way; the pad belongs in the other two. Neither
of those takes `--uv`: dora 1.0.1 installs a `git:` node into a venv inside its own clone
and then looks for its entrypoint in the dataflow's venv. README.md has the detail.
## Test
```bash
uv run pytest # 149 tests, no gamepad, arm, browser or daemon needed
uv run ruff check .
uv run ruff format --check .
```
## Architecture
Two things carry the design: **what may touch the hardware**, and **what must happen when
a source goes away**.
- **`dora_teleop/main.py`** — The dora event loop, and the one decision this node exists
to make: whether a velocity reaches the arm this tick, and from whom. **It never
blocks.** `_drive` and `_wanted_velocity` are the safety-critical pair — every path that
is not an owner actively asking for motion returns a stop, so a case nobody thought of
fails by halting rather than by carrying on. Keep it that way: the default has to be
"still".
- **`dora_teleop/joy.py`** — The pad as it arrives from `dora-joy`. `Sample.present` is
the load-bearing field: an unplugged pad reads as *absent*, not as centred, so "the
operator let go" and "the pad fell off the desk" stay distinguishable.
**`Calibration` is the other half of that seam.** It measures a pad the first time it is
seen at rest and hands the rest of the node a reading as the layout's ideal pad would
have sent it, so a worn stick's tilt is subtracted rather than deadzoned and a trigger
that rests at 0 stops being a special case. A pad whose resting axes do not fit `Layout`
reads as *absent* and is never corrected: one reading cannot tell a trigger at -1.0 from
a stick held hard over, and guessing wrong drives a real arm. It is not re-measured
while the pad is in use — doing that under a held stick would record the deflection as
centre, and the arm would move when the operator let go.
**`JoyInput.read` is the other safety-critical function in this repo.** Moving the pad
into another process added a way for it to go away that did not exist before — that
process dying — and from here that looks like silence rather than like an unplugged pad.
So silence past `JOY_TTL_S` reads as absent, which is the same answer. This works only
because `dora-joy` publishes on every tick whether or not the pad moved; if that ever
changes, this layer is gone and nothing here will say so.
- **`dora_teleop/message.py`** — The wire shapes, **declared and never inferred**.
pyarrow types a struct from its first row, so `{"value": None}` is `null` and
`{"value": 1.0}` is `double` — an inferred `command` would change its port's type
between one verb and the next.
- **`dora_teleop/mapping.py`** — Stick deflection to base-frame velocity, and the layout
that says which index is which. Pure functions, no state, no hardware, because this is
the part that has to be right and the cheapest to check. Output is always the arm node's
units: mm/s and deg/s, base frame. `is_active` lives here rather than on `Sample`
because it needs the layout: **a trigger rests at -1.0**, so the pad-is-in-use test has
to normalise triggers exactly as `velocity` does, or every connected pad reads as in use
for ever and `GAMEPAD_IDLE_RELEASE_S` never fires. `rest_zeros` and `canonical` are the
same concern one layer out: they check a resting pad against `Layout` and map it onto
the ideal one. They are the only defence against a wrong index, which is not a
theoretical failure — an Xbox One S pad reporting its triggers on 4 and 5 against a
layout expecting them on 2 and 5 read as a full-speed pitch command from an untouched
pad, held the lease for ever, and moved a real arm into a controller error.
- **`dora_teleop/arbiter.py`** — The lease. One place that answers "why did it not move",
rather than one answer per input path. Ported from the monolithic repo's `RobotService`
lease because the console is already built on it — it gates its controls on
`t.owner === "web"` and draws an owner chip. A simpler last-writer-wins scheme would mean
rewriting the page to match the node rather than the other way round.
- **`dora_teleop/ui.py`** — Translation both ways. The console's telemetry keys are a
contract: `app.js` reads `arm_state`, `owner`, `estop`, `vacuum`, `freedrive`,
`position`, `joints` by name, so renaming one breaks the page silently. `arm_state()` is
where the arm's vocabulary (`connecting|ready|moving|recovering|unavailable`) is
reconciled with the console's (`idle|moving|jogging|freedrive|estop|stopped|disconnected`).
- **`dora_teleop/config.py`** — Every environment variable, parsed once at startup, so an
unusable setting stops the node before it joins the dataflow instead of surfacing the
first time somebody picks up the pad. Errors name the variable. It also refuses deadman
layers that are out of order, which is otherwise invisible until the day it matters.
- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a
dynamic node's stdout.
- **`dora_teleop/server.py`** — The console's HTTP and WebSocket server, on a thread of
its own. Serves `dora_teleop/web/index.html` at `/` and holds the browsers at `/ws`, on
one port so the page finds its socket on its own origin. Both queues are bounded: a
flood of messages is dropped rather than grown, and a slow browser loses stale snapshots
rather than the newest one. **The browser's deadman lives here** — `clients` counts open
sockets, and the tick reads it.
- **`examples/bench.py`** — A stand-in for the arm, so `dataflow.yml`
is self-contained and CI can validate it with no sibling clones. **It is not a
simulator** and must not become one: it never integrates a velocity or moves a pose. Its
job is to close the loop so this node can be watched, not to stand in for
`dora-ulite6` — anything needing the arm to actually respond needs the arm.
- **`dora_teleop/probe.py`** — `dora-teleop-probe`: a **dynamic node** that attaches to a
running dataflow, reads the same `joy` stream this node reads, and shows the raw axes,
the buttons and the velocity that would be sent. It exists because the indices in
`mapping.py` are SDL's documented layout rather than an observed one, and a pad that
disagrees is otherwise found by watching a real arm move the wrong way. It goes through
the same `velocity()` the node runs, on the same readings, so what it prints is what the
arm would get — keep it that way, or it stops being evidence. `dataflow-gamepad.yml`
declares it as `path: dynamic`. For a pad whose layout is unknown, `dora-joy-probe`
needs no dataflow at all.
## The standard, and why it is one
This node publishes `twist` (SI) and `command` (robot-agnostic verbs), not one robot's
vocabulary. The contract is in the vault at `resources/robotics/teleop-contract.md`, which
is where it lives because no source package is shared between node repositories.
**Normalise first, scale once.** Every control source becomes a `[-1, 1]` six-vector
before anything is scaled, and the scaling happens in exactly one function. The deadzone
and the response curve are applied only to the gamepad, because they correct a worn
physical stick and a browser has none. Break this and the console silently acquires the
robot's units, which is what it used to have and the reason it fitted only one arm.
`jog` and `request` still carry the same intent in `dora-ulite6`'s units and service
names. They are deprecated, exist so the two repositories can move independently, and go
away once the arm node takes the standard ports. `LEGACY_REQUEST` in `main.py` is the only
place that knows what that node calls a verb.
`joint_jog` is specified in `dora-node.yml` and deliberately **not implemented**. A
joint-space robot needs IK and position integration against joint limits, neither of which
belongs here, and a port nothing wires reads as supported while having never run.
## Rules that are not negotiable
- **Send the change, not the state.** A velocity is a set-point the arm holds. `_drive`
sends when it changes and otherwise repeats on `JOG_KEEPALIVE_S`, which exists only to
beat the arm's own expiry. Repeating every tick made a real xArm re-plan the same motion
twenty times a second, which reads as stepping. Do not restore the every-tick re-send.
A change must never be made to wait for the repeat.
- **The stop is sent once, not every tick.** A stream of zero velocities keeps
`dora-ulite6` believing a jog is live and holds `robot_state` at `moving` forever.
- **E-STOP is never gated on the lease, and neither is its release.** From either
source, at any time. The latch (`view.estop`) is checked first in
`_wanted_velocity`, above the owner — a stop that the next stick movement overrides is
not a stop. `_clear` is the only way out and does both halves, this node's latch and the
controller's error; the pad's X and the console's RESET both go through it, so a
pad-only dataflow is not left with a latch nothing can release.
- **The deadman layers must stay in order**: `JOY_TTL_S` (0.2) < `JOG_TTL_S` (0.4) < the
arm node's `VELOCITY_COMMAND_TTL_S` (0.5). Each is the backstop for the one inside it,
and a backstop that fires first backs up nothing. `Config.from_env` enforces the first
pair; nothing can enforce the second from here.
- **An unplugged pad, a dead `dora-joy` or a departed browser stops the arm immediately**,
without waiting out any timer beyond the one that detects it.
- **A pad is driven from only after it has been seen at rest**, and a pad whose rest does
not fit `Layout` is not driven from at all. This covers the axes and **cannot cover the
buttons**: an unpressed button reads the same on every pad, so a wrong button index is
silent until somebody presses it and gets another verb. Sweeping a new pad with the
probe is the only check, and it is worth doing before the arm is powered. The cost is that a pad held when it appears
is ignored until it is let go; the alternative is guessing a numbering from one reading,
against a real arm.
- **Both control sources go through `mapping`.** The web path used to bypass it and pass
a velocity straight through, which is why the console had to speak mm/s. One conversion
point or none.
- **Declare every published Arrow schema.** See `message.py`.
- **Do not add a robot driver or a gamepad driver here.** See above. In particular: no
`pygame`, no `evdev`, and no environment variable naming a device. The web server is
the one deliberate exception and is scoped to the Control console; anything the console
cannot ask the arm to do does not belong on it.
- **Nothing on the server thread may block the control loop.** `server.py` meets the loop
at two non-blocking points only — the queue the tick drains and the snapshot handed
back. A browser that stops reading must never stall the thread that stops the arm.
## What this node cannot do
The lease binds only what goes through this node. `dora-ulite6` has no concept of an owner,
so another node publishing `request` will preempt an operator mid-motion and nothing here
can prevent it. Say so rather than implying otherwise.
`jog` has no feedback channel at all — a malformed one is logged and dropped by the arm
node, with no response and no error output. What this node sends, it learns nothing back
about except through `robot_state` and telemetry.
## Two manifest rules worth knowing
`dora-node.yml` is the typed contract a consumer wires against, and the only one they get:
no source package is shared between node repositories. `dora validate` checks a dataflow
against it without running the node, so CI needs neither a gamepad nor a robot.
`joy`, `jog`, `request`, `response`, `motion_state` and the `ui_` ports are deliberately
**untyped**. dora treats a port carrying `request_id` as pattern-correlated and skips type
checks on it, several Arrow schemas legitimately flow over each, and dora 1.0 ships no
standard joystick type. Annotating them would claim a contract neither side keeps.
## Key APIs
- **dora Python API** (`dora`): `Node()`, `node.send_output(id, array, metadata)`, event
iteration via `for event in node`.
- **The `joy` message** (`dora-joy`): the Arrow struct
`{axes: list<float32>, buttons: list<bool>, present: bool}`, published on every tick.
Raw SDL — a stick centres near 0, but **a trigger rests at -1.0**. There is no header
and no timestamp: freshness is arrival time here.
## Tools
- **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage
issues, pull requests, and other repository operations.
## Code style
- Write docstrings in NumPy style (summary line, then `Parameters`, `Returns`, `Raises`
sections as needed).
- Run `ruff check .` and `ruff format .` before committing. CI enforces both.
## Rules
- Do not mention AI tools in commit messages or documentation.
+120
View File
@@ -0,0 +1,120 @@
# dora-teleop
dora-rs teleoperation node: normalises two control sources — a gamepad read from
`dora-joy`, and a web console served by this node itself — into one owner's lease, and
emits velocity set-points and robot-agnostic commands. It carries no hardware driver: pad
I/O lives in `dora-joy`, arm I/O in a robot node.
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.12+, `uv`.
## System
```mermaid
flowchart LR
pad([gamepad]) --> joy
browser([browser]) -- "ws: held directions, verbs" --> web
web -- "ui_state snapshot" --> browser
joy["dora-joy — sibling repo"]
subgraph teleop["dora-teleop"]
web["web console — built in, HTTP + ws on one port"]
core["command arbitration"]
end
joy -- "joy: axes, buttons, present" --> core
web -- "held directions, ±1" --> core
core -- "twist SI · command verbs (standard)" --> arm
core -- "jog · request (deprecated)" --> arm
arm -. "state · poses · response" .-> core
arm["robot node"]
```
## Quick start
```bash
uv sync
dora run dataflow.yml --uv # teleop + bench stand-in: no pad, no arm
```
Open `http://localhost:7000` and drive the robot from the console. The bench answers
services and publishes telemetry so the loop closes.
### Watching the wire
Every dataflow sets `debug: enable_debug_inspection: true`, so dora can inspect the wire
without any node printing. This needs a coordinator, so it works for the arm-free dataflow:
```bash
dora up
dora start dataflow.yml --uv --name teleop-bench
dora topic list -d teleop-bench # every edge in the graph
dora topic echo -d teleop-bench teleop/jog # every velocity, live
dora topic hz -d teleop-bench teleop/jog --duration 10 # the re-send cadence
dora stop --name teleop-bench && dora down
```
## Running with hardware
| Dataflow | Nodes | Needs |
|------------------------|--------------------------------------|--------------------|
| `dataflow.yml` | teleop + bench stand-in | nothing |
| `dataflow-robot.yml` | joy + teleop + ulite6 | a pad and an arm |
| `dataflow-gamepad.yml` | the same, plus the probe, no console | a pad and an arm |
## Gamepad
| Control | Does (base frame) | Index | Units |
|----------------------|----------------------------------------------------------------|--------------|-------|
| Left stick X / Y | Translate in the horizontal plane: `+X` stick up, `+Y` stick right | axes 0, 1 | mm/s |
| Left / right trigger | Translate along Z: right trigger lifts (`+Z`), left lowers | axes 4, 5 | mm/s |
| Right stick X / Y | Rotate: `+roll` stick right, `+pitch` stick up | axes 2, 3 | deg/s |
| LB / RB | Yaw: RB is `+yaw` | buttons 6, 7 | deg/s |
| A | Close the tool | button 0 | — |
| B | Open the tool | button 1 | — |
| X | Release the E-STOP and clear the arm's error | button 3 | — |
| Y | Toggle freedrive | button 4 | — |
| Back | **E-STOP** — latches until X | button 11 | — |
The sticks move the arm in its horizontal X/Y plane only; the triggers are the one path
to Z. Up on the stick sends the arm away from the operator, the same `+X` convention the
console draws.
## Inputs and outputs
| Direction | Name | Description |
|-----------|----------------|--------------------------------------------------------|
| in | `tick` | 50 ms — change detection, deadman, telemetry |
| in | `joy` | One pad reading from `dora-joy` |
| in | `response` | An answer from the arm |
| in | `motion_state` | How a motion ended |
| in | `robot_state` | The arm's own state |
| in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees |
| in | `joint_pose` | Joint angles, six float32, degrees |
| out | `twist` | **Standard.** `{linear, angular, frame}`, SI |
| out | `command` | **Standard.** `{action, value}`, robot-agnostic verbs |
| out | `jog` | *Deprecated.* mm/s + deg/s, for `dora-ulite6` |
| out | `request` | *Deprecated.* A service call, `request_id` in metadata |
| out | `ui_state` | The console's telemetry snapshot, as JSON |
## Environment variables
| Variable | Default | Description |
|--------------------------|-----------|--------------------------------------------------------|
| `SPEED_MM_S` | `60.0` | Translation speed at full deflection |
| `SPEED_DEG_S` | `30.0` | Rotation speed at full deflection |
| `DEADZONE` | `0.12` | Stick travel near centre to ignore |
| `EXPO` | `0.4` | How much of the curve is cubic; 0 is linear |
| `JOG_TTL_S` | `0.4` | Owner silence before the arm is stopped |
| `JOG_KEEPALIVE_S` | `0.2` | How often an unchanged velocity is repeated |
| `JOY_TTL_S` | `0.2` | `joy` silence before the pad reads as gone |
| `GAMEPAD_IDLE_RELEASE_S` | `2.0` | Untouched pad before it hands the arm back |
| `STATE_POLL_S` | `0.5` | How often to ask the arm what telemetry does not carry |
| `WEB_HOST` | `0.0.0.0` | Address the console binds to |
| `WEB_PORT` | `7000` | Console port; `0` opens no port at all |
## Development
```bash
uv sync --group dev
uv run pytest # 149 tests, no gamepad, arm, browser or daemon needed
uv run ruff check .
uv run ruff format --check .
```
+116
View File
@@ -0,0 +1,116 @@
# The pad, a real arm and the probe. No bench and no browser.
#
# `dataflow.yml` is what CI validates, and it deliberately has no pad in it: the
# pad is `dora-joy`, another repo, and pulling it in by `git:` would cost that
# file its one property — that it resolves and runs from this checkout alone.
# This file is the other half. It needs the network on first build, a pad, and
# an arm.
#
# It is `dataflow-robot.yml` with the probe attached and the console shut, for
# the one job the probe exists for: checking that the pad in your hands has the
# layout `mapping.py` assumes, against the arm that will actually move. Use
# `dataflow-robot.yml` for ordinary driving.
#
# source .venv/bin/activate # not --uv; README.md says why
# dora build dataflow-gamepad.yml
# dora run dataflow-gamepad.yml
#
# `dora run`, not `dora up` + `dora start`: dora 1.0.1 refuses to start a
# dataflow holding `git:` nodes from a coordinator — "node joy has git source,
# but no `dora build` was run yet", straight after a build that succeeded. It is
# the same shape of gap as the `--uv` one above, and it costs this file
# `dora topic`, which needs a coordinator to read the wire.
#
# ROBOT_IP and TOOL_TYPE below name the arm in front of you: the bench arm at
# 192.168.1.162, with a vacuum tool. Read
# `The gamepad` in README.md before picking it up — Back is the E-STOP.
strict_types: true
debug:
enable_debug_inspection: true
nodes:
- id: joy
git: https://gitea.liria.cl/liria/dora-joy.git
tag: v0.1.1
build: pip install .
path: dora-joy
inputs:
tick: dora/timer/millis/20
outputs:
- joy
- id: teleop
path: dora-teleop
build: pip install .
inputs:
tick: dora/timer/millis/50
# queue_size 1: joy ticks at 20 ms and this node at 50, so without it the
# loop would work through a backlog of readings that have already been
# overtaken. An arm should be driven by the newest one or by none.
joy:
source: joy/joy
queue_size: 1
queue_policy: drop_oldest
response: ulite6/response
motion_state: ulite6/motion_state
robot_state: ulite6/robot_state
tcp_pose: ulite6/tcp_pose
joint_pose: ulite6/joint_pose
outputs:
- twist
- command
- jog
- request
- ui_state
env:
SPEED_MM_S: "60"
SPEED_DEG_S: "30"
# Keep the deadman layers ordered: JOY_TTL_S < JOG_TTL_S < the arm's own
# VELOCITY_COMMAND_TTL_S, which is 0.5 s below. Each backs up the one
# inside it, and a backstop that fires first backs up nothing.
JOY_TTL_S: "0.2"
JOG_TTL_S: "0.4"
# This one is about the pad, so the console stays shut and no port is
# opened. `dataflow-robot.yml` is the one that serves it.
WEB_PORT: "0"
# Declared, not spawned: `dora-teleop-probe` attaches to this id when you run
# it, and the dataflow runs perfectly well when nobody does.
- id: teleop-probe
path: dynamic
inputs:
joy: joy/joy
- id: ulite6
git: https://gitea.liria.cl/liria/dora-ulite6.git
tag: v0.3.1
build: pip install .
path: dora-ulite6
inputs:
tick: dora/timer/millis/100
request: teleop/request
jog: teleop/jog
outputs:
- response
- motion_state
- joint_pose
- tcp_pose
- robot_state
# dora type-checks before it fetches a `git:` source, so it cannot read the
# arm's own manifest from here. These three repeat what `dora-ulite6`
# v0.3.1 annotates; `response` and `motion_state` stay untyped there and so
# stay untyped here. Re-check them when moving to a new tag.
output_types:
joint_pose: std/core/v1/Float32
tcp_pose: std/core/v1/Float32
robot_state: std/core/v1/String
env:
ROBOT_IP: "192.168.1.162"
TOOL_TYPE: "vacuum"
# The outermost deadman, enforced by the controller whether or not any of
# this is alive. Every timer above must stay below it.
VELOCITY_COMMAND_TTL_S: "0.5"
# The pad does not home. Nothing moves until somebody pushes a stick.
INIT_MODE: "none"
+111
View File
@@ -0,0 +1,111 @@
# Joystick control of a real arm. Three nodes, no bench and no browser.
#
# This is the one that moves a robot. `dataflow.yml` and `dataflow-gamepad.yml`
# drive a stand-in; this one drives `dora-ulite6`, so everything the pad does
# here happens in the room. Read `The gamepad` in README.md before picking it up
# — Back is the E-STOP.
#
# Both sibling nodes are pulled by `git:`, so this runs from a fresh clone with
# no other checkouts. It needs the network on its first build, and an arm.
#
# source .venv/bin/activate
# dora build dataflow-robot.yml
# dora run dataflow-robot.yml
#
# Not `--uv`: dora 1.0.1 installs a `git:` node into a venv inside its own clone and
# then looks for its entrypoint in this dataflow's venv instead. README.md has the
# detail. `dataflow.yml` has no `git:` nodes and is fine with --uv.
#
# ROBOT_IP and TOOL_TYPE below name the arm in front of you: the bench arm at
# 192.168.1.162, with a vacuum tool.
#
# The web console comes with the node: it serves the page and holds the
# browsers' sockets itself, so `http://<this machine>:7000` is a second way to
# drive the same arm, with the same lease deciding who gets it. Set WEB_PORT to
# 0 below for a pad-only run that opens no port at all.
strict_types: true
# Lets `dora topic echo` read what is on the wire — the velocities going to the
# arm, above all. Run under `dora start` rather than `dora run` to use it.
debug:
enable_debug_inspection: true
nodes:
- id: joy
git: https://gitea.liria.cl/liria/dora-joy.git
tag: v0.1.1
build: pip install .
path: dora-joy
inputs:
tick: dora/timer/millis/20
outputs:
- joy
- id: teleop
path: dora-teleop
build: pip install .
inputs:
tick: dora/timer/millis/50
# queue_size 1: joy ticks at 20 ms and this node at 50, so without it the
# loop would work through a backlog of readings that have already been
# overtaken. An arm should be driven by the newest one or by none.
joy:
source: joy/joy
queue_size: 1
queue_policy: drop_oldest
response: ulite6/response
motion_state: ulite6/motion_state
robot_state: ulite6/robot_state
tcp_pose: ulite6/tcp_pose
joint_pose: ulite6/joint_pose
outputs:
- twist
- command
- jog
- request
- ui_state
env:
SPEED_MM_S: "60"
SPEED_DEG_S: "30"
# Keep the deadman layers ordered: JOY_TTL_S < JOG_TTL_S < the arm's own
# VELOCITY_COMMAND_TTL_S, which is 0.5 s below. Each backs up the one
# inside it, and a backstop that fires first backs up nothing.
JOY_TTL_S: "0.2"
JOG_TTL_S: "0.4"
# The console, on this machine. It is bound to every interface because
# the operator is rarely sitting at the box wired to the arm.
WEB_HOST: "0.0.0.0"
WEB_PORT: "7000"
- id: ulite6
git: https://gitea.liria.cl/liria/dora-ulite6.git
tag: v0.3.1
build: pip install .
path: dora-ulite6
inputs:
tick: dora/timer/millis/100
request: teleop/request
jog: teleop/jog
outputs:
- response
- motion_state
- joint_pose
- tcp_pose
- robot_state
# dora type-checks before it fetches a `git:` source, so it cannot read the
# arm's own manifest from here. These three repeat what `dora-ulite6`
# v0.3.1 annotates; `response` and `motion_state` stay untyped there and so
# stay untyped here. Re-check them when moving to a new tag.
output_types:
joint_pose: std/core/v1/Float32
tcp_pose: std/core/v1/Float32
robot_state: std/core/v1/String
env:
ROBOT_IP: "192.168.1.162"
TOOL_TYPE: "vacuum"
# The outermost deadman, enforced by the controller whether or not any of
# this is alive. Every timer above must stay below it.
VELOCITY_COMMAND_TTL_S: "0.5"
# The pad does not home. Nothing moves until somebody pushes a stick.
INIT_MODE: "none"
+59
View File
@@ -0,0 +1,59 @@
# The repo's own example, and what CI validates.
#
# Self-contained on purpose: teleop plus a bench stand-in for the arm and the
# browser, both built from this checkout, so `dora validate` resolves every
# manifest from `path:` and CI needs no gamepad, no robot and no sibling clones.
#
# There is **no gamepad here**, because the pad is another node and wiring it in
# by `git:` would cost this file the one property it has. For a real pad, use
# `dataflow-robot.yml`, or `dataflow-gamepad.yml` to check the pad with the
# probe — both of which drive a real arm.
#
# This is **not** the real stack. The bench answers services and publishes
# telemetry but never moves anything. README.md carries the four-node wiring a
# consumer actually uses.
strict_types: true
# Lets `dora topic echo` and `dora topic hz` read what is on the wire, so the
# velocities this node sends can be watched without the bench printing them —
# and without a robot. Costs nothing when nobody is looking.
debug:
enable_debug_inspection: true
nodes:
- id: teleop
path: dora-teleop
build: pip install .
inputs:
tick: dora/timer/millis/50
response: bench/response
robot_state: bench/robot_state
tcp_pose: bench/tcp_pose
joint_pose: bench/joint_pose
outputs:
- twist
- command
- jog
- request
- ui_state
env:
SPEED_MM_S: "60"
SPEED_DEG_S: "30"
# The console is served by this node. CI runs this dataflow, so it binds
# to localhost and stays off any interface a colleague could reach.
WEB_HOST: "127.0.0.1"
WEB_PORT: "7000"
- id: bench
path: examples/bench.py
inputs:
tick: dora/timer/millis/200
jog: teleop/jog
request: teleop/request
ui_state: teleop/ui_state
outputs:
- response
- robot_state
- tcp_pose
- joint_pose
+263
View File
@@ -0,0 +1,263 @@
# 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 neither a gamepad
# nor a robot.
#
# `joy`, `jog`, `request`, `response`, `motion_state` and `ui_state` are
# deliberately **untyped**. dora treats a port carrying `request_id` as
# pattern-correlated and skips type checks on it, several Arrow schemas
# legitimately flow over each — one per service, one per console message — and
# dora 1.0 ships no standard joystick type. Annotating them would claim a
# contract neither side keeps.
apiVersion: 1
name: dora-teleop
namespace: liria
description: Hands a robot arm to a human, from a gamepad or a browser
categories: [robot]
keywords: [teleop, gamepad, joystick, jog, arm, manipulator, web-console, operator]
runtime: python
entrypoint: dora-teleop
# `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:
description: >-
Drives everything time-based: noticing a changed velocity, repeating the
held one, expiring a
source that has gone quiet, and publishing the console's telemetry. Use 50
ms. Slower than about 100 ms and the arm node's own velocity expiry starts
firing between ticks, which an operator feels as the arm stuttering.
type: std/core/v1/UInt64
joy:
required: false
description: >-
One gamepad reading from `dora-joy`, as the Arrow struct
`{axes: list<float32>, buttons: list<bool>, present: bool}`. Raw SDL — the
deadzone, the response curve and the base-frame mapping all happen here.
Wire it with `queue_size: 1`: `dora-joy` ticks faster than this node does,
and a backlog of old readings is the last thing an arm should be driven
from.
**`present: false` stops the arm at once**, without waiting out any timer,
because an unplugged pad is not a centred pad. So does the stream going
quiet for longer than `JOY_TTL_S` — `dora-joy` publishes on every tick
whether or not the pad moved, so silence means that node died, and a dead
pad node is indistinguishable from an unplugged pad as far as the arm is
concerned.
Each pad is measured the first time it is seen sitting still, so a stick
that no longer centres has its tilt subtracted rather than deadzoned away.
A pad whose resting axes do not fit the layout this node compiles in — a
trigger where a stick should be, say — reads the same as no pad at all
and is named in the log, rather than being guessed at against a real arm.
Leave it unwired for a browser-only console. The node says so and starts.
response:
required: false
description: >-
An answer from the arm node, correlated by `request_id` in the metadata. A
failure is passed to the console as a toast; a `get_state` answer fills in
what telemetry does not carry.
motion_state:
required: false
description: >-
How a motion ended. Needed because this node sends motions with
`wait: false`, and a non-blocking motion reports its outcome here and
nowhere else.
robot_state:
required: false
description: >-
The arm's own state, one of `connecting`, `ready`, `moving`, `recovering`
or `unavailable`. Translated into the vocabulary the console draws.
type: std/core/v1/String
tcp_pose:
required: false
description: >-
Tool-tip pose, six float32 as `[x, y, z, roll, pitch, yaw]` in mm and
degrees, base frame. Shown in the console's pose readout.
type: std/core/v1/Float32
joint_pose:
required: false
description: >-
Joint angles, six float32 in degrees. Shown in the console's joint readout.
type: std/core/v1/Float32
outputs:
twist:
description: >-
**The standard motion port.** A velocity, as
`{linear: [x, y, z], angular: [x, y, z], frame}` in **SI** — metres per
second and radians per second — in the frame named by `frame`, currently
always `base`.
Sent when the velocity **changes**, and otherwise repeated every
`JOG_KEEPALIVE_S` so the consumer's own expiry never fires mid-motion — a
velocity is a set-point a consumer holds, not a frame in a sequence, and a
consumer that re-plans on every message moves in steps if it is fed one
every tick. A stop is a change: exactly one all-zero Twist, not repeatedly,
because a stream of zeroes keeps a consumer believing a jog is live. There
is no answer to a Twist, ever: it expires on the consumer's own TTL.
Both control sources are normalised to a fraction of full speed before
anything is scaled, so a gamepad and the web console asking for the same
deflection produce byte-identical Twists.
command:
description: >-
**The standard discrete port.** One thing to do, as `{action, value}`.
`action` is robot-agnostic — `gripper_open`, `gripper_close`, `stop`,
`home`, `clear`, `freedrive` — so a consumer that swaps a vacuum for a
two-finger gripper needs no change here. `value` carries the argument
where a verb has one (`freedrive` uses 1.0 and 0.0) and is null otherwise.
jog:
description: >-
**Deprecated; use `twist`.** The same motion in `dora-ulite6`'s units:
`{velocity: [vx, vy, vz, wx, wy, wz]}` in mm/s and deg/s, base frame.
Published alongside `twist` while that node learns the standard one.
Wire `twist` **or** `jog`, never both — they carry the same intent, and a
consumer reading both would be driven twice.
request:
description: >-
**Deprecated; use `command`.** The same verbs under the service names
`dora-ulite6` uses — `stop`, `grasp`, `release`, `clear`, `freedrive`,
`home` — with `request_id` in the metadata. `move_to` and `get_state` have
no standard equivalent yet and go only here.
ui_state:
description: >-
The console's telemetry snapshot, as a JSON string. Also carries error
toasts. Every key in it is read by name in the console's page, so the two
change together.
The browsers get this straight from the node's own server and do not read
this port; it is published for anyone else watching — `dora topic echo`,
a recorder, a second screen — and costs nothing when nobody is.
# Reserved, not implemented. A joint-space robot — a lerobot SO-101, say — takes
# joint targets rather than a Cartesian velocity, and no amount of scaling turns
# one into the other: it needs inverse kinematics, which is another node's job.
# When such a consumer exists, this is the port it wires, mirroring ROS's
# control_msgs/JointJog beside geometry_msgs/Twist:
#
# joint_jog:
# description: >-
# A joint-space velocity, as `{names: [str], velocities: [float]}`, the
# joints named rather than positional so a consumer cannot mistake the
# order.
#
# It is written down rather than built because a port nothing wires is worse
# than no port: it reads as supported and has never once run.
env:
SPEED_MM_S:
type: float
default: 60.0
description: >-
Translation speed at full stick deflection, in mm/s. A stick is analog, so
this is a maximum and anything less moves proportionally slower. Defaults
to 60.0.
SPEED_DEG_S:
type: float
default: 30.0
description: Rotation speed at full deflection, in deg/s. Defaults to 30.0.
DEADZONE:
type: float
default: 0.12
description: >-
Stick travel near centre to ignore, as a fraction. A worn stick that does
not centre would otherwise drive the arm on its own. What is left is
rescaled, so motion still starts from zero just outside the band.
Defaults to 0.12.
EXPO:
type: float
default: 0.4
description: >-
How much of the response curve is cubic, from 0 (linear) to 1. Fine
positioning happens near centre and a linear stick gives it almost no
resolution there. Defaults to 0.4.
JOG_TTL_S:
type: float
default: 0.4
description: >-
How long the owning source may go quiet before the arm is stopped. Keep it
**below** the arm node's VELOCITY_COMMAND_TTL_S (0.5 s by default), which
is the controller-side backstop behind this one. Defaults to 0.4.
JOG_KEEPALIVE_S:
type: float
default: 0.2
description: >-
How often to repeat a velocity that has not changed. Must be **above 0 and
below JOG_TTL_S**, which in turn sits below the arm's own
VELOCITY_COMMAND_TTL_S — so the repeat outpaces the expiry it exists to
beat. Raising it sends fewer messages and asks the controller to re-plan
less; too high and the arm stops between repeats. A change is never
delayed by it. Defaults to 0.2.
JOY_TTL_S:
type: float
default: 0.2
description: >-
How long the `joy` stream may go quiet before the pad reads as gone and the
arm is stopped. Must be **below** JOG_TTL_S. `dora-joy` publishes on every
tick, so this is really "how many missed readings to tolerate" — at a 20 ms
joy tick, the default is ten. Defaults to 0.2.
GAMEPAD_IDLE_RELEASE_S:
type: float
default: 2.0
description: >-
How long a pad may sit untouched before it hands the arm back, so a
browser can take over without anyone pressing anything. Defaults to 2.0.
STATE_POLL_S:
type: float
default: 0.5
description: >-
How often to call `get_state` for what telemetry does not carry: whether
the tool is engaged and whether freedrive is on. Defaults to 0.5.
WEB_HOST:
type: string
default: "0.0.0.0"
description: >-
The address the built-in web console binds to. Defaults to `0.0.0.0`,
because the operator is rarely sitting at the machine wired to the arm.
Set `127.0.0.1` where they are, or where the network is not yours.
WEB_PORT:
type: int
default: 7000
description: >-
The port the console is served on — the page and the browsers' WebSocket
both, so there is one URL and nothing to keep in step. **Set it to 0 to
open no port at all**, which is what a pad-only dataflow wants. Defaults
to 7000.
example: |
- id: teleop
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
tag: v0.3.0
build: pip install .
path: dora-teleop
inputs:
tick: dora/timer/millis/50
joy:
source: joy/joy
queue_size: 1
queue_policy: drop_oldest
response: ulite6/response
motion_state: ulite6/motion_state
robot_state: ulite6/robot_state
tcp_pose: ulite6/tcp_pose
joint_pose: ulite6/joint_pose
outputs:
- twist
- command
- jog
- request
- ui_state
env:
SPEED_MM_S: "60"
SPEED_DEG_S: "30"
+1
View File
@@ -0,0 +1 @@
"""Human control of a robot arm, from a gamepad or a browser."""
+78
View File
@@ -0,0 +1,78 @@
"""Who is allowed to drive the arm.
Two sources want one arm, and the arm cannot take both. This is the whole of
that decision, in one place, so there is exactly one answer to "why did it not
move" rather than one per input path.
The model comes from the monolithic repo's `RobotService` lease, and is kept
because the web console is built on it: its controls are gated on owning the
arm and it renders an owner chip. A simpler last-writer-wins scheme would mean
rewriting the console to match the node rather than the other way round.
The arm node itself has no idea any of this exists. A lease held here binds only
what goes through this node — another node publishing `request` to the arm will
preempt an operator mid-motion, and nothing here can stop it.
"""
from dora_teleop.log import log
GAMEPAD = "gamepad"
WEB = "web"
class Arbiter:
"""The lease on the arm, and the rules for handing it between sources."""
def __init__(self, idle_release_s: float):
self.idle_release_s = idle_release_s
self._owner: str | None = None
self._last_activity = 0.0
@property
def owner(self) -> str | None:
"""Who holds the arm, or None if it is free."""
return self._owner
def take(self, who: str, now: float, preempt: bool = False) -> bool:
"""Give ``who`` the arm if it is theirs to take, and say whether it worked.
A free arm goes to whoever asks. A held one stays held unless the caller
asked to preempt, which the console's TAKE CONTROL button offers
deliberately: an operator who can see the arm is better placed than this
code to decide they should have it.
"""
if self._owner not in (None, who) and not preempt:
return False
if self._owner != who:
log(f"{who} has the arm" + (f", preempting {self._owner}" if self._owner else ""))
self._owner = who
self._last_activity = now
return True
def release(self, who: str) -> None:
"""Give the arm back, if this source had it."""
if self._owner == who:
log(f"{who} released the arm")
self._owner = None
def note_activity(self, who: str, now: float) -> None:
"""Record that the holder is still there, so the idle timer does not fire."""
if self._owner == who:
self._last_activity = now
def expire_idle_gamepad(self, now: float) -> None:
"""Hand the arm back after the pad has sat untouched long enough.
Only the gamepad expires. It takes the arm implicitly, on the first
nudge of a stick, so it has to give it back the same way or a pad left
on the bench would lock the browser out forever. The web takes the arm
by pressing a button and gives it back by pressing another.
"""
if self._owner != GAMEPAD:
return
if now - self._last_activity >= self.idle_release_s:
self.release(GAMEPAD)
def may_drive(self, who: str) -> bool:
"""Whether this source's jog should reach the arm."""
return self._owner == who
+164
View File
@@ -0,0 +1,164 @@
"""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 the
first time somebody picks up the gamepad.
"""
import os
from dataclasses import dataclass, field
def env_float(name: str, default: float) -> float:
"""Read a number, naming the variable if it cannot be read as one."""
value = os.environ.get(name, "").strip()
if not value:
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:
"""Read a whole number, naming the variable if it cannot be read as one."""
value = os.environ.get(name, "").strip()
if not value:
return default
try:
return int(value)
except ValueError:
raise ValueError(f"{name} must be a whole number, not {value!r}") from None
@dataclass
class ConsoleConfig:
"""Where the built-in web console listens.
The node serves the page and holds the browsers' sockets itself, so there is
one address and one port rather than a bridge node and two. `0.0.0.0` is the
default because the operator is rarely sitting at the machine driving the
arm; bind it to `127.0.0.1` where that is not true.
"""
host: str = "0.0.0.0"
port: int = 7000
@property
def enabled(self) -> bool:
"""Whether to open the port at all. Port 0 means a pad-only dataflow."""
return self.port != 0
@classmethod
def from_env(cls) -> "ConsoleConfig":
"""Build the console's address from the environment."""
port = env_int("WEB_PORT", 7000)
if not 0 <= port <= 65535:
raise ValueError(f"WEB_PORT must be a port number, not {port}")
return cls(host=os.environ.get("WEB_HOST", "").strip() or "0.0.0.0", port=port)
@dataclass
class DeadmanConfig:
"""How long a source may go quiet before the arm is told to stop.
Four layers, outermost last. `joy_ttl_s` is how long a pad reading stays
worth acting on; `jog_keepalive_s` is how often an unchanged velocity is
repeated; `jog_ttl_s` is how long this node keeps driving without hearing
from the source; and behind all of them sits the arm node's own
`VELOCITY_COMMAND_TTL_S`, enforced there whether or not this process is
alive.
Keep them in that order — each shorter than the one behind it — or a
backstop fires before the thing it is backing up.
"""
jog_ttl_s: float = 0.4
# How often to repeat a velocity that has not changed. A velocity is a
# set-point, not a sequence: the arm holds the last one until its own expiry
# or a new one, so repeating it 20 times a second only makes the controller
# re-plan the same motion 20 times, which an operator feels as stepping.
# Low enough that the arm's VELOCITY_COMMAND_TTL_S never fires mid-motion,
# and no lower. A change — a new direction, or the stop — never waits for it.
#
# It is checked on the tick, so the period actually achieved is this rounded
# *up* to the next tick: 0.2 with a 50 ms tick repeats every 250 ms, because
# the tick at 200 ms lands a hair early and does not qualify. Budget against
# that number, not this one.
jog_keepalive_s: float = 0.2
# How long the `joy` stream may go quiet before the pad reads as gone. The
# pad lives in another node now, and that node dying looks like silence
# rather than like an unplugged pad, so silence has to mean the same thing.
# `dora-joy` publishes every tick, so it means nothing else.
joy_ttl_s: float = 0.2
# How long a gamepad may sit untouched before it gives the arm back, so the
# browser can take over without anyone having to press anything.
gamepad_idle_release_s: float = 2.0
# How often to ask the arm for the things telemetry does not carry: whether
# the tool is engaged, whether freedrive is on, whether it is ready.
state_poll_s: float = 0.5
@classmethod
def from_env(cls) -> "DeadmanConfig":
"""Build the timings from the environment."""
return cls(
jog_ttl_s=env_float("JOG_TTL_S", 0.4),
jog_keepalive_s=env_float("JOG_KEEPALIVE_S", 0.2),
joy_ttl_s=env_float("JOY_TTL_S", 0.2),
gamepad_idle_release_s=env_float("GAMEPAD_IDLE_RELEASE_S", 2.0),
state_poll_s=env_float("STATE_POLL_S", 0.5),
)
@dataclass
class Config:
"""The node's settings."""
# Full-deflection speeds. Both sources are analog — a gamepad stick and the
# console's on-screen ones — so these are the maximum and anything less
# moves proportionally slower.
speed_mm_s: float
speed_deg_s: float
# Stick travel to ignore. A worn stick that does not centre would otherwise
# drive the arm across the table on its own.
deadzone: float
# How much of the curve is cubic: 0 is linear, 1 is fully cubic. Fine
# positioning happens near centre, and expo is what buys resolution there.
expo: float
deadman: DeadmanConfig = field(default_factory=DeadmanConfig)
console: ConsoleConfig = field(default_factory=ConsoleConfig)
@classmethod
def from_env(cls) -> "Config":
"""Build the configuration from the environment, refusing anything unusable."""
deadzone = env_float("DEADZONE", 0.12)
if not 0.0 <= deadzone < 1.0:
raise ValueError(f"DEADZONE must be at least 0 and below 1, not {deadzone}")
expo = env_float("EXPO", 0.4)
if not 0.0 <= expo <= 1.0:
raise ValueError(f"EXPO must be between 0 and 1, not {expo}")
deadman = DeadmanConfig.from_env()
# The layers have to fire outermost last, and each one is a backstop for
# the one inside it. Behind both of these sits the arm node's own
# VELOCITY_COMMAND_TTL_S, which no setting here can see.
if deadman.joy_ttl_s >= deadman.jog_ttl_s:
raise ValueError(
f"JOY_TTL_S must be below JOG_TTL_S ({deadman.jog_ttl_s}), not {deadman.joy_ttl_s}"
)
# The repeat has to be quicker than the arm's own velocity expiry or the
# arm stops between repeats. That expiry cannot be read from here, but
# JOG_TTL_S is already required to sit below it, so staying under
# JOG_TTL_S keeps the repeat under both.
if not 0.0 < deadman.jog_keepalive_s < deadman.jog_ttl_s:
raise ValueError(
f"JOG_KEEPALIVE_S must be above 0 and below JOG_TTL_S ({deadman.jog_ttl_s}), "
f"not {deadman.jog_keepalive_s}"
)
return cls(
speed_mm_s=env_float("SPEED_MM_S", 60.0),
speed_deg_s=env_float("SPEED_DEG_S", 30.0),
deadzone=deadzone,
expo=expo,
deadman=deadman,
console=ConsoleConfig.from_env(),
)
+155
View File
@@ -0,0 +1,155 @@
"""The gamepad, as it arrives from `dora-joy`.
The pad itself lives in another node now. What reaches this one is a plain
reading on the `joy` input, and this module is the seam the loop reads it
through: :class:`Sample` is what the rest of the node sees, exactly as it did
when the pad was read in-process.
**The safety-critical part is :meth:`JoyInput.read`.** Moving the pad into
another process added a way for it to go away that did not exist before — that
process dying — and from here that looks like silence rather than like an
unplugged pad. So silence past `JOY_TTL_S` reads as *absent*, which is the same
answer, because a reading nobody is refreshing is not evidence of anything.
`dora-joy` publishes on every tick whether or not the pad moved, so silence
means one thing only.
The other thing this seam does is :class:`Calibration`: no two pads rest in
quite the same place, and one that rests somewhere the layout does not expect
is a pad this node must not drive an arm from. Every reading the loop sees has
been through it, so the rest of the node only ever handles a pad that behaves.
"""
import time
from dataclasses import dataclass, field
from dora_teleop import mapping
from dora_teleop.log import log
@dataclass(frozen=True)
class Sample:
"""One reading of the pad.
``present`` is the one that matters for safety: a pad that has been
unplugged reads as absent rather than as centred, so the difference between
"the operator let go" and "the operator's pad fell off the desk" is not lost.
"""
axes: list[float] = field(default_factory=list)
buttons: list[bool] = field(default_factory=list)
present: bool = False
ABSENT = Sample()
def decode(event: dict) -> Sample:
"""Read one `joy` message into a reading.
A message this node cannot make sense of reads as absent rather than as
centred, for the same reason an unplugged pad does.
"""
value = event["value"]
if not len(value):
return ABSENT
row = value[0].as_py()
return Sample(
axes=list(row.get("axes") or []),
buttons=[bool(down) for down in row.get("buttons") or []],
present=bool(row.get("present")),
)
class Calibration:
"""Where the pad in front of us rests, and whether the layout fits it.
Captured from the first reading of a pad that is sitting still, and kept
until that pad goes away. It is not re-taken while the pad is in use, which
would be the one way to get this badly wrong: an operator holding a stick
when it happened would have that deflection recorded as centre, and the arm
would then move whenever they let go.
A pad whose resting axes do not fit the layout is reported **absent** rather
than corrected. The numbering cannot be guessed from one reading — a trigger
resting at -1.0 and a stick held hard over look identical — and guessing
wrong drives a real arm. `dora-teleop-probe` shows what the pad reports, and
`Layout` in :mod:`dora_teleop.mapping` is where a pad that disagrees is
written down.
"""
def __init__(self, layout: mapping.Layout = mapping.LAYOUT):
self.layout = layout
self.mismatch: str | None = None
self._zeros: list[float] | None = None
@property
def known(self) -> bool:
"""Whether a pad has been measured at rest and may be driven from."""
return self._zeros is not None
def forget(self) -> None:
"""Drop what was learned, so the next pad to appear is measured afresh."""
self._zeros = None
self.mismatch = None
def apply(self, sample: Sample) -> Sample:
"""Return the reading as the layout's ideal pad would have sent it.
An absent pad passes straight through and takes the calibration with
it. A pad that does not fit the layout comes back absent, which every
path in this node already treats as a reason to stop.
"""
if not sample.present:
self.forget()
return sample
if self._zeros is None or len(self._zeros) != len(sample.axes):
try:
self._zeros = mapping.rest_zeros(sample.axes, self.layout)
except ValueError as wrong:
# Logged once per pad: this runs on every reading, and a pad
# that does not fit the layout does not fit it fifty times a
# second.
if self.mismatch is None:
log(
f"ignoring the gamepad: {wrong}. Run dora-teleop-probe and check Layout in mapping.py"
)
self.mismatch = str(wrong)
return ABSENT
self.mismatch = None
return Sample(
axes=mapping.canonical(sample.axes, self._zeros, self.layout),
buttons=sample.buttons,
present=True,
)
class JoyInput:
"""The latest reading, and how long ago it arrived."""
def __init__(self, ttl_s: float, layout: mapping.Layout = mapping.LAYOUT):
self.ttl_s = ttl_s
self.calibration = Calibration(layout)
self._sample = ABSENT
self._seen_at = 0.0
def accept(self, sample: Sample, moment: float) -> None:
"""Take one reading off the wire, calibrated to the pad that sent it."""
self._sample = self.calibration.apply(sample)
self._seen_at = moment
def read(self, moment: float) -> Sample:
"""Return the current reading, or absence if nobody is still sending one.
A stale reading is not a reading. `dora-joy` publishes every tick, so
silence means it died, and a dead pad node is indistinguishable from an
unplugged pad as far as the arm is concerned — which is the only safe
way to read it.
"""
if moment - self._seen_at > self.ttl_s:
return ABSENT
return self._sample
def now() -> float:
"""Return the clock the deadman runs on: monotonic, so it cannot step backwards."""
return time.monotonic()
+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"[teleop {time.strftime('%H:%M:%S')}] {message}", file=sys.stderr, flush=True)
+482
View File
@@ -0,0 +1,482 @@
"""The dora event loop: routing, the deadman, and nothing that can block.
The loop owns the one decision this node exists to make — whether a velocity
reaches the arm this tick, and from whom. Everything else is delegated:
:mod:`dora_teleop.joy` takes the pad off the wire, :mod:`dora_teleop.mapping`
turns a reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed
to, :mod:`dora_teleop.ui` speaks the console's language and
:mod:`dora_teleop.server` serves it. **No hardware is touched here at all** —
the pad is `dora-joy` and the arm is `dora-ulite6`.
**The safety-critical part is `_drive`.** An arm moving under a velocity command
keeps moving until told otherwise, so the question is never "should it start"
but "has anything happened that means it must stop". Three things mean that: the
source went quiet, the source went away, or this process is going away. All
three end in the same place — one all-zero velocity, sent once.
What goes out is the *change*: a velocity is a set-point the arm holds, not a
frame in a sequence, so an unchanged one is repeated only as often as the arm's
own expiry demands. A change — a new direction, or the stop — is never made to
wait for that timer.
"""
import argparse
import json
import time
import pyarrow as pa
from dora import Node
from dora_teleop import joy, mapping, message, server, ui
from dora_teleop.arbiter import GAMEPAD, WEB, Arbiter
from dora_teleop.config import Config
from dora_teleop.joy import JoyInput, now
from dora_teleop.log import log
from dora_teleop.mapping import LAYOUT, pressed
REQUEST_ID = "request_id"
# Still, in the normalised units both sources speak.
STOP_AXES = [0.0] * 6
# The verbs this node publishes on `command`. Robot-agnostic on purpose: a
# consumer that grows a two-finger gripper where the last one had a vacuum
# should need no change here, and neither should this node.
GRIPPER_CLOSE = "gripper_close"
GRIPPER_OPEN = "gripper_open"
STOP = "stop"
HOME = "home"
CLEAR = "clear"
FREEDRIVE = "freedrive"
# The same verbs as `dora-ulite6` names them, for the deprecated `request` port.
# This table is the whole of what used to be spread through the loop, and it is
# what a robot speaking the standard lets us delete.
LEGACY_REQUEST = {
GRIPPER_CLOSE: {"service": "grasp"},
GRIPPER_OPEN: {"service": "release"},
STOP: {"service": "stop"},
CLEAR: {"service": "clear"},
HOME: {"service": "home", "wait": False},
}
class Teleop:
"""Everything the loop remembers between ticks."""
def __init__(self, config: Config):
self.config = config
self.arbiter = Arbiter(config.deadman.gamepad_idle_release_s)
self.joy = JoyInput(config.deadman.joy_ttl_s)
self.view = ui.ArmView()
# What the browser last asked for, and when. A jog is a stream; what
# matters is whether the stream is still arriving. Normalised, like the
# pad's — the console does not know the robot's units.
self.web_axes = STOP_AXES
self.web_seen_at = 0.0
# How many browsers are on the console right now, read from the server
# every tick. The last one leaving has to stop the arm, and a socket
# that went away sends nothing to say so.
self.clients = 0
self.console: server.Console | None = None
# Whether the arm is currently being driven by us. Used to send the stop
# once rather than every tick: a stream of zeroes would keep the arm
# node believing a jog is live and hold `robot_state` at `moving`.
self.jogging = False
# The last velocity actually put on the wire, and when. `_drive` sends
# only when what it wants differs from this, so the arm is told about
# changes rather than told the same thing 20 times a second.
self.sent_axes = STOP_AXES
self.sent_at = 0.0
self.buttons: list[bool] = []
self.requests = 0
self.polled_at = 0.0
def next_request_id(self, service: str) -> str:
"""Mint a correlation id we will recognise coming back."""
self.requests += 1
return f"teleop-{service}-{self.requests}"
def main() -> None:
"""Connect to the dataflow and run until dora stops us."""
args = _parse_args()
config = Config.from_env()
# Passing the name lets the node also run standalone as a dynamic node; when
# dora spawns it, the id from the dataflow wins.
node = Node(args.name)
state = Teleop(config)
if config.console.enabled:
state.console = server.Console(config.console.host, config.console.port)
state.console.start()
try:
for event in node:
if event["type"] == "ERROR":
raise RuntimeError(event["error"])
if event["type"] != "INPUT":
continue
_handle(node, state, event)
finally:
# A motion lives in the arm's controller, not in this process. Leaving
# without zeroing the velocity leaves it travelling until the
# controller's own expiry catches up, which is the one moment nobody is
# watching for it.
_send_motion(node, state, STOP_AXES)
if state.console is not None:
state.console.stop()
def _parse_args() -> argparse.Namespace:
"""Read the node name, for when dora did not spawn us."""
parser = argparse.ArgumentParser(description="Drive a robot arm from a gamepad or a browser.")
parser.add_argument(
"--name",
default="teleop",
help="The name of the node in the dataflow, used when running as a dynamic node.",
)
return parser.parse_args()
def _handle(node: Node, state: Teleop, event: dict) -> None:
"""Route one input to whatever deals with it."""
event_id = event["id"]
if event_id == "tick":
_tick(node, state)
elif event_id == "joy":
state.joy.accept(joy.decode(event), now())
elif event_id == "robot_state":
state.view.state = event["value"][0].as_py()
elif event_id == "tcp_pose":
state.view.pose = event["value"].to_pylist()
elif event_id == "joint_pose":
state.view.joints = event["value"].to_pylist()
elif event_id == "response":
_accept_response(node, state, event)
elif event_id == "motion_state":
_accept_motion_state(node, state, event)
else:
log(f"ignoring unexpected input `{event_id}`")
# --- the tick, where the arm is actually driven ---
def _tick(node: Node, state: Teleop) -> None:
"""Decide what the arm should be doing, and say so."""
moment = now()
# Before anything else: what the browser asked for since the last tick is
# part of this tick's picture, and the client count it implies is what the
# web deadman reads below.
_drain_console(node, state)
# Absent covers three things now: no pad plugged in, a pad unplugged
# mid-jog, and `dora-joy` no longer sending. The arm cannot tell them apart
# and does not need to.
sample = state.joy.read(moment)
# Who owns the arm is settled before anything they pressed is acted on.
# The other order drops the first press after the pad has been idle: the
# lease is not held yet when the buttons are read, and by the next tick the
# button is held rather than newly down, so the edge is gone for good.
#
# A pad taking the arm the moment a stick moves is what keeps an operator
# from having to reach for a browser to use the thing in their hands.
active = mapping.is_active(sample)
if active and state.arbiter.owner is None:
state.arbiter.take(GAMEPAD, moment)
if active:
state.arbiter.note_activity(GAMEPAD, moment)
state.arbiter.expire_idle_gamepad(moment)
_read_buttons(node, state, sample)
_drive(node, state, sample, moment)
_poll_state(node, state, moment)
_publish(node, state)
def _drive(node: Node, state: Teleop, sample, moment: float) -> None:
"""Send the velocity the owner is asking for, or the stop that ends it.
Changes go out at once; an unchanged velocity is repeated only on
`jog_keepalive_s`, which exists solely so the arm's own velocity expiry
never fires mid-motion. Repeating it every tick instead made the controller
re-plan the same motion twenty times a second, which an operator feels as
the arm moving in steps rather than travelling.
The stop is a change like any other, so releasing a button is acted on the
tick it happens and never waits for the repeat.
"""
axes = _wanted_axes(state, sample, moment)
if axes != state.sent_axes:
# Includes the stop: going to all-zero is a change, sent once, and the
# `jogging` flag then keeps it from being repeated. A stream of zeroes
# would hold the arm node's `robot_state` at `moving` forever.
state.jogging = mapping.is_moving(axes)
_send_motion(node, state, axes)
return
if state.jogging and moment - state.sent_at >= state.config.deadman.jog_keepalive_s:
_send_motion(node, state, axes)
def _wanted_axes(state: Teleop, sample, moment: float) -> list[float]:
"""Return what the robot should be doing, normalised, which is usually nothing.
Every path that is not an owner actively asking for motion returns a stop.
That is deliberate: the default has to be "still", so that a case nobody
thought of fails by halting rather than by carrying on.
"""
# First, and above the lease. A stop somebody had to reach for is not undone
# by the next thumb movement: it holds until it is deliberately cleared.
if state.view.estop:
return STOP_AXES
owner = state.arbiter.owner
if owner == GAMEPAD:
# An unplugged pad is not a centred pad. Stopping here rather than
# waiting out the TTL is the difference between a dropped controller
# costing 10 ms of travel and costing 400.
if not sample.present:
return STOP_AXES
return mapping.from_pad(sample.axes, sample.buttons, state.config)
if owner == WEB:
# The console refreshes its jog every 150 ms; the browser going quiet,
# or the tab closing, has to stop the arm without anything being sent.
if state.clients == 0:
return STOP_AXES
if moment - state.web_seen_at > state.config.deadman.jog_ttl_s:
return STOP_AXES
return state.web_axes
return STOP_AXES
def _read_buttons(node: Node, state: Teleop, sample) -> None:
"""Fire the services a gamepad button asks for, on the press only.
Edge-triggered, because these are one-shot requests rather than a held
velocity: holding `grasp` should not send a request every 50 ms.
"""
previous, state.buttons = state.buttons, list(sample.buttons)
if not sample.present:
return
def tapped(index: int) -> bool:
return pressed(sample.buttons, index) and not pressed(previous, index)
# Neither the stop nor its release is gated on the lease. Somebody reaching
# for the stop is the one moment when asking who owns the arm is the wrong
# question, and a latch that cannot be released without first taking the arm
# back is a trap — an idle pad hands the lease away two seconds after the
# stop it just made. The console's RESET is ungated for the same reason.
if tapped(LAYOUT.estop):
_estop(node, state)
if tapped(LAYOUT.clear):
_clear(node, state)
if not state.arbiter.may_drive(GAMEPAD):
return
if tapped(LAYOUT.grasp):
_act(node, state, GRIPPER_CLOSE)
if tapped(LAYOUT.release):
_act(node, state, GRIPPER_OPEN)
if tapped(LAYOUT.freedrive):
_freedrive(node, state, not state.view.freedrive_on)
def _poll_state(node: Node, state: Teleop, moment: float) -> None:
"""Ask the arm for what telemetry does not carry.
Whether the tool is engaged and whether freedrive is on only come back from
`get_state`, which the arm node answers off its report cache and so can
answer during a motion.
"""
if moment - state.polled_at < state.config.deadman.state_poll_s:
return
state.polled_at = moment
_request(node, state, {"service": "get_state"})
# --- what the console asks for ---
def _drain_console(node: Node, state: Teleop) -> None:
"""Act on everything the browsers sent, then note how many are still there."""
if state.console is None:
return
for payload in state.console.take():
_accept_ui_command(node, state, payload)
_set_clients(state, state.console.clients)
def _accept_ui_command(node: Node, state: Teleop, payload: dict) -> None:
"""Take one console message and do what it says."""
command = ui.command_of(payload)
if command is None:
return
moment = now()
if command.type == "estop":
_estop(node, state)
return
if command.type == "control":
_control(node, state, command, moment)
return
if command.type == "reset":
_clear(node, state)
return
if not state.arbiter.may_drive(WEB):
_post_ui(node, state, ui.error("take control first"))
return
if command.type == "jog":
state.web_axes = mapping.from_console(command.axes)
state.web_seen_at = moment
state.arbiter.note_activity(WEB, moment)
elif command.type == "jog_stop":
state.web_axes = STOP_AXES
state.web_seen_at = moment
elif command.type == "home":
_act(node, state, HOME)
elif command.type == "gripper":
_act(node, state, GRIPPER_CLOSE if command.enabled else GRIPPER_OPEN)
elif command.type == "freedrive":
_freedrive(node, state, command.enabled)
elif command.type == "move":
_request(node, state, {"service": "move_to", "wait": False, **command.row})
def _control(node: Node, state: Teleop, command, moment: float) -> None:
"""Hand the arm to the console, or take it back."""
if command.action == "release":
state.arbiter.release(WEB)
state.web_axes = STOP_AXES
return
if not state.arbiter.take(WEB, moment, preempt=command.preempt):
_post_ui(node, state, ui.error(f"the {state.arbiter.owner} has the arm"))
def _set_clients(state: Teleop, count: int) -> None:
"""Track who is watching, because the last one leaving has to stop the arm."""
state.clients = count
if count == 0:
state.arbiter.release(WEB)
state.web_axes = STOP_AXES
# --- what the arm says back ---
def _accept_response(node: Node, state: Teleop, event: dict) -> None:
"""Absorb one answer, and tell the console about it if it was bad news."""
row = event["value"][0].as_py()
if row.get("service") == "get_state":
state.view.take_get_state(row)
return
if not row.get("ok", True):
message = row.get("message", "")
log(f"{row.get('service')} failed: {message}")
_post_ui(node, state, ui.error(message))
def _accept_motion_state(node: Node, state: Teleop, event: dict) -> None:
"""Report a motion that failed, which a non-blocking request learns no other way."""
row = event["value"][0].as_py()
if row.get("state") == "failed":
_post_ui(node, state, ui.error(row.get("message", "the motion failed")))
# --- talking to the dataflow ---
def _estop(node: Node, state: Teleop) -> None:
"""Halt the arm, from any source, held lease or not."""
log("e-stop")
state.view.estop = True
state.jogging = False
state.web_axes = STOP_AXES
_send_motion(node, state, STOP_AXES)
_act(node, state, STOP)
def _clear(node: Node, state: Teleop) -> None:
"""Release the latch and clear the controller's error, in that order.
Both, or one of the two is still saying stop: the latch is this node's
business and the error is the arm's. The console's RESET and the pad's
`clear` button are the same act and go through here, so a pad-only
dataflow is not left with a latch nothing can release.
"""
log("cleared")
state.view.estop = False
_act(node, state, CLEAR)
def _send_motion(node: Node, state: Teleop, axes: list[float]) -> None:
"""Send one motion command, on the standard port and the deprecated one.
There is no answer to either, ever — a jog is a stream that expires on the
consumer's own TTL, so the sender keeps sending and nobody waits.
`twist` is the standard: SI, framed, what a robot node should take. `jog`
is `dora-ulite6`'s mm/s and deg/s, published alongside until that node
learns the standard. Both carry the same intent, so wiring either is safe;
wiring both would drive the arm twice.
This always sends. The decision not to is `_drive`'s, so that the e-stop and
the shutdown stop — neither of which goes through it — can never be
suppressed by having sent the same thing already.
"""
config = state.config
node.send_output("twist", message.pack_twist(mapping.twist(axes, config)))
node.send_output("jog", message.pack_jog(mapping.scale(axes, config)))
state.sent_axes = list(axes)
state.sent_at = now()
def _act(node: Node, state: Teleop, action: str, value: float | None = None) -> None:
"""Ask for one discrete thing, on the standard port and the deprecated one.
The verb is robot-agnostic; `LEGACY_REQUEST` is the only place that knows
what `dora-ulite6` calls it. A robot speaking the standard needs no entry
there, which is how that table eventually goes away.
"""
node.send_output("command", message.pack_command(action, value))
row = LEGACY_REQUEST.get(action)
if row is not None:
_request(node, state, dict(row))
def _freedrive(node: Node, state: Teleop, enabled: bool) -> None:
"""Toggle freedrive. Its argument rides as the verb's value."""
_act(node, state, FREEDRIVE, value=float(enabled))
_request(node, state, {"service": "freedrive", "enabled": enabled})
def _request(node: Node, state: Teleop, row: dict) -> None:
"""Call one service on the arm under an id we will recognise coming back."""
node.send_output("request", pa.array([row]), metadata={REQUEST_ID: state.next_request_id(row["service"])})
def _publish(node: Node, state: Teleop) -> None:
"""Send the console its picture of the arm."""
_post_ui(node, state, ui.snapshot(state.view, state.arbiter.owner, state.jogging, time.time()))
def _post_ui(node: Node, state: Teleop, payload: dict) -> None:
"""Send one object to every browser, and out on the wire for anyone watching.
`ui_state` carries no browser any more — the sockets are ours. It stays
published because it is the one port that says what an operator is being
shown, which is what `dora topic echo` and the bench read it for.
"""
if state.console is not None:
state.console.broadcast(payload)
node.send_output("ui_state", pa.array([json.dumps(payload)]), metadata={"encoding": "json"})
if __name__ == "__main__":
main()
+320
View File
@@ -0,0 +1,320 @@
"""Turning a control input into motion, in one place for every source.
Pure functions, no state and no hardware, because this is the part that has to
be right and the part that is cheapest to check.
Everything passes through a **normalised** six-vector first: `[-1, 1]` per
degree of freedom, `[x, y, z, roll, pitch, yaw]`, in the robot's **base**
frame. A gamepad reaches it through :func:`from_pad`, which shapes away a worn
stick; the browser sends it already clean. Only then is it scaled, and that
happens exactly once — which is what lets the same node drive an arm in mm/s
and one in m/s without either source knowing the difference.
Two scalings exist. :func:`twist` is the standard one, SI, and is what a robot
should consume. :func:`velocity` is mm/s and deg/s for `dora-ulite6`'s `jog`
port, kept while that node learns the standard.
The screen convention comes from the old web console, so the two agree: stick
up is +X, stick right is +Y.
"""
import math
from dataclasses import dataclass
# Six degrees of freedom; these name their places in the vector.
VX, VY, VZ, WX, WY, WZ = range(6)
# Operator-facing settings are in mm/s and deg/s because that is what a person
# reading a dataflow can judge — 60 mm/s means something, 0.06 m/s less so. The
# wire is SI. These convert between the two, and are the only place that does.
MM_PER_M = 1000.0
@dataclass(frozen=True)
class Layout:
"""Which axis and button index does what, in SDL's numbering.
These are what an Xbox One S pad reports over Bluetooth, swept one control
at a time: sticks on 0-3, both triggers last, and buttons with gaps at 2 and
5. A pad that disagrees is a change here rather than a new environment
variable: an index nobody can verify from the outside is worse than a line
of code somebody can read. `dora-teleop-probe` is how you check.
**The axes can be checked at runtime and the buttons cannot.**
:func:`rest_zeros` refuses a pad whose resting axes say this numbering is
wrong, because an axis at rest has a value to compare. An unpressed button
reads the same on every pad, so a wrong button index is silent — it was
found here by `clear` toggling freedrive and `yaw_left` firing the E-STOP.
The probe, and pressing each button once, is the only check there is.
"""
left_x: int = 0
left_y: int = 1
right_x: int = 2
right_y: int = 3
left_trigger: int = 4
right_trigger: int = 5
grasp: int = 0 # A
release: int = 1 # B
clear: int = 3 # X
freedrive: int = 4 # Y
yaw_left: int = 6 # LB
yaw_right: int = 7 # RB
estop: int = 11 # Back
LAYOUT = Layout()
# The frame a velocity is expressed in. Base is what `tcp_pose` reports and what
# the console's arrows mean; tool is reserved for a consumer that wants it.
BASE_FRAME = "base"
TOOL_FRAME = "tool"
# How far a control must travel before the pad counts as being used. Raw, with
# no deadzone: this decides whether the pad is asking for the arm, and
# `velocity` decides how far to move it. A tighter threshold here than there
# would mean a stick could claim the arm and then not move it.
ACTIVITY = 0.3
# Where an untouched control sits once :func:`canonical` has had it: a stick at
# centre, a trigger at the bottom of its travel.
STICK_REST = 0.0
TRIGGER_REST = -1.0
# How far an axis may rest from those and still be believed. Wide enough for a
# worn stick's tilt and for a pad that reports a trigger it has never felt as 0,
# and far short of the full deflection a wrongly numbered axis shows.
REST_TOLERANCE = 0.25
def deadzone(value: float, width: float) -> float:
"""Ignore travel near centre, and rescale what is left to the full range.
Rescaling matters: without it the arm jumps to `width` of full speed the
instant the stick leaves the dead band, which feels like a broken control
rather than a gentle one.
"""
if width <= 0.0:
return value
size = abs(value)
if size < width:
return 0.0
scaled = (size - width) / (1.0 - width)
return scaled if value > 0 else -scaled
def expo(value: float, factor: float) -> float:
"""Bend the response so small deflections move the arm slowly.
Fine positioning happens near centre, and a linear stick gives it almost no
resolution there. `factor` is how much of the curve is cubic: 0 is linear.
"""
return (1.0 - factor) * value + factor * value**3
def trigger(value: float) -> float:
"""Return a trigger's travel as 0 at rest and 1 fully pressed.
SDL reports a trigger from -1 to 1, unlike a stick, so `TRIGGER_REST` is
where an untouched one sits. A trigger that has never moved sometimes reads
0 instead, which this function alone cannot tell from half pressed —
:func:`canonical` is what fixes that, by measuring the pad's own rest before
anything reaches here.
"""
return max(0.0, min(1.0, (value - TRIGGER_REST) / (1.0 - TRIGGER_REST)))
def axis(values: list[float], index: int) -> float:
"""Read one axis, treating a pad that does not report it as centred."""
return values[index] if index < len(values) else 0.0
def pressed(buttons: list[bool], index: int) -> bool:
"""Read one button, treating a pad that does not report it as unpressed."""
return bool(buttons[index]) if index < len(buttons) else False
def axis_names(layout: Layout = LAYOUT) -> dict[int, str]:
"""Name each axis index the layout uses."""
return {
layout.left_x: "left stick X",
layout.left_y: "left stick Y",
layout.left_trigger: "left trigger",
layout.right_x: "right stick X",
layout.right_y: "right stick Y",
layout.right_trigger: "right trigger",
}
def rest_zeros(axes: list[float], layout: Layout = LAYOUT) -> list[float]:
"""Return where each axis of an untouched pad sits, to measure deflection from.
An untouched pad is the one moment this node can check `layout` against the
thing in front of it: a stick rests near centre and a trigger near the
bottom of its travel, so an axis resting anywhere else means the numbering
is wrong, or a control is being held, or the pad is broken. All three look
the same from here and all three raise, because all three would otherwise
drive the arm from a reading nobody made — a trigger read as a stick asks
for full speed while sitting untouched on the desk.
Parameters
----------
axes
One reading, taken while nobody is touching the pad.
layout
Which index is which control.
Returns
-------
list of float
The resting value of each axis, in the order they were reported.
Raises
------
ValueError
If any axis rests where the layout says it cannot, naming the index,
what the layout calls it, and what it read.
"""
names = axis_names(layout)
triggers = (layout.left_trigger, layout.right_trigger)
for index, value in enumerate(axes):
if index in triggers:
# Either end is a resting trigger: -1 is what SDL reports, and 0 is
# what some pads report until the trigger first moves.
if abs(value - TRIGGER_REST) <= REST_TOLERANCE or abs(value - STICK_REST) <= REST_TOLERANCE:
continue
elif abs(value - STICK_REST) <= REST_TOLERANCE:
continue
name = names.get(index, "an axis the layout does not use")
raise ValueError(f"axis {index} rests at {value:+.3f}, which is not where {name} sits")
return list(axes)
def canonical(axes: list[float], zeros: list[float], layout: Layout = LAYOUT) -> list[float]:
"""Return this pad's axes as the layout's ideal pad would have reported them.
Sticks come back resting at `STICK_REST` and triggers at `TRIGGER_REST`,
whatever this particular pad does when untouched, so everything downstream
is written once for a pad that behaves. A stick that no longer centres, and
a trigger that reads 0 until it first moves, both stop being special cases
here rather than in every function that reads one.
Deflection is measured from `zeros` rather than from the ideal, which is the
whole point: the tilt is subtracted, not deadzoned away, so the travel that
is left still reaches full speed.
"""
triggers = (layout.left_trigger, layout.right_trigger)
out = []
for index, value in enumerate(axes):
zero = zeros[index] if index < len(zeros) else STICK_REST
if index in triggers:
travel = (value - zero) / (1.0 - zero) if zero < 1.0 else 0.0
out.append(TRIGGER_REST + (1.0 - TRIGGER_REST) * max(0.0, min(1.0, travel)))
else:
out.append(max(-1.0, min(1.0, value - zero)))
return out
def from_pad(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
"""Map one gamepad reading to the normalised six-vector.
Translation comes from the left stick and the triggers, rotation from the
right stick and the bumpers. Several axes move at once on purpose: a stick
is analog and blended, which is exactly what the console's ring sectors cannot do.
The deadzone and the response curve are applied here and nowhere else,
because they exist to correct a physical stick. A browser has no worn
centre, so :func:`from_console` does not use them.
"""
def shaped(value: float) -> float:
return expo(deadzone(value, config.deadzone), config.expo)
out = [0.0] * 6
# Up on the stick is negative in SDL, and up should send the arm away from
# the operator, along +X.
out[VX] = shaped(-axis(axes, layout.left_y))
out[VY] = shaped(axis(axes, layout.left_x))
out[VZ] = shaped(trigger(axis(axes, layout.right_trigger)) - trigger(axis(axes, layout.left_trigger)))
out[WX] = shaped(axis(axes, layout.right_x))
out[WY] = shaped(-axis(axes, layout.right_y))
out[WZ] = float(pressed(buttons, layout.yaw_right) - pressed(buttons, layout.yaw_left))
return out
def from_console(values) -> list[float]:
"""Read the normalised six-vector a browser sent, clamped to what it may ask for.
The console speaks the same units as a stick — a fraction of full speed per
axis — so that how fast anything goes is decided once, here, rather than
once per source. A page that has never heard of the robot cannot get its
units wrong.
"""
if not isinstance(values, list) or len(values) != 6:
return [0.0] * 6
out = []
for value in values:
if not isinstance(value, (int, float)):
return [0.0] * 6
out.append(max(-1.0, min(1.0, float(value))))
return out
def velocity(axes: list[float], buttons: list[bool], config, layout: Layout = LAYOUT) -> list[float]:
"""Map one gamepad reading straight to `dora-ulite6`'s mm/s and deg/s.
A convenience over `scale(from_pad(...))`, kept because the probe and the
tests read better for it.
"""
return scale(from_pad(axes, buttons, config, layout), config)
def scale(normalised: list[float], config) -> list[float]:
"""Scale the normalised six-vector to mm/s and deg/s, base frame.
`dora-ulite6`'s `jog` units. Deprecated in favour of :func:`twist`, and kept
only while that node learns to take the standard one.
"""
out = [value * config.speed_mm_s for value in normalised[:3]]
out += [value * config.speed_deg_s for value in normalised[3:]]
return out
def twist(normalised: list[float], config, frame: str = BASE_FRAME) -> dict:
"""Scale the normalised six-vector to a Twist, in SI.
Metres per second and radians per second, which is what a robot node should
consume: unambiguous, and the same numbers a UR or MoveIt would take. A
consumer wanting other units converts them itself, at one known boundary.
"""
linear = [value * config.speed_mm_s / MM_PER_M for value in normalised[:3]]
angular = [math.radians(value * config.speed_deg_s) for value in normalised[3:]]
return {"linear": linear, "angular": angular, "frame": frame}
def is_active(sample, layout: Layout = LAYOUT) -> bool:
"""Whether anything on the pad is being touched at all.
A trigger is not a stick. SDL rests a trigger at **-1.0**, so comparing its
raw value against a threshold reports every connected pad as being used,
for ever — which means the pad claims the arm the moment it is plugged in
and `GAMEPAD_IDLE_RELEASE_S` never fires. Triggers go through
:func:`trigger` here for the same reason they do in :func:`velocity`.
Axes the layout does not name are still counted: a pad that reports its
d-pad as two extra axes is asking for the arm when somebody presses it.
"""
if not sample.present:
return False
if any(sample.buttons):
return True
triggers = (layout.left_trigger, layout.right_trigger)
if any(trigger(axis(sample.axes, index)) > ACTIVITY for index in triggers):
return True
return any(abs(value) > ACTIVITY for index, value in enumerate(sample.axes) if index not in triggers)
def is_moving(velocity_vector: list[float]) -> bool:
"""Whether this velocity would actually drive the arm."""
return any(velocity_vector)
+46
View File
@@ -0,0 +1,46 @@
"""The wire shapes this node publishes.
Declared, never inferred. pyarrow reads `{"value": None}` as `null` and
`{"value": 1.0}` as `double`, so an inferred `command` would change the type of
its port depending on which verb was pressed — `stop` and `freedrive` would not
agree. The same hazard that makes an unplugged gamepad retype a `joy` port.
dora 1.0 has no standard type for either of these, so these structs and the
manifest that describes them are the whole contract a consumer gets.
"""
import pyarrow as pa
# A velocity, in SI. Metres per second and radians per second, in the frame
# named by `frame` — the units a robot node should consume, and the same ones a
# UR or MoveIt would take.
TWIST = pa.struct(
[
("linear", pa.list_(pa.float64())),
("angular", pa.list_(pa.float64())),
("frame", pa.string()),
]
)
# One discrete thing to do. `action` is a robot-agnostic verb; `value` carries
# its argument where it has one and is null otherwise.
COMMAND = pa.struct([("action", pa.string()), ("value", pa.float64())])
# `dora-ulite6`'s velocity, in mm/s and deg/s. Deprecated along with the `jog`
# port it goes out on.
JOG = pa.struct([("velocity", pa.list_(pa.float64()))])
def pack_twist(twist: dict) -> pa.Array:
"""Build one `twist` message."""
return pa.array([twist], type=TWIST)
def pack_command(action: str, value: float | None) -> pa.Array:
"""Build one `command` message."""
return pa.array([{"action": action, "value": value}], type=COMMAND)
def pack_jog(velocity: list[float]) -> pa.Array:
"""Build one deprecated `jog` message."""
return pa.array([{"velocity": [float(v) for v in velocity]}], type=JOG)
+163
View File
@@ -0,0 +1,163 @@
"""Show what the pad is doing to the arm, without an arm.
`dora-teleop-probe` attaches to a running dataflow as a dynamic node, reads the
same `joy` stream this node reads, and prints: the raw axes, the buttons, and the
velocity this node would send. **It cannot move anything.** It publishes no
output and never talks to the arm; it only reads.
It exists because the axis and button indices in :mod:`dora_teleop.mapping` are
SDL's documented layout rather than an observed one, and a pad that disagrees is
otherwise discovered by watching a real arm move the wrong way. It goes through
the same :func:`~dora_teleop.mapping.velocity` the node runs, on the same
readings, so what it prints is what the arm would get — keep it that way, or it
stops being evidence.
Because it reads `joy` rather than a pad, the dataflow has to declare it:
```yaml
- id: teleop-probe
path: dynamic
inputs:
joy: joy/joy
```
`dataflow-gamepad.yml` in this repo already does. Start that dataflow under a
coordinator (`dora start`, not `dora run`), then run `dora-teleop-probe` in
another terminal.
For a pad whose layout is not known at all — which index is which — use
`dora-joy-probe` from the `dora-joy` repo, which needs no dataflow.
"""
import argparse
from dora import Node
from dora_teleop import joy, mapping
from dora_teleop.config import Config
from dora_teleop.mapping import LAYOUT, axis_names
# What each velocity component is, in the order the arm takes them.
COMPONENTS = (
("vx", "mm/s", "forward / back"),
("vy", "mm/s", "left / right"),
("vz", "mm/s", "up / down"),
("wx", "deg/s", "roll"),
("wy", "deg/s", "pitch"),
("wz", "deg/s", "yaw"),
)
CLEAR = "\033[H\033[J"
def button_names(layout=LAYOUT) -> dict[int, str]:
"""Name each button index the layout uses."""
return {
layout.grasp: "grasp",
layout.release: "release",
layout.clear: "clear",
layout.freedrive: "freedrive",
layout.yaw_left: "yaw left",
layout.yaw_right: "yaw right",
layout.estop: "E-STOP",
}
def bar(value: float, width: int = 21) -> str:
"""Draw a centred bar for a value between -1 and 1.
Seeing the bar sit off-centre with the stick released is how a worn pad
announces itself, which is most of the point of looking at raw numbers.
"""
middle = width // 2
position = middle + round(max(-1.0, min(1.0, value)) * middle)
cells = ["-"] * width
cells[middle] = "|"
cells[position] = "#"
return "".join(cells)
def render(sample, driven, velocity: list[float], config: Config, mismatch: str | None = None) -> str:
"""Build the whole display for one reading.
``sample`` is the pad exactly as it was reported, because seeing the raw
numbers is the point of this tool. ``driven`` is the same reading after
calibration — what the node actually decides from — and ``velocity`` is what
it would send. A pad this node refuses reaches here as an absent ``driven``
and a ``mismatch`` saying why.
"""
lines = ["dora-teleop probe — Ctrl-C to stop", ""]
if not sample.present:
lines += [
" no gamepad.",
"",
" `dora-joy` is reporting no pad, or has stopped reporting at all.",
" Either way the arm is being told to hold still.",
]
return "\n".join(lines)
if mismatch:
lines += [
" this pad does not match the layout, and the arm will not be driven from it.",
"",
f" {mismatch}",
"",
" Either something was being held when it was plugged in, or the indices",
" below are numbered differently on this pad. The axes are raw, so they",
" say which: fix `Layout` in dora_teleop/mapping.py to match them.",
"",
]
names = axis_names()
lines.append(f" axes ({len(sample.axes)} reported)")
for index, value in enumerate(sample.axes):
name = names.get(index, "unused")
lines.append(f" {index} {name:<15} {value:+.3f} {bar(value)}")
labels = button_names()
held = [f"{index}:{labels.get(index, 'unused')}" for index, down in enumerate(sample.buttons) if down]
lines += ["", f" buttons ({len(sample.buttons)} reported)", f" {', '.join(held) or 'none pressed'}"]
lines += ["", " velocity this would send to the arm"]
for (name, units, meaning), value in zip(COMPONENTS, velocity, strict=True):
marker = " <--" if value else ""
lines.append(f" {name} {value:+8.2f} {units:<6} {meaning:<14}{marker}".rstrip())
moving = "yes" if mapping.is_moving(velocity) else "no — the arm would be told to hold still"
lines += [
"",
f" moving: {moving}",
f" asking for the arm: {'yes' if mapping.is_active(driven) else 'no'}",
]
return "\n".join(lines)
def main() -> None:
"""Attach to a running dataflow and print what the pad is doing."""
parser = argparse.ArgumentParser(description="Show what the gamepad would do to the arm. Moves nothing.")
parser.add_argument(
"--name",
default="teleop-probe",
help="The id this probe is declared under in the dataflow. Defaults to teleop-probe.",
)
args = parser.parse_args()
config = Config.from_env()
calibration = joy.Calibration()
node = Node(args.name)
for event in node:
if event["type"] != "INPUT" or event["id"] != "joy":
continue
sample = joy.decode(event)
# Through the same calibration the node drives from, so what this prints
# stays what the arm would get. What is displayed is still the raw
# reading: the indices are what a pad is checked against.
driven = calibration.apply(sample)
velocity = mapping.velocity(driven.axes, driven.buttons, config) if driven.present else [0.0] * 6
print(CLEAR + render(sample, driven, velocity, config, calibration.mismatch), flush=True)
if __name__ == "__main__":
main()
+184
View File
@@ -0,0 +1,184 @@
"""The console's web server, running inside this node.
The browser used to reach the node through `dora-websocket-transceiver`, which
meant a second node, a second port and a round trip through the dataflow for
every button press. This serves the page and holds the sockets directly, so the
console is one URL and the operator wires nothing.
**Nothing here runs on the dora thread.** aiohttp wants an asyncio loop and the
node's loop is a blocking `for event in node`, so the server owns a thread of
its own and the two sides meet at two places only: :meth:`Console.take`, which
the tick drains, and :meth:`Console.broadcast`, which hands a snapshot the other
way. Both are non-blocking by construction — a browser that stops reading must
never be able to stall the thread that stops the arm.
"""
import asyncio
import json
import queue
import threading
from pathlib import Path
from aiohttp import WSMsgType, web
from dora_teleop.log import log
INDEX = Path(__file__).parent / "web" / "index.html"
# How many console messages may pile up between two ticks before we start
# dropping them. The console sends a jog every 150 ms and the tick runs every
# 50, so reaching this at all means something is flooding us; an unbounded
# queue would answer that by growing until the node died.
INBOX_LIMIT = 256
# How many snapshots may wait on one socket. Telemetry is a snapshot, not a
# log, so a client too slow to keep up wants the newest one and not a backlog
# of the arm's recent past.
OUTBOX_LIMIT = 8
class _Client:
"""One connected browser, and the snapshots waiting to reach it."""
def __init__(self) -> None:
self.outbox: asyncio.Queue[str] = asyncio.Queue(maxsize=OUTBOX_LIMIT)
def offer(self, text: str) -> None:
"""Queue one snapshot, discarding the stalest if this client is behind."""
if self.outbox.full():
self.outbox.get_nowait()
self.outbox.put_nowait(text)
class Console:
"""The page, the sockets, and the thread they live on."""
def __init__(self, host: str, port: int):
self.host = host
self.port = port
# Read every tick by the deadman: the last browser leaving has to stop
# the arm, and a closed socket sends nothing to say so.
self.clients = 0
self._inbox: queue.Queue[dict] = queue.Queue(maxsize=INBOX_LIMIT)
self._sockets: set[_Client] = set()
self._loop: asyncio.AbstractEventLoop | None = None
self._thread: threading.Thread | None = None
self._ready = threading.Event()
# Whatever stopped the server coming up, kept so `start` can raise it on
# the caller's thread instead of leaving a traceback on stderr and a
# node that believes it is serving a console.
self._failure: BaseException | None = None
def start(self) -> None:
"""Bring the server up, or raise saying why it could not.
Refusing to start is the point. The port being taken used to leave a
traceback on the server thread and a node that carried on announcing a
console nobody could open — an operator typing the URL and getting
nothing, with the log saying everything was fine.
"""
self._thread = threading.Thread(target=self._run, name="teleop-console", daemon=True)
self._thread.start()
if not self._ready.wait(timeout=10.0):
raise RuntimeError(f"the console did not come up on {self.host}:{self.port} within 10s")
if self._failure is not None:
raise self._failure
log(f"console on http://{self.host}:{self.port}")
def take(self) -> list[dict]:
"""Return every console message that arrived since the last call."""
drained = []
while True:
try:
drained.append(self._inbox.get_nowait())
except queue.Empty:
return drained
def broadcast(self, payload: dict) -> None:
"""Hand one object to every connected browser."""
if self._loop is None:
return
text = json.dumps(payload)
self._loop.call_soon_threadsafe(self._offer, text)
def stop(self) -> None:
"""Close the sockets and stop the thread."""
if self._loop is None:
return
self._loop.call_soon_threadsafe(self._loop.stop)
if self._thread is not None:
self._thread.join(timeout=2.0)
# --- everything below runs on the server thread ---
def _run(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._loop = loop
try:
runner = loop.run_until_complete(self._listen())
except BaseException as error: # noqa: BLE001 — handed to `start` to raise
self._failure = error
self._loop = None
loop.close()
# Releases `start` at once rather than making it wait out its
# timeout for a server that is never coming up.
self._ready.set()
return
try:
loop.run_forever()
finally:
loop.run_until_complete(runner.cleanup())
loop.close()
async def _listen(self) -> web.AppRunner:
app = web.Application()
app.router.add_get("/", self._page)
app.router.add_get("/ws", self._socket)
runner = web.AppRunner(app)
await runner.setup()
await web.TCPSite(runner, self.host, self.port).start()
self._ready.set()
return runner
async def _page(self, request: web.Request) -> web.FileResponse:
"""Serve the console itself."""
return web.FileResponse(INDEX)
async def _socket(self, request: web.Request) -> web.WebSocketResponse:
"""Hold one browser's socket for as long as it stays."""
socket = web.WebSocketResponse(heartbeat=20)
await socket.prepare(request)
client = _Client()
self._sockets.add(client)
self.clients = len(self._sockets)
writer = asyncio.create_task(self._write(socket, client))
try:
async for received in socket:
if received.type == WSMsgType.TEXT:
self._accept(received.data)
finally:
writer.cancel()
self._sockets.discard(client)
self.clients = len(self._sockets)
return socket
def _accept(self, text: str) -> None:
"""Park one message from a browser for the next tick to read."""
try:
payload = json.loads(text)
except json.JSONDecodeError:
log("ignoring a console message that was not JSON")
return
try:
self._inbox.put_nowait(payload)
except queue.Full:
log("console inbox full, dropping a message")
def _offer(self, text: str) -> None:
for client in self._sockets:
client.offer(text)
async def _write(self, socket: web.WebSocketResponse, client: _Client) -> None:
while True:
await socket.send_str(await client.outbox.get())
+178
View File
@@ -0,0 +1,178 @@
"""Translation between the web console and the dataflow.
The console was written against the monolithic repo's REST API and reads a
telemetry snapshot by key. Those keys are the contract here: `app.js` does
`t.owner === "web"` and `t.estop` directly, so renaming one breaks the page
silently. They are reproduced rather than improved for that reason.
The two vocabularies do not line up, which is most of what this file is for. The
arm node reports `connecting|ready|moving|recovering|unavailable`; the console
draws `idle|moving|jogging|freedrive|estop|stopped|disconnected`. Neither is
wrong — one describes a driver's lifecycle, the other an operator's screen — and
:func:`arm_state` is where the two are reconciled.
"""
from dataclasses import dataclass, field
# What the arm node publishes on `robot_state`.
CONNECTING = "connecting"
READY = "ready"
MOVING = "moving"
RECOVERING = "recovering"
UNAVAILABLE = "unavailable"
# What the console draws.
UI_IDLE = "idle"
UI_MOVING = "moving"
UI_JOGGING = "jogging"
UI_FREEDRIVE = "freedrive"
UI_ESTOP = "estop"
UI_STOPPED = "stopped"
UI_DISCONNECTED = "disconnected"
# An arm the node cannot speak for is `disconnected` whatever the reason, because
# that is the only thing an operator can act on. `recovering` is its own case:
# the arm is there and answering, it just will not move yet.
ARM_STATE = {
CONNECTING: UI_DISCONNECTED,
UNAVAILABLE: UI_DISCONNECTED,
RECOVERING: UI_STOPPED,
READY: UI_IDLE,
MOVING: UI_MOVING,
}
POSE_KEYS = ("x", "y", "z", "roll", "pitch", "yaw")
@dataclass
class Command:
"""One thing the console asked for."""
type: str
# Normalised, `[-1, 1]` per degree of freedom — a fraction of full speed,
# exactly what a stick reports. Not a velocity: the console does not know
# the robot's units and must not have to.
axes: list[float] = field(default_factory=list)
action: str = ""
preempt: bool = False
enabled: bool = False
row: dict = field(default_factory=dict)
def command_of(payload: dict) -> Command | None:
"""Read one console message, or None if it is not one we serve.
A message we do not recognise is dropped rather than refused. The console
and this node version independently, and a page from a newer build saying
something we have not learned yet must not take the arm down.
"""
if not isinstance(payload, dict):
return None
kind = payload.get("type")
if not isinstance(kind, str):
return None
if kind == "jog":
axes = payload.get("axes") or []
if len(axes) != 6 or not all(isinstance(v, (int, float)) for v in axes):
return None
return Command(type="jog", axes=[float(v) for v in axes])
if kind in ("jog_stop", "estop", "reset", "home"):
return Command(type=kind)
if kind == "control":
return Command(
type="control",
action=str(payload.get("action", "")),
preempt=bool(payload.get("preempt", False)),
)
if kind == "gripper":
return Command(type="gripper", enabled=bool(payload.get("closed", False)))
if kind == "freedrive":
return Command(type="freedrive", enabled=bool(payload.get("enabled", False)))
if kind == "move":
row = {key: payload[key] for key in POSE_KEYS if isinstance(payload.get(key), (int, float))}
if not all(key in row for key in ("x", "y", "z")):
return None
if isinstance(payload.get("speed"), (int, float)):
row["speed"] = float(payload["speed"])
return Command(type="move", row=row)
return None
@dataclass
class ArmView:
"""What this node currently believes about the arm.
Assembled from four places — `robot_state`, `tcp_pose`, `joint_pose` and the
answers to `get_state` — because no single one of them carries everything
the console draws. Whatever has not arrived yet stays at its default, so a
page that loads before the arm is reachable renders as disconnected rather
than as a stale pose.
"""
state: str = CONNECTING
pose: list[float] = field(default_factory=list)
joints: list[float] = field(default_factory=list)
connected: bool = False
ready: bool = False
tool_engaged: bool = False
freedrive_on: bool = False
estop: bool = False
def take_get_state(self, row: dict) -> None:
"""Absorb a `get_state` answer, which carries what telemetry does not."""
self.connected = bool(row.get("connected", False))
self.ready = bool(row.get("ready", False))
self.tool_engaged = bool(row.get("tool_engaged", False))
self.freedrive_on = bool(row.get("freedrive", False))
def arm_state(view: ArmView, jogging: bool) -> str:
"""Return the state the console should draw.
Order matters, and it is the order of what an operator most needs to know.
A latched e-stop wins over everything: the arm may well read `ready`, and
saying so would invite somebody to push it. Freedrive outranks idle because
an arm that can be pushed by hand is not the same as one holding station.
"""
if view.estop:
return UI_ESTOP
if view.state in (CONNECTING, UNAVAILABLE):
return ARM_STATE[view.state]
if view.freedrive_on:
return UI_FREEDRIVE
if jogging and view.state == MOVING:
return UI_JOGGING
return ARM_STATE.get(view.state, UI_DISCONNECTED)
def snapshot(view: ArmView, owner: str | None, jogging: bool, timestamp: float) -> dict:
"""Build the telemetry object the console reads.
Every key here is read by name in `app.js`, so this and the page change
together. The names are the robot-agnostic ones: `gripper` rather than
`vacuum`, because a page that says vacuum only fits a robot with one on it,
and `units` so a page can label a pose without guessing.
"""
return {
"type": "telemetry",
"ts": timestamp,
"connected": view.connected,
"ready": view.ready,
"arm_state": arm_state(view, jogging),
"owner": owner or "none",
"estop": view.estop,
"gripper": view.tool_engaged,
"freedrive": view.freedrive_on,
"position": dict(zip(POSE_KEYS, view.pose, strict=False)) if view.pose else None,
"joints": list(view.joints),
# What `position` is in, so the page labels it rather than assuming. The
# telemetry ports carry whatever the robot publishes; only the command
# path is normalised.
"units": {"position": "mm", "rotation": "deg"},
}
def error(message: str) -> dict:
"""Build the message the console shows as a toast."""
return {"type": "error", "detail": message}
+449
View File
@@ -0,0 +1,449 @@
<title>teleop console</title>
<style>
:root {
color-scheme: dark;
--bg: #101016;
--card: #1a1a23;
--edge: #2b2b38;
--ink: #e8e8f0;
--dim: #8a8a9e;
--live: #7fd1e8;
--warn: #e8a37f;
--stop: #e2564a;
--go: #43c98b;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding-block: 16px;
padding-inline: 16px;
background: var(--bg);
color: var(--ink);
font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.wrap { max-width: 980px; margin: 0 auto; }
header {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
margin-bottom: 14px;
}
header h1 { font-size: 15px; margin: 0; margin-right: auto; letter-spacing: .04em; }
.chip {
padding: 5px 12px; border-radius: 999px; border: 1px solid var(--edge);
background: var(--card); font-size: 13px; white-space: nowrap;
}
.chip b { color: var(--live); font-weight: 600; }
.chip.estop { border-color: var(--stop); color: var(--stop); }
.chip.mine { border-color: var(--go); }
.chip.off { color: var(--dim); }
.card {
background: var(--card); border: 1px solid var(--edge); border-radius: 12px;
padding: 14px; margin-bottom: 12px;
}
.card h2 {
font-size: 11px; text-transform: uppercase; letter-spacing: .1em;
color: var(--dim); margin: 0 0 12px; font-weight: 600;
}
.bar { display: flex; flex-wrap: wrap; gap: 8px; }
button {
flex: 1 1 auto; min-height: 48px; min-width: 104px;
background: #272733; color: var(--ink); border: 1px solid #383848;
border-radius: 9px; padding: 10px 16px; font: inherit; cursor: pointer;
touch-action: manipulation;
}
button:active:not(:disabled) { background: #33334a; }
button:disabled { opacity: .3; cursor: not-allowed; }
button.stop { background: #55202a; border-color: var(--stop); color: #ffd9d4; }
button.on { background: #1f4a5a; border-color: var(--live); }
/* --- the jog rings --- */
.decks { display: flex; flex-wrap: wrap; gap: 14px; }
.deck {
flex: 1 1 340px;
background: var(--card); border: 1px solid var(--edge); border-radius: 12px;
padding: 14px;
}
.deck h2 {
font-size: 11px; text-transform: uppercase; letter-spacing: .1em;
color: var(--dim); margin: 0 0 12px; font-weight: 600;
}
.pills { display: flex; gap: 8px; margin-bottom: 12px; }
.ring { display: block; width: 100%; max-width: 330px; margin-inline: auto; touch-action: none; }
.ring .sector {
fill: #23232f; stroke: var(--edge); stroke-width: 1.5; cursor: pointer;
}
.ring .sector.holding { fill: var(--live); stroke: #bfe9f5; }
.ring .label {
fill: #a6a6bd; font-size: 15px; text-anchor: middle; dominant-baseline: middle;
pointer-events: none; font-family: inherit;
}
.ring .sector.holding + .label { fill: #0b1013; font-weight: 700; }
.ring .hub {
fill: none; stroke: var(--edge); stroke-width: 1.5;
}
.ring .hubtext {
fill: var(--dim); font-size: 13px; text-anchor: middle; dominant-baseline: middle;
pointer-events: none; font-family: inherit; letter-spacing: .08em;
}
.ring[data-off="true"] { opacity: .32; }
.ring[data-off="true"] .sector { cursor: not-allowed; }
label.speed { display: flex; align-items: center; gap: 12px; color: var(--dim); }
label.speed input { flex: 1; height: 34px; }
label.speed b { color: var(--live); min-width: 4ch; text-align: right; }
.readout { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 2px 18px; }
.row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
.row span:last-child { color: var(--live); }
#toast { color: var(--warn); min-height: 1.5em; padding: 4px 2px; }
#link { color: var(--dim); font-size: 12px; }
@media (max-width: 560px) {
body { padding-inline: 10px; }
.deck { padding: 10px; }
button { min-width: 84px; }
}
</style>
<div class="wrap">
<header>
<h1>teleop console</h1>
<span class="chip" id="chip-state">—</span>
<span class="chip" id="chip-owner">—</span>
</header>
<div class="card">
<h2>Control</h2>
<div class="bar">
<button id="take">Take control</button>
<button id="release">Release</button>
<button id="reset">Reset</button>
<button class="stop" id="estop">E&#8209;STOP</button>
</div>
</div>
<div class="decks">
<div class="deck">
<h2>Translate</h2>
<div class="pills">
<button data-axis="2" data-sign="-1">&minus;Z</button>
<button data-axis="2" data-sign="1">+Z</button>
</div>
<svg class="ring" id="translate" viewBox="0 0 200 200" aria-label="translate">
<g data-north="0,1" data-south="0,-1" data-west="1,1" data-east="1,-1"
data-labels="+X,&#8722;X,Y+,Y&#8722;" data-hub="XYZ"></g>
</svg>
</div>
<div class="deck">
<h2>Rotate</h2>
<div class="pills">
<button data-axis="5" data-sign="-1">&minus;yaw</button>
<button data-axis="5" data-sign="1">+yaw</button>
</div>
<svg class="ring" id="rotate" viewBox="0 0 200 200" aria-label="rotate">
<g data-north="4,1" data-south="4,-1" data-west="3,-1" data-east="3,1"
data-labels="+pitch,&#8722;pitch,&#8722;roll,+roll" data-hub="RXYZ"></g>
</svg>
</div>
</div>
<div class="card">
<h2>Speed</h2>
<label class="speed">
<input id="speed" type="range" min="10" max="100" step="5" value="60">
<b id="speedout">60%</b>
</label>
</div>
<div class="card">
<h2>Tool</h2>
<div class="bar">
<button id="close">Close gripper</button>
<button id="open">Open gripper</button>
<button id="freedrive">Freedrive</button>
<button id="home">Home</button>
</div>
</div>
<div class="card">
<h2>State</h2>
<div class="readout" id="state"></div>
</div>
<div id="toast"></div>
<div id="link"></div>
</div>
<script>
"use strict";
// Read by name out of the node's telemetry snapshot. Robot-agnostic: `gripper`,
// not `vacuum`, because a page that says vacuum only fits a robot with one.
const KEYS = ["arm_state", "owner", "connected", "ready", "estop", "gripper", "freedrive"];
// The node stops the robot if the jog stream dries up (JOG_TTL_S, 0.4 s).
// Resending faster than that is what makes letting go a stop, with nothing
// needing to be sent to say so.
const REFRESH_MS = 150;
// The ring, laid out like the arm's own pendant so the two do not disagree:
// a quarter each, north at the top, and a hub that is label rather than button.
const GAP_DEG = 3;
const OUTER = 95;
const INNER = 46;
const LABEL_R = 71;
const COMPASS = [
["north", 225, 315],
["east", 315, 405],
["south", 45, 135],
["west", 135, 225],
];
let socket = null;
let owner = "none";
let current = {};
let axes = [0, 0, 0, 0, 0, 0];
let refresh = null;
// Every direction currently held, as element -> {axis, sign}. A map rather than
// one value because two fingers may hold two directions, and lifting one must
// not cancel the other.
const held = new Map();
// Which element each finger is on, so a finger that slides off still releases
// the direction it started on.
const fingers = new Map();
function send(payload) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(payload));
}
}
function scale() {
return Number(document.getElementById("speed").value) / 100;
}
function recompute() {
axes = [0, 0, 0, 0, 0, 0];
const dial = scale();
for (const [, { axis, sign }] of held) axes[axis] += sign * dial;
axes = axes.map((v) => Math.max(-1, Math.min(1, v)));
}
function moving() {
return axes.some((value) => value !== 0);
}
function pump() {
if (moving()) send({ type: "jog", axes });
}
// --- drawing the ring ---
function onCircle(radius, degrees) {
const radians = (degrees * Math.PI) / 180;
return [100 + radius * Math.cos(radians), 100 + radius * Math.sin(radians)];
}
function sectorPath(from, to) {
const [ax, ay] = onCircle(OUTER, from);
const [bx, by] = onCircle(OUTER, to);
const [cx, cy] = onCircle(INNER, to);
const [dx, dy] = onCircle(INNER, from);
return `M${ax} ${ay}A${OUTER} ${OUTER} 0 0 1 ${bx} ${by}`
+ `L${cx} ${cy}A${INNER} ${INNER} 0 0 0 ${dx} ${dy}Z`;
}
function svg(name, attributes) {
const node = document.createElementNS("http://www.w3.org/2000/svg", name);
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
return node;
}
function buildRing(ring) {
const group = ring.querySelector("g");
const labels = group.dataset.labels.split(",");
// Labelled in compass order so the markup reads the way the ring looks:
// north, south, west, east.
const naming = { north: labels[0], south: labels[1], west: labels[2], east: labels[3] };
for (const [point, from, to] of COMPASS) {
const [axis, sign] = group.dataset[point].split(",").map(Number);
const sector = svg("path", {
d: sectorPath(from + GAP_DEG, to - GAP_DEG),
class: "sector",
});
sector.dataset.axis = axis;
sector.dataset.sign = sign;
group.appendChild(sector);
const middle = (from + to) / 2;
const [lx, ly] = onCircle(LABEL_R, middle);
const text = svg("text", { x: lx, y: ly, class: "label" });
text.textContent = naming[point];
group.appendChild(text);
}
group.appendChild(svg("circle", { cx: 100, cy: 100, r: INNER - 6, class: "hub" }));
const hub = svg("text", { x: 100, y: 100, class: "hubtext" });
hub.textContent = group.dataset.hub;
group.appendChild(hub);
}
// --- holding a direction ---
function grab(element) {
if (held.has(element) || element.closest("[data-off=\"true\"]")) return;
held.set(element, { axis: Number(element.dataset.axis), sign: Number(element.dataset.sign) });
element.classList.add("holding");
recompute();
pump();
// Resend while held. The node's deadman expects a stream, not one message.
if (refresh === null) refresh = setInterval(pump, REFRESH_MS);
}
function letGo(element) {
if (!held.delete(element)) return;
element.classList.remove("holding");
recompute();
if (moving()) return;
// Say so rather than going quiet: the deadman would stop it either way, but a
// direction released is an intention and 400 ms of travel is not.
send({ type: "jog_stop" });
clearInterval(refresh);
refresh = null;
}
function releaseEverything() {
for (const element of [...held.keys()]) letGo(element);
fingers.clear();
}
function wireDirection(element) {
element.addEventListener("pointerdown", (event) => {
// Keeps this finger's events coming even once it slides off, so letting go
// always reaches letGo().
element.setPointerCapture(event.pointerId);
fingers.set(event.pointerId, element);
grab(element);
});
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) {
element.addEventListener(name, (event) => {
fingers.delete(event.pointerId);
letGo(element);
});
}
element.addEventListener("contextmenu", (event) => event.preventDefault());
}
function wire() {
for (const ring of document.querySelectorAll(".ring")) buildRing(ring);
for (const element of document.querySelectorAll(".sector, .pills button[data-axis]")) {
wireDirection(element);
}
// A tab that goes away mid-jog must not leave the robot travelling. The node
// covers this too; doing it here costs one line.
window.addEventListener("blur", releaseEverything);
document.addEventListener("visibilitychange", () => {
if (document.hidden) releaseEverything();
});
const speed = document.getElementById("speed");
speed.addEventListener("input", () => {
document.getElementById("speedout").textContent = `${speed.value}%`;
recompute();
});
const click = {
take: { type: "control", action: "take", preempt: true },
release: { type: "control", action: "release" },
estop: { type: "estop" },
reset: { type: "reset" },
close: { type: "gripper", closed: true },
open: { type: "gripper", closed: false },
home: { type: "home" },
};
for (const [id, payload] of Object.entries(click)) {
document.getElementById(id).addEventListener("click", () => send(payload));
}
document.getElementById("freedrive").addEventListener("click", () => {
send({ type: "freedrive", enabled: !current.freedrive });
});
}
function render(telemetry) {
current = telemetry;
const rows = KEYS.map((key) =>
`<div class="row"><span>${key}</span><span>${telemetry[key]}</span></div>`);
const pose = telemetry.position;
if (pose) {
// The node says what the pose is in rather than the page assuming it.
const unit = (telemetry.units && telemetry.units.position) || "";
const shown = ["x", "y", "z"].map((axis) => pose[axis].toFixed(1)).join(", ");
rows.push(`<div class="row"><span>position</span><span>${shown} ${unit}</span></div>`);
}
document.getElementById("state").innerHTML = rows.join("");
const state = document.getElementById("chip-state");
state.innerHTML = `arm <b>${telemetry.arm_state}</b>`;
state.className = `chip${telemetry.estop ? " estop" : ""}`;
owner = telemetry.owner;
const mine = owner === "web";
const chip = document.getElementById("chip-owner");
chip.innerHTML = mine ? "<b>you have the arm</b>" : `held by <b>${owner}</b>`;
chip.className = `chip ${mine ? "mine" : "off"}`;
document.getElementById("freedrive").classList.toggle("on", !!telemetry.freedrive);
document.getElementById("close").classList.toggle("on", !!telemetry.gripper);
// Every control is gated on holding the lease, so one that would be refused
// looks refused. E-STOP and Reset never are.
for (const id of ["close", "open", "freedrive", "home"]) {
document.getElementById(id).disabled = !mine;
}
for (const ring of document.querySelectorAll(".ring")) {
ring.dataset.off = String(!mine);
}
for (const pill of document.querySelectorAll(".pills button[data-axis]")) {
pill.disabled = !mine;
}
if (!mine) releaseEverything();
}
function connect() {
// The node serves this page and holds the socket, so the socket is on this
// page's own origin — nothing to configure and nothing to keep in step.
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
const url = `${scheme}//${location.host}/ws`;
document.getElementById("link").textContent = `socket: ${url}`;
socket = new WebSocket(url);
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === "telemetry") render(message);
if (message.type === "error") {
document.getElementById("toast").textContent = message.detail;
}
};
socket.onclose = () => {
releaseEverything();
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
setTimeout(connect, 2000);
};
}
wire();
connect();
</script>
+107
View File
@@ -0,0 +1,107 @@
"""A bench stand-in for the arm and the browser, so teleop can run with neither.
Run by `dataflow.yml`. It answers every service, publishes plausible telemetry,
and prints the velocities it is sent — which is enough to drive the node from a
real gamepad and watch what a real arm would have been told to do.
**It is not a simulator.** It does not integrate the velocity, move a pose, or
model the arm in any way; a jog is printed and forgotten. Its job is to close
the loop so the node's own behaviour can be watched, not to stand in for
`dora-ulite6`. Anything that needs the arm to actually respond needs the arm.
"""
import json
import pyarrow as pa
from dora import Node
REQUEST_ID = "request_id"
# Somewhere in front of the robot, tool pointing down. Fixed, because a moving
# pose here would look like the arm was responding, which it is not.
POSE = [250.0, 0.0, 200.0, 180.0, 0.0, 0.0]
JOINTS = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
def main() -> None:
"""Answer whatever teleop asks and report what it would have moved."""
node = Node("bench")
announced = False
tool_engaged = False
freedrive = False
jogs = 0
shown = None
for event in node:
if event["type"] != "INPUT":
continue
if event["id"] == "tick":
if not announced:
announced = True
node.send_output("robot_state", pa.array(["ready"]))
node.send_output("tcp_pose", pa.array(POSE, type=pa.float32()), {"units_position": "mm"})
node.send_output("joint_pose", pa.array(JOINTS, type=pa.float32()), {"units": "deg"})
elif event["id"] == "jog":
velocity = event["value"][0].as_py()["velocity"]
jogs += 1
moving = any(velocity)
shown = ", ".join(f"{v:+7.2f}" for v in velocity)
print(f"jog {jogs:5d} [{shown}] {'' if moving else '<- stop'}", flush=True)
elif event["id"] == "request":
row = event["value"][0].as_py()
service = row.get("service", "")
request_id = event["metadata"].get(REQUEST_ID, "")
if service == "grasp":
tool_engaged = True
elif service == "release":
tool_engaged = False
elif service == "freedrive":
freedrive = bool(row.get("enabled", True))
if service != "get_state":
print(f"service {service} ({request_id})", flush=True)
node.send_output(
"response",
pa.array([_answer(service, tool_engaged, freedrive)]),
{REQUEST_ID: request_id},
)
elif event["id"] == "ui_state":
# No browser is attached, so this is where the console's picture of
# the arm would go. Printing it on change rather than on every tick
# makes the things worth watching — the lease moving, the arm
# starting and stopping — visible without 20 lines a second.
snapshot = json.loads(event["value"][0].as_py())
if snapshot.get("type") != "telemetry":
continue
current = (snapshot["arm_state"], snapshot["owner"], snapshot["estop"])
if current != shown:
shown = current
state, owner, estop = current
print(
f"console would show: {state}, owner {owner}" + (", E-STOPPED" if estop else ""),
flush=True,
)
def _answer(service: str, tool_engaged: bool, freedrive: bool) -> dict:
"""Build the response row the arm node would have sent."""
if service == "get_state":
return {
"ok": True,
"service": service,
"message": "ready",
"connected": True,
"ready": True,
"tool_engaged": tool_engaged,
"freedrive": freedrive,
"pose": POSE,
"joints": JOINTS,
}
return {"ok": True, "service": service, "message": f"{service} accepted by the bench"}
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
# Manifest for the bench stand-in.
#
# dora resolves a node's manifest from its `path:`, so without this file the
# bench would be validated against the teleop node's own contract and every port
# would look wrong.
apiVersion: 1
name: bench
namespace: liria
description: Bench stand-in for the arm and the browser, so teleop can run with neither
runtime: python
entrypoint: bench.py
dora: ">=1.0"
inputs:
tick:
description: Drives the telemetry the arm node would publish.
type: std/core/v1/UInt64
jog:
description: A velocity teleop wants the arm to move at. Printed, then forgotten.
request:
description: A service call. Every one is answered ok.
ui_state:
description: >-
The console snapshot teleop publishes. Printed on change, which is how the
lease moving and the arm starting and stopping can be watched without a
browser.
outputs:
response:
description: One answer per request, under the id it arrived with.
robot_state:
description: Reports `ready` once, so teleop stops drawing the arm as disconnected.
type: std/core/v1/String
tcp_pose:
description: A fixed tool-tip pose. Fixed on purpose — nothing here integrates a velocity.
type: std/core/v1/Float32
joint_pose:
description: Fixed joint angles, for the same reason.
type: std/core/v1/Float32
+50
View File
@@ -0,0 +1,50 @@
[project]
name = "dora-teleop"
version = "0.3.0"
description = "Dora node giving a human the arm, from a gamepad or a browser"
readme = "README.md"
license = { text = "MIT" }
authors = [{ name = "Liria" }]
# dora-rs 1.0 requires 3.11; this node standardises on 3.12.
requires-python = ">=3.12"
# No hardware dependency. The pad is `dora-joy` and the arm is `dora-ulite6`;
# this node decides, and serves the console that asks it to.
dependencies = [
"dora-rs >= 1.0.1",
"pyarrow >= 17.0",
# The web console: the page and the browsers' sockets on one port. Replaces
# `dora-websocket-transceiver`, which was a second node for the same job.
"aiohttp >= 3.9",
]
[dependency-groups]
dev = ["pytest >= 8.1", "ruff >= 0.9"]
[project.scripts]
dora-teleop = "dora_teleop.main:main"
dora-teleop-probe = "dora_teleop.probe:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["dora_teleop"]
[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"]
[tool.pytest.ini_options]
testpaths = ["tests"]
View File
+165
View File
@@ -0,0 +1,165 @@
"""Shared fakes. Nothing here needs a gamepad, an arm, a browser or a daemon."""
import json
import pyarrow as pa
import pytest
from dora_teleop.config import Config
from dora_teleop.joy import Sample
from dora_teleop.mapping import LAYOUT, TRIGGER_REST
# The shape `dora-joy` publishes. Declared here rather than inferred for the same
# reason it is declared there: pyarrow reads an empty `axes` list as `list<null>`,
# so a test for an absent pad would otherwise build a message no consumer sees.
JOY = pa.struct(
[
("axes", pa.list_(pa.float32())),
("buttons", pa.list_(pa.bool_())),
("present", pa.bool_()),
]
)
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 where `request_id` rides. So the tests record it themselves.
"""
def __init__(self):
self.sent = []
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 (value, metadata) pairs."""
return [(data[0].as_py(), metadata) for name, data, metadata in self.sent if name == output_id]
def jogs(self):
"""Return every velocity sent on the deprecated `jog` port, in order."""
return [row["velocity"] for row, _ in self.outputs("jog")]
def twists(self):
"""Return every Twist sent on the standard port, in order."""
return [row for row, _ in self.outputs("twist")]
def actions(self):
"""Return the verb of every standard `command`, in order."""
return [row["action"] for row, _ in self.outputs("command")]
def services(self):
"""Return the name of every service called, in order."""
return [row["service"] for row, _ in self.outputs("request")]
def ui(self):
"""Return every object handed to the bridge, parsed."""
return [json.loads(value) for value, _ in self.outputs("ui_state")]
def clear(self):
self.sent.clear()
def stick(axes=None, buttons=None, present=True):
"""Build one gamepad reading, padded to a full six axes."""
values = list(axes or [])
values += [0.0] * (6 - len(values))
# A trigger at rest reads -1, and a test that forgot to say so would look
# like both triggers held down. Which indices those are comes from the
# layout, so a test never has to be edited when a pad renumbers them.
for index in (LAYOUT.left_trigger, LAYOUT.right_trigger):
if axes is None or index >= len(axes):
values[index] = -1.0
return Sample(axes=values, buttons=list(buttons or []), present=present)
def axes_for(
left_x=0.0,
left_y=0.0,
right_x=0.0,
right_y=0.0,
left_trigger=TRIGGER_REST,
right_trigger=TRIGGER_REST,
):
"""Lay out a pad's axes by what each control is rather than by its index.
A test that numbers its axes says nothing about which control it meant, and
goes on passing while meaning something else once a pad renumbers them.
"""
values = [0.0] * 6
values[LAYOUT.left_x] = left_x
values[LAYOUT.left_y] = left_y
values[LAYOUT.right_x] = right_x
values[LAYOUT.right_y] = right_y
values[LAYOUT.left_trigger] = left_trigger
values[LAYOUT.right_trigger] = right_trigger
return values
def buttons_for(
grasp=False, release=False, clear=False, freedrive=False, yaw_left=False, yaw_right=False, estop=False
):
"""Lay out a pad's buttons by what each one does rather than by its index.
The same reason as :func:`axes_for`, and more pressing: an unpressed button
reads identically on every pad, so a test that numbers them is the only
place a wrong index could ever have been caught.
"""
wanted = {
LAYOUT.grasp: grasp,
LAYOUT.release: release,
LAYOUT.clear: clear,
LAYOUT.freedrive: freedrive,
LAYOUT.yaw_left: yaw_left,
LAYOUT.yaw_right: yaw_right,
LAYOUT.estop: estop,
}
return [wanted.get(index, False) for index in range(max(wanted) + 1)]
def joy_message(sample):
"""Build the Arrow row `dora-joy` would publish for one reading."""
return pa.array(
[
{
"axes": [float(value) for value in sample.axes],
"buttons": [bool(down) for down in sample.buttons],
"present": sample.present,
}
],
type=JOY,
)
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 {})}
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
"""Run every test against a bare environment, whatever the shell set."""
for name in (
"SPEED_MM_S",
"SPEED_DEG_S",
"DEADZONE",
"EXPO",
"JOG_TTL_S",
"JOG_KEEPALIVE_S",
"JOY_TTL_S",
"GAMEPAD_IDLE_RELEASE_S",
"STATE_POLL_S",
"WEB_HOST",
"WEB_PORT",
):
monkeypatch.delenv(name, raising=False)
@pytest.fixture
def config():
"""Return the default configuration, with expo off so a test can do the arithmetic."""
built = Config.from_env()
built.expo = 0.0
return built
+106
View File
@@ -0,0 +1,106 @@
"""The lease: who may drive the arm, and how it changes hands."""
from dora_teleop.arbiter import GAMEPAD, WEB, Arbiter
def build(idle_release_s=2.0):
return Arbiter(idle_release_s)
def test_a_free_arm_goes_to_whoever_asks():
arbiter = build()
assert arbiter.take(WEB, now := 100.0) is True
assert arbiter.owner == WEB
assert arbiter.may_drive(WEB)
assert now == 100.0
def test_a_held_arm_is_not_handed_over_for_the_asking():
arbiter = build()
arbiter.take(GAMEPAD, 100.0)
assert arbiter.take(WEB, 101.0) is False
assert arbiter.owner == GAMEPAD
def test_a_non_owner_may_not_drive():
arbiter = build()
arbiter.take(GAMEPAD, 100.0)
assert arbiter.may_drive(WEB) is False
def test_preempting_takes_a_held_arm():
# The console offers this deliberately: an operator who can see the arm is
# better placed than this code to decide they should have it.
arbiter = build()
arbiter.take(GAMEPAD, 100.0)
assert arbiter.take(WEB, 101.0, preempt=True) is True
assert arbiter.owner == WEB
def test_taking_an_arm_you_already_hold_is_not_a_conflict():
arbiter = build()
arbiter.take(WEB, 100.0)
assert arbiter.take(WEB, 101.0) is True
def test_releasing_frees_the_arm():
arbiter = build()
arbiter.take(WEB, 100.0)
arbiter.release(WEB)
assert arbiter.owner is None
def test_releasing_an_arm_somebody_else_holds_does_nothing():
arbiter = build()
arbiter.take(GAMEPAD, 100.0)
arbiter.release(WEB)
assert arbiter.owner == GAMEPAD
def test_an_idle_gamepad_hands_the_arm_back():
# A pad left on the bench would otherwise lock the browser out forever.
arbiter = build(idle_release_s=2.0)
arbiter.take(GAMEPAD, 100.0)
arbiter.expire_idle_gamepad(102.0)
assert arbiter.owner is None
def test_a_gamepad_still_being_used_keeps_the_arm():
arbiter = build(idle_release_s=2.0)
arbiter.take(GAMEPAD, 100.0)
arbiter.note_activity(GAMEPAD, 101.5)
arbiter.expire_idle_gamepad(102.0)
assert arbiter.owner == GAMEPAD
def test_the_web_never_expires_on_its_own():
# It took the arm by pressing a button and gives it back by pressing another.
arbiter = build(idle_release_s=2.0)
arbiter.take(WEB, 100.0)
arbiter.expire_idle_gamepad(999.0)
assert arbiter.owner == WEB
def test_activity_from_a_non_owner_does_not_extend_anyone_else():
arbiter = build(idle_release_s=2.0)
arbiter.take(GAMEPAD, 100.0)
arbiter.note_activity(WEB, 101.9)
arbiter.expire_idle_gamepad(102.0)
assert arbiter.owner is None
+97
View File
@@ -0,0 +1,97 @@
"""Settings: parsed once at startup, and refused there rather than mid-motion."""
import pytest
from dora_teleop.config import Config
def test_the_defaults_need_no_environment():
config = Config.from_env()
assert config.speed_mm_s == 60.0
assert config.speed_deg_s == 30.0
def test_the_deadman_is_shorter_than_the_arms_own_expiry():
# The arm node's VELOCITY_COMMAND_TTL_S defaults to 0.5 s and is the
# backstop. Ours has to fire first, or the backstop backs up nothing.
assert Config.from_env().deadman.jog_ttl_s < 0.5
def test_the_layers_fire_innermost_first():
# A stale pad reading has to be noticed before the jog deadline it feeds.
deadman = Config.from_env().deadman
assert deadman.joy_ttl_s < deadman.jog_ttl_s
def test_a_blank_variable_falls_back_to_the_default(monkeypatch):
monkeypatch.setenv("SPEED_MM_S", " ")
assert Config.from_env().speed_mm_s == 60.0
def test_a_speed_that_is_not_a_number_names_itself(monkeypatch):
monkeypatch.setenv("SPEED_MM_S", "brisk")
with pytest.raises(ValueError, match="SPEED_MM_S"):
Config.from_env()
def test_a_joy_ttl_at_or_above_the_jog_ttl_is_refused_at_startup(monkeypatch):
# Layers out of order means a backstop firing before the thing it backs up,
# which is invisible until the day something actually goes wrong.
monkeypatch.setenv("JOY_TTL_S", "0.4")
with pytest.raises(ValueError, match="JOY_TTL_S"):
Config.from_env()
def test_a_keepalive_at_or_above_the_jog_ttl_is_refused_at_startup(monkeypatch):
# The repeat has to outpace the arm's own velocity expiry. It cannot be read
# from here, but JOG_TTL_S already sits below it, so a repeat slower than
# JOG_TTL_S is a repeat the arm stops between — an arm that stutters under a
# held stick, with nothing in the logs to say why.
monkeypatch.setenv("JOG_KEEPALIVE_S", "0.4")
with pytest.raises(ValueError, match="JOG_KEEPALIVE_S"):
Config.from_env()
def test_a_keepalive_of_zero_is_refused_at_startup(monkeypatch):
# Zero would mean repeating every tick, which is the behaviour the keepalive
# exists to replace.
monkeypatch.setenv("JOG_KEEPALIVE_S", "0")
with pytest.raises(ValueError, match="JOG_KEEPALIVE_S"):
Config.from_env()
@pytest.mark.parametrize("value", ["1.0", "-0.1", "2"])
def test_a_deadzone_outside_the_stick_is_refused_at_startup(monkeypatch, value):
# A deadzone of 1 would silently make the pad inert; catching it here beats
# finding out when somebody picks it up.
monkeypatch.setenv("DEADZONE", value)
with pytest.raises(ValueError, match="DEADZONE"):
Config.from_env()
@pytest.mark.parametrize("value", ["-0.1", "1.1"])
def test_an_expo_outside_the_curve_is_refused_at_startup(monkeypatch, value):
monkeypatch.setenv("EXPO", value)
with pytest.raises(ValueError, match="EXPO"):
Config.from_env()
def test_the_timings_can_be_tuned_without_touching_the_speeds(monkeypatch):
monkeypatch.setenv("JOG_TTL_S", "0.25")
monkeypatch.setenv("JOY_TTL_S", "0.1")
monkeypatch.setenv("GAMEPAD_IDLE_RELEASE_S", "5")
config = Config.from_env()
assert config.deadman.jog_ttl_s == 0.25
assert config.deadman.joy_ttl_s == 0.1
assert config.deadman.gamepad_idle_release_s == 5.0
+89
View File
@@ -0,0 +1,89 @@
"""The page and the node are one contract, checked here rather than by eye.
`ui.py` translates for a console it cannot import, and the page reads telemetry
by key. Nothing in either language catches a rename, so a typo would surface as
a dead button on a machine that can move — which is the wrong place to find it.
"""
import json
import re
from pathlib import Path
from dora_teleop import ui
PAGE = Path(__file__).resolve().parent.parent / "dora_teleop" / "web" / "index.html"
def page():
return PAGE.read_text()
def sent_types():
"""Return every message type the page can send."""
return set(re.findall(r'type:\s*"([a-z_]+)"', page()))
def read_keys():
"""Return the telemetry keys the page renders by name."""
listed = re.search(r"const KEYS = \[(.*?)\]", page(), re.DOTALL).group(1)
return set(re.findall(r'"([a-z_]+)"', listed))
def snapshot_keys():
view = ui.ArmView()
view.pose = [1.0] * 6
return set(ui.snapshot(view, "web", False, 0.0))
def test_every_message_the_page_sends_is_one_the_node_serves():
# `data` is the bridge's own envelope, not a console command.
for kind in sent_types() - {"data"}:
assert ui.command_of({"type": kind}) is not None or kind == "jog", kind
def test_every_telemetry_key_the_page_reads_is_one_the_node_sends():
missing = read_keys() - snapshot_keys()
assert not missing, f"the page reads keys the node does not send: {sorted(missing)}"
def test_the_page_reads_the_pose_units_from_the_node():
# Rather than assuming mm, which is only true of one robot.
assert "units.position" in page().replace("telemetry.units && telemetry.units.position", "units.position")
assert "units" in snapshot_keys()
def test_the_jog_the_page_sends_is_normalised_and_accepted():
# Six axes, each a fraction of full deflection. If the page ever sent a
# velocity instead, it would have to know the robot's units.
command = ui.command_of(json.loads('{"type": "jog", "axes": [0, -0.6, 0, 0, 0, 0]}'))
assert command is not None
assert command.axes == [0.0, -0.6, 0.0, 0.0, 0.0, 0.0]
def test_the_page_can_ask_for_everything_the_node_serves():
# `move` is the one command with no button: it is a pose in the robot's own
# units, which is exactly what this page is built not to know.
served = {"jog", "jog_stop", "estop", "reset", "home", "control", "gripper", "freedrive"}
assert served <= sent_types()
def test_the_page_gates_its_jog_pad_on_holding_the_lease():
# The node refuses a jog from a non-owner and answers with a toast; a pad
# that looks live but is refused teaches an operator to distrust the screen.
assert 'owner === "web"' in page()
def test_the_published_schemas_do_not_depend_on_the_value_sent():
# pyarrow reads a null `value` as its own type, so an inferred schema would
# change the type of the `command` port between one verb and the next.
from dora_teleop import message
assert message.pack_command("stop", None).type == message.COMMAND
assert message.pack_command("freedrive", 1.0).type == message.COMMAND
assert (
message.pack_twist({"linear": [0.0] * 3, "angular": [0.0] * 3, "frame": "base"}).type == message.TWIST
)
assert message.pack_jog([0.0] * 6).type == message.JOG
+438
View File
@@ -0,0 +1,438 @@
"""The deadman: every way a source can go away, and the stop that follows it."""
import pytest
from dora_teleop import main as loop
from dora_teleop.arbiter import GAMEPAD, WEB
from dora_teleop.config import Config
from dora_teleop.joy import Sample
from tests.conftest import RecordingNode, buttons_for, input_event, joy_message, stick
STOP = [0.0] * 6
PUSHED = [0.0, -1.0]
@pytest.fixture
def clock(monkeypatch):
"""Return a clock the test moves by hand, so no test waits out a real timeout."""
class Clock:
def __init__(self):
self.t = 1000.0
def advance(self, seconds):
self.t += seconds
moved = Clock()
monkeypatch.setattr("dora_teleop.main.now", lambda: moved.t)
return moved
def build():
config = Config.from_env()
config.expo = 0.0
return RecordingNode(), loop.Teleop(config)
def send_joy(node, state, sample):
"""Deliver one reading the way `dora-joy` publishes it, decoding included."""
loop._handle(node, state, input_event("joy", joy_message(sample)))
def ticks(node, state, count=1):
"""Run `count` ticks with nothing new arriving, and return every velocity sent."""
for _ in range(count):
loop._tick(node, state)
return node.jogs()
def drive(node, state, sample, count=1):
"""Hold one reading for `count` ticks, the way a real pad and joy node would.
A pad this node has not yet seen at rest is sent one resting reading first,
because a real one is always at rest before a thumb reaches it — and the
node refuses to drive an arm from a pad it has never seen still.
"""
if not state.joy.calibration.known:
send_joy(node, state, stick())
for _ in range(count):
send_joy(node, state, sample)
loop._tick(node, state)
return node.jogs()
def web_jog(node, state, axes):
"""Deliver one console jog message, in the normalised units the page speaks."""
loop._accept_ui_command(node, state, {"type": "jog", "axes": axes})
# --- the gamepad ---
def test_a_pushed_stick_takes_the_arm_and_drives_it(clock):
# Nobody should have to reach for a browser to use the pad in their hands.
node, state = build()
jogs = drive(node, state, stick(axes=PUSHED))
assert state.arbiter.owner == GAMEPAD
assert jogs[-1][0] > 0
def test_a_gamepad_that_is_unplugged_mid_jog_stops_the_arm(clock):
# An unplugged pad is not a centred pad, and waiting out the TTL here is the
# difference between a dropped controller costing 10 ms of travel and 400.
node, state = build()
drive(node, state, stick(axes=PUSHED))
node.clear()
jogs = drive(node, state, Sample(present=False))
assert jogs == [STOP]
def test_the_joy_node_dying_stops_the_arm(clock):
# The pad is another process now, and that process dying looks like silence
# rather than like an unplugged pad. It has to mean the same thing: a
# reading nobody is refreshing is not evidence that the stick is still held.
node, state = build()
drive(node, state, stick(axes=PUSHED))
node.clear()
clock.advance(state.config.deadman.joy_ttl_s + 0.01)
jogs = ticks(node, state)
assert jogs == [STOP]
def test_a_joy_node_that_keeps_publishing_keeps_the_arm_moving(clock):
# The mirror of the test above: time passing is not by itself a reason to
# stop, or a held stick would stutter.
node, state = build()
sent = []
for _ in range(4):
clock.advance(0.05)
sent = drive(node, state, stick(axes=PUSHED))
assert sent
assert all(jog[0] > 0 for jog in sent)
def test_a_stick_returning_to_centre_stops_the_arm(clock):
node, state = build()
drive(node, state, stick(axes=PUSHED))
node.clear()
jogs = drive(node, state, stick())
assert jogs == [STOP]
def test_a_stick_at_rest_sends_one_zero_not_a_stream(clock):
# A stream of zeroes would keep the arm node believing a jog is live and
# hold robot_state at `moving` forever.
node, state = build()
drive(node, state, stick(axes=PUSHED))
node.clear()
jogs = drive(node, state, stick(), count=5)
assert jogs == [STOP]
def test_a_held_stick_is_not_resent_every_tick(clock):
# A velocity is a set-point the arm holds, not a frame in a sequence.
# Repeating it every tick made the controller re-plan the same motion twenty
# times a second, which an operator feels as the arm stepping rather than
# travelling.
node, state = build()
jogs = drive(node, state, stick(axes=PUSHED), count=4)
assert len(jogs) == 1
assert jogs[0][0] > 0
def test_a_held_stick_is_repeated_before_the_arm_would_expire_it(clock):
# The repeat exists for one reason: the arm's controller drops a velocity on
# its own, and the repeat is what keeps that backstop from firing mid-motion.
node, state = build()
drive(node, state, stick(axes=PUSHED))
node.clear()
clock.advance(state.config.deadman.jog_keepalive_s)
jogs = drive(node, state, stick(axes=PUSHED))
assert len(jogs) == 1
assert jogs[0][0] > 0
def test_a_change_of_direction_never_waits_for_the_repeat(clock):
# Sending only on change is worth nothing if the change itself is delayed:
# a press or a release has to be acted on the tick it happens, not up to a
# keepalive later.
node, state = build()
drive(node, state, stick(axes=PUSHED))
node.clear()
jogs = drive(node, state, stick(axes=[0.0, 1.0]))
assert len(jogs) == 1
assert jogs[0][0] < 0
def test_a_pad_that_was_never_wired_up_leaves_the_arm_alone(clock):
# `joy` is optional: a browser-only console wires no pad at all, and the
# node must not read that as a centred stick asking for the arm.
node, state = build()
jogs = ticks(node, state, count=3)
assert jogs == []
assert state.arbiter.owner is None
# --- the browser ---
def test_the_last_browser_leaving_stops_the_arm(clock):
# Closing the tab has to stop the arm, and nothing is sent when it does.
node, state = build()
state.clients = 1
state.arbiter.take(WEB, clock.t)
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
ticks(node, state)
node.clear()
loop._set_clients(state, 0)
jogs = ticks(node, state)
assert jogs == [STOP]
assert state.arbiter.owner is None
def test_a_browser_that_goes_quiet_stops_the_arm(clock):
# The console refreshes every 150 ms against a 400 ms deadline.
node, state = build()
state.clients = 1
state.arbiter.take(WEB, clock.t)
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
ticks(node, state)
node.clear()
clock.advance(0.5)
jogs = ticks(node, state)
assert jogs == [STOP]
def test_a_browser_that_keeps_refreshing_keeps_the_arm_moving(clock):
node, state = build()
state.clients = 1
state.arbiter.take(WEB, clock.t)
sent = []
for _ in range(4):
clock.advance(0.15)
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
loop._tick(node, state)
sent = node.jogs()
assert all(jog[0] == pytest.approx(state.config.speed_mm_s) for jog in sent)
def test_a_non_owner_jog_is_ignored(clock):
node, state = build()
state.clients = 1
state.arbiter.take(GAMEPAD, clock.t)
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
loop._tick(node, state)
assert node.jogs() == []
assert any(message.get("type") == "error" for message in node.ui())
# --- the stop that is never gated ---
def test_estop_is_never_gated_by_the_lease(clock):
# Somebody reaching for the stop is the one moment when asking who owns the
# arm is the wrong question.
node, state = build()
state.clients = 1
state.arbiter.take(GAMEPAD, clock.t)
loop._accept_ui_command(node, state, {"type": "estop"})
assert node.jogs() == [STOP]
assert "stop" in node.services()
def test_a_latched_estop_keeps_the_arm_still_under_a_pushed_stick(clock):
# A stop somebody had to reach for is not undone by the next thumb
# movement. Without this the pad stops the arm for one tick and drives it
# again on the next, which is not a stop at all.
node, state = build()
drive(node, state, stick(axes=PUSHED))
node.clear()
drive(node, state, stick(axes=PUSHED, buttons=buttons_for(estop=True)))
node.clear()
jogs = drive(node, state, stick(axes=PUSHED), count=4)
assert jogs == []
def test_the_pad_can_release_the_latch_it_set(clock):
# A pad-only dataflow has no console RESET, so a latch nothing on the pad
# can release would strand the arm until the process was restarted.
node, state = build()
drive(node, state, stick(buttons=buttons_for(estop=True)))
assert state.view.estop is True
node.clear()
drive(node, state, stick(buttons=buttons_for(clear=True)))
assert state.view.estop is False
assert "clear" in node.services()
def test_the_latch_is_released_without_holding_the_arm(clock):
# An idle pad hands the lease back two seconds after the stop it just made,
# so gating the release on the lease would be a trap.
node, state = build()
drive(node, state, stick(buttons=buttons_for(estop=True)))
state.arbiter.release(GAMEPAD)
state.arbiter.take(WEB, clock.t)
node.clear()
drive(node, state, stick(buttons=buttons_for(clear=True)))
assert state.view.estop is False
def test_the_arm_drives_again_once_the_latch_is_released(clock):
node, state = build()
drive(node, state, stick(buttons=buttons_for(estop=True)))
drive(node, state, stick(buttons=buttons_for(clear=True)))
node.clear()
jogs = drive(node, state, stick(axes=PUSHED))
assert jogs[-1][0] > 0
def test_a_latched_estop_stops_the_browser_too(clock):
# The latch is the node's, not the pad's. A console jog must not walk
# through a stop somebody made on the gamepad.
node, state = build()
state.clients = 1
drive(node, state, stick(buttons=buttons_for(estop=True)))
state.arbiter.release(GAMEPAD)
state.arbiter.take(WEB, clock.t)
node.clear()
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
jogs = ticks(node, state)
assert jogs == []
def test_the_estop_button_works_without_the_pad_holding_the_arm(clock):
node, state = build()
state.arbiter.take(WEB, clock.t)
drive(node, state, stick(buttons=buttons_for(estop=True)))
assert "stop" in node.services()
def test_a_latched_estop_is_cleared_by_reset(clock):
node, state = build()
state.view.estop = True
loop._accept_ui_command(node, state, {"type": "reset"})
assert state.view.estop is False
assert "clear" in node.services()
# --- the standard ports ---
def test_the_same_deflection_from_either_source_asks_for_the_same_motion(clock):
# The whole reason both sources are normalised before they are scaled. If
# these ever differ, one path has grown a conversion the other has not —
# which is how a console ends up in the robot's units.
node, state = build()
drive(node, state, stick(axes=[0.0, -1.0]))
from_pad = node.twists()[-1]
node, state = build()
state.clients = 1
state.arbiter.take(WEB, clock.t)
web_jog(node, state, [1.0, 0, 0, 0, 0, 0])
ticks(node, state)
from_web = node.twists()[-1]
assert from_pad == from_web
def test_the_twist_is_si_and_the_legacy_jog_is_not(clock):
# 60 mm/s is 0.06 m/s. A consumer reading the wrong port at the wrong scale
# is a thousandfold error, so this is worth pinning.
node, state = build()
drive(node, state, stick(axes=[0.0, -1.0]))
twist, jog = node.twists()[-1], node.jogs()[-1]
assert twist["linear"][0] == pytest.approx(state.config.speed_mm_s / 1000.0)
assert jog[0] == pytest.approx(state.config.speed_mm_s)
assert twist["frame"] == "base"
def test_a_stop_reaches_both_ports(clock):
node, state = build()
drive(node, state, stick(axes=PUSHED))
node.clear()
drive(node, state, stick())
assert node.jogs() == [STOP]
assert node.twists() == [{"linear": [0.0] * 3, "angular": [0.0] * 3, "frame": "base"}]
def test_a_button_asks_in_verbs_as_well_as_in_services(clock):
# The verb is what a robot speaking the standard reads; the service name is
# what dora-ulite6 reads until it does.
node, state = build()
drive(node, state, stick(buttons=buttons_for(grasp=True)))
assert node.actions() == ["gripper_close"]
assert "grasp" in node.services()
def test_every_verb_the_pad_can_ask_for_is_robot_agnostic(clock):
node, state = build()
drive(node, state, stick(buttons=buttons_for(release=True)))
drive(node, state, stick(buttons=buttons_for(freedrive=True)))
drive(node, state, stick(buttons=buttons_for(estop=True)))
drive(node, state, stick(buttons=buttons_for(clear=True)))
assert node.actions() == ["gripper_open", "freedrive", "stop", "clear"]
def test_the_first_press_after_the_pad_has_been_idle_is_not_dropped(clock):
# The lease has to be settled before the buttons are read. Read them first
# and the press arrives while nobody owns the arm, and by the next tick the
# button is held rather than newly down — so the edge is gone for good and
# the operator presses A and watches nothing happen.
node, state = build()
assert state.arbiter.owner is None
drive(node, state, stick(buttons=buttons_for(grasp=True)), count=3)
assert node.actions() == ["gripper_close"]
+150
View File
@@ -0,0 +1,150 @@
"""The pad as it arrives on the wire, and what silence on that wire means."""
import pyarrow as pa
import pytest
from dora_teleop import mapping
from dora_teleop.joy import ABSENT, JoyInput, Sample, decode
from dora_teleop.mapping import LAYOUT
from tests.conftest import axes_for, input_event, joy_message, stick
def joy_event(sample):
return input_event("joy", joy_message(sample))
def test_a_reading_survives_the_round_trip():
sample = decode(joy_event(stick(axes=[0.5, -0.5], buttons=[False, True])))
assert sample.axes[:2] == [0.5, -0.5]
assert sample.buttons == [False, True]
assert sample.present is True
def test_an_absent_pad_decodes_as_absent_not_as_centred():
sample = decode(joy_event(ABSENT))
assert sample.present is False
assert sample.axes == []
def test_a_pad_with_fewer_axes_than_the_layout_decodes_without_complaint():
# A three-axis flight stick is a strange thing to drive an arm with, but it
# must not take the node down; `mapping.axis` treats a missing one as centred.
sample = decode(joy_event(Sample(axes=[0.0, 0.0, 0.0], buttons=[False], present=True)))
assert len(sample.axes) == 3
def test_a_message_with_no_rows_reads_as_absent():
empty = input_event("joy", pa.array([], type=joy_message(ABSENT).type))
assert decode(empty) is ABSENT
def test_a_fresh_reading_is_handed_back():
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(), 100.0)
joy.accept(stick(axes=[0.5]), 100.0)
assert joy.read(100.1).axes[0] == 0.5
def test_a_reading_nobody_is_refreshing_reads_as_absent():
# dora-joy publishes every tick, so silence means it died — and a dead pad
# node is indistinguishable from an unplugged pad as far as the arm cares.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(), 100.0)
joy.accept(stick(axes=[0.5]), 100.0)
assert joy.read(100.5) is ABSENT
# --- the pad this node is actually holding ---
def test_a_pad_is_measured_where_it_rests_and_the_tilt_is_subtracted():
# A stick that no longer centres is the ordinary kind of worn pad. Its rest
# becomes zero, so it asks for nothing until it is actually pushed.
joy = JoyInput(ttl_s=0.2)
tilted = axes_for(left_x=0.2, left_y=-0.15)
joy.accept(stick(axes=tilted), 100.0)
reading = joy.read(100.1)
assert reading.present is True
assert reading.axes[LAYOUT.left_x] == pytest.approx(0.0)
assert reading.axes[LAYOUT.left_y] == pytest.approx(0.0)
assert mapping.is_active(reading) is False
def test_the_tilt_is_subtracted_rather_than_deadzoned_so_full_travel_remains():
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(axes=axes_for(left_x=0.2)), 100.0)
joy.accept(stick(axes=axes_for(left_x=1.0)), 100.0)
assert joy.read(100.1).axes[LAYOUT.left_x] == pytest.approx(0.8)
def test_a_trigger_that_rests_at_zero_does_not_read_as_half_pressed():
# Some pads report a trigger as 0 until it first moves. Read as SDL's -1..1
# that is half travel, which would lift the arm on its own.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(axes=axes_for(right_trigger=0.0)), 100.0)
reading = joy.read(100.1)
assert mapping.trigger(reading.axes[LAYOUT.right_trigger]) == pytest.approx(0.0)
assert mapping.is_active(reading) is False
def test_a_pad_that_does_not_fit_the_layout_reads_as_absent():
# The bug this exists for: a trigger on an index the layout calls a stick
# rests at -1.0, which is full deflection — an untouched pad on the desk
# asking for full speed, and holding the lease so nothing can take it back.
joy = JoyInput(ttl_s=0.2)
wrong = list(axes_for())
wrong[LAYOUT.right_y] = -1.0
joy.accept(stick(axes=wrong), 100.0)
assert joy.read(100.1) is ABSENT
assert "right stick Y" in joy.calibration.mismatch
def test_a_pad_held_when_it_appears_is_refused_until_it_is_let_go():
# A stick held at startup and a wrongly numbered axis look identical in one
# reading, so the safe answer is the same for both: no arm until it rests.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(axes=axes_for(left_y=-1.0)), 100.0)
assert joy.read(100.1) is ABSENT
joy.accept(stick(), 100.2)
assert joy.read(100.3).present is True
def test_rest_is_not_re_measured_while_the_pad_is_in_use():
# Re-measuring under a held stick would record that deflection as centre,
# and the arm would then move the moment the operator let go.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(), 100.0)
joy.accept(stick(axes=axes_for(left_y=-1.0)), 100.1)
joy.accept(stick(axes=axes_for(left_y=-1.0)), 100.2)
assert joy.read(100.3).axes[LAYOUT.left_y] == pytest.approx(-1.0)
def test_a_pad_that_goes_away_is_measured_again_when_it_comes_back():
# The second pad is not the first one, and may not rest where it did.
joy = JoyInput(ttl_s=0.2)
joy.accept(stick(axes=axes_for(left_x=0.2)), 100.0)
joy.accept(Sample(present=False), 100.1)
joy.accept(stick(axes=axes_for(left_x=-0.2)), 100.2)
assert joy.read(100.3).axes[LAYOUT.left_x] == pytest.approx(0.0)
def test_nothing_ever_arriving_reads_as_absent():
# A dataflow with no `joy` wired at all, which is a browser-only console.
assert JoyInput(ttl_s=0.2).read(100.0) is ABSENT
+218
View File
@@ -0,0 +1,218 @@
"""Stick deflection to base-frame velocity: units, signs and the shaping curve."""
import pytest
from dora_teleop import mapping
from dora_teleop.mapping import LAYOUT, VX, VY, VZ, WX, WY, WZ
from tests.conftest import axes_for, buttons_for, stick
def test_a_stick_at_rest_asks_for_nothing(config):
velocity = mapping.velocity(stick().axes, [], config)
assert velocity == [0.0] * 6
assert not mapping.is_moving(velocity)
def test_pushing_the_stick_up_moves_the_arm_along_positive_x(config):
# Up is negative in SDL, and up should send the arm away from the operator.
velocity = mapping.velocity(stick(axes=[0.0, -1.0]).axes, [], config)
assert velocity[VX] == pytest.approx(config.speed_mm_s)
assert velocity[VY] == 0.0
def test_pushing_the_stick_right_moves_the_arm_along_positive_y(config):
velocity = mapping.velocity(stick(axes=[1.0, 0.0]).axes, [], config)
assert velocity[VY] == pytest.approx(config.speed_mm_s)
def test_the_right_trigger_lifts_and_the_left_one_lowers(config):
up = mapping.velocity(axes_for(right_trigger=1.0), [], config)
down = mapping.velocity(axes_for(left_trigger=1.0), [], config)
assert up[VZ] == pytest.approx(config.speed_mm_s)
assert down[VZ] == pytest.approx(-config.speed_mm_s)
def test_both_triggers_together_cancel(config):
velocity = mapping.velocity(axes_for(left_trigger=1.0, right_trigger=1.0), [], config)
assert velocity[VZ] == 0.0
def test_an_untouched_trigger_does_not_read_as_half_pressed(config):
# SDL reports a trigger from -1 to 1, and one that has never moved sometimes
# reads 0 until it does. Treating that as half travel would lift the arm.
assert mapping.trigger(-1.0) == 0.0
assert mapping.trigger(0.0) == pytest.approx(0.5)
assert mapping.trigger(1.0) == 1.0
def test_the_right_stick_turns_the_tool_in_degrees_per_second(config):
velocity = mapping.velocity(axes_for(right_x=1.0, right_y=-1.0), [], config)
assert velocity[WX] == pytest.approx(config.speed_deg_s)
assert velocity[WY] == pytest.approx(config.speed_deg_s)
def test_the_bumpers_yaw_in_opposite_directions(config):
right = mapping.velocity(stick().axes, buttons_for(yaw_right=True), config)
left = mapping.velocity(stick().axes, buttons_for(yaw_left=True), config)
assert right[WZ] == pytest.approx(config.speed_deg_s)
assert left[WZ] == pytest.approx(-config.speed_deg_s)
def test_several_axes_move_at_once(config):
# The whole reason a pad beats the console's four-sector dial: the dial can
# only ever ask for one axis.
velocity = mapping.velocity(axes_for(left_x=1.0, left_y=-1.0, right_trigger=1.0), [], config)
assert velocity[VX] > 0
assert velocity[VY] > 0
assert velocity[VZ] > 0
def test_drift_inside_the_deadzone_is_ignored(config):
# A worn stick that does not centre would otherwise drive the arm across the
# table with nobody touching it.
velocity = mapping.velocity(stick(axes=[config.deadzone / 2, 0.0]).axes, [], config)
assert velocity == [0.0] * 6
def test_motion_starts_from_zero_just_outside_the_deadzone(config):
# Without rescaling, the arm would jump to a fraction of full speed the
# instant the stick left the dead band.
just_outside = mapping.deadzone(config.deadzone + 1e-9, config.deadzone)
assert just_outside == pytest.approx(0.0, abs=1e-6)
def test_the_deadzone_still_reaches_full_speed_at_full_deflection(config):
assert mapping.deadzone(1.0, config.deadzone) == pytest.approx(1.0)
assert mapping.deadzone(-1.0, config.deadzone) == pytest.approx(-1.0)
def test_expo_slows_small_deflections_without_capping_large_ones():
assert mapping.expo(0.5, 0.0) == pytest.approx(0.5)
assert mapping.expo(0.5, 1.0) == pytest.approx(0.125)
assert mapping.expo(1.0, 1.0) == pytest.approx(1.0)
assert mapping.expo(-0.5, 1.0) == pytest.approx(-0.125)
def test_a_pad_reporting_fewer_axes_is_read_as_centred_not_as_an_error(config):
velocity = mapping.velocity([0.0, 0.0], [], config)
assert velocity[VX] == 0.0
assert velocity[WZ] == 0.0
# --- is the pad being used at all ---
def test_a_pad_at_rest_is_not_asking_for_the_arm():
# SDL rests a trigger at -1.0, so a raw threshold reports every connected
# pad as in use for ever: the pad claims the arm the moment it is plugged
# in and GAMEPAD_IDLE_RELEASE_S never fires.
assert mapping.is_active(stick()) is False
def test_an_absent_pad_is_not_asking_for_the_arm():
assert mapping.is_active(stick(present=False)) is False
def test_a_nudged_stick_is_asking_for_the_arm():
assert mapping.is_active(stick(axes=[0.5])) is True
def test_a_held_trigger_is_asking_for_the_arm():
assert mapping.is_active(stick(axes=axes_for(left_trigger=1.0))) is True
def test_a_held_button_is_asking_for_the_arm():
assert mapping.is_active(stick(buttons=buttons_for(grasp=True))) is True
def test_an_axis_the_layout_does_not_use_still_counts():
# A pad reporting its d-pad as two extra axes is asking for the arm when
# somebody presses it, whether or not this node maps it to anything.
assert mapping.is_active(stick(axes=axes_for() + [1.0])) is True
# --- reading a pad that is not the layout's ideal one ---
def test_a_resting_pad_measures_as_resting():
zeros = mapping.rest_zeros(axes_for())
assert zeros == axes_for()
def test_a_small_tilt_is_accepted_as_rest():
# A worn stick is an ordinary pad, not a broken one.
assert mapping.rest_zeros(axes_for(left_x=0.2, right_y=-0.2))
def test_an_axis_resting_where_the_layout_says_it_cannot_is_refused():
wrong = axes_for()
wrong[LAYOUT.right_y] = -1.0
with pytest.raises(ValueError, match="right stick Y"):
mapping.rest_zeros(wrong)
def test_the_refusal_names_the_index_and_what_it_read():
# It is read off a terminal by somebody deciding what to put in `Layout`.
wrong = axes_for()
wrong[LAYOUT.right_x] = 0.9
with pytest.raises(ValueError, match=r"axis 2 rests at \+0.900"):
mapping.rest_zeros(wrong)
def test_a_trigger_resting_at_either_end_is_accepted():
# -1 is what SDL reports; 0 is what some pads report until it first moves.
assert mapping.rest_zeros(axes_for(left_trigger=-1.0, right_trigger=0.0))
def test_an_axis_the_layout_does_not_use_still_has_to_rest():
# A d-pad reported as two extra axes counts towards `is_active`, so one
# stuck at an extreme would hold the arm as surely as a named one.
with pytest.raises(ValueError, match="axis 6"):
mapping.rest_zeros(axes_for() + [1.0])
def test_canonical_puts_a_tilted_stick_back_at_centre():
tilted = axes_for(left_x=0.2)
canonical = mapping.canonical(tilted, mapping.rest_zeros(tilted))
assert canonical[LAYOUT.left_x] == pytest.approx(0.0)
def test_canonical_puts_a_trigger_back_at_its_own_rest():
zeroed = axes_for(left_trigger=0.0, right_trigger=0.0)
canonical = mapping.canonical(zeroed, mapping.rest_zeros(zeroed))
assert canonical[LAYOUT.left_trigger] == pytest.approx(mapping.TRIGGER_REST)
assert canonical[LAYOUT.right_trigger] == pytest.approx(mapping.TRIGGER_REST)
def test_a_trigger_measured_from_zero_still_reaches_full_travel():
zeros = mapping.rest_zeros(axes_for(right_trigger=0.0))
canonical = mapping.canonical(axes_for(right_trigger=1.0), zeros)
assert mapping.trigger(canonical[LAYOUT.right_trigger]) == pytest.approx(1.0)
def test_a_calibrated_pad_at_rest_asks_for_nothing(config):
tilted = axes_for(left_x=0.2, left_y=-0.15, right_trigger=0.0)
canonical = mapping.canonical(tilted, mapping.rest_zeros(tilted))
assert mapping.velocity(canonical, [], config) == [0.0] * 6
+115
View File
@@ -0,0 +1,115 @@
"""The probe: what it draws, without a pad or a terminal."""
from dora_teleop import mapping, probe
from dora_teleop.joy import Sample
from tests.conftest import buttons_for, stick
def test_a_centred_stick_puts_the_marker_in_the_middle():
drawn = probe.bar(0.0)
assert drawn[len(drawn) // 2] == "#"
def test_the_bar_leans_the_way_the_stick_is_pushed():
left = probe.bar(-1.0)
right = probe.bar(1.0)
assert left.index("#") < left.index("|")
assert right.index("#") > right.index("|")
def test_a_reading_beyond_full_deflection_stays_on_the_bar():
# SDL has been known to report slightly outside -1..1 on a worn stick.
assert "#" in probe.bar(-4.0)
assert "#" in probe.bar(4.0)
def test_no_gamepad_says_so_and_says_the_arm_is_being_held_still(config):
# Absence covers both "no pad" and "dora-joy stopped sending", and the probe
# must not imply the arm is waiting on something it is not.
drawn = probe.render(Sample(present=False), Sample(present=False), [0.0] * 6, config)
assert "no gamepad" in drawn
assert "hold still" in drawn
def test_every_axis_the_pad_reports_is_shown_even_the_unused_ones(config):
# A pad whose sticks land on indices we do not use is exactly what this is
# for, so nothing may be hidden.
sample = Sample(axes=[0.0] * 8, buttons=[], present=True)
drawn = probe.render(sample, sample, [0.0] * 6, config)
assert "unused" in drawn
assert "axes (8 reported)" in drawn
def test_a_pressed_button_is_named(config):
sample = stick(buttons=buttons_for(grasp=True))
drawn = probe.render(sample, sample, [0.0] * 6, config)
assert "grasp" in drawn
def test_nothing_pressed_says_so_rather_than_showing_an_empty_line(config):
drawn = probe.render(stick(), stick(), [0.0] * 6, config)
assert "none pressed" in drawn
def test_the_velocity_is_shown_in_the_units_the_arm_takes(config):
pushed = stick(axes=[0.0, -1.0])
velocity = mapping.velocity(pushed.axes, [], config)
drawn = probe.render(pushed, pushed, velocity, config)
assert "mm/s" in drawn
assert "deg/s" in drawn
assert f"{config.speed_mm_s:+8.2f}" in drawn
def test_a_still_arm_is_reported_as_still(config):
drawn = probe.render(stick(), stick(), [0.0] * 6, config)
assert "told to hold still" in drawn
def test_a_moving_arm_is_reported_as_moving(config):
pushed = stick(axes=[0.0, -1.0])
drawn = probe.render(pushed, pushed, [10.0, 0, 0, 0, 0, 0], config)
assert "moving: yes" in drawn
def test_whether_the_pad_is_asking_for_the_arm_is_shown(config):
# The probe is where a resting trigger claiming the arm would have been
# visible, so it says what `is_active` thinks rather than leaving it implied.
rest, pushed = stick(), stick(axes=[0.0, -1.0])
assert "asking for the arm: no" in probe.render(rest, rest, [0.0] * 6, config)
assert "asking for the arm: yes" in probe.render(pushed, pushed, [0.0] * 6, config)
def test_a_pad_that_does_not_fit_the_layout_is_called_out(config):
# This is the tool for exactly that pad, so the reason goes on the screen
# rather than only in the node's log.
wrong = stick(axes=[0.0, 0.0, 0.0, -1.0, -1.0, -1.0])
mismatch = "axis 3 rests at -1.000, which is not where right stick Y sits"
drawn = probe.render(wrong, Sample(present=False), [0.0] * 6, config, mismatch)
assert "does not match the layout" in drawn
assert mismatch in drawn
assert "asking for the arm: no" in drawn
def test_the_raw_axes_are_still_shown_for_a_pad_that_does_not_fit(config):
# The numbers are the evidence: they are what `Layout` gets corrected from.
wrong = stick(axes=[0.0, 0.0, 0.0, -1.0, -1.0, -1.0])
drawn = probe.render(wrong, Sample(present=False), [0.0] * 6, config, "axis 3")
assert "-1.000" in drawn
assert "axes (6 reported)" in drawn
+223
View File
@@ -0,0 +1,223 @@
"""The embedded console server: a real socket, and the deadman it feeds.
These start the server for real rather than faking it. The thing worth checking
is the boundary between the two threads — a browser's message reaching the tick,
and a browser going away reaching the deadman — and a fake on either side of
that boundary would check nothing.
"""
import asyncio
import json
import socket
import time
import aiohttp
import pytest
from dora_teleop import main as loop
from dora_teleop.arbiter import WEB
from dora_teleop.config import Config, ConsoleConfig
from dora_teleop.server import Console
from tests.conftest import RecordingNode
def free_port():
"""Ask the OS for a port nobody is using."""
with socket.socket() as held:
held.bind(("127.0.0.1", 0))
return held.getsockname()[1]
@pytest.fixture
def console():
"""Run a real server on a real port, and stop it however the test ends."""
running = Console("127.0.0.1", free_port())
running.start()
try:
yield running
finally:
running.stop()
def visit(console, exchange):
"""Open one browser's socket against the server and run `exchange` on it."""
async def session():
async with aiohttp.ClientSession() as client:
url = f"http://{console.host}:{console.port}"
async with client.ws_connect(f"{url}/ws") as socket_:
return await exchange(client, url, socket_)
return asyncio.run(session())
def wait_for(predicate):
"""Block until the server thread catches up, rather than guessing at a sleep."""
for _ in range(200):
if predicate():
return
time.sleep(0.01)
raise AssertionError("the server never got there")
def test_the_page_is_served_at_the_root(console):
async def exchange(client, url, socket_):
async with client.get(url) as answer:
return answer.status, await answer.text()
status, body = visit(console, exchange)
assert status == 200
assert "teleop console" in body
def test_a_message_from_a_browser_reaches_the_tick(console):
# The console's half of the boundary: what the page sends has to be waiting
# on the queue the next tick drains, in the shape the loop reads.
async def exchange(client, url, socket_):
await socket_.send_str(json.dumps({"type": "jog", "axes": [1.0, 0, 0, 0, 0, 0]}))
await asyncio.sleep(0.05)
visit(console, exchange)
assert console.take() == [{"type": "jog", "axes": [1.0, 0, 0, 0, 0, 0]}]
assert console.take() == []
def test_a_snapshot_reaches_the_browser(console):
# The other half: a telemetry object handed over from the dora thread comes
# out of the socket as the page's own JSON, with no envelope around it.
async def exchange(client, url, socket_):
console.broadcast({"type": "telemetry", "arm_state": "idle"})
return json.loads(await socket_.receive_str())
assert visit(console, exchange) == {"type": "telemetry", "arm_state": "idle"}
def test_a_browser_that_is_not_talking_json_is_ignored(console):
# A page from a newer build saying something we cannot parse must not take
# the arm down, and must not stop the messages after it being read.
async def exchange(client, url, socket_):
await socket_.send_str("{not json")
await socket_.send_str(json.dumps({"type": "estop"}))
await asyncio.sleep(0.05)
visit(console, exchange)
assert console.take() == [{"type": "estop"}]
def test_the_server_counts_the_browsers_on_it(console):
assert console.clients == 0
seen = []
async def exchange(client, url, socket_):
await asyncio.sleep(0.05)
seen.append(console.clients)
visit(console, exchange)
assert seen == [1]
# And the socket closing is what the deadman below reads.
wait_for(lambda: console.clients == 0)
def test_a_flood_from_one_browser_cannot_grow_without_bound(console):
# An unbounded queue would answer a flood by growing until the node died,
# and the node dying is the one thing that must not stop the arm from being
# stopped.
async def exchange(client, url, socket_):
for index in range(600):
await socket_.send_str(json.dumps({"type": "jog_stop", "n": index}))
await asyncio.sleep(0.2)
visit(console, exchange)
assert 0 < len(console.take()) <= 256
# --- where the server meets the deadman ---
def build():
config = Config.from_env()
config.expo = 0.0
return RecordingNode(), loop.Teleop(config)
def test_the_last_browser_leaving_takes_the_arm_back(console):
# The web deadman used to arrive as a `clients` message from the bridge. It
# is now read straight off the server, and it has to mean the same thing.
node, state = build()
state.console = console
state.arbiter.take(WEB, 1000.0)
loop._drain_console(node, state)
assert state.clients == 0
assert state.arbiter.owner is None
assert state.web_axes == [0.0] * 6
def test_a_node_with_no_console_is_left_alone():
# A pad-only dataflow opens no port, and the tick must not read a lease
# release out of a server that was never started.
node, state = build()
state.clients = 1
state.arbiter.take(WEB, 1000.0)
loop._drain_console(node, state)
assert state.arbiter.owner == WEB
# --- the port itself ---
def test_the_console_is_on_by_default(monkeypatch):
monkeypatch.delenv("WEB_PORT", raising=False)
settings = ConsoleConfig.from_env()
assert settings.enabled is True
# Pinned because it is the number in the README, the manifest and every
# dataflow: a default that drifts from the docs sends an operator to a port
# nothing is listening on.
assert settings.port == 7000
def test_port_zero_means_no_port_is_opened(monkeypatch):
# How `dataflow-gamepad.yml` asks for a pad and nothing else.
monkeypatch.setenv("WEB_PORT", "0")
assert ConsoleConfig.from_env().enabled is False
def test_a_port_that_is_not_a_port_stops_the_node(monkeypatch):
monkeypatch.setenv("WEB_PORT", "99999")
with pytest.raises(ValueError, match="WEB_PORT"):
ConsoleConfig.from_env()
def test_a_port_that_is_not_a_number_stops_the_node(monkeypatch):
monkeypatch.setenv("WEB_PORT", "eight-thousand")
with pytest.raises(ValueError, match="WEB_PORT"):
ConsoleConfig.from_env()
def test_a_port_already_in_use_stops_the_node(console):
# Found the hard way: the bind failed on the server thread, `start` waited
# out its timeout and then announced a console anyway, and the node ran on
# serving nothing. An operator opening the URL got silence while the log
# said it was up.
second = Console(console.host, console.port)
with pytest.raises(OSError):
second.start()
second.stop()
def test_stopping_a_console_that_never_started_is_harmless():
# `main` stops the console in a `finally`, which runs whether or not the
# server ever came up.
Console("127.0.0.1", 0).stop()
+139
View File
@@ -0,0 +1,139 @@
"""Translation both ways: what the console asks for, and what it is shown."""
from dora_teleop import ui
from dora_teleop.arbiter import GAMEPAD, WEB
# --- what the console asks for ---
def test_a_jog_carries_its_six_numbers():
command = ui.command_of({"type": "jog", "axes": [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]})
assert command.type == "jog"
assert command.axes == [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]
def test_a_jog_of_the_wrong_length_is_not_a_jog():
assert ui.command_of({"type": "jog", "axes": [1, 2, 3]}) is None
def test_a_jog_carrying_something_that_is_not_a_number_is_not_a_jog():
assert ui.command_of({"type": "jog", "axes": [1, 2, 3, 4, 5, "fast"]}) is None
def test_a_message_we_have_never_heard_of_is_dropped_not_refused():
# The console and this node version independently; a page from a newer build
# must not take the arm down by saying something we have not learned yet.
assert ui.command_of({"type": "hologram"}) is None
assert ui.command_of({}) is None
assert ui.command_of("not even an object") is None
def test_taking_control_carries_whether_it_may_preempt():
command = ui.command_of({"type": "control", "action": "take", "preempt": True})
assert (command.action, command.preempt) == ("take", True)
def test_the_gripper_button_says_which_way():
assert ui.command_of({"type": "gripper", "closed": True}).enabled is True
assert ui.command_of({"type": "gripper", "closed": False}).enabled is False
def test_a_move_needs_a_position():
assert ui.command_of({"type": "move", "x": 1.0, "y": 2.0}) is None
command = ui.command_of({"type": "move", "x": 250.0, "y": 0.0, "z": 200.0, "speed": 40})
assert command.row == {"x": 250.0, "y": 0.0, "z": 200.0, "speed": 40.0}
# --- what the console is shown ---
def test_an_arm_that_cannot_be_reached_reads_as_disconnected():
view = ui.ArmView(state=ui.UNAVAILABLE)
assert ui.arm_state(view, jogging=False) == ui.UI_DISCONNECTED
def test_an_arm_still_starting_up_reads_as_disconnected():
view = ui.ArmView(state=ui.CONNECTING)
assert ui.arm_state(view, jogging=False) == ui.UI_DISCONNECTED
def test_a_latched_estop_outranks_everything_else():
# The arm may well read ready, and saying so would invite somebody to push it.
view = ui.ArmView(state=ui.READY, estop=True, freedrive_on=True)
assert ui.arm_state(view, jogging=True) == ui.UI_ESTOP
def test_freedrive_outranks_idle():
# An arm that can be pushed by hand is not one holding station.
view = ui.ArmView(state=ui.READY, freedrive_on=True)
assert ui.arm_state(view, jogging=False) == ui.UI_FREEDRIVE
def test_a_moving_arm_we_are_jogging_reads_as_jogging():
view = ui.ArmView(state=ui.MOVING)
assert ui.arm_state(view, jogging=True) == ui.UI_JOGGING
assert ui.arm_state(view, jogging=False) == ui.UI_MOVING
def test_a_recovering_arm_is_there_but_will_not_move():
view = ui.ArmView(state=ui.RECOVERING)
assert ui.arm_state(view, jogging=False) == ui.UI_STOPPED
def test_a_state_we_do_not_know_reads_as_disconnected_rather_than_as_ready():
view = ui.ArmView(state="dancing")
assert ui.arm_state(view, jogging=False) == ui.UI_DISCONNECTED
def test_the_snapshot_carries_every_key_the_page_reads_by_name():
view = ui.ArmView(state=ui.READY, pose=[1, 2, 3, 4, 5, 6], joints=[0] * 6, connected=True)
snapshot = ui.snapshot(view, WEB, jogging=False, timestamp=123.0)
for key in (
"type",
"ts",
"connected",
"ready",
"arm_state",
"owner",
"estop",
"gripper",
"units",
"freedrive",
"position",
"joints",
):
assert key in snapshot
assert snapshot["position"] == {"x": 1, "y": 2, "z": 3, "roll": 4, "pitch": 5, "yaw": 6}
def test_an_arm_with_no_pose_yet_reports_none_rather_than_a_stale_one():
snapshot = ui.snapshot(ui.ArmView(), None, jogging=False, timestamp=0.0)
assert snapshot["position"] is None
assert snapshot["owner"] == "none"
def test_the_owner_is_named_so_the_page_can_gate_its_jog_pad():
assert ui.snapshot(ui.ArmView(), GAMEPAD, False, 0.0)["owner"] == "gamepad"
assert ui.snapshot(ui.ArmView(), WEB, False, 0.0)["owner"] == "web"
def test_get_state_fills_in_what_telemetry_does_not_carry():
view = ui.ArmView()
view.take_get_state({"connected": True, "ready": True, "tool_engaged": True, "freedrive": True})
assert (view.connected, view.ready, view.tool_engaged, view.freedrive_on) == (True, True, True, True)
Generated
+899
View File
@@ -0,0 +1,899 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "aiohappyeyeballs"
version = "2.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" },
]
[[package]]
name = "aiohttp"
version = "3.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
{ name = "aiosignal" },
{ name = "attrs" },
{ name = "frozenlist" },
{ name = "multidict" },
{ name = "propcache" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "yarl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
{ url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
{ url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
{ url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
{ url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
{ url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
{ url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
{ url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
{ url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
{ url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
{ url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
{ url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
{ url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
{ url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
{ url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
{ url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
{ url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
{ url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
{ url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
{ url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
{ url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
{ url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
{ url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
{ url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
{ url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
{ url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
{ url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
{ url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
{ url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
{ url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
{ url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
{ url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
{ url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
{ url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
{ url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
{ url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
{ url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
{ url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
{ url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
{ url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
{ url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
{ url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
{ url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
{ url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
{ url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
{ url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
{ url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
{ url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
{ url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
{ url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
{ url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
{ url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
{ url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
{ url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
{ url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
{ url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
{ url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
{ url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
{ url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
]
[[package]]
name = "aiosignal"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "dora-rs"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "pyarrow" },
{ name = "pyyaml" },
]
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 = [
{ 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/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/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/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/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/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/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/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/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/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]]
name = "dora-teleop"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
{ name = "dora-rs" },
{ name = "pyarrow" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "aiohttp", specifier = ">=3.9" },
{ name = "dora-rs", specifier = ">=1.0.1" },
{ name = "pyarrow", specifier = ">=17.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.1" },
{ name = "ruff", specifier = ">=0.9" },
]
[[package]]
name = "frozenlist"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
{ url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
{ url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
{ url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
{ url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
{ url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
{ url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
{ url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
{ url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
{ url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
{ url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
{ url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
{ url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
{ url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
{ url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
{ url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
{ url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
{ url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
{ url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
{ url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
{ url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
{ url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
{ url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
{ url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
{ url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
{ url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
{ url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
{ url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
{ url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
{ url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
{ url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
{ url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
{ url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
{ url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
{ url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
{ url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
{ url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
{ url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
{ url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
{ url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
{ url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
{ url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
{ url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
{ url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
{ url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
{ url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
{ url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
{ url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
{ url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
{ url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
{ url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
{ url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
{ url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
{ url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
{ url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
{ url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
{ url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]]
name = "idna"
version = "3.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "multidict"
version = "6.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/95/989c1b5ca17b72128661530cd6e351a0a83cda9a4d6c036e9ed976c18931/multidict-6.8.0.tar.gz", hash = "sha256:5cd4637ce76312ba1e05eb9c5193fec231f64fee0944e135fa1e951242355b37", size = 122412, upload-time = "2026-09-09T13:57:57.967Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/83/a4621577679149ea001806f5963f3fc687c391c1bd5217157be2278863f5/multidict-6.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22a310ad37672a261e55a8b5e28d0ae08cfb68abb1f46418ccd19835c3b8e836", size = 84146, upload-time = "2026-09-09T13:53:49.163Z" },
{ url = "https://files.pythonhosted.org/packages/09/00/236b063f3e606055a3a9ba8faa5d40e6c688b059a58056b055f213476f46/multidict-6.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bb8c7da8c861391f7ae48e3593762be2dabe405109e01aec520fbe1a6d15d14b", size = 51049, upload-time = "2026-09-09T13:53:50.46Z" },
{ url = "https://files.pythonhosted.org/packages/91/9d/954b139bfa969855f2d4cb5ae7b7d44dd7106f754305b6e21a9068213aa7/multidict-6.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10456943903744ae1249728161c96bd9d2f7eb5ee17fcc2ffda2dc32e1bb36c7", size = 49362, upload-time = "2026-09-09T13:53:51.878Z" },
{ url = "https://files.pythonhosted.org/packages/d7/8a/8774f5b3f6d5266ecd1117876e04b405f0f1ce19aa750b35a826efe6cfe4/multidict-6.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:658f5a1895b804423d97b22d06fc0d0b171c7c01dcc3aa9c8faf0c0e26a249a5", size = 278619, upload-time = "2026-09-09T13:53:53.44Z" },
{ url = "https://files.pythonhosted.org/packages/db/47/736080fec911ed9f2dd57ccab5a8145e4f17c4987de0bfc27bee20e4d170/multidict-6.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c10b22860dbd09982d0b8993b66231a861bea2993d4a817ff35273f6ea285a", size = 283771, upload-time = "2026-09-09T13:53:55.048Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d5/b7f41f59b0583f092602308a5e7c16ec5efd00d60214b22511e89a38dd19/multidict-6.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:088b04a66b3c1fce6fe4d771ec184a0426262d0b86709c908477b4ac7965df40", size = 262108, upload-time = "2026-09-09T13:53:56.631Z" },
{ url = "https://files.pythonhosted.org/packages/54/b2/a52dc06c6e2598672308e3d392fd85b837b23c25dda459bedaea84985080/multidict-6.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9caef53b20a105c0d66518a34be2f71b2783de8d091767575ef86f6ea422236d", size = 289899, upload-time = "2026-09-09T13:53:58.415Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/00cda7983f37d119b86f1f89d5b4cf771ecb6d0fedeb9a0971758d6d6d4a/multidict-6.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a5e1583c14775580da05641240ce0d93f36ce3ddef3d5083a827468b0bcfe874", size = 293025, upload-time = "2026-09-09T13:53:59.973Z" },
{ url = "https://files.pythonhosted.org/packages/c6/c7/4544cc02e45bbfac4d8788b05379bb360021fd8c53fa74b0f624126ac188/multidict-6.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003a3bddb32915c3f67096ea41d24e53edf710edb65a1f5d0c70ab40b0e4d20b", size = 287410, upload-time = "2026-09-09T13:54:01.652Z" },
{ url = "https://files.pythonhosted.org/packages/43/1a/7abed90b8eba381842235bfa6f4d730204fd7deb374fc87e3ec9b2c2b4ac/multidict-6.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35977263d9bf506dbc65349f63b3b8c91606d4abc110990945e3b94bc671319c", size = 255878, upload-time = "2026-09-09T13:54:03.366Z" },
{ url = "https://files.pythonhosted.org/packages/25/3e/73fae10e15fc4d711975337caff7e494c87de5d0189afe3518b21b945326/multidict-6.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9dc7b4ff6ef184504b49ef9a4113d49a646653b2ce89f5f48c1f57cdf6ba081", size = 277831, upload-time = "2026-09-09T13:54:04.963Z" },
{ url = "https://files.pythonhosted.org/packages/c5/cf/01cfc81492933331147004861bdff201d8adeba8485ecd8f490e755fe7e8/multidict-6.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:887f9a975996032c686719eb7b3e1e7942fab5079c2b778bbd9afe9a9d78244f", size = 275096, upload-time = "2026-09-09T13:54:06.661Z" },
{ url = "https://files.pythonhosted.org/packages/de/59/e9a3773b17297fa1e38fd4b3c6f5f2f458380796be62eca7d0d77c250618/multidict-6.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f3071e6515cc63714d014da8f738ae9fa3997c476203f3cd46de380c2376ed7b", size = 279803, upload-time = "2026-09-09T13:54:08.389Z" },
{ url = "https://files.pythonhosted.org/packages/64/9d/2d712a2605b3971908e3b4f5eb6f98c353d9991e106f684d0e08ae581814/multidict-6.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5f3a2af441670d80ce5fdf13b6c1b421fc1fc7fc5182d58ac7486738bb2b742", size = 284595, upload-time = "2026-09-09T13:54:10.17Z" },
{ url = "https://files.pythonhosted.org/packages/58/6c/21aded8586e552b29892268c576e5745d1a894c5451c9866ca3c06b7ec50/multidict-6.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:31e8901637e20ccb3cf8f8848b5d0f7a00462bf5b34f7cf3dcbb2753b18e8b39", size = 252641, upload-time = "2026-09-09T13:54:11.811Z" },
{ url = "https://files.pythonhosted.org/packages/2a/70/56a415ae0a45e5eae2ec817d46aeb72a1ae777863621c85f1f39d329275b/multidict-6.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:13967dca8b2f33230a1427b52438326bb1c9101a1df22a3309ed3fcbbb3c96f0", size = 283369, upload-time = "2026-09-09T13:54:13.59Z" },
{ url = "https://files.pythonhosted.org/packages/08/7e/7b7cd611fd94bf2f6bd16244c50495867ba394d5baaf8e6e487d39494ab3/multidict-6.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad474c11d851b6fc97cb625e4822bc0cbd567fc07dc2602e28faec5a36b42bbb", size = 281653, upload-time = "2026-09-09T13:54:15.174Z" },
{ url = "https://files.pythonhosted.org/packages/33/4a/b19a5892ef2ef6c68ae278b4f1504b82e01037baedd92c55d37e55ecad00/multidict-6.8.0-cp312-cp312-win32.whl", hash = "sha256:7bb0dad75068fee80fcb60f88569722c199d8656a16706702dc6e3b786819c90", size = 47936, upload-time = "2026-09-09T13:54:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/29/00/1952f9f282aa71e7c3db3a6b47afb689d0ddf283dbded7e6326a91d421c9/multidict-6.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:7d26dc8f070c0ec5579e987fa615ffd6883086106eefdff9e10d160fc5630630", size = 51723, upload-time = "2026-09-09T13:54:18.05Z" },
{ url = "https://files.pythonhosted.org/packages/49/b5/c9d57dbafe25b8f3460ce2961c968539a81ff7a70160c44dcfd4255cbcd1/multidict-6.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:e6ec7d37841609a691b96a10b4fde386c7cd93ebbb939f59c9f23325ee788395", size = 48492, upload-time = "2026-09-09T13:54:19.42Z" },
{ url = "https://files.pythonhosted.org/packages/84/1f/d7112c2dd7db02677097be72fb65542f51a5aa73cb472b87ec211ba9e0dd/multidict-6.8.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:ec0a4d066356054d569a66e0a94691a2058b680be5e710298f61db11a3c4609f", size = 54197, upload-time = "2026-09-09T13:54:20.814Z" },
{ url = "https://files.pythonhosted.org/packages/ae/24/876015abbcb4a179d946579eb77b778eb5a948fc8381bc7928ba895bc051/multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:714597cb5d5e15a8a449d2ae23c45b486a9e8fa33c462c7a33d7f35b65d92943", size = 47787, upload-time = "2026-09-09T13:54:22.51Z" },
{ url = "https://files.pythonhosted.org/packages/06/c1/ceb7d25f8a567599db2eb19b08cac58d67ff553cff42dcadbea9aba56a20/multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0db3a4d1e264e225037a6023888972c25206a96e016021a5bea41c9a939f2a9", size = 48815, upload-time = "2026-09-09T13:54:23.986Z" },
{ url = "https://files.pythonhosted.org/packages/18/e3/e1c6e9c3818c34b782f23ce5fdba3eaa34ec6750dc53078dfac80fa59be7/multidict-6.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:27747162712e85c84598d364425dbf1714ff335bdb6ba3171c4e5081196e8916", size = 83484, upload-time = "2026-09-09T13:54:25.674Z" },
{ url = "https://files.pythonhosted.org/packages/4a/a0/c23f78a4badee9a5b3e760495c661c62a92c340a1dfd00f829cd16e256bb/multidict-6.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:847d6082ae694dc95e548acb201bc100e1cfa96513bc71fdcb86f709dad6c435", size = 50763, upload-time = "2026-09-09T13:54:27.135Z" },
{ url = "https://files.pythonhosted.org/packages/c4/fe/db552d402a3f6b650f5d3ae11b82b93833836aebb51bcda22d8691121129/multidict-6.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:88a6df88567680504ae28bfa7a1f2f64243d91e79a40b2c92ef42efc531e23da", size = 49029, upload-time = "2026-09-09T13:54:28.483Z" },
{ url = "https://files.pythonhosted.org/packages/01/b4/546853fba19dcef77cdf91fc173faf0b02284a49106cf250511166b4ec5c/multidict-6.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:560b211fc3bd4a1e1c6de44f6d38113bf5b410dfc89a4c0d2a3c0edbf1a0dfb8", size = 278863, upload-time = "2026-09-09T13:54:30.145Z" },
{ url = "https://files.pythonhosted.org/packages/ee/3f/4b52dac7db547936eb762123ac1d99df23f92fdb358bae600e322f611247/multidict-6.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202436df907c15adbb94360296c425ea53cf8968a5d2cff9b5b9790ae1972b33", size = 283915, upload-time = "2026-09-09T13:54:31.937Z" },
{ url = "https://files.pythonhosted.org/packages/fd/6e/c0dfbf170e49a91bcb9ce850d51cb98357f3033c5227529200ca7625853e/multidict-6.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c46a08bf070d6849fed483e9d9833f9d06aecb8382ed985be0b38508b3ae958e", size = 260704, upload-time = "2026-09-09T13:54:33.529Z" },
{ url = "https://files.pythonhosted.org/packages/91/02/56973a060ab8dfc2e80bb6797682f6577aff7123cdb1de1a568670ae3499/multidict-6.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cd560498ae8e1bcc955643c1d78eb8e338226d07a983c656ea8c4443d3eec0f", size = 290243, upload-time = "2026-09-09T13:54:35.408Z" },
{ url = "https://files.pythonhosted.org/packages/d5/67/69112989f131bdea4a87b74e82cb0a2daf37880cd92b0e6f0420020adceb/multidict-6.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:758233648ac47b07c575224c4eadd73c8929c3b4c31e2afcfea935fde1cda735", size = 291131, upload-time = "2026-09-09T13:54:37.205Z" },
{ url = "https://files.pythonhosted.org/packages/c2/75/9435f68b0cfc442d4917de85c26f2b2e1292630883414a25576083fa2469/multidict-6.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:122adc7c46ac1e31ecfc7f81b2530533dccafdba70f5d741649f87e336c63384", size = 287551, upload-time = "2026-09-09T13:54:38.835Z" },
{ url = "https://files.pythonhosted.org/packages/13/08/2ee4838081d6587849611aa7ec722c4cb2469e912fd0eaee980e7bac064c/multidict-6.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8125e60f3c70e323ac07dd8b3635f7b3bbc5c3a9ac04ae5988f668ff7ae28a18", size = 254591, upload-time = "2026-09-09T13:54:40.806Z" },
{ url = "https://files.pythonhosted.org/packages/94/f1/05673b51191f77f4198b8e4b35f16ea71c0300c72ca8aa027a66a61b6edc/multidict-6.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83ff054b04915be5c15680da6c6012474a2cc2bf534129a0e8c6a99f17ba7238", size = 278204, upload-time = "2026-09-09T13:54:42.672Z" },
{ url = "https://files.pythonhosted.org/packages/45/4f/b6cf74322b3fbd3e011a1e903730191922291a7779f6d404114c2189b806/multidict-6.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:930c6058047410e3edff445f5a6e4457f2e089042dede00e2d18ce06f3ceae2e", size = 275600, upload-time = "2026-09-09T13:54:44.348Z" },
{ url = "https://files.pythonhosted.org/packages/1b/ab/958bbb04377159ff03c7314cd9d8a48dd6fc4f78c840589c22ab155ee9c7/multidict-6.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13e26f59f0eecfc5f67c663ad550ffdaf62c0f657547cde387f6c86af1c9449e", size = 279793, upload-time = "2026-09-09T13:54:46.086Z" },
{ url = "https://files.pythonhosted.org/packages/a0/3a/706605ab0dfc4179748ee7949829e63c6f14ae28667aceeefaf2c701807f/multidict-6.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd789a294d8e098528be29b2669b83005ce569339f8cef167fc0274c3115c34c", size = 284751, upload-time = "2026-09-09T13:54:47.793Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a5/567e36c013ad023546de633079c6b22101dd43226b193cba00e6399703be/multidict-6.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3126f2a96704505aa4e92a72d6e8a5d7f29d40a987ced8bf69e29d71dfc71fbc", size = 250812, upload-time = "2026-09-09T13:54:49.509Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5f/6b0b64aa0cd346b07831dabaa6ccda0e73014c5df044b68baa763f0f0552/multidict-6.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:23c9ee89967b6a9b4048acb3b93b660ed714ce9c8bf3bbe652959bc120dc02dc", size = 281606, upload-time = "2026-09-09T13:54:51.288Z" },
{ url = "https://files.pythonhosted.org/packages/31/8c/b846b6796f26d496efb07fedef2b69f6de533da32a56f12d236722a96157/multidict-6.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a62e302fc8cd6aa8972207e7e951d1fdee7c1dda18568305041d19f0e2c00f5", size = 281733, upload-time = "2026-09-09T13:54:53.05Z" },
{ url = "https://files.pythonhosted.org/packages/f0/f3/bf14a39d4af5697fd9404baaf70a0aeeb82d258b95de5cb16b1a7f98ae6f/multidict-6.8.0-cp313-cp313-win32.whl", hash = "sha256:093167d22a8c95af30f597b8a5686f20a14512989942d4be804d119899caca20", size = 47738, upload-time = "2026-09-09T13:54:54.676Z" },
{ url = "https://files.pythonhosted.org/packages/19/0a/598511a5741a3cb374971b3b02eda8a09896118ba528a54795f7e7e8bfb4/multidict-6.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:f25b61a708bd276e8cbb6afcbbf1b8e793a3be70ba0a842d0b8692020f83b706", size = 51609, upload-time = "2026-09-09T13:54:56.38Z" },
{ url = "https://files.pythonhosted.org/packages/fd/b7/6f5c1bd4ffe42d4a6db0f2f65491d4088e9c25c990358fb31a614621d664/multidict-6.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb36381e1f9f9d06eba2f10bdd438e5d20c07d5b55e1a3eee30b9f44cbf52316", size = 48280, upload-time = "2026-09-09T13:54:58.03Z" },
{ url = "https://files.pythonhosted.org/packages/ab/85/153341590e233a967c1d6791a83402d01693dec0f4c1f695606ef16c7ed2/multidict-6.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:f8b09b25e0f4dc2ea9e2adbb1cc3ba11a94d6fa3dd978ae659c8743052e1afbc", size = 53758, upload-time = "2026-09-09T13:54:59.563Z" },
{ url = "https://files.pythonhosted.org/packages/fd/ff/44f72d516ece0398683ef52061797d83a74b16b8c1e4587408e97959d783/multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1f57c414be82490bc0e0305fdb834186229b2d9b6a35fa0afd1eb1a772d125ab", size = 47495, upload-time = "2026-09-09T13:55:01.382Z" },
{ url = "https://files.pythonhosted.org/packages/50/5f/6e118f761b024dd35d26c2fe7ba41572bb0e8ac5f8cfccbbcbc2ff76da4e/multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:00be37bde741bf60871082cd347a093218c44886e99231b7516671c70f2c280d", size = 48540, upload-time = "2026-09-09T13:55:02.989Z" },
{ url = "https://files.pythonhosted.org/packages/e8/4b/3eed744491b32f0e318e7db89dc06858732362f706e8d045fa9ab51a343a/multidict-6.8.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e37b744849fb631bb52e3dadde35ffeee365a6c41cf71257b5b7acc9cd83fd38", size = 83130, upload-time = "2026-09-09T13:55:04.554Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/95c2c0ddcccb9a41ffbaa5df8ea059a8ff81916b7617a8847ecd89ed8061/multidict-6.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c2b2a96cf1dd99fe7867be4c013314225f4d5786e6685906e29932d42aca6f11", size = 50574, upload-time = "2026-09-09T13:55:06.387Z" },
{ url = "https://files.pythonhosted.org/packages/f5/b7/f4f4989594f99bc121ad9277090c4e49819b08ab1a96e132b628a9e10b7d/multidict-6.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bea7df027015856ba5d0a88e3b4777ff8cb5c66b58fc108050fe79d4dd9d4d2d", size = 48786, upload-time = "2026-09-09T13:55:08.131Z" },
{ url = "https://files.pythonhosted.org/packages/b2/86/f1d86a0222f31fb3df8eef3d6c9abf7e8d65d49edd8d0d7e7afaf23d23cc/multidict-6.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d3da668e903c934ed0b587ecacfed6901f6ae6384a6e975887592b61845e78bc", size = 276670, upload-time = "2026-09-09T13:55:09.803Z" },
{ url = "https://files.pythonhosted.org/packages/03/50/6945c50f86a978b2bcace9ca344165ff80883be47d984489bbba8fa0ab20/multidict-6.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64eaeda36ee8d88f9e8616a587a8c66a663283cf6e0dcf013c1ddd8c758e4aef", size = 279339, upload-time = "2026-09-09T13:55:11.685Z" },
{ url = "https://files.pythonhosted.org/packages/ee/2c/e649889ba23fd1f4442a85427b99d9e6261226b2ac31914aa7f5b241d947/multidict-6.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac746cb365bac1c462da9e3e6ab8904a8efe2217a56b0b2e3d9480f41d2b2602", size = 252549, upload-time = "2026-09-09T13:55:13.527Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f8/1023b66e011b1395fb160dabb0f0608ef67e569f0bdb2c1d5ac9b2f2adc6/multidict-6.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18f0e06360c3e451a3ab800355773c8d125a758238d780c800b0ee5e90ee903c", size = 286203, upload-time = "2026-09-09T13:55:15.19Z" },
{ url = "https://files.pythonhosted.org/packages/7e/6c/48aea545cbda6d0444848ec23d988c13b86538a00a1b7d3868cc2382ff94/multidict-6.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69708fecaa88bcb2341397b49fc95057a835b02a3670c551b37f95dd79e64e3a", size = 285039, upload-time = "2026-09-09T13:55:16.928Z" },
{ url = "https://files.pythonhosted.org/packages/68/2a/066123b17291671bf67d2a5c65ee81a48de53913bd1b1578791519eacdb0/multidict-6.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9606f583e7acaf61e7b3f56074e14037b9af7cb194590edfc0114b3ae5931ff7", size = 281075, upload-time = "2026-09-09T13:55:19.155Z" },
{ url = "https://files.pythonhosted.org/packages/47/20/4f0b2c485da2e8a659cc677717a3745872918c9c85064491a1ef75d7a3bf/multidict-6.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1f66fe6a021173d0d47968491791966b9f3e6d61115f2491744aa0c07a6e67af", size = 250431, upload-time = "2026-09-09T13:55:21.07Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c7/b9a288901577aa0b82c33c64d52246c88076d260ad7b6c16b021ca0f8e99/multidict-6.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:be007d1aee2cbd530347dcafedb400891a3b5f1bd7135f95cf5d5b330b5219ee", size = 273891, upload-time = "2026-09-09T13:55:22.887Z" },
{ url = "https://files.pythonhosted.org/packages/da/51/0ba50cab2cfd067988de2abb73f23076ac727fe18d03f1368a59def64727/multidict-6.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8457aff3c12a89a8e1c4674de5c777857fbc429f40fe117a3d29538547cbc364", size = 265262, upload-time = "2026-09-09T13:55:24.77Z" },
{ url = "https://files.pythonhosted.org/packages/0f/d6/e5be1117dbca6eb9ce231142b7e20599418bb3500147db51bf844ce8afcb/multidict-6.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:95c27b4f3f04320fc44e338573f40c5c956b504a7fcf081a157fd0b02579311c", size = 278033, upload-time = "2026-09-09T13:55:26.67Z" },
{ url = "https://files.pythonhosted.org/packages/d2/28/cad0afaec3caa56ea2c1ceed43c164d62ad3e83e950daf0d0c87bcf9dca7/multidict-6.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d244cf6b52b5ba1c34c3832f4652a668ebb36d95949b96eed9a1c54d916a90dd", size = 281717, upload-time = "2026-09-09T13:55:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d2/025702df0b69b856db70a4d66f77622f51c3d99771ec9a07f3ca80f7e098/multidict-6.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5bbbb696c8024475b1877d14ce20d5f1cc05b8f6d786cea0fe3aa7fedc02e891", size = 247124, upload-time = "2026-09-09T13:55:30.497Z" },
{ url = "https://files.pythonhosted.org/packages/b4/96/9dddca563f06a921956389c0bc9b894355b98b0bdf62299e2560c50afb6d/multidict-6.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:cbd86f9787c5e2f5fd27d8b21458222f107347c6731c4e93dde68f554b466a2d", size = 275954, upload-time = "2026-09-09T13:55:32.57Z" },
{ url = "https://files.pythonhosted.org/packages/ec/91/8b2f1f2a774a955665f268340a2b59db7020c5f12baac02ae9ef1b1660cf/multidict-6.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2f8a4b0b4d639d525928c7f30de527bfdf9ead6e44a5e8cb9c50aced5e4590cb", size = 275508, upload-time = "2026-09-09T13:55:34.368Z" },
{ url = "https://files.pythonhosted.org/packages/b6/1a/e2cabdfc0880a61a99d2b8bc361035036fb5a2c6af31ea3fa054ba1065c5/multidict-6.8.0-cp314-cp314-win32.whl", hash = "sha256:8890c89d662560e51c55ac1304d6f919b23942abe9ae1127cb1de9aa6132fa52", size = 46938, upload-time = "2026-09-09T13:55:36.057Z" },
{ url = "https://files.pythonhosted.org/packages/b9/7c/11234bcba62c22a58f2ba168499cfe3531f49de3edd5090d04a8c6cdc936/multidict-6.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:45cc39ba50fb0754a4359b90f8229ae08598fe2266abe3521b4e5a9ba916534a", size = 50291, upload-time = "2026-09-09T13:55:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/ab/61/793668439df924752a8137d6db0de97ed1add494779b01e4764dfc60571b/multidict-6.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0264f8d5cb0a803f650a6a8572dfa0cd1e099a2234c588dc8fb220b415b865f", size = 47622, upload-time = "2026-09-09T13:55:39.335Z" },
{ url = "https://files.pythonhosted.org/packages/9c/b8/3c091b929e6b5b2f6e0eba2232178e76d4503c8b96b92dfc281ff1d823be/multidict-6.8.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1969971900b0871530f9b62280dcc2d75688e74d2a69262bc01faf2b96c78f04", size = 88789, upload-time = "2026-09-09T13:55:41.086Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d7/3df83fab22dd64615db71e3b3cc1346b581d1459719637ce52144f9f6558/multidict-6.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8180b635290a75af8478f1b3e9810135381ae24833293fe77b85c1c21ff842ab", size = 53399, upload-time = "2026-09-09T13:55:42.685Z" },
{ url = "https://files.pythonhosted.org/packages/2d/78/41bd04c04b0aed16540c4856c9e012afc1c254298da154398308df05e26a/multidict-6.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4261863fc8b5ab1b815ede94e592e94c6af5b04616014929057e61859e7382a9", size = 51597, upload-time = "2026-09-09T13:55:44.569Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6b/7bc4cdddf624e1e7e0231734b1331729ea46df10d7c8fd3fce79756e7d0e/multidict-6.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0b143d53590e89f43153d81d505a8448d4d57354354385aef8a51d67ffefa27e", size = 264391, upload-time = "2026-09-09T13:55:46.548Z" },
{ url = "https://files.pythonhosted.org/packages/dc/b6/d2a946e5938771e92c39354563e535ef6bc6dfe399dd4307c6df8dfea183/multidict-6.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da1c112c5784ccd9d32cd90be6739fee32644e874eff6ae8f0497cba3e352e58", size = 264680, upload-time = "2026-09-09T13:55:49.915Z" },
{ url = "https://files.pythonhosted.org/packages/33/6b/3f9e981c42e7eb9329918523f0f9362ceb0ac3ee0ee1165c28f674249d75/multidict-6.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f7eefd0233a7c33ca980a5cfef26f1e9b5e2137839e752a99963696729f12d91", size = 235420, upload-time = "2026-09-09T13:55:51.92Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e1/a3a33a039fb6d381800ae5d1d587b697b8c27fcdfe48819420f08703acba/multidict-6.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:348bb85e2038b40c007383616d73f734869063772372519549ebd7da1723d1a4", size = 270309, upload-time = "2026-09-09T13:55:54.023Z" },
{ url = "https://files.pythonhosted.org/packages/95/5d/8b06724a957f2e480f159b9550988a67810fbe9555a09c5f6a2a4b829607/multidict-6.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:095f62ea4e7a3be2f6c567ab695ce10e950f2adb905c1bec82281593e0b2d2ad", size = 275169, upload-time = "2026-09-09T13:55:55.948Z" },
{ url = "https://files.pythonhosted.org/packages/ab/32/8f3dfe2ffa5d0df2a95f71e63c2f11fe3b5e1771f26ef73bb1af84de83f8/multidict-6.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be569fff1d85cd29391c431c5641c8772acb75bbdc61e60a8e82fceb9023d385", size = 264900, upload-time = "2026-09-09T13:55:57.803Z" },
{ url = "https://files.pythonhosted.org/packages/6b/73/d5829fc00a055d6ab445e0876346ee9cdee670766cd4190dc0a496188c0f/multidict-6.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3533a03e4e789baf6a286e7b0b1b6da3f3d7c3eab569686ee29ee1d8b52e2cb4", size = 242486, upload-time = "2026-09-09T13:56:00.002Z" },
{ url = "https://files.pythonhosted.org/packages/b7/58/e8d7874038e31e0533182d1c3c5331a856b9c849a71bb26a21850e8c91e1/multidict-6.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1bdb9b8fba5a9aef673ec90db3f55b1ce743f2fbdea4d37dc04d14ccdfc153ff", size = 259916, upload-time = "2026-09-09T13:56:01.802Z" },
{ url = "https://files.pythonhosted.org/packages/4d/f8/1b56a7401acda20efc016440f4fad3bef66c4aee54ca080ec143881ebb0d/multidict-6.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8d7b66c9e09c0bb0add2b5895e646b62a0849e71155066f215523de6b95cbe6", size = 251209, upload-time = "2026-09-09T13:56:03.767Z" },
{ url = "https://files.pythonhosted.org/packages/bd/5b/68d67a9e302b0645a747ba910c30eb41f2834fcdc1d85f53eae2dfceee0a/multidict-6.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:563d6500ca80dac7bba6f48a78e0ffd87e21a7d4d24642c6503a2ddccd70c110", size = 264505, upload-time = "2026-09-09T13:56:05.795Z" },
{ url = "https://files.pythonhosted.org/packages/18/13/4dc304ba2c5f5307b474ab2ce1ed1f6b02b0b4e233c182e3981ed436c2e3/multidict-6.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:346ac52e56bcda320c0dcdfdd081947ed7cada33afea4e2284bef7b0733bff9b", size = 264916, upload-time = "2026-09-09T13:56:09.079Z" },
{ url = "https://files.pythonhosted.org/packages/dc/0f/7b1f729d18369915009185201be5d0b8df0e525340fe6a600d2f8441d6cf/multidict-6.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4ee953a5ebaeed38dc21cc032ed17a9d9782802e00042200497ab4b01b0bf7c0", size = 236839, upload-time = "2026-09-09T13:56:11.273Z" },
{ url = "https://files.pythonhosted.org/packages/22/d1/eba1b88b18b7019d9136303fe77909257c40fabde5aaf138a4d900b6ce3c/multidict-6.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2f79cc3e8039a8cf5c77e0811b0807953fd52d0863b9b76970b20d696dc64a78", size = 265307, upload-time = "2026-09-09T13:56:13.379Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a6/6c1e4106faa27118ac612f4d664eaf909de252634785286262a627108e58/multidict-6.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43a4b56555bbcf8af161e7c7682bd93eec10f068c95844511864c018c8e5e13b", size = 259041, upload-time = "2026-09-09T13:56:15.666Z" },
{ url = "https://files.pythonhosted.org/packages/c0/bc/ecfb8b6faa8e158a71b03bdf7f947f30e0bc5d899cc357573a76ab7bb1e5/multidict-6.8.0-cp314-cp314t-win32.whl", hash = "sha256:48ea524a25a1cd5972cf293bc95713918cba0bcd6fa9b992d906c857c546abe2", size = 50628, upload-time = "2026-09-09T13:56:17.837Z" },
{ url = "https://files.pythonhosted.org/packages/30/7f/e27fb699b70ad24dbd02ddee604658acb36f907c03c045baffe4ea774501/multidict-6.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d0be2b832435001bc623ca7f1499ca1a853d4f082fb61221a80ce71132f50b26", size = 55592, upload-time = "2026-09-09T13:56:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/eb/7a/76de70b2f6733696803f1ee56abe44a3757a52777383032c7373d3fea0f4/multidict-6.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:62b8e291a4f7edbf7cde7a43d831d893ba443a1b627498b53581943b0e348feb", size = 50300, upload-time = "2026-09-09T13:56:21.516Z" },
{ url = "https://files.pythonhosted.org/packages/ce/32/4de7320ae032dc768090d11f708d2d386df3db04cb6b8b0db0230cfc66c3/multidict-6.8.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:e192018b732f7b168e6604cbdf40fa8e05c996693b9eb445a0d8a73f4b77c5d3", size = 53761, upload-time = "2026-09-09T13:56:23.192Z" },
{ url = "https://files.pythonhosted.org/packages/5c/45/ecb641309dc2cdc6040f18e22c68eb5e94398f9404c4365d810f4292e053/multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b25426f9f6ed402835617c8f23609a47045f91ecff365eb6734817e039a8ed25", size = 47505, upload-time = "2026-09-09T13:56:24.902Z" },
{ url = "https://files.pythonhosted.org/packages/eb/68/87d6161b9fef11943e0b894203da3fff561933ca3c9b2952b6e7100e9c9f/multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa6c2880709c84457de104385b704fc28860f27e442ad13966fc4af8e714fe9c", size = 48549, upload-time = "2026-09-09T13:56:26.574Z" },
{ url = "https://files.pythonhosted.org/packages/97/f7/d852d2276407640cdbd29fe11cac6e93f70f59542cba174ef9d146738946/multidict-6.8.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eabb03dc3e4ed6333ecd1cc9826ec80e7a98b5506deeb832d7260c8e44166d23", size = 83157, upload-time = "2026-09-09T13:56:28.227Z" },
{ url = "https://files.pythonhosted.org/packages/14/e3/16fe7ffa6090591d83cf6bc2486e77ce891705fb6d0191823140928311b5/multidict-6.8.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:59e539c4eb4d3a53b0e630a6ba2b2f2824732b5e73f90e30a280f12fde157b15", size = 50578, upload-time = "2026-09-09T13:56:30Z" },
{ url = "https://files.pythonhosted.org/packages/d0/0c/e38e41c1087a599f86ff58a01f358abf7c4db3c26a3e90eebb3e02193ef1/multidict-6.8.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:835d5a90b11d1f5f8200ff3cc8316bded76eebebc92436398947a27657e645e7", size = 48815, upload-time = "2026-09-09T13:56:32.056Z" },
{ url = "https://files.pythonhosted.org/packages/d3/f0/eb691f42af8e7775992f57904ec75dc356fc7cdc896e5f30879decdd26f2/multidict-6.8.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d2d236b8a44ae91536a12ebcb996bdb31cf27425f36b4d05c87f2ba2716050ba", size = 274804, upload-time = "2026-09-09T13:56:36.741Z" },
{ url = "https://files.pythonhosted.org/packages/87/05/28472ccfeb43c00a043c0385ca4294da21a5957859fb7860e2ebdb3e3011/multidict-6.8.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb9a60b7faa5d37c426fa91cf4d6738182a1f2755b9fab7c9c64cd466c4ce51e", size = 279693, upload-time = "2026-09-09T13:56:38.531Z" },
{ url = "https://files.pythonhosted.org/packages/f3/a1/2b4fe73e5fecff807b47650a155c391a103136428cb21d6ba8e39c5912b5/multidict-6.8.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ef606c15cac6c90279acf34120784b6f36662cbf382defd3955cd8f1115336b", size = 254969, upload-time = "2026-09-09T13:56:40.407Z" },
{ url = "https://files.pythonhosted.org/packages/44/e0/c97d1822783dfe52e02fd150fa3f02eb22410211a9e2615f71541803ed4b/multidict-6.8.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:68186a2d4051c8ffd17be33553bea2ec9bbc8ef860fe2980a221d96126296f31", size = 286392, upload-time = "2026-09-09T13:56:42.234Z" },
{ url = "https://files.pythonhosted.org/packages/2f/d9/772f1339e1d051236bcc137b0eac2b4aaaa0bbb56aaf924e9aaba901d9c1/multidict-6.8.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2cc66abb85e2108c9ff8a1c0d20fa260bf690bbb33caef4ff3ecb2c2cbdfff5d", size = 285348, upload-time = "2026-09-09T13:56:44.255Z" },
{ url = "https://files.pythonhosted.org/packages/06/ae/cd045747e4680362e02955a82c468e95b5e4d319e3a79574b3fb677de568/multidict-6.8.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69b3e519a132bb943b0daae15fc8c2168706b17f826481d32a32a5e784b129e3", size = 282721, upload-time = "2026-09-09T13:56:46.088Z" },
{ url = "https://files.pythonhosted.org/packages/35/14/0802d9a3aae4ef21eaa39adbd729a380fa095932105e1424e417b53e783f/multidict-6.8.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e41226ecf607f062fe34a2f4cf64ad3a89e3a0180dc800b463b6b14c06dd10dc", size = 253168, upload-time = "2026-09-09T13:56:48.07Z" },
{ url = "https://files.pythonhosted.org/packages/b1/64/3f92298bab8fbe1332e708863fb55b66e755be6f416b3459720d48b33af9/multidict-6.8.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:610c7637bc36b90f39e6c66f710f93d57018f83d53e1e187caaa218c6892b95f", size = 274209, upload-time = "2026-09-09T13:56:50.023Z" },
{ url = "https://files.pythonhosted.org/packages/08/c2/2001ac0eac1a8b7390a5902d7115f66d4f256268057a502200b6ab12dad7/multidict-6.8.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:65c85c79f5a2c04fbbc18f006c014674dc5fdf270cb978d8862c82c6f694e60c", size = 268044, upload-time = "2026-09-09T13:56:52.033Z" },
{ url = "https://files.pythonhosted.org/packages/0d/90/78a9e26c85f89abd562a67f7fcbaef9007fd5c37bb9efac19f1cf604e7c2/multidict-6.8.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:628ff11e6720f90acd0c305dfa3339f04a783a20de8cda6ac333ba46447261e8", size = 274806, upload-time = "2026-09-09T13:56:53.975Z" },
{ url = "https://files.pythonhosted.org/packages/3d/71/713bd445421b21531234c1f3630b768192cb9d80c8b1c5b05c5b505ff4c0/multidict-6.8.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0935971bffd0b479fc90c4811ca787703e93fcb6afea939a375dfc80285ab368", size = 281890, upload-time = "2026-09-09T13:56:55.848Z" },
{ url = "https://files.pythonhosted.org/packages/de/a5/1387c538663e2dc8c27bbc7cd6955cb66de0f55c780cf7cd0fc06a1a16ca/multidict-6.8.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9442b14eec262a1f74369bbd07e75bc5155105164649a4b9fbc1ebc7b8fb0b14", size = 249749, upload-time = "2026-09-09T13:56:58.01Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/104296c9d70896b9759ce0812aa4899fab76d8b16bb32dcc5a78ab547c89/multidict-6.8.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:397599503b718f0137f26d3f6532d6955069cd2e5917c47ef581495bc2529ff8", size = 276138, upload-time = "2026-09-09T13:57:03.591Z" },
{ url = "https://files.pythonhosted.org/packages/f7/0a/f2a0c2658e9d7ff5964ec2820a02054558636fafd663230ddc8310b8ed39/multidict-6.8.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9e37024b41d7a7e7e9cce14b248d54707c21c2a2ea30a47b71bdcefcafec00f2", size = 277077, upload-time = "2026-09-09T13:57:06.024Z" },
{ url = "https://files.pythonhosted.org/packages/98/50/bc46566caffba5c1c4a510519156371edf7c4ecd35c9ef917d0c1803487d/multidict-6.8.0-cp315-cp315-win32.whl", hash = "sha256:071da134651b04a8507dfb331ac0988f376337c2aea59486bf20989fb5b5a64e", size = 46930, upload-time = "2026-09-09T13:57:08.009Z" },
{ url = "https://files.pythonhosted.org/packages/6f/1a/cafb31049ecc1a6ce52bcc69fa436cca239adc057b1718a0c49044848663/multidict-6.8.0-cp315-cp315-win_amd64.whl", hash = "sha256:3bafff8598f0528017ddc74194e5451d5c22d046c98935f8f86247b0f286e4f8", size = 50294, upload-time = "2026-09-09T13:57:09.986Z" },
{ url = "https://files.pythonhosted.org/packages/6b/51/00e037da14cd1d894b123e0bbe62de5c561679a6ab23ab1c009f2965dcda/multidict-6.8.0-cp315-cp315-win_arm64.whl", hash = "sha256:e886ef8c9879105fe4fc99417447b3a5f35d1131412ce839470bd2089fe2043f", size = 47626, upload-time = "2026-09-09T13:57:11.738Z" },
{ url = "https://files.pythonhosted.org/packages/52/f7/aeb947982197e8b4f5c4da3961ee473ea5a050b94a6ff3b88baf64621401/multidict-6.8.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:883284137e25318ed9735b742ae46341a864888fae28e8b6314c4f84da080f08", size = 88801, upload-time = "2026-09-09T13:57:13.957Z" },
{ url = "https://files.pythonhosted.org/packages/35/d8/593948c016c3f850e3cd56a4e0144151eb409d2b8690f0c0ce7f7d33dbea/multidict-6.8.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:ca52b9ec80851366197577154c862c4c4c7036ca76ae94cef5cb59c5cfeab944", size = 53376, upload-time = "2026-09-09T13:57:15.94Z" },
{ url = "https://files.pythonhosted.org/packages/58/b9/097a05bca533027c0477b6a90bf927dbbb4b23cc9090bbb37a2e972af8d5/multidict-6.8.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:91fa75d0a693832106d98f66c849f034f21c828d14437f1fb97d3784aab89e84", size = 51629, upload-time = "2026-09-09T13:57:17.685Z" },
{ url = "https://files.pythonhosted.org/packages/fe/07/938ed21967f12380d0b8861645fb65a942f3669e31d5163ed94d23103b61/multidict-6.8.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:307c1acd812fe897e7fbe10c6758822e8c04be4e7c60a9f54901cdf8b5ab8bc3", size = 261967, upload-time = "2026-09-09T13:57:19.752Z" },
{ url = "https://files.pythonhosted.org/packages/89/e8/e66bf843fd29c01712dde9edeb9f4ad0ffab06ab4ada4b721ad7bc73b3d5/multidict-6.8.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf684986a2490628f059a99dd107b566a2d34cf947f8eb8387e0500a1f90c5", size = 265923, upload-time = "2026-09-09T13:57:21.784Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f8/e9be849b225af28a8eee2c6bfea23594a777c753fe97e2ff7e2180c8935a/multidict-6.8.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:77745725125d01fd613b6db043362aa7c6bfbfdb23d45dbfc3d92bf58160af62", size = 239380, upload-time = "2026-09-09T13:57:24.3Z" },
{ url = "https://files.pythonhosted.org/packages/ab/67/4dbad08f5081978c591afae9e836ec9ddae90e9e76be6d6ce10757483dc4/multidict-6.8.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8daafaa0b2eb43f76898ced78b1e0fb91b38c4fa50da516c18067f2a2d578c20", size = 271591, upload-time = "2026-09-09T13:57:26.611Z" },
{ url = "https://files.pythonhosted.org/packages/92/3f/e9c97222d7e104e54e556f118ec7d091ab41a0c10c630f2b97e5b43f5404/multidict-6.8.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c68e0c0649d17c2d0339e3674e86a4aeba4a7e6b21c1e394cf947a95433b31d0", size = 276091, upload-time = "2026-09-09T13:57:28.997Z" },
{ url = "https://files.pythonhosted.org/packages/26/ca/728e7ce05ac9c0303554e7162e74d91fe49e65bad7dfbb377f783dd32c0a/multidict-6.8.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8a5ac357ac283490a8d1899b0383355fd1f8634b14ba0d59e4c0dd97db85556", size = 266493, upload-time = "2026-09-09T13:57:31.256Z" },
{ url = "https://files.pythonhosted.org/packages/8f/74/7c658d2769863af16fb7d7c6be50b29659892a06a632858863eee3a31842/multidict-6.8.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46029e6e27a3ec0dc55b53f58df82d10f04c5e111f78248279b530bedad2c30a", size = 245302, upload-time = "2026-09-09T13:57:33.464Z" },
{ url = "https://files.pythonhosted.org/packages/2d/2c/d4350a20a0e8c66a447d694e8713438262665203fe826c3f4e385f052b72/multidict-6.8.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8d1046b5427dcafe6e8a0e07527dd74f1ee694006160162f53f3a17f15aad3b4", size = 261016, upload-time = "2026-09-09T13:57:35.651Z" },
{ url = "https://files.pythonhosted.org/packages/1f/78/83df999c8beb72a012cfac42f2b833c4a48f8e836fd4407747b355a2430e/multidict-6.8.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:f1f4a220db6ed7c8fd16b6d644ffd1f082651693204daf3275e049fadc849e39", size = 255021, upload-time = "2026-09-09T13:57:37.758Z" },
{ url = "https://files.pythonhosted.org/packages/fb/13/f2c0a2dac6d91f74aa124f3e9f07ec497ceae5ed2df2753d249601cd7262/multidict-6.8.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:029897732a9c798737457e382bf84e8c64237eff224a90aea2639f4413c45e4e", size = 263066, upload-time = "2026-09-09T13:57:39.897Z" },
{ url = "https://files.pythonhosted.org/packages/59/1d/730008d4639ace731bbb1399e1ac13cbdf506f7d6fb861d75044ffb3994d/multidict-6.8.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:29be9fd289e9ab8f480996ea2f686e1654b80242033843cb11691688329423f1", size = 266510, upload-time = "2026-09-09T13:57:42.39Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ca/bec67a5d206dc5748e50c93f6f71deec14305c3657cfe250c3887caf7839/multidict-6.8.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:29631224698de1e42abc8fa7658d830e0aed0029785144b5832b695da5adef2f", size = 239423, upload-time = "2026-09-09T13:57:44.304Z" },
{ url = "https://files.pythonhosted.org/packages/9e/db/5f153fe51fbac7d80f3bb8bd6fab8db8b6cd061e7a11371676dfed3712bc/multidict-6.8.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:962f18c59a000f30b084ea2e6b8001521bb315efd4e5f10acf9fb36f366b7882", size = 266902, upload-time = "2026-09-09T13:57:46.297Z" },
{ url = "https://files.pythonhosted.org/packages/89/0f/9efca48a351551de4dc0c183f523109dbe87c645a4732d5f1c70b4880dca/multidict-6.8.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c60e50bc5b07faac92fd3a20fa21cc8cf3e3f7204d2867b206c73293ebc19101", size = 260887, upload-time = "2026-09-09T13:57:48.268Z" },
{ url = "https://files.pythonhosted.org/packages/df/d8/bb879a62e0809448e53f6237e71670066ecf3bbc5896a7a6705b6628d86a/multidict-6.8.0-cp315-cp315t-win32.whl", hash = "sha256:fc5460940f50dff00731b4132366840ba9685286ea88ea104b661899084f3fea", size = 50533, upload-time = "2026-09-09T13:57:50.31Z" },
{ url = "https://files.pythonhosted.org/packages/fe/62/3e5308d8871636e4b9620e4b3acfcf2b5caf79b19d317690ec13f7fc8b57/multidict-6.8.0-cp315-cp315t-win_amd64.whl", hash = "sha256:b367c342327717d644db4c0ddb37ceb655c84822215ea0773a3a36911b74b71d", size = 55572, upload-time = "2026-09-09T13:57:52.301Z" },
{ url = "https://files.pythonhosted.org/packages/b9/cc/d3c10e10ee3bb7a7b4abbb3157306b2ce7e0018c9c2d16b32b468739d2b7/multidict-6.8.0-cp315-cp315t-win_arm64.whl", hash = "sha256:0c1c4debad7337627b86837abdf0237ca3cb3d7e17de7eab0177c263878546d4", size = 50322, upload-time = "2026-09-09T13:57:54.099Z" },
{ url = "https://files.pythonhosted.org/packages/b1/ee/be4e1a4b7a2b27f4fb6936510d4bebcb41b0562c946930ad26916e069cf9/multidict-6.8.0-py3-none-any.whl", hash = "sha256:75daa15ca16d6285eb2e104b2f05ee6f8d9836c68da3ce5c85f615a0450eed0e", size = 16297, upload-time = "2026-09-09T13:57:56.106Z" },
]
[[package]]
name = "numpy"
version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
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]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
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 = [
{ 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]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "propcache"
version = "0.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" },
{ url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" },
{ url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" },
{ url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" },
{ url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" },
{ url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" },
{ url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" },
{ url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" },
{ url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" },
{ url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" },
{ url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" },
{ url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" },
{ url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" },
{ url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" },
{ url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" },
{ url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" },
{ url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" },
{ url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" },
{ url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" },
{ url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" },
{ url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" },
{ url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" },
{ url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" },
{ url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" },
{ url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" },
{ url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" },
{ url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" },
{ url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" },
{ url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" },
{ url = "https://files.pythonhosted.org/packages/33/c9/07e227b930c8ae513b8ef1aae3793499be097bffcdf7aee4fb8b33db4cd1/propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565", size = 85933, upload-time = "2026-09-16T00:15:16.073Z" },
{ url = "https://files.pythonhosted.org/packages/e4/e1/6710bb44510c4e4a8e0f004bbaf3cecfd048141309c77bae56d4e5a6ebc1/propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993", size = 50179, upload-time = "2026-09-16T00:15:17.377Z" },
{ url = "https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b", size = 51942, upload-time = "2026-09-16T00:15:18.589Z" },
{ url = "https://files.pythonhosted.org/packages/f1/74/70ac8430e28f21e442c7bcb964eb46c4363f6881ade4aa0e978bfd8d503a/propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc", size = 232647, upload-time = "2026-09-16T00:15:19.905Z" },
{ url = "https://files.pythonhosted.org/packages/72/95/f222f13b6fe623310be0eb61a673bf26df439ce27e563ca8e422d0818777/propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d", size = 241541, upload-time = "2026-09-16T00:15:21.3Z" },
{ url = "https://files.pythonhosted.org/packages/a2/3e/763e370340db16115c5e63ad46e21ef0770a7f06928b3d3b62d8f8edfca4/propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f", size = 245332, upload-time = "2026-09-16T00:15:22.802Z" },
{ url = "https://files.pythonhosted.org/packages/96/d3/e97cd6f5de2176bd90ed4076c7a9b5e09d0f0b9687d00a576507988bb62c/propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9", size = 232757, upload-time = "2026-09-16T00:15:24.374Z" },
{ url = "https://files.pythonhosted.org/packages/f9/4c/6766e5f60bcda26d244333aa71d0a702c1c9b21b251d543c7af5953d1eee/propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf", size = 204389, upload-time = "2026-09-16T00:15:25.667Z" },
{ url = "https://files.pythonhosted.org/packages/b8/5e/ec4bb09a70b26ea99d76a8292c3383b960b296de2b347ac9986678f1761c/propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d", size = 228217, upload-time = "2026-09-16T00:15:27.11Z" },
{ url = "https://files.pythonhosted.org/packages/e1/7d/b53922ba7d9e5bf797324e63aa05906ec240871899f779628df068743e2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3", size = 216947, upload-time = "2026-09-16T00:15:28.532Z" },
{ url = "https://files.pythonhosted.org/packages/ff/39/b62eee45e5ea4de094a258cbb3b01c1e856ca51ddfd95b43135c5effd1eb/propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc", size = 233457, upload-time = "2026-09-16T00:15:29.977Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a9/feec61ed296d993db9dd097e0f6723e3f576a647722367547495e4c5b05c/propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d", size = 204131, upload-time = "2026-09-16T00:15:31.74Z" },
{ url = "https://files.pythonhosted.org/packages/92/4d/411ef380cddad28dc001f1c6d75ec72c76cd3817030f68ec1ccfba0ec6c1/propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792", size = 234820, upload-time = "2026-09-16T00:15:33.087Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/c988229753629ef1cfd5198337a83e624780ea2b3787efe9e747c05aad2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b", size = 228350, upload-time = "2026-09-16T00:15:34.533Z" },
{ url = "https://files.pythonhosted.org/packages/12/49/5ef1c5cf98591da3c5b952b39e6a298084cc1ce353bc70f85e82397a5036/propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea", size = 43578, upload-time = "2026-09-16T00:15:35.957Z" },
{ url = "https://files.pythonhosted.org/packages/1e/9e/a0ac821a2229186af5e2e3c3635a78abb23cfddca57f38513ab5d70420f3/propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983", size = 46304, upload-time = "2026-09-16T00:15:37.655Z" },
{ url = "https://files.pythonhosted.org/packages/a1/19/c8d0d36a9d16cba5dcee67d389c9333b988c8986a653a61c00a451817a46/propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac", size = 43440, upload-time = "2026-09-16T00:15:39.091Z" },
{ url = "https://files.pythonhosted.org/packages/2c/e9/42f1da77cacfc184e6ec929557ef653b7961bbf6f1da460b9221273948b3/propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0", size = 90672, upload-time = "2026-09-16T00:15:40.306Z" },
{ url = "https://files.pythonhosted.org/packages/cf/2f/4b79940908c6ab8c795097c102999d7bc1f7e0b8604dfd1c232f9d99d67a/propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5", size = 52586, upload-time = "2026-09-16T00:15:41.575Z" },
{ url = "https://files.pythonhosted.org/packages/eb/07/02196ae6320c110235bb343f90dbd34be41f8b8964a3ee30db84ec12579e/propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d", size = 54335, upload-time = "2026-09-16T00:15:43.027Z" },
{ url = "https://files.pythonhosted.org/packages/6f/44/f48b9a131985659924df5fa5093f68fe72c7ee375329802989ba3126efc6/propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788", size = 297567, upload-time = "2026-09-16T00:15:44.373Z" },
{ url = "https://files.pythonhosted.org/packages/04/a1/418d956d2735139f77fc35262179f1f52c23aa666de5a8ab3819c1ae7854/propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56", size = 297477, upload-time = "2026-09-16T00:15:46.048Z" },
{ url = "https://files.pythonhosted.org/packages/69/fd/ff811fdb6d3d3e67fd9bbfb75881675d34a42d0ef29a45d33e3e233dde07/propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574", size = 302669, upload-time = "2026-09-16T00:15:47.458Z" },
{ url = "https://files.pythonhosted.org/packages/fc/57/527910c455b5ec62f6871bef45d4f79fea16cb8c966ba0d4a07f0339ddc4/propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370", size = 287908, upload-time = "2026-09-16T00:15:48.99Z" },
{ url = "https://files.pythonhosted.org/packages/1d/86/f69ab82707534a0cb2057bdca04f9200a71214c7551800f9d34d6ac39e4f/propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320", size = 249804, upload-time = "2026-09-16T00:15:50.486Z" },
{ url = "https://files.pythonhosted.org/packages/27/19/60677af50d93be4256213de7cd487f056944c048b9c0b6f2e45b3a30f666/propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9", size = 282344, upload-time = "2026-09-16T00:15:52.029Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f7/a0057808a91fb3b6a5f3602b528f0cdcb3d53e0ff8315d73fabdfdf8fec4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7", size = 270167, upload-time = "2026-09-16T00:15:53.466Z" },
{ url = "https://files.pythonhosted.org/packages/83/c8/f4a865490df0dc0c8531d4e59ac411cb6dc24bb255d2396a6f1c60a368f4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4", size = 286551, upload-time = "2026-09-16T00:15:54.995Z" },
{ url = "https://files.pythonhosted.org/packages/b0/67/b4faebde9da4e8173d0e5a30e8cd31335914af7ef350b988f27fec588cfd/propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9", size = 249595, upload-time = "2026-09-16T00:15:56.505Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/52e1dd5636e9f5a27f6b5a4b4e2f33c322fd72afe956c397d82523ec4a80/propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e", size = 286700, upload-time = "2026-09-16T00:15:57.985Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0e/30b2b324b93ff31a0bab539c102aae59e84e444031b2742150a7646aa1bb/propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57", size = 280500, upload-time = "2026-09-16T00:15:59.487Z" },
{ url = "https://files.pythonhosted.org/packages/64/36/721bb59f682ff060d0c8df64274fca8cd0521b1a54506c2eedaef795b7f5/propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97", size = 46121, upload-time = "2026-09-16T00:16:01.349Z" },
{ url = "https://files.pythonhosted.org/packages/c1/86/0b1b80fa1ac3a0aac44e2922a6964fbe9cd52af5eab8fa933bf9e90b030c/propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12", size = 49154, upload-time = "2026-09-16T00:16:02.901Z" },
{ url = "https://files.pythonhosted.org/packages/69/4f/9fe6f05a47cb550c823155052116f710064b6be5c6e8ec4e9faae7e18115/propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827", size = 46005, upload-time = "2026-09-16T00:16:04.266Z" },
{ url = "https://files.pythonhosted.org/packages/58/25/895a11d1e4c5c2acc6d816e2bece34e02d9dc92f2182ae276cd819e9e804/propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760", size = 85634, upload-time = "2026-09-16T00:16:05.599Z" },
{ url = "https://files.pythonhosted.org/packages/58/41/c0acd69271de7a1cf439e77d5d60c18575fd09bad56e798b95fa23458ea4/propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51", size = 50084, upload-time = "2026-09-16T00:16:07.384Z" },
{ url = "https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae", size = 51692, upload-time = "2026-09-16T00:16:08.956Z" },
{ url = "https://files.pythonhosted.org/packages/e9/07/057bdd3a9609ffad59b06239cceee784b047f6c720247bfaa36d2103e138/propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51", size = 232947, upload-time = "2026-09-16T00:16:10.466Z" },
{ url = "https://files.pythonhosted.org/packages/fa/dd/d36ad35986718530498a65e45e3713f9f0e6a580f192ef02d2ef7cae9b52/propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1", size = 241250, upload-time = "2026-09-16T00:16:12.056Z" },
{ url = "https://files.pythonhosted.org/packages/fb/81/f1459415cdb6c10d46942779de39bb59a77b38e5a76bb1def9227962eb45/propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f", size = 245150, upload-time = "2026-09-16T00:16:13.596Z" },
{ url = "https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c", size = 232166, upload-time = "2026-09-16T00:16:15.126Z" },
{ url = "https://files.pythonhosted.org/packages/b9/c6/5a79e0eda3e7b6987d03d8c622ff6d52a42165a12e8418eb37694b9cc4b4/propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c", size = 206085, upload-time = "2026-09-16T00:16:16.713Z" },
{ url = "https://files.pythonhosted.org/packages/4e/72/940aed42c73f9da345ca2de0f6e835c726498159abca5f1ef14fb0a2af8a/propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162", size = 228460, upload-time = "2026-09-16T00:16:18.352Z" },
{ url = "https://files.pythonhosted.org/packages/85/71/3f54e1535c8f323d91ba566044d7c2b39ff6f6a2f1d0bd9071779d07b9b3/propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a", size = 218350, upload-time = "2026-09-16T00:16:20.064Z" },
{ url = "https://files.pythonhosted.org/packages/a8/f4/025890cc389ac3ec485ecec607d4a7ca47e15bfa2a465746ab98af602536/propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826", size = 233156, upload-time = "2026-09-16T00:16:21.624Z" },
{ url = "https://files.pythonhosted.org/packages/04/29/b39cae08c87c140d3d274f0a2c058cb5588e836175c3309e260b230ab07d/propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831", size = 206206, upload-time = "2026-09-16T00:16:23.204Z" },
{ url = "https://files.pythonhosted.org/packages/18/61/e16462ef18a87247dc9ebbd5c606f46d5ce67e708bd9cc734dd0d9222564/propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c", size = 234469, upload-time = "2026-09-16T00:16:24.841Z" },
{ url = "https://files.pythonhosted.org/packages/9f/84/b6a1490922427204fc47df920ed002eec709621de6b79b11592bf45c623a/propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38", size = 227311, upload-time = "2026-09-16T00:16:26.549Z" },
{ url = "https://files.pythonhosted.org/packages/ff/5c/5a59527582e9bcb694b2f08b9894134b65a0f5f79dbff174f054f5f74ed0/propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123", size = 43512, upload-time = "2026-09-16T00:16:28.313Z" },
{ url = "https://files.pythonhosted.org/packages/26/07/93cf699ed363681e754d7c3fad587fb09ef6b65618ee193332ad16a68d7b/propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3", size = 46264, upload-time = "2026-09-16T00:16:29.751Z" },
{ url = "https://files.pythonhosted.org/packages/65/10/fef04fbdcd44a4a163cb5ff5674599c6d6fdefd64a5a459438f9ad2ba042/propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30", size = 43395, upload-time = "2026-09-16T00:16:31.5Z" },
{ url = "https://files.pythonhosted.org/packages/70/f6/7e2f4dab0b92ab46111bd48cee9ee1e5f519514c44e3779ede5358d7ada0/propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f", size = 89825, upload-time = "2026-09-16T00:16:43.115Z" },
{ url = "https://files.pythonhosted.org/packages/9f/8b/dfeff925cb6ced97ede701d5c6a99998da963c6f2e06abbf879c9dac5b54/propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3", size = 52159, upload-time = "2026-09-16T00:16:44.754Z" },
{ url = "https://files.pythonhosted.org/packages/24/6c/924c810be5b7cf218ef47e707cf06d34adb4e3f3a31e3c24c55c6d945a88/propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68", size = 53956, upload-time = "2026-09-16T00:16:46.762Z" },
{ url = "https://files.pythonhosted.org/packages/3f/b6/9ed0a5c939b58b6bed740a05b5d0f919f0b318d03284b4b6d81a0fe8a29a/propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c", size = 295235, upload-time = "2026-09-16T00:16:48.577Z" },
{ url = "https://files.pythonhosted.org/packages/5a/eb/5ce886e902a2e781dddf110993d5329458a9b1a8626b876c65e5e25bf413/propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589", size = 294463, upload-time = "2026-09-16T00:16:50.539Z" },
{ url = "https://files.pythonhosted.org/packages/f2/88/c98f49183ecd3e5b204a556f0ca47baa02c2206a500fe8c7ec1726297b0a/propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62", size = 300081, upload-time = "2026-09-16T00:16:52.423Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/c5090f9e6f67cbc30a2b744c7bb0f8006dcba5ec1b0d82f866ae1cc7c5c4/propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72", size = 285360, upload-time = "2026-09-16T00:16:54.141Z" },
{ url = "https://files.pythonhosted.org/packages/ac/9c/34a55396910583ed07926669ab309dde2213a2dec05a7e946bb90ad66908/propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9", size = 248014, upload-time = "2026-09-16T00:16:56.062Z" },
{ url = "https://files.pythonhosted.org/packages/cd/b5/c0a142b656093ca397039dd3fe166cbb87c945712b534546514a24cd2611/propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece", size = 280662, upload-time = "2026-09-16T00:16:58.044Z" },
{ url = "https://files.pythonhosted.org/packages/54/28/fab2809c2e337fe26becea9648e84d5cef46075c91b826acb13e4f9dd04e/propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a", size = 266149, upload-time = "2026-09-16T00:16:59.702Z" },
{ url = "https://files.pythonhosted.org/packages/3a/11/7ddf336288b2678a5f054f8da2e2bd1a719f5d4b7de714d9c6bd588a2313/propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2", size = 283097, upload-time = "2026-09-16T00:17:01.459Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ae/351b1a5225f5473c411d9a612a229ae147cf0cf65c72ad838b87219ea8e8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b", size = 248160, upload-time = "2026-09-16T00:17:03.298Z" },
{ url = "https://files.pythonhosted.org/packages/3f/d0/7f79f061e30d135bb615c9782c94a74652033d00b49254edbbf35a9165a8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284", size = 283036, upload-time = "2026-09-16T00:17:05.238Z" },
{ url = "https://files.pythonhosted.org/packages/53/3c/016f1cad8bf4c428d748cf399b2bac603026fbfd6966e6a5579b5c5b6956/propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad", size = 279350, upload-time = "2026-09-16T00:17:06.881Z" },
{ url = "https://files.pythonhosted.org/packages/ea/60/d8f72cb24b412487ed4c397f539117d3b74c3c33dd32020e91fe00a958a8/propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec", size = 45874, upload-time = "2026-09-16T00:17:08.567Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ef/8bae0a316d406644450522f2f3d44a4e19632f5f3bb60d1d0e6c53842616/propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432", size = 48574, upload-time = "2026-09-16T00:17:10.077Z" },
{ url = "https://files.pythonhosted.org/packages/57/be/bcc053f66a97355683884b448198e79580fae8e8fa4d96b9bb01614e9913/propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e", size = 45625, upload-time = "2026-09-16T00:17:11.377Z" },
{ url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" },
]
[[package]]
name = "pyarrow"
version = "25.0.1"
source = { registry = "https://pypi.org/simple" }
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 = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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 = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
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 = [
{ 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]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
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 = [
{ 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]]
name = "pyyaml"
version = "6.0.3"
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" }
wheels = [
{ 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/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/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.16.7"
source = { registry = "https://pypi.org/simple" }
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 = [
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "yarl"
version = "1.25.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" },
{ url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" },
{ url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" },
{ url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" },
{ url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" },
{ url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" },
{ url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" },
{ url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" },
{ url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" },
{ url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" },
{ url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" },
{ url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" },
{ url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" },
{ url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" },
{ url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" },
{ url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" },
{ url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" },
{ url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" },
{ url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" },
{ url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" },
{ url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" },
{ url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" },
{ url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" },
{ url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" },
{ url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" },
{ url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" },
{ url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" },
{ url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" },
{ url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" },
{ url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" },
{ url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" },
{ url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" },
{ url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" },
{ url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" },
{ url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" },
{ url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" },
{ url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" },
{ url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" },
{ url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" },
{ url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" },
{ url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" },
{ url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" },
{ url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" },
{ url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" },
{ url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" },
{ url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" },
{ url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" },
{ url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" },
{ url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" },
{ url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" },
{ url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" },
{ url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" },
{ url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" },
{ url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" },
{ url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" },
{ url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" },
{ url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" },
{ url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" },
{ url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" },
{ url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" },
{ url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" },
{ url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" },
{ url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" },
{ url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" },
{ url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" },
{ url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" },
{ url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" },
{ url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" },
{ url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" },
{ url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" },
{ url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" },
{ url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" },
{ url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" },
{ url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" },
{ url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" },
{ url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" },
{ url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" },
{ url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" },
{ url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" },
{ url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" },
{ url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" },
{ url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" },
{ url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" },
{ url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" },
{ url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" },
{ url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" },
{ url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" },
{ url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" },
{ url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" },
{ url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" },
{ url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" },
{ url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" },
]