Author SHA1 Message Date
cristhian 7eeb83bf6d scope pytest to this repository's tests
validate / validate (pull_request) Successful in 59m14s
`dora run` vendors a `git:` node's source into git/ inside the checkout, and
pytest collected that node's tests along with these: running the webcam example
and then the test suite reported 67 tests, 26 of them another project's. A
failure there would have gone red here, for a reason no one would find quickly.
2026-09-15 06:51:02 -03:00
cristhian 344a527097 record that the webcam example is verified, and pin v0.1.1
The example carried a caveat saying it validated but could not be run, because
a `git:` node could not be fetched. The organization is public now, so it can:
dora clones dora-webcam at the pinned tag, builds both nodes, and runs them
against a real camera to a clean exit.

The pin moves to v0.1.1, whose documentation shows the https URL. v0.1.0 points
at an ssh:// URL that dora cannot authenticate, so anyone copying the wiring out
of that tag would have hit the failure this example exists to demonstrate the
absence of.

What a `git:` node does require is still recorded, since it is not obvious:
anonymous read, which on Gitea means a public organization and not merely a
public repository.
2026-09-15 06:50:43 -03:00
cristhian c178afd58e add depth and camera intrinsics, and adopt the typed Arrow image (#4)
validate / validate (push) Successful in 59m16s
Adds a depth panel tiled beside the image, the camera_info input holding the
pinhole model, and std/media/v1/Image as the payload for both image and depth.
Builds with uv, lints markdown, keeps the workflow where act looks for it, and
carries a worked example wiring dora-webcam in.
2026-09-15 09:36:16 +00:00
cristhian f7171de2af fetch git nodes over https
validate / validate (pull_request) Successful in 59m10s
ssh:// cannot authenticate under dora-cli 1.0.1: it fetches through libgit2
with no credential callback, so the only transport that can work is anonymous
https. The documented URLs move accordingly, and the repository is now
dora-image-view rather than dora-plot.

The note about what a `git:` node needs is corrected while here. The cause is
not that a repository is private but that the fetch is anonymous: on Gitea a
repository inside a private organization is internal and answers an anonymous
clone with 401 even when the repository itself is marked public. The
organization has to be public too.
2026-09-15 06:29:37 -03:00
cristhian 15e0b9155a add a worked example wiring dora-webcam into this node
The only dataflow here drove the viewer from a test fixture, so nothing showed
how to consume a real producer. dataflow_webcam.yml wires dora-webcam v0.1.0
into it: the camera publishes std/media/v1/Image and this node draws it, with
depth, camera_info, text and bbox simply unwired, which is what every input
being optional buys.

The dependency points one way only. A consumer naming its producer is an arrow;
dora-webcam names no consumer, so there is no cycle.

Two things are deliberate. The camera's output type is repeated in the dataflow
because a `git:` node's manifest is not read at validate time, only at build, so
without it the two contracts are never compared -- a deliberate mismatch is
caught with the annotation and passes silently without it. And the file carries
no strict_types: a camera publishes an image and nothing else, and strict
validation warns about every typed input a dataflow leaves unwired.

Interoperability was verified directly, feeding dora-webcam's packed payload
into this node's decoder across bgr8, rgb8, jpeg, png and yuv420: the struct
types are identical and the lossless encodings round-trip exactly.

The dataflow validates but was not run: with dora-cli 1.0.1 a `git:` node
pointing at a private repository does not build over either transport, which
the file and the README record.
2026-09-15 06:26:10 -03:00
cristhian 363bf006be lint markdown recursively
`scan *.md` only matched the repository root, so a file added under any
subdirectory would have stopped being checked without the CI step noticing.
Scanning recursively follows the files instead of the layout, and
--respect-gitignore keeps it off .venv and .dora.
2026-09-15 06:20:14 -03:00
cristhian c9715a34d5 move the workflow to .github/workflows
act looks in .github/workflows by default, and running it locally before a
commit is one of this repository's rules; keeping the workflow where act cannot
see it meant every invocation needed -W. Gitea Actions reads both locations, so
only one of them may exist or a push fires the run twice.
2026-09-15 06:14:00 -03:00
cristhian c1dba8998d build with uv rather than pip
The build command was `pip install .`, which is what the dora docs use because
`--uv` rewrites it. Spelling it `uv pip install .` means the dataflow never
installs through pip at all, whether or not `--uv` was passed. The cost is that
a node then needs a virtualenv to install into, so dataflows must be run with
`--uv`; AGENTS.md says so.

Note that the PR #3 branch still carries `pip install .`; landing this branch
on top of it is what corrects that.
2026-09-15 06:09:33 -03:00
cristhian 093715e78f add markdown linting and cut AGENTS.md back to rules
AGENTS.md was a per-module tour of the code, which restated what the code says
and had already drifted: it described the example as emitting three inputs when
it emits five. It is now the conventions an agent cannot infer -- dora version,
uv, ruff, numpy docstrings, the platform requirement, act, and what not to do --
and nothing else. The gotchas it carried are explained at their point of use in
the code instead.

pymarkdown lints the markdown, configured in pyproject.toml and run in CI
beside ruff. It caught a fenced block with no language and an over-long table
row. ruff's pydocstyle convention moves to numpy.

GEMINI.md is removed.
2026-09-15 06:03:40 -03:00
cristhian 7d9ebe8b38 move the synthetic source into tests
It is a test fixture, so it lives in tests/. It was only ever an example by
placement: CI runs it as the end-to-end gate, SOURCE_FRAMES exists so CI can
bound it, and it publishes depth and camera_info because those inputs are typed
and strict validation fails on a typed input the dataflow does not wire -- none
of which is pedagogy. The worked example of wiring this node is the README's
usage block, against a real camera.

It stays a dora node rather than a pytest case because what it exercises is the
wiring and the event loop, which only a running dataflow covers. pytest does
not collect it: the filename does not start with `test_`.

dataflow.yml stays at the repository root rather than moving beside it. A
node's working directory is the directory of the dataflow file, and both nodes
build with `pip install .`, which needs pyproject.toml in that directory.
2026-09-15 05:31:47 -03:00
cristhian 2b70d07868 docs: correct the example's description and say what it is for
The README still described the source as emitting "the three inputs this node
draws". It emits five outputs -- the depth map and the intrinsics were added
without the prose following.

Also records that the example is the CI gate as well as the demo, and that the
second role is what has been shaping it: it publishes depth and camera_info
because those inputs are typed and strict validation fails on a typed input the
dataflow does not wire. Worth knowing before adding the next output.
2026-09-15 05:30:03 -03:00
cristhian 8a7bda5867 use std/media/v1/Image for image and depth
Both inputs now declare dora's Arrow struct {width, height, encoding, data}
rather than a flat buffer plus loose metadata. The payload describes itself, so
nothing about its shape has to be agreed out of band, and the frame travels as
one Arrow value instead of a buffer whose meaning lives in a sidecar dict.

Depth uses the same type. dora's registry has no depth type, and a depth map is
an image whose pixels are distances -- the reading ROS takes as well -- so it
arrives with a single-channel encoding: 32FC1/float32 or 16UC1/mono16/uint16.
`units` stays in message metadata because the struct has no field for it.

decode.IMAGE_TYPE is the single definition of the struct; the example source
and the tests build payloads from it rather than respelling the schema.

The older shapes are still decoded -- a flat UInt8 buffer with
width/height/encoding for image, a flat Float32 buffer with shape/units for
depth -- so a producer's payload can move before its manifest does. That is
only half a migration path: type compatibility is directional, so a producer
still declaring std/core/v1/UInt8 fails validate against an
std/media/v1/Image input regardless of what it sends, and has to update its
manifest too.

Verified: 41 tests, validate clean with 4 contracts injected, the dataflow runs
to a clean exit, and a composite rendered through the struct path is identical
to the one rendered before the migration.
2026-09-15 05:27:40 -03:00
cristhian a6fa3ce32b accept std/media/v1/Image payloads
dora's typed image is an Arrow struct of width, height, encoding and data --
it carries inside the payload what this node has been reading out of message
metadata. The decoder now takes either shape: a struct describes itself and
its metadata is ignored, a flat UInt8 buffer is described by width/height/
encoding beside it, and everything after unpacking is common.

The declared contract stays std/core/v1/UInt8, deliberately. Type
compatibility is directional -- Image satisfies a Bytes input, but a
std/core/v1/UInt8 output wired into an std/media/v1/Image input is a validate
error -- and dora-zed declares image_bgr as std/core/v1/UInt8. Flipping this
manifest now would break the camera. Accepting both shapes first is what lets
the producers move before the contract does.

output_framing is not part of this. It selects the wire format, raw or
arrow-ipc, and is not required for images: a struct payload was verified to
arrive identically under both framings, and validate does not ask for
arrow-ipc when an output is annotated std/media/v1/Image. It earns its keep
when the wire must be self-describing, such as crossing languages or
recording.
2026-09-15 05:25:18 -03:00
cristhian 92abe8a446 refactor: type the example source inline instead of shipping a manifest
A dora-node.yml describes exactly one node and is the unit `dora hub publish`
operates on, which expects it beside pyproject.toml. This repository ships one
node, so it should carry one manifest. examples/dora-node.yml described a test
fixture that nobody installs and that has no package of its own -- a manifest
for something that is not a node.

The source's outputs are now annotated with `output_types:` in the dataflow,
which is the documented way to type an output without a manifest: "only
annotated outputs are type-checked". It runs as `path: python` + `args:`
because `dora validate` matches a manifest to a `path:` node by entrypoint, so
a path node would be tried against the root manifest, fail to match
`dora-image-view`, and warn -- fatal under strict_types. Through the
interpreter there is nothing to match.

The type gate is unchanged in strength: the viewer's contract is still
injected and compared against the source's annotations, and a deliberate
Float32 -> String swap still fails validate.

The cost is that the fixture no longer gets a managed venv of its own and runs
against the project environment. That applies to the fixture only; the shipped
node still uses path: + build:, which is what a consumer exercises.

This supersedes the approach in "fix strict-type validation by giving the
example source a manifest" and the doc commit that cited the
manifest-next-to-a-path-node rule as justification.
2026-09-15 05:21:36 -03:00
cristhian 1c7a9c816d docs: cite the manifest-next-to-a-path-node rule
The comment on examples/dora-node.yml read as an apology for a workaround.
It is the documented mechanism: a manifest describes exactly one node, and one
sitting next to a plain `path:` node is picked up automatically, which is how a
node gets contract validation without being published.
2026-09-15 05:17:33 -03:00
cristhian 5a0f13d4db add camera_info input for the pinhole model
Nothing in a dataflow carries fx, fy, cx and cy except the camera, and every
pixel of a depth map needs them to become a 3D point. The node now accepts
them so whatever computes that next has them to hand.

They are read from two places because dora-zed publishes them twice: the
camera_info message, and the metadata of every frame. The per-frame copy is
not redundant -- camera_info goes out once per camera session, so a viewer
started after the camera would otherwise never see it, and reading frame
metadata means wiring the input is optional even for a camera that publishes
it.

A nine-element row-major K is accepted alongside the compact [fx, fy, cx, cy],
since that is how ROS spells the same model in sensor_msgs/CameraInfo.

The model is drawn under the caption rather than only stored. A depth map read
through the wrong intrinsics looks entirely plausible, and this is where that
becomes visible.

as_float32 moves to a payload module: camera_info arrives as a raw byte slice
for the same reason the depth map does, and the reinterpretation is now shared
rather than written twice.

Verified with dora-cli 1.0.1: validate passes with four contracts injected on
each side, the dataflow runs to a clean exit, and a composed frame was
rendered and inspected with the model drawn on it.
2026-09-15 05:09:07 -03:00
cristhian 0efeacd538 add a depth map panel
The node showed a colour image only, so debugging a depth pipeline meant
either trusting the numbers or writing a throwaway viewer. It now keeps one
panel per frame stream -- image and depth -- and tiles them side by side,
scaled to a common height. Wiring one is a plain viewer; wiring both shows
them together. bbox and text stay overlays rather than panels.

The contract follows what dora-zed already publishes: float32 [H, W] in
millimetres with 0 for missing. It is sent there as a raw byte slice, so the
payload arrives as UInt8 despite the Float32 contract and is reinterpreted
rather than converted.

Auto-ranging the colormap over the valid pixels' full extent, rather than over
a percentile of them, is deliberate and was not the first attempt: trimming
the tails collapsed on the scene the node exists for. A small object in front
of a large wall is a few percent of the pixels, so both tails landed on the
wall and the object clipped away. Rendering a frame and looking at it found
that; every test passed either way.

Verified with dora-cli 1.0.1: validate passes with both contracts injected,
the dataflow runs both nodes to a clean exit, and a composed frame was
rendered and inspected.
2026-09-15 05:06:50 -03:00
19 changed files with 1383 additions and 261 deletions
@@ -69,6 +69,8 @@ jobs:
- run: uv run ruff check . - run: uv run ruff check .
- run: uv run pymarkdown scan -r --respect-gitignore .
- run: uv run pytest -q - run: uv run pytest -q
# strict_types: true in the dataflow turns a type warning into a non-zero # strict_types: true in the dataflow turns a type warning into a non-zero
+22 -42
View File
@@ -1,48 +1,28 @@
# AGENTS.md # AGENTS.md
This file provides guidance to AI coding assistants when working with code in this repository. Rules for AI coding assistants working in this repository.
## What this is - **dora >= 1.0.** Verified against `dora-cli 1.0.1`, which CI pins in
`.github/workflows/validate.yml`.
- **`uv` for everything:** `uv sync --group dev`, `uv run pytest`,
`uv run ruff check .`, `uv run python ...`. Never `pip` into the ambient
environment. A node's `build:` is `uv pip install .`, so dataflows must be run
with `--uv`.
- **`ruff` is the linter and the formatter.** `uv run ruff check .` must be clean.
- **Markdown is linted too.** `uv run pymarkdown scan -r --respect-gitignore .` must be clean. Its line
length is set in `pyproject.toml`.
- **numpy-style docstrings.**
- **Dependencies must ship prebuilt wheels for linux-x86_64, linux-aarch64 and
macos-arm64.** `uv sync` is the whole install: no system packages, no compiler.
- **Run the workflow locally with `act` before asking for a commit:**
A dora-rs Python node that displays a video stream with overlays: it keeps a current frame, bbox set and text string, and every `image` event redraws all three into an OpenCV window. It is a display sink — it produces no outputs. [README.md](README.md) documents the inputs, the encodings and the environment variables; this file covers only what the code and the README do not say. ```bash
act pull_request -P ubuntu-latest=catthehacker/ubuntu:act-latest
```
Everything the example dataflow needs is in this checkout. Do not reintroduce a dependency on another repository — the example must keep running with no camera, no detector and no network. Workflows live in `.github/workflows/`, not `.gitea/workflows/`, so that `act`
finds them with no `-W`. Gitea Actions reads both locations; keep only this one,
or a run fires twice.
**It is Python on purpose.** The node was first written in Rust against the `opencv` crate; that crate generates its bindings from the OpenCV headers installed on the build machine, so it needs `libopencv-dev` and `libclang-dev` on every machine and every architecture and does not cross-compile. Every dependency here ships prebuilt wheels for linux-x86_64, linux-aarch64 and macos-arm64, so `uv sync` is the whole install. Do not reintroduce a dependency that has to be compiled from source. - **Do not commit or push.** Leave changes in the working tree and wait to be asked.
- **Do not mention AI tools in commit messages or documentation.**
## Commands
```bash
uv sync --group dev # deps plus pytest and ruff
uv run pytest && uv run ruff check . # no camera/dataflow/display needed
dora run dataflow.yml --uv # the example, in a window
CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv # the same, headless
```
`dora build --uv` / `dora run --uv` rewrites each manifest's `build: pip install .` to `uv pip install .` and gives the node its own managed venv under `.dora/python-envs/<node-id>/`, so what it imports at runtime is what was installed at build time.
## Architecture
- **`dora_image_view/main.py`** — Argument parsing and the dora event loop, nothing else. The env var wins over the CLI flag for `VIEW_WIDTH`/`VIEW_HEIGHT`. The upstream node spells these `PLOT_WIDTH`/`PLOT_HEIGHT`; they were renamed with the node, so a dataflow copied from upstream needs its env keys changed. `Node(args.name)` rather than `Node()` so the node also runs standalone as a dynamic node.
- **`dora_image_view/decode.py`** — `to_bgr()`: every supported encoding to a writable BGR array. Two behaviours are deliberate, not bugs: `bgr8` **copies** (the payload may be a read-only view on shared memory, and the frame is annotated in place), and `yuv420` converts with `COLOR_YUV420p2RGB` rather than `...2BGR` — the upstream node converts to RGB and then displays it as BGR, and that channel swap is part of the behaviour callers already see.
- **`dora_image_view/view.py`** — `View` (the overlay state and the window) and `Bboxes`. `headless` (from `CI=true`) skips only the window: every input is still decoded, which is what makes the node safe in a pipeline test.
- **`dora_image_view/bbox.py`** — Parses the `bbox` struct into xyxy corners. `xywh` is centre-based (x, y are the box centre). `_flat()` unwraps the single-element list that `pa.array([{...}])` produces around each field, and reads a field stored flat as-is.
- **`examples/source.py`** — The synthetic source node: an animated frame, the box around the square in it, and a caption. It exists so the example dataflow exercises all three inputs without a camera or a detector, and so CI can run the dataflow rather than only validate it. `SOURCE_FRAMES` bounds the run, which is what lets CI wait for a clean exit instead of killing it on a timeout.
- **`dora-node.yml`** — Node manifest (`apiVersion: 1`). `dora validate` injects these types into any dataflow that reaches the node by `path:`; a `git:`-sourced node does **not** pick it up, which is why the example builds from the checkout. `image` and `text` carry type URNs naming the element type because each payload is a raw buffer described by its metadata, not an Arrow struct. `bbox` is deliberately **untyped**: senders disagree on whether its numeric fields are 32- or 64-bit, and the node reads either.
- **`examples/dora-node.yml`** — Manifest for the source node, and not optional. `dora validate` matches a manifest to a `path:` node by entrypoint; with no manifest beside `examples/source.py`, the root `dora-node.yml` is tried against it, does not match its entrypoint, and warns — which `strict_types: true` turns into a failure. Declaring the source's types here rather than as `output_types:` in `dataflow.yml` is also what makes them *checked*: an inline annotation shadows the manifest, so the manifest would be injected but never compared.
- **`dataflow.yml`** — The example and the CI gate, with `strict_types: true`. Both nodes are `path:` nodes with a manifest, so both contracts are injected and compared against each other; `bbox` is annotated on neither side, matching the manifests.
- **`.gitea/workflows/validate.yml`** — CI: lints, runs the unit tests, validates the dataflow and runs it headless. Needs no camera and no display. One job on purpose — the gates themselves take about a second, and are dwarfed by preparing the runner.
## Why CI installs libgl1
`opencv-python` links against libGL at import time, so `import cv2` fails on the bare runner image and CI installs the shared libraries first. At roughly sixteen seconds it is the most expensive step in the job, several times everything else combined.
`opencv-python-headless` needs none of them, but it also has no `imshow`, which is this node's whole purpose. Removing the step therefore means making the GUI wheel an optional extra — headless by default, `pip install .[gui]` for a real window — and that is a change to what a consumer installs, not just to CI. It has not been done.
## A caveat on typed optional inputs
`dora validate --strict` injects a contract type for *every* typed manifest input and then warns `input_types key "..." not found in inputs list` for any the dataflow does not wire. So a typed optional input forces the example dataflow to wire it. If a typed input is added to the manifest, either wire it in `dataflow.yml` or leave it untyped.
## Rules
- Do not mention AI tools in commit messages or documentation.
-3
View File
@@ -1,3 +0,0 @@
# GEMINI.md
See [AGENTS.md](AGENTS.md) for repository guidance.
+127 -31
View File
@@ -1,8 +1,8 @@
# dora-image-view # dora-image-view
Dora node that overlays bounding boxes, labels and text on an image input and displays Dora node that displays a colour image and a depth map side by side in one OpenCV
it in an OpenCV window. A display sink for debugging a vision pipeline: it produces no window, with bounding box, text and camera intrinsics overlays. A display sink for debugging a
outputs. vision pipeline: it produces no outputs.
Based on [`opencv-plot`](https://github.com/dora-rs/dora-hub/tree/main/node-hub/opencv-plot) Based on [`opencv-plot`](https://github.com/dora-rs/dora-hub/tree/main/node-hub/opencv-plot)
from dora-hub, brought up to the dora 1.0 node contract and packaged with `uv`. from dora-hub, brought up to the dora 1.0 node contract and packaged with `uv`.
@@ -14,12 +14,14 @@ Add to your dataflow:
```yaml ```yaml
nodes: nodes:
- id: view - id: view
git: ssh://git@gitea.liria.cl:5001/liria/dora-image-view.git git: https://gitea.liria.cl/liria/dora-image-view.git
tag: v0.1.0 tag: v0.1.0
build: pip install . build: uv pip install .
path: dora-image-view path: dora-image-view
inputs: inputs:
image: camera/image image: camera/image_bgr
depth: camera/depth
camera_info: camera/camera_info
bbox: detector/bbox bbox: detector/bbox
text: detector/text text: detector/text
``` ```
@@ -27,16 +29,20 @@ nodes:
Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own Run the dataflow with `--uv` (`dora run dataflow.yml --uv`) so the node gets its own
managed environment. managed environment.
Wire `image` from any node publishing one of the encodings below, and `bbox` from any Every input is optional, and each of `image` and `depth` fills one panel of the window,
detector publishing the struct below. Both inputs are optional: an `image` alone is a tiled left to right in that order. Wiring one of them is a plain viewer;
plain viewer. wiring several shows them side by side, scaled to a common height. `bbox`, `text` and `camera_info`
are overlays rather than panels — boxes are drawn on the `image` panel, in its own
pixels, and the caption on the composite.
### Runnable example ### End-to-end test
[`dataflow.yml`](dataflow.yml) is self-contained. [`examples/source.py`](examples/source.py) [`dataflow.yml`](dataflow.yml) is self-contained. [`tests/source.py`](tests/source.py)
is a synthetic source node that emits an animated frame, the box around the square is a synthetic source node that emits an animated frame, a depth map of the same scene,
moving in it, and a caption — the three inputs this node draws — so the example needs the camera intrinsics, the box around the square moving in the frame, and a caption —
no camera, no detector and no network: every input this node draws — so it needs no camera, no detector and no network. It is
a test fixture rather than a worked example; the realistic wiring is the block at the
top of this file. Run it to watch the node work:
```bash ```bash
dora run dataflow.yml --uv dora run dataflow.yml --uv
@@ -45,9 +51,12 @@ dora run dataflow.yml --uv
A window opens with a square bouncing across a gradient, boxed and labelled. Press `q` A window opens with a square bouncing across a gradient, boxed and labelled. Press `q`
to quit. to quit.
It builds both nodes from this checkout (`path:` + `build:`) rather than from a tag, It builds the viewer from this checkout (`path:` + `build:`) rather than from a tag,
which is what lets `dora validate` pick up the manifest described below. which is what lets `dora validate` pick up the manifest described below.
CI runs the same dataflow headless on every push, which is what makes it a gate rather
than a demo that rots.
Set `SOURCE_FRAMES` to stop the source after a fixed number of frames instead of running Set `SOURCE_FRAMES` to stop the source after a fixed number of frames instead of running
until you quit; that plus `CI=true` is how the same dataflow runs headless as a test: until you quit; that plus `CI=true` is how the same dataflow runs headless as a test:
@@ -57,25 +66,58 @@ CI=true SOURCE_FRAMES=30 dora run dataflow.yml --uv
`SOURCE_WIDTH` and `SOURCE_HEIGHT` (default 640x480) set the frame size. `SOURCE_WIDTH` and `SOURCE_HEIGHT` (default 640x480) set the frame size.
### With a real camera
[`dataflow_webcam.yml`](dataflow_webcam.yml) wires
[dora-webcam](https://gitea.liria.cl/liria/dora-webcam) into this node, and is the
worked example of consuming something that is not a test fixture:
```bash
dora run dataflow_webcam.yml --uv
```
The camera publishes `std/media/v1/Image` and this node draws it. `depth`,
`camera_info`, `text` and `bbox` are simply unwired — every input is optional, so a
plain viewer is the result.
It carries no `strict_types`, deliberately. A camera publishes an image and nothing
else, and `dora validate --strict` warns about every *typed* input this node declares
that a dataflow leaves unwired. The strictness belongs to `dataflow.yml`, which drives
all of them.
Verified end to end: dora clones dora-webcam at the pinned tag, builds both nodes, and
runs them against a real camera to a clean exit.
**A `git:` node needs anonymous read access.** dora-cli 1.0.1 fetches through libgit2
with no credential callback, so `ssh://` cannot authenticate at all and anything asking
for credentials fails with `remote authentication required but no callback set`. On
Gitea, marking a repository public is not sufficient — a repository inside a private
organization is internal and answers an anonymous clone with `401`, so the organization
has to be public too.
### Node manifest ### Node manifest
[`dora-node.yml`](dora-node.yml) declares the node's inputs with type URNs. No source [`dora-node.yml`](dora-node.yml) declares the node's inputs with type URNs. No source
package is shared between node repositories, so it is the only contract a consumer has, package is shared between node repositories, so it is the only contract a consumer has,
and it is what makes a wiring mistake fail at build time instead of at runtime. The and it is what makes a wiring mistake fail at build time instead of at runtime. The
example source has a manifest of its own, so `dora validate` checks the two against example source is typed inline in the dataflow with `output_types:`, so `dora validate`
each other: checks the two against each other:
``` ```text
$ dora validate dataflow.yml $ dora validate dataflow.yml
node "source": injected 2 contract type(s) from .../examples/dora-node.yml node "view": injected 4 contract type(s) from .../dora-node.yml
node "view": injected 2 contract type(s) from .../dora-node.yml
All type annotations OK. All type annotations OK.
``` ```
`image` is `std/core/v1/UInt8` and `text` is `std/core/v1/String`; the payloads are raw There is one manifest because this repository ships one node. The example source is a
buffers described by their metadata, so the URN names the element type rather than a test fixture rather than something anyone installs, so it has none, and runs through
struct. `bbox` is deliberately left untyped — senders disagree on whether its numeric the interpreter to keep `dora validate` from matching the viewer's manifest against it.
fields are 32- or 64-bit, and the node reads either.
`image` is `std/core/v1/UInt8`, `depth` and `camera_info` are `std/core/v1/Float32` and
`text` is `std/core/v1/String`; the payloads are raw buffers described by their
metadata, so the URN names the element type rather than a struct. `bbox` is deliberately
left untyped — senders disagree on whether its numeric fields are 32- or 64-bit, and the
node reads either.
## Behavior ## Behavior
@@ -86,8 +128,10 @@ of that state:
bbox (green rectangle plus `label, conf` text), draws the stored text at the top left, bbox (green rectangle plus `label, conf` text), draws the stored text at the top left,
resizes to `VIEW_WIDTH`x`VIEW_HEIGHT` if both are set, and shows it in a window titled resizes to `VIEW_WIDTH`x`VIEW_HEIGHT` if both are set, and shows it in a window titled
`Dora Node: dora-image-view`. Press `q` in the window to quit. `Dora Node: dora-image-view`. Press `q` in the window to quit.
- A **`depth`** event colour-maps the distances onto the depth panel and redraws.
- A **`camera_info`** event stores the pinhole model and draws it under the caption.
- A **`bbox`** or **`text`** event only updates stored state; it is drawn on the next - A **`bbox`** or **`text`** event only updates stored state; it is drawn on the next
`image` redraw. redraw.
Setting `CI=true` suppresses the display: the node still decodes every input but opens Setting `CI=true` suppresses the display: the node still decodes every input but opens
no window. That is what makes it safe to run in a pipeline test. no window. That is what makes it safe to run in a pipeline test.
@@ -96,16 +140,58 @@ no window. That is what makes it safe to run in a pipeline test.
| Input | Payload | Metadata | | Input | Payload | Metadata |
|---|---|---| |---|---|---|
| `image` | Flat `UInt8` array | `width`, `height`, `encoding` | | `image` | `std/media/v1/Image` struct | none — the struct describes itself |
| `depth` | `std/media/v1/Image` struct, one channel | `units` (defaults to `mm`) |
| `camera_info` | `[fx, fy, cx, cy]` or a 9-element K | `distortion`, `distortion_model` |
| `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` | | `bbox` | Arrow struct array, element `[0]` with fields `bbox`, `conf`, `labels` | `format` |
| `text` | Arrow array of size 1 | — | | `text` | Arrow array of size 1 | — |
Supported `encoding` values: `bgr8`, `rgb8`, `jpeg`/`jpg`/`jpe`/`bmp`/`webp`/`png` `image` and `depth` are both `std/media/v1/Image`: an Arrow struct of `width: UInt32`,
`height: UInt32`, `encoding: Utf8` and `data: LargeBinary`. The payload describes
itself, so nothing about its shape has to travel as message metadata.
Depth is an image whose pixels are distances — dora's type registry has no depth type,
and this is how ROS spells it too. Its encodings are `32FC1`/`float32` and
`16UC1`/`mono16`/`uint16`, one channel per pixel, and `units` metadata still switches
millimetres to metres.
Older payloads are still decoded, so a producer can move at its own pace: a flat `UInt8`
buffer with `width`/`height`/`encoding` metadata for `image`, and a flat `Float32`
buffer with `shape`/`units` metadata for `depth`.
**Migrating a producer.** dora's type compatibility is directional — `Image` satisfies a
`Bytes` input, but a `std/core/v1/UInt8` output wired into an `std/media/v1/Image` input
is a `dora validate` error. A producer still declaring `std/core/v1/UInt8` or
`std/core/v1/Float32` therefore has to change its manifest as well as its payload.
Supported `image` `encoding` values: `bgr8`, `rgb8`, `jpeg`/`jpg`/`jpe`/`bmp`/`webp`/`png`
(encoded byte stream), `yuv420`, `avif`. `width` and `height` are only needed by the raw (encoded byte stream), `yuv420`, `avif`. `width` and `height` are only needed by the raw
encodings — the rest carry their own dimensions. An unsupported encoding raises. encodings — the rest carry their own dimensions. An unsupported encoding raises.
Supported `format` values: `xyxy` and `xywh` (centre-based, converted to `xyxy`). Supported `format` values: `xyxy` and `xywh` (centre-based, converted to `xyxy`).
`depth` is in millimetres unless `units` is `m`. In a depth map, `0`
and any non-finite value mean missing depth and are drawn black. A payload sent as a
raw byte slice — which is what a C++ node does — arrives as `UInt8` despite the
`Float32` contract, and is reinterpreted rather than converted.
### Camera intrinsics
`camera_info` carries the pinhole model the depth map is interpreted through, either as
the four values `[fx, fy, cx, cy]` or as a nine-element row-major `K`, which is how ROS
spells the same model in `sensor_msgs/CameraInfo`. Distortion coefficients are read from
the `distortion` metadata.
The node displays the model and holds it. It needs none of it to draw a panel; it is
there for what reads it next — turning a depth map into a point cloud needs `fx`, `fy`,
`cx` and `cy` for every pixel, and nothing else in a dataflow carries them.
Intrinsics are also picked up from the metadata of any `image` or `depth` frame that
carries `fx`, `fy`, `cx` and `cy`, so **wiring `camera_info` is optional even for a
camera that publishes it**. That matters because a camera typically publishes
`camera_info` once per session: a viewer started after the camera would otherwise never
see it.
```python ```python
node.send_output("image", pa.array(frame.ravel()), node.send_output("image", pa.array(frame.ravel()),
{"width": 640, "height": 480, "encoding": "bgr8"}) {"width": 640, "height": 480, "encoding": "bgr8"})
@@ -127,11 +213,21 @@ None — it is a display sink.
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
| `VIEW_WIDTH` | image width | Resize the displayed frame to this width, in pixels. | | `VIEW_WIDTH` | composite width | Resize the displayed composite to this width, in pixels. |
| `VIEW_HEIGHT` | image height | Resize the displayed frame to this height, in pixels. | | `VIEW_HEIGHT` | composite height | Resize the displayed composite to this height, in pixels. |
| `VIEW_DEPTH_MIN_MM` | auto | Pin the near end of the depth colour range, in millimetres. |
| `VIEW_DEPTH_MAX_MM` | auto | Pin the far end of the depth colour range, in millimetres. |
Resizing only happens when both are set. Both are also available as `--view-width` and Resizing only happens when both `VIEW_WIDTH` and `VIEW_HEIGHT` are set. Both are also
`--view-height`; the environment variable wins when both are given. available as `--view-width` and `--view-height`; the environment variable wins when both
are given.
Each end of the depth range is auto-ranged over the valid pixels of the current frame
unless it is pinned, so pinning one end leaves the other automatic. Auto-ranging makes
an unconfigured sensor legible at the cost of colours that shift from frame to frame;
pin both ends to compare frames against each other. The range covers the full extent of
the valid pixels rather than a percentile of them, so a small near object against a
large wall stays visible.
`CI=true` suppresses the display window; it is not a configuration knob. `CI=true` suppresses the display window; it is not a configuration knob.
+41 -6
View File
@@ -1,31 +1,66 @@
# The repository's own example, and its end-to-end test: a synthetic source # The end-to-end test: a synthetic source feeding the viewer. Everything it
# feeding the viewer. Everything it needs is in this checkout -- no camera, no # needs is in this checkout -- no camera, no detector, no network -- which is
# detector, no network -- so it is also what CI runs. # what lets CI run it rather than only validate it.
#
# It lives at the repository root rather than beside tests/source.py because a
# node's working directory is the directory of the dataflow file, and both
# nodes build with `uv pip install .`, which needs pyproject.toml in that
# directory.
# #
# Both nodes are built from the checkout with `path:` + `build:`, which is what # Both nodes are built from the checkout with `path:` + `build:`, which is what
# lets `dora validate` pick up the typed contract in dora-node.yml. A consumer # lets `dora validate` pick up the typed contract in dora-node.yml. A consumer
# wires the viewer from a tag instead -- see README.md. # wires the viewer from a tag instead -- see README.md.
# #
# Run it with `--uv` so every node gets its own managed environment. # Run it with `--uv` so every node gets its own managed environment.
# strict_types makes a type warning a non-zero exit. It also means a *typed*
# manifest input must be wired below: `dora validate --strict` warns
# `input_types key "..." not found in inputs list` for any it leaves unwired, so
# a new typed input on the node has to be driven from tests/source.py with a
# matching entry in `output_types` here, or left untyped.
strict_types: true strict_types: true
nodes: nodes:
- id: source - id: source
path: examples/source.py # Run through the interpreter rather than as `path: tests/source.py`.
#
# A dora-node.yml describes exactly one node and is what gets published
# (`dora hub publish` expects it beside pyproject.toml), and this
# repository ships one node: dora-image-view. The example source is a test
# fixture, not a node anyone installs, so it gets no manifest.
#
# That has a mechanical consequence. `dora validate` matches a manifest to
# a `path:` node by entrypoint, so a path node here would be tried against
# the root manifest, would not match its `dora-image-view` entrypoint, and
# would warn -- which strict_types turns into a failure. Going through the
# interpreter leaves nothing for it to match, and `output_types` below is
# the documented way to type an output without one.
path: python
args: tests/source.py
# The source needs dora-rs, numpy and pyarrow; installing the project gives # The source needs dora-rs, numpy and pyarrow; installing the project gives
# it exactly the versions pinned in uv.lock. # it exactly the versions pinned in uv.lock.
build: pip install . build: uv pip install .
inputs: inputs:
tick: dora/timer/millis/33 tick: dora/timer/millis/33
outputs: outputs:
- image - image
- depth
- camera_info
- bbox - bbox
- text - text
# bbox is deliberately absent: it is untyped on the viewer's side too,
# because senders disagree on 32- vs 64-bit numeric fields.
output_types:
image: std/media/v1/Image
text: std/core/v1/String
depth: std/media/v1/Image
camera_info: std/core/v1/Float32
- id: view - id: view
path: dora-image-view path: dora-image-view
build: pip install . build: uv pip install .
inputs: inputs:
image: source/image image: source/image
depth: source/depth
camera_info: source/camera_info
bbox: source/bbox bbox: source/bbox
text: source/text text: source/text
+40
View File
@@ -0,0 +1,40 @@
# A real camera into the viewer, and the worked example of wiring this node to
# something that is not a test fixture.
#
# Not run by CI: it needs a camera and it fetches dora-webcam over the network.
# dataflow.yml is the gate, and needs neither.
#
# This needs anonymous read access to the camera's repository. dora-cli 1.0.1
# fetches through libgit2 with no credential callback, so ssh:// cannot
# authenticate at all and anything asking for credentials fails with "remote
# authentication required but no callback set". On Gitea, marking the repository
# public is not enough: a repository inside a private organization is internal
# and answers an anonymous clone with 401, so the organization must be public.
#
# `strict_types` is deliberately absent. A camera publishes an image and nothing
# else, and `dora validate --strict` warns about every *typed* input this node
# declares that a dataflow leaves unwired -- depth, camera_info and text here.
# The wiring is correct; the strictness is aimed at the self-contained dataflow,
# which drives all of them.
nodes:
- id: camera
git: https://gitea.liria.cl/liria/dora-webcam.git
tag: v0.1.1
build: uv pip install .
path: dora-webcam
inputs:
tick: dora/timer/millis/33
outputs:
- image
# dora-webcam's own manifest says this, but a `git:` node's manifest is not
# read at validate time -- only at build. Repeating it here is what makes
# the two contracts checked against each other now rather than at runtime.
output_types:
image: std/media/v1/Image
- id: view
path: dora-image-view
build: uv pip install .
inputs:
image: camera/image
+48 -14
View File
@@ -7,27 +7,28 @@
apiVersion: 1 apiVersion: 1
name: dora-image-view name: dora-image-view
namespace: liria namespace: liria
description: Overlays bounding boxes, labels, and text on an image input and displays it in an OpenCV window description: Displays an image and a depth map side by side in an OpenCV window, with bounding box, text and camera intrinsics overlays
categories: [visualization, debug] categories: [visualization, debug]
keywords: [opencv, view, bbox, bounding-box, image, visualization, object-detection] keywords: [opencv, view, bbox, bounding-box, image, depth, intrinsics, camera-info, visualization, object-detection]
runtime: python runtime: python
entrypoint: dora-image-view entrypoint: dora-image-view
# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and # Run the dataflow with --uv, which gives the node its own managed venv under
# gives the node its own managed venv under `.dora/python-envs/`, so the # `.dora/python-envs/`, so what it imports at runtime is what was installed
# dependencies it sees at runtime are the ones installed here. # here. Without --uv this needs a virtualenv to install into.
build: pip install . build: uv pip install .
dora: ">=1.0" dora: ">=1.0"
inputs: inputs:
image: image:
type: std/core/v1/UInt8 type: std/media/v1/Image
required: false required: false
description: >- description: >-
Base image to draw on: a flat UInt8 buffer described by the metadata Base image to draw on: an Arrow struct {width, height, encoding, data}.
{width, height, encoding}. Supported encodings: bgr8, rgb8, Supported encodings: bgr8, rgb8, jpeg/jpg/jpe/bmp/webp/png (encoded
jpeg/jpg/jpe/bmp/webp/png (encoded bytes), yuv420, avif. Receiving this bytes), yuv420, avif. Receiving this input triggers a redraw and display.
input triggers a redraw and display. A flat UInt8 buffer described by {width, height, encoding} metadata is
still decoded, for producers that have not moved to the struct.
bbox: bbox:
required: false required: false
description: >- description: >-
@@ -41,10 +42,41 @@ inputs:
description: >- description: >-
Text string to overlay at the top-left corner. Stored and drawn on the Text string to overlay at the top-left corner. Stored and drawn on the
next image redraw. next image redraw.
camera_info:
type: std/core/v1/Float32
required: false
description: >-
The pinhole model the depth map is interpreted through: [fx, fy, cx, cy],
or a nine-element row-major K as ROS spells it in sensor_msgs/CameraInfo.
Distortion coefficients are read from the `distortion` metadata. Stored
and displayed, and held for readers that turn depth into 3D. A sender
that also puts fx/fy/cx/cy in each frame's metadata does not need to wire
this input -- they are picked up from there, which is what covers a
camera that published its intrinsics before this node started.
depth:
type: std/media/v1/Image
required: false
description: >-
Depth map as an Arrow struct {width, height, encoding, data}, one channel
per pixel. Supported encodings: 32FC1/float32 and 16UC1/mono16/uint16 --
dora has no depth type, and a depth map is an image whose pixels are
distances, which is how ROS spells it too. Millimetres unless the `units`
metadata says `m`; 0 and non-finite values are missing depth and draw
black. Colour-mapped into its own panel, which receiving this input
redraws. A flat Float32 buffer with {shape, units} metadata is still
decoded, for producers that have not moved to the struct.
outputs: {} outputs: {}
env: env:
VIEW_DEPTH_MIN_MM:
type: float
description: >-
Pin the near end of the depth colour range, in millimetres. Each end is
auto-ranged over the valid pixels of the current frame when it is unset.
VIEW_DEPTH_MAX_MM:
type: float
description: Pin the far end of the depth colour range, in millimetres.
VIEW_WIDTH: VIEW_WIDTH:
type: int type: int
description: Resize the displayed frame to this width (pixels). Defaults to the image input width. description: Resize the displayed frame to this width (pixels). Defaults to the image input width.
@@ -54,11 +86,13 @@ env:
example: | example: |
- id: view - id: view
git: ssh://git@gitea.liria.cl:5001/liria/dora-image-view.git git: https://gitea.liria.cl/liria/dora-image-view.git
tag: v0.1.0 tag: v0.1.0
build: pip install . build: uv pip install .
path: dora-image-view path: dora-image-view
inputs: inputs:
image: camera/image image: camera/image_bgr
depth: camera/depth
camera_info: camera/camera_info
bbox: detector/bbox bbox: detector/bbox
text: detector/text text: detector/text
+56 -5
View File
@@ -1,10 +1,19 @@
"""Decoding of the `image` input into a BGR frame that can be drawn on.""" """Decoding of the `image` input into a BGR frame that can be drawn on.
Two payload shapes are accepted, because dora has both in the field. The typed
one is `std/media/v1/Image`: an Arrow struct carrying width, height, encoding
and data together, so the frame is self-describing. The older one is a flat
UInt8 buffer whose dimensions and encoding travel beside it in the message
metadata, which is what a node declaring `std/core/v1/UInt8` sends -- dora-zed
among them. Everything after unpacking is identical.
"""
import io import io
import cv2 import cv2
import numpy as np import numpy as np
import pillow_avif # noqa: F401 # registers the AVIF plugin with Pillow import pillow_avif # noqa: F401 # registers the AVIF plugin with Pillow
import pyarrow as pa
from PIL import Image from PIL import Image
# Encodings that carry their own dimensions, so `width`/`height` metadata is not # Encodings that carry their own dimensions, so `width`/`height` metadata is not
@@ -14,13 +23,25 @@ SELF_DESCRIBING = frozenset({"jpeg", "jpg", "jpe", "bmp", "webp", "png"})
SUPPORTED = SELF_DESCRIBING | {"bgr8", "rgb8", "yuv420", "avif"} SUPPORTED = SELF_DESCRIBING | {"bgr8", "rgb8", "yuv420", "avif"}
def to_bgr(storage, encoding: str, width: int | None, height: int | None) -> np.ndarray: # std/media/v1/Image. A payload carrying these fields describes itself, so its
# message metadata is not consulted. Exported as an Arrow type so a producer --
# the example source, the tests -- builds it from one definition.
IMAGE_TYPE = pa.struct([
("width", pa.uint32()),
("height", pa.uint32()),
("encoding", pa.utf8()),
("data", pa.large_binary()),
])
IMAGE_FIELDS = tuple(field.name for field in IMAGE_TYPE)
def to_bgr(storage, metadata: dict) -> np.ndarray:
"""Decode one `image` payload into a writable BGR array. """Decode one `image` payload into a writable BGR array.
`width` and `height` come from the message metadata and are only required by Accepts either an `std/media/v1/Image` struct or a flat buffer described by
the raw encodings, which carry no dimensions of their own. the message metadata.
""" """
buffer = storage.to_numpy().astype(np.uint8) buffer, encoding, width, height = unpack(storage, metadata)
if encoding == "bgr8": if encoding == "bgr8":
# Copy so the frame can be annotated: to_numpy() may be a view on the # Copy so the frame can be annotated: to_numpy() may be a view on the
@@ -53,6 +74,36 @@ def to_bgr(storage, encoding: str, width: int | None, height: int | None) -> np.
raise RuntimeError(f"unsupported image encoding: {encoding}") raise RuntimeError(f"unsupported image encoding: {encoding}")
def unpack(storage, metadata: dict) -> tuple[np.ndarray, str, int | None, int | None]:
"""Split a payload of either shape into buffer, encoding, width and height."""
if is_image_struct(storage):
# One image per message: the struct is a single-row array.
row = storage[0].as_py()
return (
np.frombuffer(row["data"], dtype=np.uint8),
str(row["encoding"]).lower(),
row["width"],
row["height"],
)
return (
storage.to_numpy().astype(np.uint8),
str(metadata.get("encoding", "bgr8")).lower(),
metadata.get("width"),
metadata.get("height"),
)
def is_image_struct(storage) -> bool:
"""Whether the payload is an Arrow struct with the std/media/v1/Image fields."""
fields = getattr(getattr(storage, "type", None), "fields", None)
if fields is None:
return False
return all(name in {field.name for field in fields} for name in IMAGE_FIELDS)
def _reshape(buffer: np.ndarray, width: int | None, height: int | None, encoding: str): def _reshape(buffer: np.ndarray, width: int | None, height: int | None, encoding: str):
width, height = _dimensions(width, height, encoding) width, height = _dimensions(width, height, encoding)
return _take(buffer, width * height * 3, encoding).reshape(height, width, 3) return _take(buffer, width * height * 3, encoding).reshape(height, width, 3)
+146
View File
@@ -0,0 +1,146 @@
"""Decoding and colouring of the `depth` input.
A depth map is a single-channel distance image, not a picture: it is turned into
something displayable by mapping a distance range onto a colormap. Decoding
normalises to float32 millimetres with 0 for missing, so the colouring only ever
sees one representation.
"""
import os
import cv2
import numpy as np
from dora_image_view.decode import is_image_struct
from dora_image_view.payload import as_float32
# Perceptually uniform and readable in both directions, unlike JET.
COLORMAP = cv2.COLORMAP_TURBO
MISSING = 0.0
# Single-channel depth encodings and the pixel type each names. The ROS spellings
# are the ones senders use; a depth map is an image whose pixels are distances,
# which is why it arrives as std/media/v1/Image rather than a type of its own --
# dora's registry has no depth type.
DEPTH_DTYPES = {
"32fc1": np.float32,
"float32": np.float32,
"16uc1": np.uint16,
"mono16": np.uint16,
"uint16": np.uint16,
}
def to_millimetres(storage, metadata: dict) -> np.ndarray:
"""Decode one `depth` payload into a float32 array of millimetres.
Missing depth is 0. Non-finite values are normalised to 0 as well, so
everything downstream only has to test for it.
"""
depth, height, width = _values(storage, metadata)
if depth.size < height * width:
raise RuntimeError(
f"depth payload holds {depth.size} values, "
f"expected at least {height * width} for the declared shape",
)
depth = depth[: height * width].reshape(height, width).copy()
depth[~np.isfinite(depth)] = MISSING
if units(metadata) == "m":
depth *= 1000.0
return depth
def colorize(depth_mm: np.ndarray) -> np.ndarray:
"""Map millimetres onto the colormap, leaving missing depth black."""
low, high = resolve_range(depth_mm)
normalised = np.clip((depth_mm - low) / (high - low), 0.0, 1.0)
frame = cv2.applyColorMap((normalised * 255).astype(np.uint8), COLORMAP)
frame[depth_mm <= MISSING] = 0
return frame
def resolve_range(depth_mm: np.ndarray) -> tuple[float, float]:
"""Pick the millimetre range to map onto the colormap.
Each end is taken from the environment if it is set there and auto-ranged
over the valid pixels otherwise, so pinning one end does not force the
other. Auto-ranging re-reads the frame every time, which is what makes an
unconfigured sensor legible and also what makes the colours shift between
frames.
The auto range is the full extent of the valid pixels, not a percentile of
them. Trimming the tails reads as the safer choice but breaks the scene
this viewer exists for: a small object in front of a large flat wall is a
few percent of the pixels, so both tails land on the wall, the range
collapses, and the object -- the thing being looked at -- clips to one
colour with everything else.
"""
low = _env("VIEW_DEPTH_MIN_MM")
high = _env("VIEW_DEPTH_MAX_MM")
if low is None or high is None:
valid = depth_mm[depth_mm > MISSING]
auto_low, auto_high = (
(float(valid.min()), float(valid.max())) if valid.size else (0.0, 1.0)
)
low = auto_low if low is None else low
high = auto_high if high is None else high
# An empty or single-valued frame would otherwise divide by zero.
if high <= low:
high = low + 1.0
return float(low), float(high)
def _values(storage, metadata: dict) -> tuple[np.ndarray, int, int]:
"""Read the payload of either shape into float32 values plus its shape."""
if is_image_struct(storage):
row = storage[0].as_py()
encoding = str(row["encoding"]).lower()
dtype = DEPTH_DTYPES.get(encoding)
if dtype is None:
raise RuntimeError(
f"unsupported depth encoding: {encoding}; "
f"expected one of {', '.join(sorted(DEPTH_DTYPES))}",
)
values = np.frombuffer(row["data"], dtype=dtype).astype(np.float32)
return values, int(row["height"]), int(row["width"])
values = as_float32(storage)
height, width = _hw(metadata, values.size)
return values, height, width
def _hw(metadata: dict, size: int) -> tuple[int, int]:
shape = metadata.get("shape")
if shape is not None and len(shape) >= 2:
return int(shape[0]), int(shape[1])
height, width = metadata.get("height"), metadata.get("width")
if height and width:
return int(height), int(width)
raise RuntimeError(
"depth needs a `shape` [height, width] or `height`/`width` metadata; "
f"got a payload of {size} values and neither",
)
def units(metadata: dict) -> str:
"""Read the distance unit the sender declared, defaulting to millimetres."""
return str(metadata.get("units", "mm")).lower()
def _env(name: str) -> float | None:
value = os.getenv(name)
return float(value) if value else None
+89
View File
@@ -0,0 +1,89 @@
"""The pinhole camera model a depth map is interpreted through.
The node does not need intrinsics to draw anything -- it displays them, and
holds them for whatever reads them next. Back-projecting a depth map into a
point cloud is the obvious such reader: every pixel needs fx, fy, cx and cy to
become an XYZ point, and nothing else in a dataflow carries them.
Two senders are handled because dora-zed publishes the same numbers twice: once
per camera session on `camera_info`, and again in the metadata of every frame.
The per-frame copy is what makes a viewer that started after the camera did
still end up with intrinsics.
"""
from dataclasses import dataclass
from dora_image_view.payload import as_float32
# [fx, fy, cx, cy], the four numbers dora-zed publishes. Also accepted is a
# nine-element row-major K, which is how ROS spells the same model in
# sensor_msgs/CameraInfo.
COMPACT = 4
K_MATRIX = 9
# Indices of fx, fy, cx, cy in a row-major K:
# [fx, 0, cx,
# 0, fy, cy,
# 0, 0, 1]
K_INDICES = (0, 4, 2, 5)
@dataclass(frozen=True)
class Intrinsics:
"""A pinhole model: focal lengths and principal point, both in pixels."""
fx: float
fy: float
cx: float
cy: float
distortion: tuple[float, ...] = ()
model: str = ""
def __str__(self) -> str:
"""Render for the overlay, short enough to fit across a panel."""
return f"fx {self.fx:.1f} fy {self.fy:.1f} cx {self.cx:.1f} cy {self.cy:.1f}"
def parse(storage, metadata: dict) -> Intrinsics:
"""Read one `camera_info` payload.
Accepts the four-element [fx, fy, cx, cy] dora-zed sends and the
nine-element row-major K that ROS does.
"""
values = as_float32(storage)
if values.size >= K_MATRIX:
fx, fy, cx, cy = (float(values[index]) for index in K_INDICES)
elif values.size >= COMPACT:
fx, fy, cx, cy = (float(value) for value in values[:COMPACT])
else:
raise RuntimeError(
f"camera_info holds {values.size} values, "
f"expected {COMPACT} as [fx, fy, cx, cy] or {K_MATRIX} as K",
)
return Intrinsics(fx, fy, cx, cy, _distortion(metadata), _model(metadata))
def from_metadata(metadata: dict) -> Intrinsics | None:
"""Read intrinsics a sender attached to a frame, or None if it attached none."""
try:
fx, fy, cx, cy = (
float(metadata["fx"]),
float(metadata["fy"]),
float(metadata["cx"]),
float(metadata["cy"]),
)
except (KeyError, TypeError, ValueError):
return None
return Intrinsics(fx, fy, cx, cy, _distortion(metadata), _model(metadata))
def _distortion(metadata: dict) -> tuple[float, ...]:
values = metadata.get("distortion") or ()
return tuple(float(value) for value in values)
def _model(metadata: dict) -> str:
return str(metadata.get("distortion_model", ""))
+34 -12
View File
@@ -1,15 +1,17 @@
"""A dora display sink: overlays bounding boxes and text on an image input. """A dora display sink: overlays bounding boxes and text on an image input.
An `image` event decodes the frame and immediately redraws. `bbox` and `text` `image` and `depth` each fill one panel of the window and redraw. `bbox`,
events only update stored state, which the next `image` event draws. `text` and `camera_info` events only update stored state, which the next redraw
draws.
""" """
import argparse import argparse
import os import os
import numpy as np
from dora import Node from dora import Node
from dora_image_view import bbox, decode from dora_image_view import bbox, decode, depth, intrinsics
from dora_image_view.view import View from dora_image_view.view import View
@@ -36,15 +38,20 @@ def main() -> None:
event_id = event["id"] event_id = event["id"]
metadata = event["metadata"] metadata = event["metadata"]
if event_id == "image": # A sender that puts intrinsics on every frame keeps them current even
frame = decode.to_bgr( # if the camera_info message itself was published before this node
event["value"], # started, which is when dora-zed sends it.
metadata.get("encoding", "bgr8").lower(), view.intrinsics = intrinsics.from_metadata(metadata) or view.intrinsics
metadata.get("width"),
metadata.get("height"), panel = _decode_panel(event_id, event["value"], metadata)
)
if view.show(frame): if panel is not None:
name, frame = panel
view.panels[name] = frame
if view.show():
break break
elif event_id == "camera_info":
view.intrinsics = intrinsics.parse(event["value"], metadata)
elif event_id == "bbox": elif event_id == "bbox":
view.bboxes = bbox.parse(event["value"], metadata.get("format", "xyxy").lower()) view.bboxes = bbox.parse(event["value"], metadata.get("format", "xyxy").lower())
elif event_id == "text": elif event_id == "text":
@@ -53,9 +60,24 @@ def main() -> None:
print(f"ignoring unexpected input `{event_id}`") print(f"ignoring unexpected input `{event_id}`")
def _decode_panel(event_id: str, value, metadata: dict) -> tuple[str, np.ndarray] | None:
"""Decode a frame-bearing input into the panel name and frame it fills.
Returns None for an input that carries no frame, which is what tells the
event loop to treat it as overlay state instead.
"""
if event_id == "image":
return "image", decode.to_bgr(value, metadata)
if event_id == "depth":
return "depth", depth.colorize(depth.to_millimetres(value, metadata))
return None
def _parse_args() -> argparse.Namespace: def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="OpenCV plotter: draws text and bounding boxes on an image.", description="OpenCV viewer: image and depth, with bbox and text overlays.",
) )
parser.add_argument( parser.add_argument(
"--name", "--name",
+18
View File
@@ -0,0 +1,18 @@
"""Reading raw dora payloads, independently of what they mean."""
import numpy as np
def as_float32(storage) -> np.ndarray:
"""Read a payload as float32, whether it arrived as floats or as bytes.
A node that sends its payload as a raw byte slice -- which is what a C++
node does -- produces a UInt8 arrow array even though its manifest declares
Float32, so the buffer has to be reinterpreted rather than converted.
"""
buffer = storage.to_numpy()
if buffer.dtype == np.uint8:
return buffer.view(np.float32)
return buffer.astype(np.float32, copy=False)
+70 -8
View File
@@ -1,16 +1,22 @@
"""The display state: the frame, the overlays drawn on it, and the window.""" """The display state: the panels, the overlays drawn on them, and the window."""
from dataclasses import dataclass, field from dataclasses import dataclass, field
import cv2 import cv2
import numpy as np import numpy as np
from dora_image_view.intrinsics import Intrinsics
WINDOW = "Dora Node: dora-image-view" WINDOW = "Dora Node: dora-image-view"
GREEN = (0, 255, 0) GREEN = (0, 255, 0)
WHITE = (255, 255, 255) WHITE = (255, 255, 255)
FONT = cv2.FONT_HERSHEY_SIMPLEX FONT = cv2.FONT_HERSHEY_SIMPLEX
FONT_SCALE = 0.5 FONT_SCALE = 0.5
# Left to right in the composite. Fixed rather than insertion-ordered so a panel
# does not move when a stream drops a frame.
PANELS = ("image", "depth")
@dataclass @dataclass
class Bboxes: class Bboxes:
@@ -23,9 +29,11 @@ class Bboxes:
@dataclass @dataclass
class View: class View:
"""Overlay state, plus the window it is displayed in. """Panel state, plus the window the panels are displayed in.
`headless` suppresses the window; the node still decodes every input. Each panel holds the most recent frame of one stream, as BGR. They are
tiled left to right, so wiring only `depth` shows only depth. `headless`
suppresses the window; the node still decodes every input.
""" """
width: int | None = None width: int | None = None
@@ -33,13 +41,18 @@ class View:
headless: bool = False headless: bool = False
bboxes: Bboxes = field(default_factory=Bboxes) bboxes: Bboxes = field(default_factory=Bboxes)
text: str = "" text: str = ""
panels: dict[str, np.ndarray] = field(default_factory=dict)
intrinsics: Intrinsics | None = None
def show(self, frame: np.ndarray) -> bool: def show(self) -> bool:
"""Draw the overlays on `frame`, resize it, and display it. """Draw the overlays, tile the panels, and display them.
Returns whether the viewer asked to quit by pressing `q`. Returns whether the viewer asked to quit by pressing `q`.
""" """
self._draw(frame) frame = self.compose()
if frame is None:
return False
if self.width and self.height: if self.width and self.height:
frame = cv2.resize(frame, (self.width, self.height)) frame = cv2.resize(frame, (self.width, self.height))
@@ -50,7 +63,49 @@ class View:
cv2.imshow(WINDOW, frame) cv2.imshow(WINDOW, frame)
return cv2.waitKey(1) & 0xFF == ord("q") return cv2.waitKey(1) & 0xFF == ord("q")
def _draw(self, frame: np.ndarray) -> None: def compose(self) -> np.ndarray | None:
"""Tile the populated panels into one frame, tallest panel setting the height."""
panels = [(name, self.panels[name]) for name in PANELS if name in self.panels]
if not panels:
return None
# The boxes are in image pixels, so they are drawn before any scaling.
if "image" in self.panels:
self._draw_bboxes(self.panels["image"])
tallest = max(panel.shape[0] for _, panel in panels)
scaled = [self._label(_scale(panel, tallest), name) for name, panel in panels]
frame = scaled[0] if len(scaled) == 1 else cv2.hconcat(scaled)
# On the composite, not on a panel: the caption describes the frame as a
# whole, and a panel it was drawn on might not be the leftmost one.
cv2.putText(frame, self.text, (20, 20), FONT, FONT_SCALE, WHITE, 1)
# Showing the camera model is the point of receiving it here: a depth
# map read through the wrong intrinsics looks entirely plausible, and
# this is where that becomes visible.
if self.intrinsics is not None:
cv2.putText(frame, str(self.intrinsics), (20, 40), FONT, FONT_SCALE, WHITE, 1)
return frame
def _label(self, panel: np.ndarray, name: str) -> np.ndarray:
"""Name a panel, so two side by side are not read as one picture."""
if len(self.panels) > 1:
cv2.putText(
panel,
name,
(10, panel.shape[0] - 10),
FONT,
FONT_SCALE,
WHITE,
1,
)
return panel
def _draw_bboxes(self, frame: np.ndarray) -> None:
for corners, conf, label in zip( for corners, conf, label in zip(
self.bboxes.corners, self.bboxes.corners,
self.bboxes.conf, self.bboxes.conf,
@@ -69,4 +124,11 @@ class View:
1, 1,
) )
cv2.putText(frame, self.text, (20, 20), FONT, FONT_SCALE, WHITE, 1)
def _scale(panel: np.ndarray, height: int) -> np.ndarray:
"""Match a panel to the composite height, keeping its aspect ratio."""
if panel.shape[0] == height:
return panel
width = max(round(panel.shape[1] * height / panel.shape[0]), 1)
return cv2.resize(panel, (width, height))
-52
View File
@@ -1,52 +0,0 @@
# Manifest for the example source node.
#
# Its reason to exist is as much mechanical as documentary: `dora validate`
# matches a node manifest to a `path:` node by entrypoint, and without a
# manifest here the repository-root dora-node.yml -- whose entrypoint is
# `dora-image-view` -- gets tried against `examples/source.py`, does not match,
# and warns. Under `strict_types: true` that warning fails the run.
#
# Declaring the types here rather than as `output_types:` in dataflow.yml is
# what makes them checked: an inline annotation shadows the manifest, so the
# manifest would be injected but never compared against dora-image-view's.
apiVersion: 1
name: source
namespace: liria
description: Synthetic image, bbox and text source for the dora-image-view example
categories: [debug]
keywords: [example, test, synthetic]
runtime: python
entrypoint: source.py
build: pip install .
dora: ">=1.0"
inputs:
tick:
required: true
description: Timer tick; one frame is emitted per tick.
outputs:
image:
type: std/core/v1/UInt8
description: >-
Animated bgr8 frame as a flat UInt8 buffer, with metadata
{width, height, encoding}.
bbox:
description: >-
The box around the moving square: an Arrow struct {bbox, conf, labels}
with metadata {format: xyxy}. Untyped to match the dora-image-view input.
text:
type: std/core/v1/String
description: A fixed caption.
env:
SOURCE_WIDTH:
type: int
description: Frame width in pixels. Defaults to 640.
SOURCE_HEIGHT:
type: int
description: Frame height in pixels. Defaults to 480.
SOURCE_FRAMES:
type: int
description: Stop after this many frames. 0, the default, runs until the viewer quits.
-73
View File
@@ -1,73 +0,0 @@
"""Synthetic source node: this repository's stand-in for a camera and a detector.
Emits an animated frame, the box around the square moving in it, and a caption --
the three inputs dora-image-view draws -- so the example dataflow needs no camera, no
detector and no network.
"""
import os
import numpy as np
import pyarrow as pa
from dora import Node
WIDTH = int(os.getenv("SOURCE_WIDTH", "640"))
HEIGHT = int(os.getenv("SOURCE_HEIGHT", "480"))
# 0 runs until the viewer quits; a positive count is what lets CI run the
# dataflow to completion instead of killing it on a timeout.
FRAMES = int(os.getenv("SOURCE_FRAMES", "0"))
BOX = 96
SPEED = 6
CAPTION = "dora-image-view synthetic source"
def main() -> None:
"""Send one image, bbox and text per tick."""
node = Node("source")
sent = 0
for event in node:
if event["type"] != "INPUT":
continue
frame, corners = render(sent)
node.send_output(
"image",
pa.array(frame.ravel()),
{"width": WIDTH, "height": HEIGHT, "encoding": "bgr8"},
)
node.send_output(
"bbox",
pa.array([{
"bbox": corners,
"conf": np.array([0.99]),
"labels": np.array(["square"]),
}]),
{"format": "xyxy"},
)
node.send_output("text", pa.array([CAPTION]))
sent += 1
if FRAMES and sent >= FRAMES:
break
def render(index: int) -> tuple[np.ndarray, np.ndarray]:
"""Draw frame `index` and return it with the xyxy corners of the square in it."""
frame = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8)
frame[:, :, 0] = np.linspace(40, 140, HEIGHT, dtype=np.uint8)[:, None]
travel = WIDTH - BOX
# A triangle wave over `index`: the square starts at the left edge, runs to
# the right edge, and comes back.
left = travel - abs((index * SPEED) % (2 * travel) - travel)
top = (HEIGHT - BOX) // 2
frame[top : top + BOX, left : left + BOX] = (60, 200, 255)
corners = np.array([left, top, left + BOX, top + BOX], dtype=np.float64)
return frame, corners
if __name__ == "__main__":
main()
+18 -2
View File
@@ -24,7 +24,11 @@ dependencies = [
dora-image-view = "dora_image_view.main:main" dora-image-view = "dora_image_view.main:main"
[dependency-groups] [dependency-groups]
dev = ["pytest >= 8.1", "ruff >= 0.9"] dev = [
"pymarkdownlnt>=0.9.40",
"pytest >= 8.1",
"ruff >= 0.9",
]
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
@@ -33,12 +37,24 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["dora_image_view"] packages = ["dora_image_view"]
[tool.pytest.ini_options]
# Only this repository's tests. `dora run` vendors a `git:` node's source into
# git/ inside the checkout, and pytest would otherwise collect that node's tests
# too -- reporting another project's results as this one's, and going red when
# they fail.
testpaths = ["tests"]
[tool.ruff.lint] [tool.ruff.lint]
extend-select = ["D", "I", "N", "RET", "UP"] extend-select = ["D", "I", "N", "RET", "UP"]
[tool.ruff.lint.pydocstyle] [tool.ruff.lint.pydocstyle]
convention = "pep257" convention = "numpy"
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
# Test names are the documentation here; a docstring would only repeat them. # Test names are the documentation here; a docstring would only repeat them.
"tests/*" = ["D1"] "tests/*" = ["D1"]
[tool.pymarkdown]
# The prose here wraps near ruff's 88; 100 leaves room for a long link or table
# row without inviting unwrapped paragraphs.
plugins.md013.line_length = 100
+131
View File
@@ -0,0 +1,131 @@
"""Synthetic source node: the test's stand-in for a camera and a detector.
Emits an animated frame, a depth map of the same scene, the intrinsics, the box
around the square moving in it, and a caption -- every input dora-image-view draws --
so dataflow.yml needs no camera, no detector and no network.
This is a test fixture, not an example of how to wire the node; see the README for
that. It is a dora node rather than a pytest case because what it exists to exercise
is the wiring and the event loop, which only a running dataflow covers. pytest does
not collect it: the filename does not start with `test_`.
The depth map describes the same scene as the image: a flat back wall with the square
standing in front of it. `camera_info` is published once, on the first tick, the way a
real camera publishes it once per session -- which is exactly the case the viewer has
to cope with by reading intrinsics from frame metadata as well.
"""
import os
import numpy as np
import pyarrow as pa
from dora import Node
from dora_image_view.decode import IMAGE_TYPE
WIDTH = int(os.getenv("SOURCE_WIDTH", "640"))
HEIGHT = int(os.getenv("SOURCE_HEIGHT", "480"))
# 0 runs until the viewer quits; a positive count is what lets CI run the
# dataflow to completion instead of killing it on a timeout.
FRAMES = int(os.getenv("SOURCE_FRAMES", "0"))
BOX = 96
SPEED = 6
CAPTION = "dora-image-view synthetic source"
WALL_MM = 2000.0
SQUARE_MM = 800.0
# A plausible pinhole for a frame this size.
FX = FY = float(WIDTH)
CX, CY = WIDTH / 2, HEIGHT / 2
DISTORTION = [0.0, 0.0, 0.0, 0.0, 0.0]
# Attached to every frame as well as published once, which is what dora-zed does
# and what lets a viewer that started late still learn the camera model.
INTRINSICS_METADATA = {"fx": FX, "fy": FY, "cx": CX, "cy": CY, "distortion": DISTORTION}
def main() -> None:
"""Send one image, depth map, bbox and text per tick, and camera_info once."""
node = Node("source")
sent = 0
for event in node:
if event["type"] != "INPUT":
continue
if sent == 0:
node.send_output(
"camera_info",
pa.array(np.array([FX, FY, CX, CY], dtype=np.float32)),
{"shape": [4], "dtype": "float32", "layout": "C", "distortion": DISTORTION},
)
frame, corners = render(sent)
node.send_output("image", image_struct(frame, "bgr8"), INTRINSICS_METADATA)
node.send_output(
"bbox",
pa.array([{
"bbox": corners,
"conf": np.array([0.99]),
"labels": np.array(["square"]),
}]),
{"format": "xyxy"},
)
depth = render_depth(corners)
node.send_output(
"depth",
image_struct(depth, "32FC1"),
{"units": "mm", **INTRINSICS_METADATA},
)
node.send_output("text", pa.array([CAPTION]))
sent += 1
if FRAMES and sent >= FRAMES:
break
def image_struct(frame: np.ndarray, encoding: str) -> pa.Array:
"""Pack a frame as std/media/v1/Image.
The struct carries the width, height and encoding a receiver needs, so none
of it has to travel as message metadata. `frame` may be any dtype: the
encoding names how to read the bytes back.
"""
return pa.array(
[{
"width": frame.shape[1],
"height": frame.shape[0],
"encoding": encoding,
"data": frame.tobytes(),
}],
type=IMAGE_TYPE,
)
def render(index: int) -> tuple[np.ndarray, np.ndarray]:
"""Draw frame `index` and return it with the xyxy corners of the square in it."""
frame = np.zeros((HEIGHT, WIDTH, 3), dtype=np.uint8)
frame[:, :, 0] = np.linspace(40, 140, HEIGHT, dtype=np.uint8)[:, None]
travel = WIDTH - BOX
# A triangle wave over `index`: the square starts at the left edge, runs to
# the right edge, and comes back.
left = travel - abs((index * SPEED) % (2 * travel) - travel)
top = (HEIGHT - BOX) // 2
frame[top : top + BOX, left : left + BOX] = (60, 200, 255)
corners = np.array([left, top, left + BOX, top + BOX], dtype=np.float64)
return frame, corners
def render_depth(corners: np.ndarray) -> np.ndarray:
"""Depth of the scene in `render`: a flat wall with the square in front of it."""
depth = np.full((HEIGHT, WIDTH), WALL_MM, dtype=np.float32)
left, top, right, bottom = (int(value) for value in corners)
depth[top:bottom, left:right] = SQUARE_MM
return depth
if __name__ == "__main__":
main()
+252 -13
View File
@@ -4,15 +4,21 @@ import io
import cv2 import cv2
import numpy as np import numpy as np
import pyarrow as pa
import pytest import pytest
from PIL import Image from PIL import Image
from dora_image_view import bbox, decode from dora_image_view import bbox, decode, depth, intrinsics
from dora_image_view.view import Bboxes, View from dora_image_view.view import Bboxes, View
WIDTH, HEIGHT = 12, 8 WIDTH, HEIGHT = 12, 8
def _meta(encoding: str, width: int | None = None, height: int | None = None) -> dict:
"""Build message metadata as a node sending a flat buffer attaches it."""
return {"encoding": encoding, "width": width, "height": height}
class FakeStorage: class FakeStorage:
"""Stands in for the Arrow array dora hands to the node.""" """Stands in for the Arrow array dora hands to the node."""
@@ -30,7 +36,7 @@ def frame() -> np.ndarray:
def test_bgr8_round_trips_unchanged(frame): def test_bgr8_round_trips_unchanged(frame):
decoded = decode.to_bgr(FakeStorage(frame.ravel()), "bgr8", WIDTH, HEIGHT) decoded = decode.to_bgr(FakeStorage(frame.ravel()), _meta("bgr8", WIDTH, HEIGHT))
np.testing.assert_array_equal(decoded, frame) np.testing.assert_array_equal(decoded, frame)
@@ -38,7 +44,7 @@ def test_bgr8_round_trips_unchanged(frame):
def test_rgb8_round_trips_unchanged(frame): def test_rgb8_round_trips_unchanged(frame):
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
decoded = decode.to_bgr(FakeStorage(rgb.ravel()), "rgb8", WIDTH, HEIGHT) decoded = decode.to_bgr(FakeStorage(rgb.ravel()), _meta("rgb8", WIDTH, HEIGHT))
np.testing.assert_array_equal(decoded, frame) np.testing.assert_array_equal(decoded, frame)
@@ -53,7 +59,7 @@ def test_self_describing_encodings_need_no_dimensions(frame, suffix, encoding):
ok, buffer = cv2.imencode(suffix, frame) ok, buffer = cv2.imencode(suffix, frame)
assert ok assert ok
decoded = decode.to_bgr(FakeStorage(buffer.ravel()), encoding, None, None) decoded = decode.to_bgr(FakeStorage(buffer.ravel()), _meta(encoding))
assert decoded.shape == (HEIGHT, WIDTH, 3) assert decoded.shape == (HEIGHT, WIDTH, 3)
@@ -61,7 +67,7 @@ def test_self_describing_encodings_need_no_dimensions(frame, suffix, encoding):
def test_yuv420_is_decoded(frame): def test_yuv420_is_decoded(frame):
planes = cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420) planes = cv2.cvtColor(frame, cv2.COLOR_BGR2YUV_I420)
decoded = decode.to_bgr(FakeStorage(planes.ravel()), "yuv420", WIDTH, HEIGHT) decoded = decode.to_bgr(FakeStorage(planes.ravel()), _meta("yuv420", WIDTH, HEIGHT))
assert decoded.shape == (HEIGHT, WIDTH, 3) assert decoded.shape == (HEIGHT, WIDTH, 3)
@@ -71,7 +77,7 @@ def test_avif_is_decoded(frame):
Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)).save(buffer, format="AVIF") Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)).save(buffer, format="AVIF")
payload = np.frombuffer(buffer.getvalue(), dtype=np.uint8) payload = np.frombuffer(buffer.getvalue(), dtype=np.uint8)
decoded = decode.to_bgr(FakeStorage(payload), "avif", None, None) decoded = decode.to_bgr(FakeStorage(payload), _meta("avif"))
assert decoded.shape == (HEIGHT, WIDTH, 3) assert decoded.shape == (HEIGHT, WIDTH, 3)
@@ -79,7 +85,7 @@ def test_avif_is_decoded(frame):
def test_decoded_frame_is_writable(): def test_decoded_frame_is_writable():
storage = FakeStorage(np.arange(2 * 3 * 3, dtype=np.uint8)) storage = FakeStorage(np.arange(2 * 3 * 3, dtype=np.uint8))
decoded = decode.to_bgr(storage, "bgr8", width=3, height=2) decoded = decode.to_bgr(storage, _meta("bgr8", 3, 2))
# The frame is annotated in place, so it must not be a read-only view. # The frame is annotated in place, so it must not be a read-only view.
decoded[0, 0] = 0 decoded[0, 0] = 0
@@ -88,24 +94,22 @@ def test_decoded_frame_is_writable():
def test_raw_encodings_need_dimensions(): def test_raw_encodings_need_dimensions():
storage = FakeStorage(np.zeros(18, dtype=np.uint8)) storage = FakeStorage(np.zeros(18, dtype=np.uint8))
with pytest.raises(RuntimeError, match="width"): with pytest.raises(RuntimeError, match="width"):
decode.to_bgr(storage, "bgr8", width=None, height=None) decode.to_bgr(storage, _meta("bgr8"))
def test_short_payload_is_rejected(): def test_short_payload_is_rejected():
storage = FakeStorage(np.zeros(4, dtype=np.uint8)) storage = FakeStorage(np.zeros(4, dtype=np.uint8))
with pytest.raises(RuntimeError, match="expected at least"): with pytest.raises(RuntimeError, match="expected at least"):
decode.to_bgr(storage, "bgr8", width=3, height=2) decode.to_bgr(storage, _meta("bgr8", 3, 2))
def test_unsupported_encoding_is_rejected(): def test_unsupported_encoding_is_rejected():
storage = FakeStorage(np.zeros(4, dtype=np.uint8)) storage = FakeStorage(np.zeros(4, dtype=np.uint8))
with pytest.raises(RuntimeError, match="unsupported image encoding"): with pytest.raises(RuntimeError, match="unsupported image encoding"):
decode.to_bgr(storage, "tiff", width=None, height=None) decode.to_bgr(storage, _meta("tiff"))
def test_xywh_is_converted_to_corners(): def test_xywh_is_converted_to_corners():
import pyarrow as pa
value = pa.array( value = pa.array(
[ [
{ {
@@ -130,6 +134,241 @@ def test_headless_show_draws_without_a_window():
labels=np.array(["cat"]), labels=np.array(["cat"]),
) )
frame = np.zeros((32, 32, 3), dtype=np.uint8) frame = np.zeros((32, 32, 3), dtype=np.uint8)
view.panels["image"] = frame
assert view.show(frame) is False assert view.show() is False
assert frame.any(), "the overlays should have marked the frame" assert frame.any(), "the overlays should have marked the frame"
def test_show_with_no_panels_is_a_no_op():
assert View(headless=True).show() is False
def test_panels_are_tiled_left_to_right_at_a_common_height():
view = View(headless=True)
view.panels["image"] = np.zeros((20, 10, 3), dtype=np.uint8)
# Half the height and half the width: scaling to the common height should
# double both, so it contributes 20 columns, not 10.
view.panels["depth"] = np.zeros((10, 10, 3), dtype=np.uint8)
composite = view.compose()
assert composite.shape[0] == 20
assert composite.shape[1] == 10 + 20
def test_depth_decodes_millimetres_and_reshapes():
values = np.array([100.0, 200.0, 300.0, 400.0], dtype=np.float32)
decoded = depth.to_millimetres(FakeStorage(values), {"shape": [2, 2]})
np.testing.assert_allclose(decoded, [[100.0, 200.0], [300.0, 400.0]])
def test_depth_reinterprets_a_float32_payload_sent_as_bytes():
values = np.array([1500.0, 2500.0], dtype=np.float32)
decoded = depth.to_millimetres(
FakeStorage(values.view(np.uint8)),
{"shape": [1, 2], "dtype": "float32"},
)
np.testing.assert_allclose(decoded, [[1500.0, 2500.0]])
def test_depth_converts_metres_to_millimetres():
decoded = depth.to_millimetres(
FakeStorage(np.array([1.5, 2.0], dtype=np.float32)),
{"shape": [1, 2], "units": "m"},
)
np.testing.assert_allclose(decoded, [[1500.0, 2000.0]])
def test_depth_normalises_non_finite_values_to_missing():
values = np.array([np.inf, np.nan, 500.0, 0.0], dtype=np.float32)
decoded = depth.to_millimetres(FakeStorage(values), {"shape": [2, 2]})
np.testing.assert_allclose(decoded, [[0.0, 0.0], [500.0, 0.0]])
def test_depth_without_a_shape_is_an_error():
with pytest.raises(RuntimeError, match="shape"):
depth.to_millimetres(FakeStorage(np.zeros(4, dtype=np.float32)), {})
def test_colorize_leaves_missing_depth_black_and_colours_the_rest():
depth_mm = np.array([[0.0, 500.0], [1000.0, 1500.0]], dtype=np.float32)
frame = depth.colorize(depth_mm)
assert frame.shape == (2, 2, 3)
np.testing.assert_array_equal(frame[0, 0], [0, 0, 0])
assert frame[1, 1].any(), "valid depth should be coloured"
def test_depth_range_is_pinned_by_the_environment(monkeypatch):
monkeypatch.setenv("VIEW_DEPTH_MIN_MM", "200")
monkeypatch.setenv("VIEW_DEPTH_MAX_MM", "600")
assert depth.resolve_range(np.array([[1000.0]], dtype=np.float32)) == (200.0, 600.0)
def test_one_pinned_end_leaves_the_other_auto_ranged(monkeypatch):
monkeypatch.setenv("VIEW_DEPTH_MIN_MM", "0")
low, high = depth.resolve_range(np.array([[100.0, 900.0]], dtype=np.float32))
assert low == 0.0
assert high > 0.0
def test_depth_range_of_an_empty_frame_does_not_divide_by_zero():
low, high = depth.resolve_range(np.zeros((2, 2), dtype=np.float32))
assert high > low
def test_camera_info_reads_the_compact_four_value_form():
values = np.array([600.0, 601.0, 320.0, 240.0], dtype=np.float32)
model = intrinsics.parse(FakeStorage(values), {"distortion": [0.1, 0.2]})
assert (model.fx, model.fy, model.cx, model.cy) == (600.0, 601.0, 320.0, 240.0)
assert model.distortion == (0.1, 0.2)
def test_camera_info_reads_a_ros_k_matrix():
# Row-major K: [fx, 0, cx, 0, fy, cy, 0, 0, 1].
k = np.array(
[600.0, 0.0, 320.0, 0.0, 601.0, 240.0, 0.0, 0.0, 1.0],
dtype=np.float32,
)
model = intrinsics.parse(FakeStorage(k), {})
assert (model.fx, model.fy, model.cx, model.cy) == (600.0, 601.0, 320.0, 240.0)
def test_camera_info_reinterprets_a_payload_sent_as_bytes():
values = np.array([600.0, 601.0, 320.0, 240.0], dtype=np.float32)
model = intrinsics.parse(FakeStorage(values.view(np.uint8)), {})
assert model.fx == 600.0
assert model.cy == 240.0
def test_camera_info_that_is_too_short_is_an_error():
with pytest.raises(RuntimeError, match="camera_info"):
intrinsics.parse(FakeStorage(np.zeros(3, dtype=np.float32)), {})
def test_intrinsics_are_read_from_frame_metadata():
model = intrinsics.from_metadata(
{"fx": 600.0, "fy": 601.0, "cx": 320.0, "cy": 240.0},
)
assert model is not None
assert (model.fx, model.cy) == (600.0, 240.0)
def test_frame_metadata_without_intrinsics_yields_none():
assert intrinsics.from_metadata({"width": 640, "height": 480}) is None
def test_the_camera_model_is_drawn_on_the_composite():
view = View(headless=True)
view.panels["image"] = np.zeros((40, 80, 3), dtype=np.uint8)
without = view.compose().copy()
view.intrinsics = intrinsics.Intrinsics(600.0, 601.0, 320.0, 240.0)
with_model = view.compose()
assert not np.array_equal(without, with_model), "the model should be drawn"
def _image_struct(frame: np.ndarray, encoding: str = "bgr8"):
"""Build a payload shaped like std/media/v1/Image."""
return pa.array(
[{
"width": frame.shape[1],
"height": frame.shape[0],
"encoding": encoding,
"data": frame.tobytes(),
}],
type=decode.IMAGE_TYPE,
)
def test_image_struct_is_decoded(frame):
# No metadata at all: the struct describes itself.
decoded = decode.to_bgr(_image_struct(frame), {})
np.testing.assert_array_equal(decoded, frame)
def test_image_struct_wins_over_message_metadata(frame):
# Metadata disagreeing with the struct must not be consulted.
decoded = decode.to_bgr(_image_struct(frame), _meta("rgb8", 1, 1))
np.testing.assert_array_equal(decoded, frame)
def test_image_struct_carrying_an_encoded_frame_is_decoded(frame):
ok, encoded = cv2.imencode(".png", frame)
assert ok
payload = np.frombuffer(encoded.tobytes(), dtype=np.uint8).reshape(1, -1)
decoded = decode.to_bgr(_image_struct(payload, "png"), {})
np.testing.assert_array_equal(decoded, frame)
def test_a_decoded_image_struct_frame_is_writable(frame):
decoded = decode.to_bgr(_image_struct(frame), {})
decoded[0, 0] = (1, 2, 3)
assert tuple(decoded[0, 0]) == (1, 2, 3)
def test_depth_is_decoded_from_an_image_struct():
values = np.array([[100.0, 200.0], [300.0, 400.0]], dtype=np.float32)
# No shape metadata: the struct carries its own dimensions.
decoded = depth.to_millimetres(_image_struct(values, "32FC1"), {})
np.testing.assert_allclose(decoded, values)
def test_depth_image_struct_reads_a_uint16_encoding():
values = np.array([[1500, 2500]], dtype=np.uint16)
decoded = depth.to_millimetres(_image_struct(values, "16UC1"), {})
np.testing.assert_allclose(decoded, [[1500.0, 2500.0]])
def test_depth_image_struct_still_honours_the_units_metadata():
values = np.array([[1.5, 2.0]], dtype=np.float32)
decoded = depth.to_millimetres(_image_struct(values, "32FC1"), {"units": "m"})
np.testing.assert_allclose(decoded, [[1500.0, 2000.0]])
def test_depth_image_struct_with_an_unsupported_encoding_is_rejected():
values = np.zeros((1, 2), dtype=np.float32)
with pytest.raises(RuntimeError, match="unsupported depth encoding"):
depth.to_millimetres(_image_struct(values, "rgb8"), {})
def test_a_decoded_depth_struct_is_writable():
values = np.array([[100.0, 0.0]], dtype=np.float32)
decoded = depth.to_millimetres(_image_struct(values, "32FC1"), {})
decoded[0, 0] = 42.0
assert decoded[0, 0] == 42.0
Generated
+289
View File
@@ -6,6 +6,34 @@ resolution-markers = [
"python_full_version < '3.12'", "python_full_version < '3.12'",
] ]
[[package]]
name = "application-file-scanner"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "py-walk" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ea/22/e872546d298103527380955f51191ff87cd178e6aac62bb87de73c3e074f/application_file_scanner-0.6.4.tar.gz", hash = "sha256:581c48c5017345747be7f49507da84fec36d1f7b4f67003e9fbaf2f0bc6a3f66", size = 28540, upload-time = "2026-01-19T23:32:15.695Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/83/e9a5a14c1b6c20ad44abcdb7bb2f34d860aa4f4ee64c526247066b6c42fb/application_file_scanner-0.6.4-py3-none-any.whl", hash = "sha256:49c211c60f1932812477facc38701d58037b07b031fdb6a6061fdee3fd53e35f", size = 15445, upload-time = "2026-01-19T23:32:14.4Z" },
]
[[package]]
name = "application-properties"
version = "0.9.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyjson5" },
{ name = "pyyaml" },
{ name = "tomli" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/bb/321da0e373416080801d05b8dd9c0a8da77fe305c697adf8c025470ce170/application_properties-0.9.3.tar.gz", hash = "sha256:7dc7d8f23d11e539427e7b8e3afa70353e159c16f703bad6dd082cc6cdfeeaa8", size = 43304, upload-time = "2026-06-02T03:52:07.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/72/3ac1bda73e9c95444b1e13a0de73e847048a776e5ac5b3977c37f04167f9/application_properties-0.9.3-py3-none-any.whl", hash = "sha256:32dbd62b3fb657ec1c7fdc352590463c2ffd14950ffd258a5c091e8798d8aec0", size = 24595, upload-time = "2026-06-02T03:52:06.416Z" },
]
[[package]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" version = "0.4.6"
@@ -15,6 +43,19 @@ 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" }, { 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 = "columnar"
version = "1.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "toolz" },
{ name = "wcwidth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/0d/a0b2fd781050d29c9df64ac6df30b5f18b775724b79779f56fc5a8298fe9/Columnar-1.4.1.tar.gz", hash = "sha256:c3cb57273333b2ff9cfaafc86f09307419330c97faa88dcfe23df05e6fbb9c72", size = 11386, upload-time = "2021-12-27T21:58:56.123Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/00/a17a5657bf090b9dffdb310ac273c553a38f9252f60224da9fe62d9b60e9/Columnar-1.4.1-py3-none-any.whl", hash = "sha256:8efb692a7e6ca07dcc8f4ea889960421331a5dffa8e5af81f0a67ad8ea1fc798", size = 11845, upload-time = "2021-12-27T21:58:54.388Z" },
]
[[package]] [[package]]
name = "dora-image-view" name = "dora-image-view"
version = "0.1.0" version = "0.1.0"
@@ -31,6 +72,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "pymarkdownlnt" },
{ name = "pytest" }, { name = "pytest" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -47,6 +89,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "pymarkdownlnt", specifier = ">=0.9.40" },
{ name = "pytest", specifier = ">=8.1" }, { name = "pytest", specifier = ">=8.1" },
{ name = "ruff", specifier = ">=0.9" }, { name = "ruff", specifier = ">=0.9" },
] ]
@@ -427,6 +470,18 @@ 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" }, { 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 = "py-walk"
version = "0.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sly" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/b5/e2f3fab1e11d4089b1c3dfd72175fdb2408ff8028e01bdb0d308923609bb/py_walk-0.3.3.tar.gz", hash = "sha256:a1b28d6079f27203fa3098b69a98572675b3ff5bd02286c43e6dacd66615f879", size = 1815727, upload-time = "2024-10-26T14:30:39.421Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/38/56b67abdbf6797475dfe2f62d391b4a6ead851c76acbaf07e118e53651b6/py_walk-0.3.3-py3-none-any.whl", hash = "sha256:238fc018165138021ce0bfd9c351cdc473d3120ccc5534df35611b92608c94d5", size = 14537, upload-time = "2024-10-26T14:30:38.06Z" },
]
[[package]] [[package]]
name = "pyarrow" name = "pyarrow"
version = "25.0.1" version = "25.0.1"
@@ -479,6 +534,150 @@ 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" }, { 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 = "pyjson5"
version = "2.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/9a/3db19560e968d6e85b2a4ddf4b949c6ebf9dd1dcfb5a9f37736f8adeb927/pyjson5-2.0.1.tar.gz", hash = "sha256:a5b0e322e847b198a50d8a1ef16d6b2b19129644dc018d76773e81ef1487ca39", size = 352242, upload-time = "2026-05-15T16:12:54.931Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/75/bd6b2297beef9730e7f265679de7105fbc9a49dd85dff28563851b9c679a/pyjson5-2.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1727cebec0ca73597188057bb826cd96c8503ced9b22a9cc73501f2e68d91ea0", size = 297438, upload-time = "2026-05-15T16:05:33.756Z" },
{ url = "https://files.pythonhosted.org/packages/ed/18/663ab5ad9aff987889440a2cb6f0415d334850444aba759211aa3eec10aa/pyjson5-2.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4833d46e8dc4bcba54db038305c8a7455d05cf8533d52a2297abff634cf69fa3", size = 155300, upload-time = "2026-05-15T16:05:35.039Z" },
{ url = "https://files.pythonhosted.org/packages/1d/57/9f68629021052044cf46bb89652a442f14505f3087d786ac4b18268235b9/pyjson5-2.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d73d09a5cad4e3325bd6737697d649a4594c4fde8df8eb784ed795dea845435f", size = 151680, upload-time = "2026-05-15T16:05:36.26Z" },
{ url = "https://files.pythonhosted.org/packages/ac/06/21da19b96b3215d266bf4662147d5ef70f65daf9bd4e82305ae61584908a/pyjson5-2.0.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ac66d237c78b9ecda45506225c7eed648d2e6750532e15e1ac2052acf3e2d444", size = 189073, upload-time = "2026-05-15T16:05:37.691Z" },
{ url = "https://files.pythonhosted.org/packages/bc/5b/48ec81dcee70f8d188ac04499207e3c8a893ec306a1ba4db5c38988f98c1/pyjson5-2.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef1c0fa8af1cac361b2bbb030be942ead42bf872f279bc8e2666a8d5d6e7e7ee", size = 171985, upload-time = "2026-05-15T16:05:39.852Z" },
{ url = "https://files.pythonhosted.org/packages/f3/89/d79ac6cd317b3488e38f225c2cd8315fa54145faf939295b853bae70fe3c/pyjson5-2.0.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2e3ade7f34e1ad2b270fdaa6a11c31c7cb5f07379a422e9a714791d345e4bf", size = 169481, upload-time = "2026-05-15T16:05:41.751Z" },
{ url = "https://files.pythonhosted.org/packages/3f/db/e15cc07bf375a59d102536b233171eec621d1b8e9412596c0ef656828baf/pyjson5-2.0.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:55780f15c6d00a8d7847c7291c1a09492beab61460cf1b4725177b189cfaab65", size = 191706, upload-time = "2026-05-15T16:05:43.54Z" },
{ url = "https://files.pythonhosted.org/packages/4f/64/3c1c2b350071ccbfcc3a8e4eae66af960cc3193008cf47846cc1c775aebb/pyjson5-2.0.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f80ec4e18b2a8d42832f843b48cf06e77240c8e9cb046ea7e10752e8135ce87", size = 195462, upload-time = "2026-05-15T16:05:45.164Z" },
{ url = "https://files.pythonhosted.org/packages/19/87/e0774939571faad2609fce9865ffd7a30b648f828fc2ca5acc364411f0ce/pyjson5-2.0.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94a645be2b846a6a1155296047176099b6ce987030fb7723208d364cb1b73eed", size = 183993, upload-time = "2026-05-15T16:05:46.952Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a2/ce59cab1733bed125193765f8d55ff316f151703eeb5c264b63200c4b26a/pyjson5-2.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2c34ddc5a211ecebcfadeb9c698abd047245bac83ab7340811b77fd41aab76e4", size = 182419, upload-time = "2026-05-15T16:05:48.351Z" },
{ url = "https://files.pythonhosted.org/packages/4f/8d/cf77e9193cbff64a8488fa005c62544d102fcc7f8d233932b8c026adf00a/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3614a579966cc22bc4e83599a786bd875e2c1de4baf71898763623f670df69a2", size = 1153689, upload-time = "2026-05-15T16:05:50.295Z" },
{ url = "https://files.pythonhosted.org/packages/27/d3/fb75865e713871e0fbb2c6c46a1569365f8778c59601939432dcf540b9d4/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1925fa0b53bf722d6d263e1eeafd6c009dd4dc975bc481e03786497b2c25eb08", size = 1013811, upload-time = "2026-05-15T16:05:52.246Z" },
{ url = "https://files.pythonhosted.org/packages/26/7f/678c7a3ca28439558f42e78560d5e8096918e5fa1995edfc23702cb10a90/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:497b4d61617db4498a20b47827939034d9234c695d44df7dc907ec455068070e", size = 1327361, upload-time = "2026-05-15T16:05:54.035Z" },
{ url = "https://files.pythonhosted.org/packages/bd/86/f4b57480f00d546031f6ec6dda17780f996cceb1adf02fed586d2d985aa7/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d8ca0d3e4fc92dd20ae76d067ae692cac35161f83f0712c5b20d8f7ba73730b9", size = 1251975, upload-time = "2026-05-15T16:05:56.082Z" },
{ url = "https://files.pythonhosted.org/packages/bb/72/27be9cdb773cdeb334d423d57cc68be30c280df3d3b163c68b7a963d4e50/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6899d9e22660862fb9baf3eb5ac9b2887eb478b18d14f95de94feffed5ce0558", size = 1188190, upload-time = "2026-05-15T16:05:57.841Z" },
{ url = "https://files.pythonhosted.org/packages/cf/59/c9672530a00d433e5411440940d2454d39b19fde29c69eab064af136a157/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4671741493245604579cd9a9c044fd12341a0a61cf0fda8fb844812e29a1a488", size = 1367103, upload-time = "2026-05-15T16:05:59.577Z" },
{ url = "https://files.pythonhosted.org/packages/b3/14/356b2a67f1d6d062df8345c98b1337556f75c18ca0f9f32921d2a28e1946/pyjson5-2.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1f5ae05506fc86bbe5716ec102cd023d20d2051f43f4eb3a741b557616c3245d", size = 1217145, upload-time = "2026-05-15T16:06:01.671Z" },
{ url = "https://files.pythonhosted.org/packages/93/ca/dbf1f6f20cd54be3588b890648f46c1a0db111c7c988d3325e31c14f3510/pyjson5-2.0.1-cp311-cp311-win32.whl", hash = "sha256:90ef60fe98b6d0eaef3a6cdc81a6b1c6acb135a061bc695c00f3b6fdb86f3f90", size = 114798, upload-time = "2026-05-15T16:06:03.289Z" },
{ url = "https://files.pythonhosted.org/packages/48/87/933712d67031be67cf7c0298d655026b46b30539144f9f12f9084d80b03b/pyjson5-2.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9f5d62dfe22810a3ad2252c1b0d7f89b0673255263410d999d88bfd6f46b0ca8", size = 134709, upload-time = "2026-05-15T16:06:04.683Z" },
{ url = "https://files.pythonhosted.org/packages/c4/77/ce42988444b3e567d2b949970f96949e61aadbe535ceacb12afc2570b553/pyjson5-2.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:25ee8af17c19d0a31adc3672adc9c92429130e171e6fbe374468519892fd218b", size = 117230, upload-time = "2026-05-15T16:06:06.173Z" },
{ url = "https://files.pythonhosted.org/packages/68/41/be622b742fe4749fefa9d8a923b1cf5e51a1e8e4b4930fa8ea8c531b243d/pyjson5-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ee62394660d54e76ff8a968c8194b252be23d184f05a00238d4def9624454ea6", size = 302636, upload-time = "2026-05-15T16:06:08.006Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e2/50378c3bec9164c1bb983ca15ed59f8be498981b790a90d1410a1016ffe2/pyjson5-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:58dfa4cf5c327e14c530b16c23e812c021fe53b19e6f9cdfce257d8c02244895", size = 158588, upload-time = "2026-05-15T16:06:09.684Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7d/5de0f82c144b6fc2fa2e3fde5517257733a91d24734b5d2415b7e991e8fd/pyjson5-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:07cb90a31f962a6de9e49592a306046ee015da73510c6d215e9481a2a21c9ac2", size = 153741, upload-time = "2026-05-15T16:06:11.114Z" },
{ url = "https://files.pythonhosted.org/packages/f5/ac/174ca02e5f3ed8245dc48247e534880bbec1528c0c4eaa768fafa9417dda/pyjson5-2.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ef4026a90a68cd2bcb64d89ca064a1615b60a61c7694714e2b8ef30434f365b5", size = 185450, upload-time = "2026-05-15T16:06:12.883Z" },
{ url = "https://files.pythonhosted.org/packages/8f/0d/bfc553176b7e109b72f23697ab1fd1a77d7fd5c6626d6e23936f13d5884e/pyjson5-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04b4929a7adf0e0720da92ac54c92d408e8057158be3ccc34da17c7767a9af78", size = 163331, upload-time = "2026-05-15T16:06:14.314Z" },
{ url = "https://files.pythonhosted.org/packages/b3/05/b0e67106f78c8ea5ffcf6ce0b8421f38a04d5f37ea88ac70123199755bcb/pyjson5-2.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e889b039d6bffda9a6b8d822073bbdb4a60166af22f075e7faa0c03c2dfcf17b", size = 166429, upload-time = "2026-05-15T16:06:16.076Z" },
{ url = "https://files.pythonhosted.org/packages/81/3c/2366f8ecf053434470eafe1f120f2e235d13015fc50d7f06e55e39b550ab/pyjson5-2.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a08e99d0a33463728fb016f5eec680504499c4be280dac016de2da70ec55b0db", size = 180499, upload-time = "2026-05-15T16:06:17.544Z" },
{ url = "https://files.pythonhosted.org/packages/76/65/9a70876d4dd09ce17678024266406b94b8f1edf9db6c2e3bf76cfa653a13/pyjson5-2.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:035c094feb6a9d4312183f9f34228d976102eefb971e9e3dd972d8c7900a466a", size = 187469, upload-time = "2026-05-15T16:06:19.212Z" },
{ url = "https://files.pythonhosted.org/packages/5b/43/0c0bcbf9a9ef7fdcd49dc0992a3a206244c2c2baa7b409932464cf26f1a5/pyjson5-2.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d178025a5317db44663dc87aab90f34674991b3da40dbedc3c108e7b03b6466", size = 176032, upload-time = "2026-05-15T16:06:21.153Z" },
{ url = "https://files.pythonhosted.org/packages/82/2c/32213674010c44db265d45e977a431cbde971c45342b483f7af337811403/pyjson5-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:381ca5b3172bccf9c29c5823a0173ab9118d579d0f5bb4fca20b803daa6e72d5", size = 171477, upload-time = "2026-05-15T16:06:23.017Z" },
{ url = "https://files.pythonhosted.org/packages/f1/49/da5417d592ac75a23d1ca2dfda846391ccf137edd89054b780866c592b65/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8e158caf0dd7fbf7afe12f92fcc9cba26bceac4b38615c087fd592e9e3240d9f", size = 1145571, upload-time = "2026-05-15T16:06:24.757Z" },
{ url = "https://files.pythonhosted.org/packages/60/f8/1f0c326b3a3267f28b7b9e2ac07121386ec20ee8f9dd49955af4faa17ba7/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c8010e9a02b6a0719234f8d074639f9051afe579ff0f74d3367af1d8b4c7d839", size = 1010388, upload-time = "2026-05-15T16:06:26.574Z" },
{ url = "https://files.pythonhosted.org/packages/de/c8/76f2739a8715061755f12f4d2795f6c4079aeadd166c56d0992521442d5b/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3543e067d1d8ab6cb1cd41add38a42eaf82e9a7e802e8a08f1acda9f22199b00", size = 1323111, upload-time = "2026-05-15T16:06:28.805Z" },
{ url = "https://files.pythonhosted.org/packages/50/7a/683e1586fa076fb344aec23a647d67f427847b6bffb1001d56e86aa8da33/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3e496938e7e0defc33bfbd0ff581d4ffdc428c598cc5d679232f34df27f7330a", size = 1240671, upload-time = "2026-05-15T16:06:30.473Z" },
{ url = "https://files.pythonhosted.org/packages/c0/8e/6638b0b22344b23cae1271b0211668be54b3f76183bd8d4f89a2edd8e562/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2badbc101908123905a38789cc552b99ab896a7ec0ce4fa6ee2bb7b613c85c1b", size = 1178013, upload-time = "2026-05-15T16:06:32.212Z" },
{ url = "https://files.pythonhosted.org/packages/7a/21/d44540d3cdc670e5c6e665471361f557ef8c7d56964cd433524208b3707b/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:b1b0df4898e2e046fa1e9b5df436fe11d9b681b1e5fc9877508d982d2059aaf1", size = 1354907, upload-time = "2026-05-15T16:06:33.922Z" },
{ url = "https://files.pythonhosted.org/packages/3c/74/fceedd2709760f5c85e75b508002af9b4c2da8af20a80e62b2d0e2358a15/pyjson5-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8fcc96c429888f2293f672e33d01da8a0847cd5116a773c9a1903b8bc2f12bc1", size = 1208847, upload-time = "2026-05-15T16:06:35.883Z" },
{ url = "https://files.pythonhosted.org/packages/b8/3a/d2f062b5801b3034ecb16ffb0801d7b4a1d6abae4cd4615482dbf62f1ed8/pyjson5-2.0.1-cp312-cp312-win32.whl", hash = "sha256:48c97e2f7f02171948f8a7ed6c9b2b2faea00653a3348d9e810238a688f07b6c", size = 115562, upload-time = "2026-05-15T16:09:24.807Z" },
{ url = "https://files.pythonhosted.org/packages/26/13/2265cf16720defdc87672b68ff5edf83820bd76e59e32f8384c9cb1ae619/pyjson5-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8dea9976eea0aa7af8b5ad49df3fa831d6bda08ffd7bb11409d6a81961491219", size = 136176, upload-time = "2026-05-15T16:09:25.931Z" },
{ url = "https://files.pythonhosted.org/packages/9c/92/a8947e646ce642555ac22b31f44b7168c81b0e364d0f2711d571955229b6/pyjson5-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:264ab65fb7d68a763567c2302151f1f074bf053c8479939aa8e75f5f9518fe31", size = 116752, upload-time = "2026-05-15T16:09:27.274Z" },
{ url = "https://files.pythonhosted.org/packages/93/2c/0619f89a9576f335ba63eb851e73ba507480a8c7f28a2e7de2501ed3d303/pyjson5-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8a661d292801b38434d5288bf7329f9b50b3a693b1d2941c7595175842692eba", size = 301821, upload-time = "2026-05-15T16:09:28.721Z" },
{ url = "https://files.pythonhosted.org/packages/91/31/7824913ec71e7421d6a57bc06228f3e2d946d8e8f738f898572dded0dc57/pyjson5-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cb5c1c066038ce6e1922d9d5be23f5cd14d5b5a3c96e6358aa5d0e379014a93", size = 158201, upload-time = "2026-05-15T16:09:30.261Z" },
{ url = "https://files.pythonhosted.org/packages/bc/7d/4ddb249563a838425242342d2cf67976ccc292a831b543a92fa1a8a83b11/pyjson5-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7417eab751817fa5f070e41975d9df4aec3532d21389073318161f63cd96d636", size = 153244, upload-time = "2026-05-15T16:09:32.04Z" },
{ url = "https://files.pythonhosted.org/packages/02/39/7622416ac0570d9ce377447bd5b2ec9383c1282e63cc6d0b65779f1336fa/pyjson5-2.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cc4802093b4ab9039367774988486fd04754cf416c32aebf94a94bafd8b8a478", size = 185568, upload-time = "2026-05-15T16:09:33.719Z" },
{ url = "https://files.pythonhosted.org/packages/d8/90/2f317da231477b77481020d79c73bbe10625e4925ceeae77603726ef1763/pyjson5-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc3181274ed19ecb2315cf85a499bf83002554f42c72453666c616059d665a7b", size = 163169, upload-time = "2026-05-15T16:09:34.865Z" },
{ url = "https://files.pythonhosted.org/packages/02/b3/20023c3cfe2f2c2523007d7031b5b7ad7ccd8856d2665547683cebaa6f8e/pyjson5-2.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5398306b8aa253e620af9ed8085767de9cd28d6846da45e535e80fcffa9f33d7", size = 166514, upload-time = "2026-05-15T16:09:36.383Z" },
{ url = "https://files.pythonhosted.org/packages/e2/1c/b863e502153477b2845a54b688b72436457aca5107b300fca95e98184551/pyjson5-2.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b97b592287d52a6ec5af84dca94aab02b185f72f8ddb6fc99c58efb8891be5cd", size = 179907, upload-time = "2026-05-15T16:09:38.166Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b2/413fcb76632e5f6fa89a2ec83976755b5bb4696c6d9eca9ff3c70cd717b6/pyjson5-2.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4efbf5e910a3aa6f330a92d1ae940dda5ed662976ed5056526baaf2e1821f95d", size = 187171, upload-time = "2026-05-15T16:09:39.624Z" },
{ url = "https://files.pythonhosted.org/packages/d6/11/66151b819407ab589aef36582038257f1ef42dc065e3423b3d8274f4fcd2/pyjson5-2.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e042f9b869e7f21d5f53a2638e5d0cf1daaba2342127c8777c502daf972602e", size = 175564, upload-time = "2026-05-15T16:09:41.356Z" },
{ url = "https://files.pythonhosted.org/packages/f2/98/deb70690dab994474ea527cba91f43ae55928bcff498c441e812b60fc1e2/pyjson5-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:26074370d7a6dd38b6e8c28a2f10c790fc6dec938ec22ec3de6c93c97d195f10", size = 171530, upload-time = "2026-05-15T16:09:42.78Z" },
{ url = "https://files.pythonhosted.org/packages/a9/be/969752a3a052d00698b6e1dd926c627d7a6475a7c8dd390db148363544f8/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3cb163a495096716a7a01d052b60bda0308ecfaeddcb2d50a247ba9d707e3e40", size = 1145510, upload-time = "2026-05-15T16:09:45.005Z" },
{ url = "https://files.pythonhosted.org/packages/d0/be/4c5c92cdda5a911ee4450a74281782335e7ce6715638308322beb96be639/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5fdd66bdddc2d53421ab7427bedcd2ca9b2f1b4b3b464381aa60d305c94be38c", size = 1010114, upload-time = "2026-05-15T16:09:47.081Z" },
{ url = "https://files.pythonhosted.org/packages/89/1e/72283bc505d77dcdab7eaeb0020cf0fd79a05d2991e886d1fcfa73e88ae1/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4ad222a3eff1cc93f9b70894c5f3d2ff5e46531e88feaa4818e329d5f3ae4307", size = 1322915, upload-time = "2026-05-15T16:09:48.885Z" },
{ url = "https://files.pythonhosted.org/packages/06/b4/94a09e744a6bb6e76108b61a28a7cc5ecab1b8105cacde7548359b3b74a3/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:63f5cf25113dd1bf3bfeda211d99413e58ec6b9036ff34f7b69f85f23695e9c4", size = 1240126, upload-time = "2026-05-15T16:09:50.638Z" },
{ url = "https://files.pythonhosted.org/packages/af/29/4549380cae425ee112f3c154606c35f5211c12ddf415f079c2b23f2d493b/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b0001eecce9080e6c170e978786501e8931e2075a3d15f5260e1bdd6a45e8976", size = 1178022, upload-time = "2026-05-15T16:09:52.639Z" },
{ url = "https://files.pythonhosted.org/packages/1c/1a/5a8a869e855645858e45dbd077204cec3a85b2f2e669bc3c8cefe9c4a70e/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40371c73cc83ed81914028786a6cc5950bdf300ae82443df83a5dfa80c582fca", size = 1355161, upload-time = "2026-05-15T16:09:55.177Z" },
{ url = "https://files.pythonhosted.org/packages/82/32/827066cd946447648275a892f494f4572d78e6a79d877fa6b6c14af599d8/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c9dca542eeb6e8fbbc1fe3373bfd18ae6250f1aedc78f39242ac12c8837091b8", size = 1208864, upload-time = "2026-05-15T16:09:56.919Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cc/a9bc12aff47d8bfd3074a21e3fb056ec0028fac28f8ec7e0fe4449d050f5/pyjson5-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d30c8b2c91d530be475a8fab6dde8c3bcd3e89b999c9e0f05aec2bf2c24b0638", size = 115461, upload-time = "2026-05-15T16:10:30.711Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c7/fa3fc956fbc3fa6250c8b99ef75a8f52b691780fca1bfb368340283bd898/pyjson5-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:67f8f5b8d3e3b2ca5f618c928729986aa00fb588c476c0d0d3a151633ff41e0d", size = 135836, upload-time = "2026-05-15T16:10:32.062Z" },
{ url = "https://files.pythonhosted.org/packages/f0/0a/e457b20d1e36a766d3ccd09c418b7ef41acabb679ff2248bb1cd38247ce6/pyjson5-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:bdf30ce5b1242f63254d936b30af75cae237fab0ee71cc2544163c82b6bc9201", size = 116652, upload-time = "2026-05-15T16:10:33.39Z" },
{ url = "https://files.pythonhosted.org/packages/2b/4c/6266789e615576b62d2db6de26a3b4b8f4cc7a8ff23fe24e48363bca1682/pyjson5-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:954c14d40022fa40e0d36e0ebd337e9e90fd71145592d25a0e2868344e3daca0", size = 320487, upload-time = "2026-05-15T16:09:58.38Z" },
{ url = "https://files.pythonhosted.org/packages/dc/75/c4a563034805e20b274fe4db963e1d480001931d88fe70b7d082033b7dcb/pyjson5-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f57529b98d21e8b76f8bc51af9d3c3057dd04a92287fd21ef7ec55255ca6f5c5", size = 166879, upload-time = "2026-05-15T16:09:59.849Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d2/5e6ec3580379794e9a99a402465f74586e93e93814974172202d1254fa89/pyjson5-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:545b655ef0b59f39fc29e2b63b4dadd45e447ad77bad2fdca859ff4db69e21f5", size = 162235, upload-time = "2026-05-15T16:10:01.147Z" },
{ url = "https://files.pythonhosted.org/packages/5b/48/b2d0e868ef8375eb0696ddc74f71028fc4fcd26cdddaeb34485214f2dc87/pyjson5-2.0.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b519de49dd4cbf254179749e5c6bfe9bd8fd1f97e7755bff2e17cc6f79f85aa5", size = 182162, upload-time = "2026-05-15T16:10:02.523Z" },
{ url = "https://files.pythonhosted.org/packages/ce/18/fed02a3d68c3badb87394e8420b72d7cb165ade208c1e02561637409aa99/pyjson5-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de48fbd0466c114c17f408be7bcbc5b3aedeb29ff5f73187022915e6d5eb7817", size = 169487, upload-time = "2026-05-15T16:10:03.876Z" },
{ url = "https://files.pythonhosted.org/packages/8b/90/dc897332dda24e949828616e8e74d7937b587a56789f3aca148fcadddde2/pyjson5-2.0.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fe6516ff9ab94368c18ff8ac0ebdb53fac9e047da093d43e67d5e7cd98cdd0bf", size = 164128, upload-time = "2026-05-15T16:10:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/39/09/90104baeab58adfe63bda804174ef1806b1cabb1692e047fabc2f4aa1799/pyjson5-2.0.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ea7aadacac7ac6661117b708175f6beff75e132322c08bbf3794d8ec4ba572b4", size = 184306, upload-time = "2026-05-15T16:10:06.986Z" },
{ url = "https://files.pythonhosted.org/packages/89/73/613efc3cdab5cf9ec399e6c5a0463b672e5f5cbd693a3a4d82cbdda19476/pyjson5-2.0.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5c339bcac113dfc30a1f88f23af273d67bb6169abee1684c0dd27daa71a32f9", size = 192239, upload-time = "2026-05-15T16:10:08.727Z" },
{ url = "https://files.pythonhosted.org/packages/47/2a/17b97e02b37ab9353fc9713e56470be3d2539501ed18d6c676cfbcd0a57c/pyjson5-2.0.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:075de0f1e5e0ffee174a8941b5d9d8af632772a6e5eebe92fb0899db7f6ab7fa", size = 177652, upload-time = "2026-05-15T16:10:10.19Z" },
{ url = "https://files.pythonhosted.org/packages/1c/43/a05331ba88fd1aa09ca418548c6b06692e34b2a64b109bb363e01ecafdeb/pyjson5-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c463c9bf508c1d316191898a3c0bfc03dd3de67bdc02f480b0742ac5c859eaeb", size = 174860, upload-time = "2026-05-15T16:10:11.976Z" },
{ url = "https://files.pythonhosted.org/packages/2e/00/5d253751f4d27b7e63fdfd765041593fa588395f45b9cea496878d964448/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:54427c8cad0c1a516bad5b4457167cde1e7ef7ee563e28a944f59b3c7413b6a0", size = 1151556, upload-time = "2026-05-15T16:10:13.608Z" },
{ url = "https://files.pythonhosted.org/packages/9c/f7/a30eed477295e92dfd59553063cf82bf510a7052efc7720c9c50d0082fdb/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:ca04839cecd364cb53de7ebcd81fd5915193707d968730ea1c8d4ff5c9c2f1a8", size = 1012330, upload-time = "2026-05-15T16:10:15.171Z" },
{ url = "https://files.pythonhosted.org/packages/9c/60/80818fad90a99336f8bfc686d76d0b2d7c8bb51e8de5d031620fa1a2d7a1/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13a2de8b14aeb576b1623c8af419510bf871b8d9cc2308db41a46bcc94d00f2f", size = 1320970, upload-time = "2026-05-15T16:10:16.863Z" },
{ url = "https://files.pythonhosted.org/packages/7c/3f/090eb3e0971d067defb58bbfd738f74a09be495c6f4f00d5d76a10755bca/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c8a689f5252e9791bfbc8fa916999202556a1e8c7b24dfd72de1ad84c493e10a", size = 1244775, upload-time = "2026-05-15T16:10:19.416Z" },
{ url = "https://files.pythonhosted.org/packages/61/38/e7546ad733affe51a5462bad21bceb4fd659baf24930bd141627ce56279b/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3dc0c2c80fd9d1e4c26f8b6c91a54d38b48648b93ae3f09355856c75fda3e460", size = 1182149, upload-time = "2026-05-15T16:10:21.696Z" },
{ url = "https://files.pythonhosted.org/packages/90/2c/016647580d8a82ee53b4cb9ddb96deb4b157ccea9624bba61de4aa2cd25c/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:13d78b3e2c60b81bd7e6b00e6ab24a664ba1fd64b5d86baa65c4cdfae1fff7ae", size = 1358579, upload-time = "2026-05-15T16:10:23.649Z" },
{ url = "https://files.pythonhosted.org/packages/01/45/8b84ff7a0c4d8c12a76e097c67af62abe986a18d1ab2540764dd6eaa0253/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a7b640dbeabbd7d975170f793f90a7500cc5d510aeae9364a1bc558bcc5336ed", size = 1211015, upload-time = "2026-05-15T16:10:25.4Z" },
{ url = "https://files.pythonhosted.org/packages/cf/48/d8f34de7a7319f5966bdfd10f133e689ba01f138d2040bf792f1ca8b18e1/pyjson5-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:46e7c5525034fde8abf3aaf100ed7660c1b618ccd3205544ed7c99e4c55cf201", size = 131660, upload-time = "2026-05-15T16:10:26.747Z" },
{ url = "https://files.pythonhosted.org/packages/06/aa/0bd437252134115a846ffc061078c85f8e8c325c86abcaa862c134d3c57e/pyjson5-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:207c00e7f9641e0358bdc848a1c0610f26f2e8bd51d73bd64e5a0ab3b725f1d8", size = 157717, upload-time = "2026-05-15T16:10:28.053Z" },
{ url = "https://files.pythonhosted.org/packages/24/61/7849b04a0dc78f73905189400ac99c45de8eec2ec451ce9c6990fb3588e5/pyjson5-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4917d5b6186bfa48ec4d85326e2a09769a5f7844963307ab2710429e1f743264", size = 126281, upload-time = "2026-05-15T16:10:29.378Z" },
{ url = "https://files.pythonhosted.org/packages/89/41/70aa1cb1fb0a3ac4c9b8cd405c0c85ba935c53fdfae6271b8eae364b92d1/pyjson5-2.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:573fecd7cad4e24d232053f9ebf331c80fe1b0ed6e2064f7614797a7f691e7ca", size = 303706, upload-time = "2026-05-15T16:10:35.163Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d9/caf44bf3d33b9502dc4b8ed5d0c7a8af8fbe33001e82414c0407f39f18bd/pyjson5-2.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a8ade508a046a5407a322c098e3b7e6033216b158a7746eb8962b7a4cdbf9248", size = 158915, upload-time = "2026-05-15T16:10:36.496Z" },
{ url = "https://files.pythonhosted.org/packages/3f/94/f2bab1ce2eac8f77e16dcd0a1ba39de20733a84b9961c3504af0dd68bceb/pyjson5-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4635e349bb1d1f1e854dc790f14be31b6cb198d6453848e8d86818104074f805", size = 154199, upload-time = "2026-05-15T16:10:37.902Z" },
{ url = "https://files.pythonhosted.org/packages/82/c4/d94573ca37486af3d6a72f2582844d7d490d8e55639e0cfba371ce91442f/pyjson5-2.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d274697f81f143abac12ce256a06b61635c30d0f8cc11eefae7182655dac9a5", size = 186060, upload-time = "2026-05-15T16:10:39.603Z" },
{ url = "https://files.pythonhosted.org/packages/03/cc/c42e697def319b286fdcb912939c044cc94bd1cfc7338b6dbb566f817f29/pyjson5-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be4e2242e55a2651fd8696cfd67ec8e04f54f4ed6c089cceea2b63763a7516d8", size = 165796, upload-time = "2026-05-15T16:10:40.967Z" },
{ url = "https://files.pythonhosted.org/packages/3f/dd/f22a5f0e619ef22b8e32520a91bd92f815d50f9ec2d67cfe5974eab9476c/pyjson5-2.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af855c680feaa39cae4a44914ee2863eeae1549f37ce58069c747b3d4803999a", size = 165262, upload-time = "2026-05-15T16:10:42.391Z" },
{ url = "https://files.pythonhosted.org/packages/99/8b/90e22ecb12d51ccdc68325b6b051002e6103cc4600a335d9ed13828acdd9/pyjson5-2.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb769d90516da904e6cf0ba58f3d5830f4a5804486b5e85c5ca42ba3146d187a", size = 181608, upload-time = "2026-05-15T16:10:43.914Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ab/237f9036eed73e08cf8861466d3d1182ef1c88506bd29955740dcadb24ff/pyjson5-2.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6484c14e07aa46abeb3cb2ff0204a765260763ad7cf3f87f601f26b20ed607f3", size = 189582, upload-time = "2026-05-15T16:10:45.367Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3d/3df8d5f003910a9291e5f04fa178f626e3c8553a5986dc62589ca74195ff/pyjson5-2.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc03673adb544324500d79b2acfe2ced038998b2aceb564d335de9cc238cbdb0", size = 176321, upload-time = "2026-05-15T16:10:47.134Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d9/a458a54f780bafec1b44ee3c27cb3007a83fb7bd4f1c17ac6bf519fd6151/pyjson5-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d714daf784bec2e14fc06c0c26e4a373a6157eaeddaed2d5a7b7b6ae2aabc33", size = 171956, upload-time = "2026-05-15T16:10:49.174Z" },
{ url = "https://files.pythonhosted.org/packages/78/d8/1010e0147c8862dc0881cf3656364d3d87a5e336d290fe4cb0b92223e14c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5cf3ded356730e08b5941a16db6525efc0de26f35468ab09ad573056c4efc6e5", size = 1147679, upload-time = "2026-05-15T16:10:51.005Z" },
{ url = "https://files.pythonhosted.org/packages/19/2f/91c8d8cdb4a2e4bbe8f14b9b57d717988e14830f2ed4a4f08d503cb5edde/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cc00d4669fb4170c28b2c9ed3cd1c2af9f21727c55d6866030cc154f31f68747", size = 1008216, upload-time = "2026-05-15T16:10:53.252Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d7/25c3f8693660a35be7bb4eb71b0d10d9a6981e986723a4881d607f0c6462/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a77a91c2e019476345c03cde105da44100daaabc3fa56f82f2bd9eb2ebbcb698", size = 1323856, upload-time = "2026-05-15T16:10:55.517Z" },
{ url = "https://files.pythonhosted.org/packages/c8/25/82f0f83556ff68ec0ca1129ba9afeb14149cd421dc74823cdf10c209c95c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9ae54e5d717982cb7a082c700ab1f7f965c6aedea6fcb003ec4bdeac4f02bf52", size = 1241860, upload-time = "2026-05-15T16:10:57.635Z" },
{ url = "https://files.pythonhosted.org/packages/c1/01/2c4695fc06a0f3f29041a875d3bb24f904c9ae7b3d8dbfa1f8db17ce995c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ce4f5b67b3a6fab623a31d83416b51a5b2c97cb172194f214078484bbb4783a6", size = 1178443, upload-time = "2026-05-15T16:10:59.398Z" },
{ url = "https://files.pythonhosted.org/packages/9a/ae/c10f534037b096aca21b90017ac9d41f791572dc3ec31fcd92db8ddcd256/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f8d2e61f7bed0b40cfe5f62375581bf56d7b5b9707c9d74747b5931149d7d376", size = 1357739, upload-time = "2026-05-15T16:11:01.189Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f0/655224f78ede087aa6a295c4745423e0104dd31f67928675b1a4cea72a0a/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2930140edd1a64eb42689993c5ae317bca2e6bc785b5dc5053bd5e9d184c98ad", size = 1209542, upload-time = "2026-05-15T16:11:03.364Z" },
{ url = "https://files.pythonhosted.org/packages/73/52/a7b2a26625136fcd0f92e17beaaae51e04923cc2e28502db354d4de061b4/pyjson5-2.0.1-cp314-cp314-win32.whl", hash = "sha256:51733f91dda897239ca10928f363ce6f4b5eadaf797e74477f6c1c3222c185a7", size = 118342, upload-time = "2026-05-15T16:11:40.009Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8e/79db426fff3a41610076989d4060cc18a6508ebd3c7877155f6709eea102/pyjson5-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:698c73aacff49ea35bbbf7f700a97785626201ea7aa2e1f1e0a4fe23788c3be2", size = 138408, upload-time = "2026-05-15T16:11:41.757Z" },
{ url = "https://files.pythonhosted.org/packages/c7/1b/385da14c05412bca15e86551dde91959686c8bddf7e78722bdc627fa6815/pyjson5-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:d447e2e5756f89abfd0ad82d1438075bcbc701c1cc9279b43d24825aaac67356", size = 120905, upload-time = "2026-05-15T16:11:43.146Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/cd69739556d304d3ea48064ea7d47e5ff7321b0032d7ad78864bceaa0cae/pyjson5-2.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5603be4f0cb9685d7c2cbd408ddec21b33f415252bee00ac7969f20b5789a1d1", size = 321150, upload-time = "2026-05-15T16:11:05.07Z" },
{ url = "https://files.pythonhosted.org/packages/84/d4/6d98268ed07a2cac1e79634fcd3dc280d2503ade5f3c465f7aa095951444/pyjson5-2.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:87267520a256b3f2dba7f2995b745e8b3f2dd72bbf8436010a5191f7809f8ab6", size = 167540, upload-time = "2026-05-15T16:11:06.822Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c6/8855a9462bdbc8306cba62793e23cc2952dfcbcbcab8e0413f570c891361/pyjson5-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:77e58307b74019d90aff9f2781bcffbbea3315b15fc814f8fa9b8d334b956ff4", size = 162533, upload-time = "2026-05-15T16:11:08.218Z" },
{ url = "https://files.pythonhosted.org/packages/a6/a3/1092f68538ee71ed8027393ab5e71aa9c4a114e493e30a55e46e34c2ffb9/pyjson5-2.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd1712038837342dea73ed526883e53c6e85a158242b6f574b9ad9cdd3199c7f", size = 184165, upload-time = "2026-05-15T16:11:09.639Z" },
{ url = "https://files.pythonhosted.org/packages/46/63/bb9f42a4047284ea59064c134c88dfac3b7f5273e40fe548d6a086cea452/pyjson5-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dba36677c77aed5d680827c65478933705ca156a34838f1250ea191ffc27662", size = 170075, upload-time = "2026-05-15T16:11:11.105Z" },
{ url = "https://files.pythonhosted.org/packages/55/2a/0379aa5184e986d0f9af2919712c148d81928ce7d2d7950407e271b1df3b/pyjson5-2.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:04356d0c09e58907f60675d33859f269473479b90add5026231ef2fadba5207e", size = 164100, upload-time = "2026-05-15T16:11:12.909Z" },
{ url = "https://files.pythonhosted.org/packages/15/d5/2087af69695c28c7fe796afc73dec7f0b0b9bc123ec329e45928d58d4705/pyjson5-2.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b331a0fbe2ae26f4ccb88045ebaecc00aeeeab23ff858f9a2223cedd969ec6d6", size = 184674, upload-time = "2026-05-15T16:11:14.448Z" },
{ url = "https://files.pythonhosted.org/packages/2a/06/cf1b2744e07f1689cb0ac663e162a060b81cd358474754fe9f3bf02d5398/pyjson5-2.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ca55878d923ba5764254ad1c020c22b33e6161b3555d1f457b8060f2b3c6c17", size = 193047, upload-time = "2026-05-15T16:11:15.969Z" },
{ url = "https://files.pythonhosted.org/packages/54/e6/a6b0deb6a3f393907c6d9115a7ac8e27557109204b56b05d3b57c51fc2ca/pyjson5-2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35e18d11e4c2034b78a664affe18c153a8efbf8872cf6bed7f12dc4fb819d440", size = 178712, upload-time = "2026-05-15T16:11:17.364Z" },
{ url = "https://files.pythonhosted.org/packages/6e/48/9e5c16daba56dfdb481baab85d1197e14ae3f403902fc23cdca14d14351b/pyjson5-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b710a7489b6e134890eaa1fdb285e9644a1e730c1cfcf1cd90a87859b87a84a9", size = 175642, upload-time = "2026-05-15T16:11:18.781Z" },
{ url = "https://files.pythonhosted.org/packages/c3/32/f07d4dbd8cf733f9e3f3e9c9abedfbc23da1e5cfa7fed46aebae07ca6f25/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:08920c0dd6aba3fb6bc6c849e6f05731c5fb2716cd651de424ff60cdd12eae65", size = 1152219, upload-time = "2026-05-15T16:11:20.54Z" },
{ url = "https://files.pythonhosted.org/packages/01/9d/0ddbb89d381bc27f3740850d5d664c1f6f76620ad879ff5eb2506a4dfd6c/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e166dadb3275025cff5ee8602131372a3ea171c6727b3fc6e44697348e3972c5", size = 1013305, upload-time = "2026-05-15T16:11:22.702Z" },
{ url = "https://files.pythonhosted.org/packages/bb/93/0f44886391dd2249ad9cf98c2deb3f26d1a115dd5a215629af7455765968/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ea3c2b4e7b8e209e7f59bd6a925d79b69cd0a4ffa12f2df0c6af55514c8b2227", size = 1322840, upload-time = "2026-05-15T16:11:24.918Z" },
{ url = "https://files.pythonhosted.org/packages/d4/29/c478dc24dfcfc07e407ff8669df265ecc51fed5e7d47e51a01f17fd1c53f/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b710fc8ce00c984c8865131f05a8536163e1d3caffb9d081647879eca7424670", size = 1245221, upload-time = "2026-05-15T16:11:26.873Z" },
{ url = "https://files.pythonhosted.org/packages/e2/f0/5fef8c47c5b7052da79af6425a021040402a9a09b37c5687d91f98696a27/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9784d79275d1b06d95f2920c3f260ce5d3f6671f65a1fae1464f83fbfc834a0", size = 1182991, upload-time = "2026-05-15T16:11:29.258Z" },
{ url = "https://files.pythonhosted.org/packages/90/33/4d3a9d3159cdff1f1886d39f1991e6c8d5927f9b606cfc9743b5bef98c2a/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0ef86c53d0e14991b5b0fe58f225e3d8faec437aa0a70da4762525b6bc2fca10", size = 1359104, upload-time = "2026-05-15T16:11:31.651Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/20eb16c52453aea0af68a697f4058378c9ff871bd2f0bea28eb76ffe12f8/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1d9faccf5a9e86f14104eec31c13428ef8baeb867182ce107e0c68fcc5c62477", size = 1212847, upload-time = "2026-05-15T16:11:33.767Z" },
{ url = "https://files.pythonhosted.org/packages/b9/a3/69bbe93275eafb7802a5b29230cc5c28f9b924c68d15b3ab2d43cfe19825/pyjson5-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:2df497af7ab03cf82d9278a4707a83d0ae4e6d727f919a883b5ccec3f7d92650", size = 139137, upload-time = "2026-05-15T16:11:35.487Z" },
{ url = "https://files.pythonhosted.org/packages/48/45/b865ca6e0ae6887bc2b6b17cd123470ae43ac0fa04a2362d77d4b9f5bd43/pyjson5-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3b31cf4f4a4f01800812865af3b02f6700cefb9377e4d9a9c6b78fdcf41fd973", size = 169412, upload-time = "2026-05-15T16:11:36.887Z" },
{ url = "https://files.pythonhosted.org/packages/68/55/02c734459fef0a955ab3e7e745df415d5f9fc873a4c288765f60f22fbc13/pyjson5-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:15f0d8baea89d35c6c01c60b944c778a79cac10f77f71b727f1b244e2c7a8ccb", size = 129061, upload-time = "2026-05-15T16:11:38.712Z" },
]
[[package]]
name = "pymarkdownlnt"
version = "0.9.40"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "application-file-scanner" },
{ name = "application-properties" },
{ name = "columnar" },
{ name = "py-walk" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/ea/a784da505745c5eb19b7b6be62a0cee4c25c42ba72e010413a7ebaa40ca7/pymarkdownlnt-0.9.40.tar.gz", hash = "sha256:9e6b1137b9f4b496da10f838bc30f66e4c182a0bf53d93d3afce65a541ded091", size = 467022, upload-time = "2026-09-13T20:29:33.319Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/1e/e2d60317291cedff329cfe08dff7d5a2aefe51ef5f336997e1f2e8a08a94/pymarkdownlnt-0.9.40-py3-none-any.whl", hash = "sha256:de92072911fcb1e5476a7d453078d51457329f5e5cfd78c37d29efc363500262", size = 549389, upload-time = "2026-09-13T20:29:31.653Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.1.1" version = "9.1.1"
@@ -574,3 +773,93 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" },
{ url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" },
] ]
[[package]]
name = "sly"
version = "0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/41/8a/59e943f7b27904c7756a7b565ffbd55f3841f5cd3d2da2b2b0713c49e488/sly-0.5.tar.gz", hash = "sha256:251d42015e8507158aec2164f06035df4a82b0314ce6450f457d7125e7649024", size = 66702, upload-time = "2022-10-25T14:35:30.592Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/4d/c96d807295183f2360329cd8d8bf5e8072c53d664125b3858c04153f026e/sly-0.5-py3-none-any.whl", hash = "sha256:20485483259eec7f6ba85ff4d2e96a4e50c6621902667fc2695cc8bc2a3e5133", size = 28864, upload-time = "2022-10-25T14:35:28.054Z" },
]
[[package]]
name = "tomli"
version = "2.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
]
[[package]]
name = "toolz"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" },
]
[[package]]
name = "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 = "wcwidth"
version = "0.8.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/36/57/ed58088fafdf4c55a0ad6bde846502567645424d7ebf325230b9237f4085/wcwidth-0.8.3.tar.gz", hash = "sha256:d128512515fbf4612e0ff21fd6380399210318b7b54a9af59dff8454cf9730eb", size = 1458450, upload-time = "2026-08-28T18:10:06.875Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/0e/57f6bb3024a597b2e8ec4aee710ffe62ddc95af2e2bb1ee7a7abdc22c68c/wcwidth-0.8.3-py3-none-any.whl", hash = "sha256:d5b73dba6158a595ec9370350e7f2637bcac8d6c5e4fde34f30fcffb6103a5e4", size = 331669, upload-time = "2026-08-28T18:10:04.909Z" },
]