Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0f5e332ca | ||
|
|
b237f4cc9a | ||
|
|
e3fc538e5d | ||
|
|
eede2e5774 | ||
|
|
7a0847893c | ||
|
|
7c1c33f5b1 | ||
|
|
da1b079b7b | ||
|
|
ffbba0f4d1 | ||
|
|
ebd3b3e493 | ||
|
|
1b63ce97c9 | ||
|
|
177511af1f | ||
|
|
d336f8d499 | ||
|
|
885b7a569f | ||
|
|
ac2540629e | ||
|
|
fb14545850 | ||
|
|
e9fa4bf36b | ||
|
|
8a7bfb02e0 | ||
|
|
7250396a1f | ||
|
|
56b86699bd | ||
|
|
9f56b2b334 | ||
|
|
04f0d57068 | ||
|
|
c9cb4b8c9e | ||
|
|
bde8582de3 |
@@ -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"
|
||||
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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 .
|
||||
```
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
"""Human control of a robot arm, from a gamepad or a browser."""
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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}
|
||||
@@ -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‑STOP</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="decks">
|
||||
<div class="deck">
|
||||
<h2>Translate</h2>
|
||||
<div class="pills">
|
||||
<button data-axis="2" data-sign="-1">−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,−X,Y+,Y−" data-hub="XYZ"></g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="deck">
|
||||
<h2>Rotate</h2>
|
||||
<div class="pills">
|
||||
<button data-axis="5" data-sign="-1">−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,−pitch,−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>
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user