diff --git a/.gitignore b/.gitignore index e092f79..f1718c6 100644 --- a/.gitignore +++ b/.gitignore @@ -175,3 +175,6 @@ cython_debug/ pyevio/repomix-output.txt repomix-output.txt + +# Rust build output +rust/target/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7dee91f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,85 @@ +# Changelog + +All notable changes to pyevio are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added + +- `rust/`: a cargo workspace beside the Python package. `evio-stream` is a streaming, read-only EVIO v6 reader library over any `std::io::Read` (v6 files, bare record streams and the SRO 8-word block stream, both byte orders, LZ4/LZ4_BEST/gzip records, checked bank walk, fADC250 frame walk) with the `evio-count` and `evio-dump` tools; it reproduces the golden manifest, the corpus and the SRO reference numbers and decodes every hit of a 15.5 GB raw file in about 3 s on one thread. `evio-tui` is a minimal terminal viewer on top of it (records, events, bank tree and words, frame summary). See `rust/README.md`. Not part of the Python wheel. +- `stream_stats.start_worker_services()`: starts the multiprocessing forkserver and resource tracker early, so a program that captures stderr (Textual replaces it with an object whose `fileno()` is -1) can still run the parallel analysis; `compute_stream_stats` calls it too. +- File check "AIS entries identical": the decoder counts the aggregation info segment entries and distinct values per frame; `stream_stats` and the viewer Overview report frames whose AIS repeats one stream's entry in every slot. +- Decode warnings: a malformed SIB segment after the time slice segment (an AIS whose length runs past the SIB end, or with no entries) touches no hit, so the decoder counts it in `warnings` / `warning_messages` (per frame and in total) instead of `errors`; `StreamHits.validate()` passes, `stream_stats` carries the totals, the viewer shows a "decode warnings" check, `pyevio debug` prints the messages and the Parquet example prints them to stderr. Stats version 3: caches written by versions 1 and 2 are recomputed. +- `pyevio stats` prints the decode warnings and the AIS check (frames with an aggregation info segment, frames whose segment repeats one entry), the same checks the viewer shows. +- Reading of JLab SRO hardware streams: files with no file header and 8-word block headers whose version word is 4 with no bit-info bits open natively as `EvioFile.header_layout == "sro-8word"`. The whole header chain is checked at open; an evio-library v4 file (a header with the last-record or dictionary bit, or a 14-word header) raises the version error instead. `FileHeader.layout`, `FileHeader.container_version`, `FileHeader.for_sro_stream()`, `RecordHeader.is_sro_block_word()`. +- Reading of bare EVIO v6 record streams (an evio writer buffer dump, 14-word record headers with no file header) as `EvioFile.header_layout == "v6-records"`; `EvioFile.record_header_problem(offset)` names what is wrong with a record header; `pyevio info` prints the layout and the file checks. +- Compressed records: LZ4, LZ4_BEST and gzip records are decompressed on first access. `Record.buffer` holds the index array, user header and events (the file mmap for an uncompressed record, the decompressed bytes otherwise); `Record.release()` drops the buffer; `Record.compression_type`. +- Salvage of a truncated final record: a record whose declared length runs past the end of the file stays the last record, its complete events are read, and `EvioFile.checks` reports `("truncated_tail", declared_bytes, present_bytes, events_salvaged)`. `Record.is_truncated`, `Record.declared_end`, `EvioFile.record_event_count(record)`. +- File checks and walk errors: `EvioFile.checks` (`truncated_tail`, `bad_record_header`), `Record.errors` and `Bank.errors` record why a scan or a child walk stopped, with offsets. +- `EvioFile.close()`; `EvioFile.trailer` and `Record.is_trailer`; `RecordHeader.header_type`, `RecordHeader.is_trailer`, `RecordHeader.pad1`/`pad2`/`pad3`; `FileHeader.file_type`. +- `Segment` and `TagSegment` (one-word headers) as children of banks of segments and tagsegments; `Bank.child_class()`; `Bank(..., limit=)` bounds a bank to its parent, event or buffer. +- `pyevio.decoders.fadc250_stream`: the one copy of the streaming fADC250 frame walk and hit-word layout, as pure functions over `uint32` event words (`decode_frames`, `decode_frame`, `event_words`, `unpack_hit_words`, `validate_hit_words`, `concatenate_stream_hits`). Returns hit columns (`frame`, `event_index`, `roc`, `stream`, `port`, `channel`, `time_raw`, `t_ns`, `charge`) and per-frame, per-ROC-bank, per-empty-bank and per-non-fADC-bank tables. The module behind each port comes from the 0x41 payload map: fADC250 ports are decoded, other modules (DCRB) are counted as words. +- `pyevio.stream_stats`: one parallel pass over a streaming file to rate, charge, time-in-frame, occupancy and hits-per-frame histograms, empty banks and other-module words per port, and frame checks (stride, missing and duplicate frames, out-of-order frames, timestamp-step outliers). `StreamStats` round-trips through `.npz` with a version field; the timestamp tick length is the knob `tick_ns`. +- `pyevio stats FILE [--out FILE.npz] [--workers N] [--rate-bins N] [--tick-ns X] [--records N]`. +- `pyevio debug` prints a stream-frame summary for 0xFF60 events and the module, hits and other-module words per ROC bank. +- `pyevio web`: a browser viewer for CODA streaming fADC250 files (`docs/web_viewer.md`), and `examples/fadc250_stream_to_parquet.py`, which writes the hits of a file to Parquet. `pyevio web FILE` serves the file's folder and opens the file at once; the page has a tab icon, so browsers no longer log a 404 for `/favicon.ico`. +- `pyevio gui [FILE | FOLDER]`: a console viewer (Textual, optional extra `pyevio[gui]`) with the browser viewer's four tabs in the terminal — file checks and totals, plotext plots at file, ROC and channel level, the paged record table and the structure tree down to hit words with a detail panel of fields and raw words — plus a file-open screen; it reads through the same `pyevio.web` model functions as `pyevio web` and shares its statistics cache (`--cache-dir`, `--workers`), and `s` / `S` save the plots as PNG through matplotlib (`docs/console_gui.md`). +- Golden-set harness `tests/golden/test_golden.py` (one test per row of the evio golden manifest, path in `GOLDEN_MANIFEST`) and the SRO corpus tests `tests/golden/test_corpus.py` (`PYEVIO_CORPUS=1`). +- Documentation: `docs/stream_decoding.md` (frame layout, hit word, time units, striping, `stream_stats` outputs, file quirks); `docs/evio6_file_structure.md` gains an SRO hardware layout section. + +### Changed + +- `EvioFile.record_count`, `get_record()`, `get_records()` and `iter_records()` cover data records only. The trailer record is `EvioFile.trailer` (or `None`) and yields no events. `stream_stats.n_records` counts data records, `n_trailer_records` the trailer. +- `Bank.size`, `Bank.data_length` and `Bank.end_offset` count the length word plus the words that follow it (see "leaf banks lost their last word" under Fixed); `Bank.to_numpy()` returns one more element per leaf bank than before and uses the file byte order. +- Event offsets of a compressed record (`Record.get_event_offsets()`, `Record.scan_events()`) index `Record.buffer`. Code that read event bytes from `evio_file.mm` must read from `record.buffer`. +- `EvioFile` caches one `Record` (the last accessed) instead of every record. +- `EvioFile.__exit__` propagates the `BufferError` that `close()` raises while a numpy view of the mmap is alive; `__del__` stays silent. +- The error for files pyevio does not read names the detected EVIO version and points at the evio tools (`evio2xml`, `eviocopy`). HIPO files (file type id `HIPO`) open as v6 files. +- `examples/fadc250_stream_to_parquet.py`: a `stream` column, `port` widened to `uint16`, hits sorted by (frame, event index, t_ns) and frames by (frame, event index), `DECODER_VERSION` "2"; event bytes come from `record.buffer` and no `Event` objects are kept. +- `RecordHeader.parse()` and `BufferReader` raise without printing hex dumps first. +- Packaging: pytest, pyyaml and pyarrow sit in the default `dev` dependency group, so `uv run pytest` works without extras; setuptools package discovery is restricted to `pyevio*`. +- `pyevio web` cache key: the file path, size and modification time without the stats version. An entry written by another version is recomputed and overwritten at the same name instead of staying behind as an orphan. +- `textual` leaves the core dependencies: it is installed by the `pyevio[gui]` extra (with `textual-plotext`) only, so the library installs with click, lz4, rich, numpy and matplotlib. + +### Removed + +- `docs/evio_roc_time_slice_bank.md` and `docs/evio_roc_tsb.png`: the bank layout they described does not match SRO data. `docs/specs.md` moves to `docs/history/specs.md` as the pre-fix goal statement. +- The `pyevio ui` command and the `pyevio/ui/` package (a hard-coded sample tree); `pyevio gui` replaces them. + +### Fixed + +- Leaf banks lost their last word: `Bank` counted the length word as part of the declared length, so every `to_numpy()` and child walk stopped one word short. +- Trailer records produced phantom events past the end of the file: the trailer's record index was read as event lengths. +- LZ4, LZ4_BEST and gzip records were read as uncompressed bytes and produced garbage events. +- An event or bank length that ran past its record or the file was trusted; corrupt data raised deep inside numpy or struct calls. Every length is checked against its container and an overrun ends the walk with a recorded error. +- A truncated final record, garbage after the last record, or a second file header (two files concatenated) was accepted as a record. Record headers are validated before use and a problem ends the scan with a `bad_record_header` check. +- `with EvioFile(...)` leaked the mmap and the file descriptor silently while a numpy view was alive. +- A dictionary or first event of odd length shifted every event: the user header length is stored padded to whole words. +- v4 and v1—3 files failed with a generic "Unsupported EVIO version" error. +- HIPO files (file type id `HIPO`) were rejected. +- `EvioFile.get_event()` raised on every call (a 2-tuple unpacked into four names); `pyevio event` by global index failed with it. +- Children of a bank of segments or tagsegments were read as two-word banks: the first segment word of a streaming frame's stream info bank was taken for a length, and the walk stopped with an entry in `Bank.errors` and no children. +- `pyevio event N --record R` looked the event up by global index instead of its index within the record. +- SRO hardware files (8-word block headers, version word 4, no file header) could not be opened. +- Two bank-scanning tests failed on `main` because their fixtures declared lengths that disagreed with their byte sizes; corrected to EVIO length semantics. +- `uv run` and `pip install` could not build the package: setuptools auto-discovery picked up a second top-level directory as a package. +- `pyevio debug` printed "Error accessing event" for every event (it called a method `Record` does not have) and skipped every payload bank with two or more hits. +- `pyevio debug` displayed bank extents one word short. +- The hit-word layout and the frame walk existed in five copies (viewer, Parquet example, `pyevio debug`, two analysis examples) that had drifted apart; the Parquet example and `pyevio debug` call the shared decoder. The viewer's copy and `examples/analyze_evio_data.py` / `examples/analyze_roc_data2.py` remain. +- The Parquet example crashed (`IndexError`) when the last ROC bank of a frame had no children or ended in an empty payload bank. +- The example's `validate()` checked a `uint16` column against 65,536, which could never fail, with `assert`, which `python -O` removes; the raw 14-bit time field is checked and `ValueError` raised. +- Duplicate frame numbers interleaved the hits of both copies in the Parquet output, so the per-frame hit ranges pointed at the wrong rows. +- Every crate was decoded as fADC250, so DCRB channel masks became bogus hits; the module type comes from the payload map. +- Decoder and statistics: fixed caps of 8 ROCs and 64 boards dropped crates silently; each crate sends two ROC banks per frame, so 12 crates make 24. The decoder and `stream_stats` have no caps: histogram axes grow with the data, both streams of a crate merge by ROC id, and the stream identity (ROC bank num mask) is kept per hit. The viewer's own caps remain. +- Empty payload banks (length 1, num 1) and payload data type 0x00 were treated as anomalies. +- A file of a 4-way striped run reported 3 of every 4 frames missing, and time was computed from frame numbers; the stride is read from the data and time from timestamps. +- The frame period and the timestamp tick were guessed from `timestamp / frame` of the first frame; the period is the median timestamp step per frame and the tick length is a knob. +- Rate histograms binned from frame 0 to the last frame, so a later stripe squeezed into the right edge and partial last bins read low; `stream_stats` bins from the first to the last timestamp and normalizes by the frames each bin covers. +- The Parquet example, `stream_stats` and `pyevio debug` read event bytes from the file mmap, which is wrong for compressed records, and the example kept mmap views past the `with` block. +- Every command printed a traceback for a file pyevio cannot read (an EVIO v4 file, a file without the magic number); the message goes to stderr with exit code 1. +- `pyevio event` and `pyevio debug` printed the offsets of a compressed record as if they were file offsets; a note names them as offsets in the decompressed record buffer, and `pyevio event --hexdump` reads the event from that buffer. +- `pyevio --version` printed 0.1.0; it prints `pyevio.__version__`. +- The `pyevio FILE` shortcut for `pyevio info FILE` never ran (click resolves the command name first); the dead branch is removed and `pyevio` with no arguments prints the help. +- `pyevio event N` by global index printed `Record #None`; it prints the record the event lives in. +- `pyevio dump --record R` with R out of range printed the message and exited 0; it is a parameter error (exit code 2). The `--hexdump` title of `pyevio event` lost a stray `)`. +- Files that set the last-record bit on every record showed 1 record and 0 events; the scan continues while another record header follows, and records without an event index are walked by the length word of each event bank. diff --git a/README.md b/README.md index b78cbc5..82c58c7 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,33 @@ # pyevio -A Python library and command-line tool for reading and introspecting EVIO (Event Input/Output) files from Jefferson Lab. +A Python library and command-line tool for reading and inspecting EVIO (Event Input/Output) files from Jefferson Lab. ## Overview -`pyevio` provides both a Python API and a command-line interface for: +`pyevio` provides a Python API and a command-line interface for: -- Reading and parsing EVIO v6 files efficiently -- Handling both compressed and uncompressed records -- Inspecting file structure, records, and events -- Navigating hierarchical bank structures -- Converting data to NumPy arrays for analysis -- Specialized handling of ROC Time Slice Banks with fADC250 data +- Reading EVIO v6 files, record by record and event by event, through a memory map +- Reading the raw streaming files the JLab SRO hardware writes (8-word block headers, no file header) +- Decompressing LZ4, LZ4_BEST and gzip records on access +- Walking the bank tree of an event (banks, segments, tagsegments) with every length checked against its container +- Converting bank data to NumPy arrays +- Decoding CODA streaming fADC250 time frames into hit arrays and computing histograms and file checks over a whole file in one parallel pass +- Browsing a file — checks, plots, record table, structure tree — in a browser (`pyevio web`, [docs/web_viewer.md](docs/web_viewer.md)) or in the terminal over SSH (`pyevio gui`, [docs/console_gui.md](docs/console_gui.md)); the two share one statistics cache + +The repository also holds [`rust/`](rust/README.md), a cargo workspace with `evio-stream`, a streaming read-only EVIO v6 reader in Rust that passes the same golden and SRO rulers (about 3 s for every hit of a 15.5 GB raw file on one thread), its `evio-count` and `evio-dump` tools, and `evio-tui`, a minimal terminal viewer. It is independent of the Python package. + +## What pyevio reads + +| file | how it is read | +|---|---| +| EVIO v6 file (file type id `EVIO` or `HIPO` in word 0, 14-word headers) | native; dictionary and first event in the user header are skipped with their padding | +| Records compressed with LZ4, LZ4_BEST or gzip | decompressed on first access into a per-record buffer | +| A trailer record with or without an index | exposed as `EvioFile.trailer`; it yields no events | +| JLab SRO hardware stream: no file header, 8-word block headers with version word 4 and no bit-info bits | native, as `header_layout == "sro-8word"`; a final block cut by the end of the file keeps its complete events and is reported in `checks` | +| EVIO v4 or v1—3 file written by the evio library | `ValueError` naming the version and pointing at the evio tools (`evio2xml`, `eviocopy`) | +| EVIO v6 record stream without a file header (an evio writer buffer dump) | native, as `header_layout == "v6-records"`; records start at offset 0 and `EvioFile.header` is a stand-in | + +The SRO detection walks the whole chain of 8-word headers at open: a chain in which some header carries a bit-info bit (last-record, dictionary) is an evio-library v4 file and raises the version error. The format details are in [docs/evio6_file_structure.md](docs/evio6_file_structure.md) (container format) and [docs/stream_decoding.md](docs/stream_decoding.md) (streaming fADC250 payload). ## Installation @@ -19,167 +35,168 @@ A Python library and command-line tool for reading and introspecting EVIO (Event pip install pyevio ``` -## Requirements - -- Python 3.9+ -- Dependencies (automatically installed): - - click - - lz4 - - rich - - numpy - - matplotlib +The browser viewer installs as an extra: `pip install 'pyevio[web]'` (see [docs/web_viewer.md](docs/web_viewer.md)); the console viewer as `pip install 'pyevio[gui]'` (see [docs/console_gui.md](docs/console_gui.md)). -## Command-Line Usage +Requirements: Python 3.9 or later. Dependencies installed with the package: click, lz4, rich, numpy, matplotlib. -The `pyevio` command provides several subcommands for file analysis: +## Python API -### Basic File Information +Objects nest the way the file does: `EvioFile` → `Record` → `Event` → `Bank`, `Segment`, `TagSegment`. -```bash -# Display file metadata -pyevio info sample.evio +```python +from pyevio import EvioFile -# Or simply (default command is info) -pyevio sample.evio +with EvioFile("run.evio") as evio_file: + print(evio_file.header_layout) # "v6" or "sro-8word" + print(evio_file.record_count) # data records; the trailer is evio_file.trailer + print(evio_file.get_total_event_count()) + print(evio_file.checks) # [] for a clean file + + record = evio_file.get_record(0) + for event in record.get_events(): + root = event.get_bank() # the event's top-level bank + for child in root.get_children(): # Bank, Segment or TagSegment, typed by the parent + print(hex(child.tag), child.data_type, child.length) + data = root.to_numpy() # None for a container bank ``` -This shows general file information including: -- Magic number and format version -- Endianness -- Record count -- User header details -- Record summary +### Records and events -### Examining Records +- `record_count`, `get_record(i)`, `iter_records()` cover the data records. The trailer record (header type 3) is `evio_file.trailer`, or `None` when the file ends in a plain record. `Record.is_trailer` tells the two apart. +- `Record.get_events()` builds `Event` objects; `Record.get_event_offsets()` returns `(offset, length)` pairs without building objects, which is the way to walk a large file. +- For an uncompressed record, offsets are file offsets and `Record.buffer` is the file mmap. For an LZ4, LZ4_BEST or gzip record, `Record.buffer` is the decompressed bytes and every offset indexes that buffer. Read event bytes through `record.buffer`; `Record.release()` drops the decompressed buffer and the Event cache. +- `EvioFile` caches one `Record` (the last accessed), so a compressed file never sits in memory whole. +- A final record whose declared length runs past the end of the file stays the last record: `Record.is_truncated` is true, `record.header.event_count` is the header's claim and `len(record.get_event_offsets())` is the number of complete events present. `record_count` counts that record. -```bash -# Detailed view of record #5 -pyevio record sample.evio 5 +### Checks and errors -# Show all records without truncation -pyevio info sample.evio --full +- `EvioFile.checks` lists what the record scan found wrong with the file: `("truncated_tail", declared_bytes, present_bytes, events_salvaged)` and `("bad_record_header", offset, message)` where the scan stopped on bytes that are no record header (garbage, a byte-swapped header, a second file header of two concatenated files). +- `Record.errors` and `Bank.errors` list why an event or child walk stopped early: a length that runs past its container, a decompression failure. A bank whose declared length passes its parent is clamped to the parent; no read runs past a container because a length word said so. -# Show hexdump of file header -pyevio info sample.evio --hexdump -``` +### Closing -### Inspecting Record Structure +`close()` or the end of the `with` block releases the mmap and the file. A numpy array or memoryview that still refers to the mmap makes `close()` raise `BufferError` after the file is closed; drop the views (`del`) and call `close()` again. Such views come from `np.frombuffer` on the mmap, for example `fadc250_stream.event_words()` on an uncompressed record; `Bank.to_numpy()` and `Bank.get_data()` return copies. -```bash -# Dump structure of record #3 -pyevio dump sample.evio 3 +### Bank lengths -# Limit tree display depth -pyevio dump sample.evio 3 --depth=3 +A length word counts the words that follow it: a structure with length N occupies N + 1 words. `Bank.size`, `data_length` and `end_offset` follow that rule, and `Bank.get_children()` types the children by the parent's data type (banks of banks hold `Bank`, banks of segments hold `Segment`, banks of tagsegments hold `TagSegment`). -# Show data preview (first 5 elements) -pyevio dump sample.evio 3 --preview=5 +## Streaming fADC250 decoder and statistics -# Include hex dumps of data -pyevio dump sample.evio 3 --hexdump +`pyevio.decoders.fadc250_stream` holds the one copy of the frame walk and the hit-word layout. It works on `uint32` event words and returns numpy columns (`frame`, `roc`, `stream`, `port`, `channel`, `time_raw`, `t_ns`, `charge`) plus per-frame, per-ROC-bank and per-empty-bank tables. Only ports the payload map names as fADC250 are decoded as hits; other modules (DCRB) are counted as words. + +```python +from pyevio import EvioFile +from pyevio.decoders import fadc250_stream as fs + +with EvioFile("sro_000791.evio.00000") as evio_file: + record = evio_file.get_record(0) + buffer = record.buffer + events = [fs.event_words(buffer, offset, length, evio_file.header.endian) + for offset, length in record.get_event_offsets()] + hits = fs.decode_frames(events) + print(hits.n_stream_frames, hits.n_hits, hits.frames["frame"][:4]) + del events # mmap views; close() refuses while they live ``` -### Examining Individual Events +`pyevio.stream_stats` runs the decoder over a whole file in parallel (one worker per record range) and returns `StreamStats`: rate, charge and time-in-frame histograms per (ROC, port, channel), occupancy, hits per frame, empty banks per port, and frame checks (stride, missing and duplicate frames, out-of-order frames, timestamp-step outliers). The result round-trips through `.npz`. -```bash -# Display event #2 from record #3 -pyevio event sample.evio 2 --record=3 +```python +from pyevio.stream_stats import compute_stream_stats, StreamStats + +stats = compute_stream_stats("sro_000791.evio.00000", workers=8) +print(stats.n_frames, stats.n_hits, stats.checks.stride, stats.checks.missing_frames) +print(stats.rocs, stats.ports) # axes of every histogram +rate = stats.rate_hz(roc=7) # Hz per time bin, summed over ports and channels +charge = stats.per_roc(stats.charge_hist) # (roc, 128) counts; edges in stats.charge_edges +stats.save("run791.npz") +stats = StreamStats.load("run791.npz") # rejects a file written by another stats version +``` -# Alternatively, access by global event index -pyevio event sample.evio 25 +The frame layout, the hit word, the time units and the checks are described in [docs/stream_decoding.md](docs/stream_decoding.md). `examples/fadc250_stream_to_parquet.py` writes the hits of a file to Parquet with the same decoder. -# Show event data as hex dump -pyevio event sample.evio 2 --record=3 --hexdump -``` +## Command-line usage + +`pyevio --help` lists the commands; `pyevio COMMAND --help` lists the options of one command. -### Advanced Debugging +### File information ```bash -# Debug record #1 -pyevio debug sample.evio --record=1 +pyevio info run.evio # header fields and a record table +pyevio info run.evio --full # every record row +pyevio info run.evio --hexdump +``` + +### Records and events -# Focus on a specific event within the record -pyevio debug sample.evio --record=1 --event=0 +```bash +pyevio record run.evio 5 # record 5: header, events, tag statistics +pyevio record run.evio 5 --analyze # event size distribution +pyevio event run.evio 25 # event 25 by global index +pyevio event run.evio 2 --record 3 # event 2 of record 3 +pyevio event run.evio 25 --hexdump +``` -# Focus on a specific payload (for ROC Time Slice Banks) -pyevio debug sample.evio --record=1 --payload=0 +### Event word dumps -# Show hex dumps -pyevio debug sample.evio --record=1 --hexdump +```bash +pyevio dump run.evio # words of event 2 (the default start) as hex +pyevio dump run.evio 100 --start 200 # 100 events from global index 200 +pyevio dump run.evio 100 --record 5 # 100 events of record 5 +pyevio dump run.evio --as dec --only 0,1,2 ``` -### Raw Hexadecimal View +### Streaming statistics ```bash -# Display 30 words (default) from byte offset 0x100 -pyevio hex sample.evio 0x100 --bytes +pyevio stats run.evio # totals, frame checks, per-ROC table +pyevio stats run.evio --out run.npz # save the histograms +pyevio stats run.evio --workers 8 --records 200 --tick-ns 1 --rate-bins 256 +``` -# Display 50 words starting at word 200 -pyevio hex sample.evio 200 --size=50 +### Debugging a streaming record -# Specify endianness -pyevio hex sample.evio 0x100 --bytes --endian='>' +```bash +pyevio debug run.evio --record 1 # frame summary per event +pyevio debug run.evio --record 1 --event 0 # one event, every ROC bank +pyevio debug run.evio --record 1 --payload 0 --hexdump ``` -### Experimental UI +### Raw words ```bash -# Launch textual UI (requires textual package) -pyevio ui sample.evio +pyevio hex run.evio 256 --bytes # 30 words from byte offset 256 (decimal offsets only) +pyevio hex run.evio 200 --size 50 # 50 words from word 200 +pyevio hex run.evio 256 --bytes --endian '<' # the default is big-endian ``` -## Understanding EVIO File Structure +### Other commands -EVIO files are organized hierarchically: +```bash +pyevio ana run.evio --per-record # event tag statistics over the file +pyevio gui run.evio # console viewer (Textual), see docs/console_gui.md +pyevio gui /path/to/runs # the same, picking a file from a folder +pyevio web /path/to/runs # browser viewer, see docs/web_viewer.md +pyevio web run.evio # the same, opening this file at once +``` -1. **File Header**: Contains metadata about the file -2. **Records**: Containers for events (may be compressed in v6) -3. **Events**: Contains banks of data -4. **Banks**: Hierarchical containers that can hold other banks or data - - **Container Banks**: Hold other banks - - **Data Banks**: Hold actual data (integers, floats, etc.) +## Development -Special bank types include: -- **ROC Time Slice Banks** (tag 0xFF30): Contain fADC250 data with timestamps -- **Physics Events** (tag 0xFF31): Contain physics data -- **ROC Raw Data Records** (tag & 0xFF10): Contains raw detector data +```bash +git clone https://github.com/JeffersonLab/pyevio +cd pyevio +uv sync # installs the package and the dev group (pytest, pyyaml, pyarrow) +uv run pytest -q +``` -## Python API Usage +Two test sets need data outside the repository and skip when it is absent: -For more advanced use cases, you can use the Python API: +- The golden-set harness (`tests/golden/test_golden.py`) reads the evio golden manifest from the path in `GOLDEN_MANIFEST` and the files from the `golden_root` the manifest names. One test runs per manifest row: event counts, no event past the end of the file, and the version error for v4 and v1—3 files. +- The SRO corpus tests (`tests/golden/test_corpus.py`) scan 15 GB files and run only with `PYEVIO_CORPUS=1`: -```python -from pyevio import EvioFile -import numpy as np - -# Open an EVIO file -with EvioFile("sample.evio") as evio_file: - # Get file info - print(f"Records: {evio_file.record_count}") - print(f"Events: {evio_file.get_total_event_count()}") - - # Get a specific record - record = evio_file.get_record(3) - - # Get events from the record - events = record.get_events() - - # Process an event - event = events[0] - bank = event.get_bank() - - # For ROC Time Slice Banks - if event.is_roc_time_slice_bank(): - ts_bank = event.get_bank() - timestamp = ts_bank.get_formatted_timestamp() - print(f"Timestamp: {timestamp}") - - # Get waveform data - waveform_data = ts_bank.get_payload_data(0) - - # Convert to NumPy - np_data = np.array(waveform_data) -``` + ```bash + PYEVIO_CORPUS=1 uv run pytest tests/golden/test_corpus.py -v + ``` ## License @@ -187,4 +204,4 @@ with EvioFile("sample.evio") as evio_file: ## Contributors -[Contributor information] \ No newline at end of file +[Contributor information] diff --git a/docs/console_gui.md b/docs/console_gui.md new file mode 100644 index 0000000..6a397e2 --- /dev/null +++ b/docs/console_gui.md @@ -0,0 +1,142 @@ +# Console viewer + +`pyevio gui` opens the same viewer as [`pyevio web`](web_viewer.md) in the terminal: file checks and totals, channel occupancy, rate, charge and timing plots, a record table and a structure tree from the file header down to single fADC hit words. It is a [Textual](https://textual.textualize.io/) app: no server, no browser, no port to forward, so it runs as it is in an SSH session on the node that holds the file. Both viewers read through the same model functions of `pyevio.web` (`report`, `structure`, `jobs`, `cache`; none of them imports FastAPI) and share the statistics cache, so they show the same numbers for the same file and a file analyzed in one opens at once in the other. + +Plots are written for CODA streaming fADC250 files (0xFF60 built time frames). Any other EVIO v6 file shows the record table and the structure tree. + +## Install and start + +The viewer needs Textual and textual-plotext, installed as the optional extra: + +```bash +pip install 'pyevio[gui]' +``` + +```bash +pyevio gui run.evio # open a file +pyevio gui # pick a file in the current folder +pyevio gui /path/to/runs # pick a file in another folder +pyevio gui run.evio --workers 8 --cache-dir /scratch/pyevio-cache +``` + +Without the extra, `pyevio gui` prints `The console viewer needs Textual and textual-plotext: pip install 'pyevio[gui]'` and exits with code 1. The rest of pyevio does not need Textual. + +PNG export (`s` and `S` in the Plots tab) uses matplotlib, which the package installs anyway. + +### Analysis and cache + +Opening a file starts one analysis job (`pyevio stats` on all cores minus two, or `--workers N`) in a background thread; the status line under the header shows the records done, the time left and a progress bar, and the Overview and Plots tabs fill when it finishes. The Records and Structure tabs read the file directly and work while the analysis runs. Opening another file cancels the running job. + +Results are cached as `.npz` under `~/.cache/pyevio/stats` (or `--cache-dir DIR`), keyed by the file path, size and modification time, the same cache `pyevio web` writes and reads: a second open of an unchanged file, in either viewer, is immediate, and the status line says `cached`. An entry written by an older statistics version is recomputed and overwritten in place. The cache folder can be deleted at any time. + +### Over SSH + +Run `pyevio gui` on the node that holds the file; the terminal is all it needs. Textual uses the full terminal size, so a larger window shows more rows, more tree lines and finer plots (see [Limitations](#limitations) for what the plots need). `pyevio gui` has no `--port`, no token and no network interface: it is one process reading one file. + +## Keys + +The footer lists the keys of the active tab. Everything in the footer also works with the mouse: tabs, the selection boxes of the Plots tab, tree nodes, table rows and scrolling. Ctrl+P opens Textual's command palette. + +**Everywhere** + +| key | action | +|---|---| +| `o` | open a file (the open screen) | +| `1` `2` `3` `4` | Overview, Plots, Records, Structure | +| `q` | quit | + +**Open screen** (`o`, or the start without a file) + +| key | action | +|---|---| +| Enter | in the path field: open the typed file, or re-root the tree at the typed folder; in the tree: open the selected file | +| Tab | move between the path field and the tree | +| Backspace | with the tree focused: go to the parent folder (in the path field it deletes a character) | +| Ctrl+A | with the tree focused: list every file, not only EVIO names (in the path field it moves the cursor home) | +| Escape | cancel, keep the current file | + +The tree lists folders and files whose names look like EVIO files; dot files are hidden. A file pyevio cannot read (not EVIO, an EVIO v4 file) is refused with the reason in the status line, and the current file stays open. + +**Records** + +| key | action | +|---|---| +| Up, Down, PageUp, PageDown | move the row cursor; past the last or first row of the page the next or previous page loads | +| `]` `[` | next and previous page of 500 records | +| Enter | open the record in the Structure tab | + +**Structure** + +| key | action | +|---|---| +| Up, Down | move between tree nodes; the detail panel follows | +| Enter | expand or collapse the node; on a `… N more` row, load the next page of children | +| Space | expand or collapse the node | +| `n` `p` | next and previous window of 64 raw words in the detail panel | + +**Plots** + +| key | action | +|---|---| +| `f` `r` `c` | file, ROC or channel level | +| `]` `[` | next and previous ROC | +| `}` `{` | next and previous port of the ROC | +| `+` `-` | next and previous channel; past channel 15 the next port's channel 0 follows (`=` also steps forward) | +| Tab | move the focus to the next plot or selection box | +| `s` | save the focused plot (else the first) as a PNG in the working directory | +| `S` | save every plot of the current level | + +`]` and `[` page the record table in the Records tab and step the ROC in the Plots tab; the keys of a tab are active only while that tab is shown. + +## What it shows + +The header names the file; the status line shows the analysis state (`analyzing 1,234 of 7,891 records, 20 s left`, `analysis done (7,891 records, cached)`) and, in red, the last error. Errors also pop up as a notification for a few seconds. + +### Overview + +Totals (records, events, stream frames, hits, hit rate, hits per frame, duration and live time, modules with their word counts, decode errors and warnings with the first message), "what stands out" (the error and warn checks, then the first decode messages; `all checks pass` otherwise), the checks table with the status coloured, the file facts (header layout, byte order, record counts declared and scanned, trailer position as the file header stores it, the record-scan checks in words) and one row per ROC with its modules, ports, hits, other-module words and empty banks. The pane scrolls; the screenshot shows its top. + +![Overview tab](images/console_gui_overview.svg) + +### Plots + +All histograms come from `pyevio stats`; the pane draws the bins it receives with [plotext](https://github.com/piccolomo/plotext) and rebuilds them on every resize. The row of selection boxes above the plots sets the level, the ROC, the port and the channel; the keys set the same things and the boxes follow. + +**File:** hits per frame, total hit rate over time (seconds from the first frame; empty bins are frames without hits or missing frames) and the occupancy heatmap of hits per ROC and payload port. A dash marks a port the ROC's 0x41 map does not list; the counts are printed in the cells when they fit (see [Limitations](#limitations)). + +![File-level plots](images/console_gui_plots_file.svg) + +**ROC:** the port × channel occupancy of the ROC, its hit rate over time, charge spectrum and hit time within the frame. The charge spectrum has a logarithmic y axis (`hits (log)`), as in the browser and the PNG: the bars are drawn at log10(1 + count), so empty bins stay at zero, and the ticks are labelled with the real counts (`1`, `10`, `100`, `1k`, …). + +![Per-ROC plots](images/console_gui_plots_roc.svg) + +**Channel:** hit rate, charge spectrum and time in frame of one channel; `+` and `-` step through the channels of the ROC port by port. + +The heatmap is laid out by the pane itself, not by plotext: every row is the same whole number of lines and every column the same whole number of columns, the lines and columns left over are margins, and each count is written into its own cell. Its colour ramp is square-root scaled, as in the browser, so a few hot channels do not flatten the rest. In a plot lower than it has rows (16 ports in the 14 lines of a 40-line terminal) two rows share a line as half blocks, the upper row the colour of the `▀` and the lower one its background, and every other row is labelled; in one lower than half its rows the plot says how many lines it needs. + +### Records + +A paged table of the record headers: index, offset, size, events declared and found, header type, compression, last-record bit, index array, truncation, header quirks and walk errors; problem cells are red, quirks yellow. One page of 500 rows is loaded at a time, so a file of a million records costs one page of strings. The line above the table says which rows are shown; the trailer is the last row of the last page. Enter opens the record in the Structure tab. + +![Records tab](images/console_gui_records.svg) + +### Structure + +Left, the tree pyevio builds: file → records and trailer → events → banks and segments → hit words. Children load one page of 500 when a node is expanded; a page short of the total ends with a `… N more (press Enter to load)` row. Inside a stream frame the nodes are labeled by role as in the browser: stream info with its time-slice and aggregation-info (AIS) segments, ROC banks with ROC id and stream, ROC stream info with the payload-port map, payload banks with port, module and hit count, and the decoded hits (`hit 3 — ch 3 t 1232 ns q 14 `, with `FLAG` when bit 31 is set). Records with walk errors say so in their label. + +Right, the highlighted node: its id, every field of its header, its walk errors in red, the decoded payload-port map when it has one, and a hex dump of its raw words, 64 per window (`n` / `p`), eight per line with the byte offset of the line first. Record headers are read at their file offset; events and banks from their record's buffer, with a note when that buffer is a decompressed LZ4 or gzip record. Hits have fields but no words. + +![Structure tab](images/console_gui_structure.svg) + +## PNG export + +`s` writes the focused plot (the one with the highlighted border; Tab moves it) as a PNG in the current working directory, `S` every plot of the level. The name is the file's stem, the histogram and the selection: `sro_000791.evio.00000_hits_per_frame.png`, `sro_000791.evio.00000_charge_roc7_port1_ch3.png`. A notification shows the path. The PNGs are drawn by matplotlib with the Agg backend, so they work on a node without a display, at the histogram's full binning: the charge spectrum gets a logarithmic y axis, the occupancy a Blues colour map with the square-root scale, the counts in the cells, grey cells for unmapped ports and a colour bar. + +## Limitations + +- Plots have character resolution: a histogram with more bins than the plot is wide is rebinned to fit (the counts add up, the rate is averaged over the frames of the merged bins), and a plot narrower than 16 columns or lower than 6 rows stays blank. The PNG export keeps every bin. +- The occupancy heatmap needs one line per row under its three bottom lines (axis, tick labels, titles), or half a line per row with the rows paired as half blocks: 16 ports take a plot 19 lines high (a terminal of about 50 lines in the 2 × 2 grid) for one row per line and 11 lines for the paired rows, below which the plot says how many lines it needs. +- The counts in the occupancy heatmap are printed only when every count of the plot fits in its cell with a column to spare on each side. With 16 ports or channels across and counts in the millions (`10.6M`, five characters) that takes cells seven columns wide, a plot about 120 columns wide, i.e. a terminal of about 250 columns in the 2 × 2 grid; the screenshots above are at 260 × 50. Narrower, the cells show the colour only, but the dash of an unmapped port is always printed while each row has a line of its own; with the rows paired an unmapped port is grey only. +- The detail column of the Structure tab cannot take the focus (a focusable widget inside a hidden tab would pull Textual back to that tab, see `pyevio/gui/structure.py`); it scrolls with the mouse wheel only. The tree and the record table scroll with the keys. +- The step keys of the Plots tab (`]` `[` `}` `{` `+` `-`) are not listed in the footer; the hint line next to the selection boxes lists them. + diff --git a/docs/evio6_file_structure.md b/docs/evio6_file_structure.md index 4d0f071..8620e17 100644 --- a/docs/evio6_file_structure.md +++ b/docs/evio6_file_structure.md @@ -5,7 +5,7 @@ A big factor for introducing yet another evio version was the desire to compress **FILE FORMAT** -Below is a diagram of how an HIPO/evio6 file is laid out. Each file has its own file header which appears once, at the very beginning of the file. It is always uncompressed. That is followed by an optional, uncompressed index array which contains pairs of 4-byte integers. Each pair, one pair per record, is the length of a record in bytes followed by the event count in that record. Generally, the index array is not used with evio files. +Below is a diagram of how an HIPO/evio6 file is laid out. Each file written by the evio library has its own file header which appears once, at the very beginning of the file. It is always uncompressed. The raw files the JLab SRO hardware writes have no file header; see "SRO hardware layout" below. That is followed by an optional, uncompressed index array which contains pairs of 4-byte integers. Each pair, one pair per record, is the length of a record in bytes followed by the event count in that record. Generally, the index array is not used with evio files. That, in turn, is followed by an optional, uncompressed user header which is just an array of data to be defined by the user. This array is padded to a 4-byte boundary. To be more specific, it holds user data in HIPO files, but in evio files the user header will only store any dictionary or first event provided to the file writer. The dictionary and first event are placed into a record and written out as the user header. More on records later. Finally, any index array and user header is followed by any number of records including the last record which may be a trailer. More on the trailer later. @@ -70,7 +70,7 @@ After the file header, index array, and user header, the file is divided into ** **![](data:image/jpeg;base64...)** -Each record has a header. The header is followed by an index of events lengths – one word per length in the unit of bytes. This index is **not** optional. +Each record has a header. The header is followed by an index of event lengths – one word per length in the unit of bytes. The evio library always writes this index. Some writers omit it (index array length 0, as the SRO hardware does); pyevio then walks the events by the length word of each event bank, bounded by the record's event count and data end. It is followed by an optional user header which functions in the same manner as the one in the file layout. It is an array of data defined by the user. This array is padded to a 4-byte boundary. For the evio format, if writing to a file, any dictionary and/or first event are exclusively placed in the file’s user header and nothing is ever placed in a record’s user header. If writing to a buffer, any dictionary and/or first event are exclusively placed in the user header in the format of a record (in other words, a record within a record). The EventWriter class, when writing to a buffer only writes one record and so that will contain the dictionary/first event. If using the Writer class, it’s data format agnostic and any user data may be written with any record as part of the user header. @@ -104,14 +104,14 @@ MSB(31)                           LSB(0) 1. The record length is number of 32 bit words in the record (including itself). In general, this will vary from record to record. 2. The record number is an id # used by the event writer. Can be used by reader to ensure data is received in the proper order. -3. The header length is the number of 32 bit words in this header - set to 14 by default. This can be made larger but not smaller. Even though, theoretically, it can be changed, there are no means to do this or take advantage of the extra memory through the evio libraries. +3. The header length is the number of 32 bit words in this header - set to 14 by default. The evio library never writes another value. The SRO hardware writes 8-word headers that stop after the magic number (see "SRO hardware layout"); pyevio reads both and treats words 8—13 of an 8-word header as 0. 4. The event count is the number of events in this record - always integral. 5. The index array length is the length, in bytes, of the following index of event lengths. 6. The version is the current evio format version number (6) and takes up the lowest 8 bits. The bit info word is used to store the various useful data listed in the table below. 7. The user “header” is just a user-defined array which may contain anything. This record header entry is the length of the user header in bytes. If writing to a buffer and if there is a dictionary / first event, they will be stored in the user header and the length of that data will be stored here. If writing to a file, there is no user header data in a record so this entry will be 0. 8. The magic number is the value 0xc0da0100 and is used to check endianness. -9. The uncompressed data length is the padded length, in bytes, of the entire uncompressed record. -10. The highest 4 bits signify the type of compression used in the record (see table below). The other bits contain the padded length of the compressed data in 32-bit words. +9. The uncompressed data length is the length, in bytes, of the events alone. The index array and the user header are not included: the evio C++ and Java writers store the event bytes here in every record, and pyevio sizes the decompressed buffer as index array length + padded user header length + this word. +10. The highest 4 bits signify the type of compression used in the record (see table below). The other bits contain the padded length of the compressed data in 32-bit words; pad 3 (bits 24—25 of the bit info word) counts the filler bytes in the last word that are not part of the compressed stream. The compressed data starts right after the header; LZ4 and LZ4_BEST use the LZ4 block format (no frame header). 11. User register #1, 64 bits. 12. User register #2, 64 bits. @@ -156,7 +156,9 @@ In an evio file, the ending record can, but does not have to be, a regular recor **TRAILER’S HEADER** -In either case, bit #10 of the bit info word in the record header needs to be set – indicating that this is the last record. In the case of the trailer, bits 28-31 (seen in the table above) will indicate that this is a trailer. For an evio trailer, the value in those bits is 3. Also, the version number is placed in that same word. Everything after the magic number is 0. +In either case, bit #9 of the bit info word in the record header needs to be set – indicating that this is the last record (bit #10 is the file header's "trailer has an index" flag). In the case of the trailer, bits 28-31 (seen in the table above) will indicate that this is a trailer. For an evio trailer, the value in those bits is 3. Also, the version number is placed in that same word, which makes the word 0x30000206 for a plain evio trailer. The event count is 0. Everything after the magic number is 0. + +pyevio exposes the trailer as `EvioFile.trailer` and excludes it from `record_count` and `iter_records()`; `Record.scan_events()` returns no events for it, because its index array lists records. MSB(31)                           LSB(0) <-------------------- 32 bits ------------------> @@ -170,7 +172,7 @@ MSB(31)                           LSB(0) | **Index Array Length** | | **0x 30 00 02 06** | | **0** | -| **0xcoda0100** | +| **0xc0da0100** | | **0** | | **0** | | **0** | @@ -184,7 +186,18 @@ If the file header’s index array is non-existent, the trailer position can be If no record length information is available in either the file header or the trailer, the reader will scan the whole file from beginning to end to obtain it. -**Chapter 11** +pyevio always scans: it ignores the file header's index array and trailer position and steps from record header to record header by the record length word. Before a header is used it must hold the magic number at word 7, a header length of 8 or 14 with those words present, a record length of at least the header length, and no file type id in word 0 (which marks a second file header, as in two files concatenated). Bytes that fail this end the scan with a `("bad_record_header", offset, message)` entry in `EvioFile.checks`. A record whose length runs past the end of the file is kept as the last record, its complete events are read, and `("truncated_tail", declared_bytes, present_bytes, events_salvaged)` goes to `checks`. The scan continues past a record with the last-record bit set while another record header follows, because some writers set the bit on every record. + +**SRO HARDWARE LAYOUT** + +The raw files written by the JLab SRO hardware (for example `sro_000791.evio.00000`) carry EVIO v6 streaming events in a container that predates the v6 file layout: + +* No file header. The first block header starts at byte 0. +* Every block header is 8 words: `{block length (words), block number, 8, event count, 0, 4, 0, 0xc0da0100}`. Word 5 is exactly 4: version 4 with no bit-info bits. Words 8—13 of the v6 record header (uncompressed length, compression, user registers) do not exist, so the blocks are uncompressed and hold no user header. +* No index array: the events follow the header directly and are walked by their length words. +* The last-record bit is never set and there is no trailer. The file ends after its last block, and the last block may be cut by the end of the file (3 of the 4 files of run 791 end this way). + +The first block of an evio-library v4 file has the same shape, so a file without a file type id is identified by its whole header chain: every 8-word header must have word 5 equal to 4. A header with the last-block bit (0x200) or the dictionary bit (0x100) set, a version other than 4, or a 14-word header marks an evio-library file, and pyevio raises the version error for it. pyevio reports the layout as `EvioFile.header_layout == "sro-8word"`, with a stand-in `FileHeader` whose `version` is 6 (the content) and `container_version` 4 (the block headers); `trailer` is `None` and `record_count` comes from the scan, the cut tail included. # EVIO Data Format diff --git a/docs/evio_roc_time_slice_bank.md b/docs/evio_roc_time_slice_bank.md deleted file mode 100644 index 6e2b8b1..0000000 --- a/docs/evio_roc_time_slice_bank.md +++ /dev/null @@ -1,120 +0,0 @@ - -# ROC Time Slice Bank - -ROC Time Slice Bank - -![[evio_roc_tsb.png]] - -- "ROC Time Slice Bank" that has: - - Header - - Stream Info Bank (SIB) - - Data Banks - -I "ROC Time Slice Bank"-Header structure: -- 1st word (32 bit) - ROC Bank Length -- 2nd word (32 bit) - ROC_ID, 0x10, SS. In particular: - - First 16 bit ROC ID - - 0x10 - means Bank of banks - - SS (8 bits) - Stream Status. Bits 7 - error or not, Bits 6-4 Total Streams, Bits 3-0 Stream Mask - -II "Stream Info Bank (SIB)" - Structure: -- 1st word (32 bit) - Stream Info Length -- 2nd word (32 bit) - 0xFF30, 0x20, SS (same as above) -- Time slice segment (TSS) words: - 1. TSS Header: 0x31, 0x01, TSS Len (16 bits) - 2. Frame Number (32 bit) - 3. Timestamp (31-0) - 4. Timestamp (63-32) -- Aggregation Info Segment (AIS): - 1. AIS Header - 0x41, 0x85, AIS Len. (!) Important here - 2nd byte: top 2 bits = 2 for padding if odd # payloads (else 0), lower 6 bits is type 5 (unsigned short) => 0x85 (1000 0101) - 2. All other words - Payloads infos in 16 bit half-words. Number of payload infos is given in AIS Len. Since we read 32 bits, payloads go like (Payload2, Payload1) - word 1, (Payload4, Payload3) - word 2. If number of payloads is odd, the last word will be (0, PayloadN). There can't be more than 20 Payload infos in one AIS. Now how what is Payload info consists of: - - bits 11-8 - Module ID - - Bond? - 7 - - Lane ID - 6, 5 - - Payload Port # - 4-0 - -III "Data Bansks" structure (words): -1. Payload Port (PP) 1 Length -2. PP 1 ID, 0x0, SS -... PP 1 DATA -... -- Payload Port N Length -- 5PP N ID, 0x0, SS PP N Data - - -Let me explain what happens and how we should scan banks: - -Here is the full event hex dump -``` -0 0x00055118 87110 00 00 00 15 0 21 21 .... -1 0x0005511c 87111 ff 60 10 01 65376 4097 4284485633 .`.. -2 0x00055120 87112 00 00 00 07 0 7 7 .... -3 0x00055124 87113 ff 31 20 01 65329 8193 4281409537 .1 . -4 0x00055128 87114 32 01 00 03 12801 3 838926339 2... -5 0x0005512c 87115 00 00 00 00 0 0 0 .... -6 0x00055130 87116 00 00 00 00 0 0 0 .... -7 0x00055134 87117 00 00 00 00 0 0 0 .... -8 0x00055138 87118 42 01 00 01 16897 1 1107361793 B... -9 0x0005513c 87119 00 02 00 11 2 17 131089 .... -10 0x00055140 87120 00 00 00 0b 0 11 11 .... -11 0x00055144 87121 00 02 10 11 2 4113 135185 .... -12 0x00055148 87122 00 00 00 07 0 7 7 .... -13 0x0005514c 87123 ff 30 20 11 65328 8209 4281344017 .0 . -14 0x00055150 87124 31 01 00 03 12545 3 822149123 1... -15 0x00055154 87125 00 00 00 00 0 0 0 .... -16 0x00055158 87126 00 00 00 00 0 0 0 .... -17 0x0005515c 87127 00 00 00 00 0 0 0 .... -18 0x00055160 87128 41 85 00 01 16773 1 1099235329 A... -19 0x00055164 87129 00 00 00 00 0 0 0 .... -20 0x00055168 87130 00 00 00 01 0 1 1 .... -21 0x0005516c 87131 00 0f 00 01 15 1 983041 .... -``` - -We start with event length and header - -``` -87110 00 00 00 15 -87111 ff 60 10 01 -``` - -If we count `0x15=21` we will jump to the last word of the event. - -Now goes the first bank: -87112 00 00 00 07 - it says the bank content is the next 7 words and the last bank word is at offset 87119 -Read the next line. It is still inside event? Then it should be the next bank length. -87120 00 00 00 0b where 0xb=11. If we add 11 to the offset we correctly jump to offset 87131 which is the last word of the event. Bingo! Our event banks structure validated! - -Now we come back and see what bank is that? - -``` -11 0x00055144 87121 00 02 10 11 2 4113 135185 .... -12 0x00055148 87122 00 00 00 07 0 7 7 .... -``` -0x10 means it is a bank of banks. So we can jump in and see the the first child bank is FF30 - streaming! - -Now if we look at header we see - -I "ROC Time Slice Bank"-Header structure: -- 1st word (32 bit) - ROC Bank Length -- 2nd word (32 bit) - ROC_ID, 0x10, SS. In particular: - - First 16 bit ROC ID - - 0x10 - means Bank of banks - - SS (8 bits) - Stream Status. Bits 7 - error or not, Bits 6-4 Total Streams, Bits 3-0 Stream Mask - -Which means that 00 02 is ROC_ID and 0x11 is a status code - -00 00 00 07 0 7 - Stream Info bank length so it goes to the last line 87129 00 00 00 00 0 0 0, which is actually payload1 AIS record with module id = 0 - -FInally we have the final bank. The first line says it is 1 word which is 00 0f 00 01 - our payload - -``` -20 0x00055168 87130 00 00 00 01 0 1 1 .... -21 0x0005516c 87131 00 0f 00 01 15 1 983041 .... -``` - -Conclusion: -We find banks by taking first word and calculating where bank ends and validate that the end should correspond to our event end, another bank end, etc. - - - - \ No newline at end of file diff --git a/docs/evio_roc_tsb.png b/docs/evio_roc_tsb.png deleted file mode 100644 index 0645263..0000000 Binary files a/docs/evio_roc_tsb.png and /dev/null differ diff --git a/docs/specs.md b/docs/history/specs.md similarity index 99% rename from docs/specs.md rename to docs/history/specs.md index 5820914..008bac8 100644 --- a/docs/specs.md +++ b/docs/history/specs.md @@ -1,3 +1,5 @@ +Historical: the goal statement before the 2026-10 reader fixes; see README.md + # **pyevio - EVIO v6 Reader Specification (v1.1)** **A Python library and CLI toolkit for reading/introspecting EVIO v6 files** diff --git a/docs/images/console_gui_open.svg b/docs/images/console_gui_open.svg new file mode 100644 index 0000000..6644804 --- /dev/null +++ b/docs/images/console_gui_open.svg @@ -0,0 +1,252 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + pyevio + + + + + + + + + + ⭘pyevio — /data/evio-lib/corpus/sro_000791.evio.00000.v6 +sro_000791.evio.00000.v6 — analysis done (7,891 records, cached) +Overvi╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +━━━━━━━│Open an EVIO file — type a path and press Enter, or pick one below│━━━━━━━ +▼ file│▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎│ +├── ▼ │▊/data/evio-lib/corpus▎│ +│   ├─│▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎│ +│   │ ││ +│   │ │📂 corpus│ +│   │ │├── 📄 sro_000791.evio.00000.v6│ +│   │ │├── 📄 sro_000791.evio.00000.v6.lz4│ +│   │ │├── 📄 sro_000791.evio.00001.v6│ +│   │ │├── 📄 sro_000791.evio.00001.v6.lz4│ +│   │ │├── 📄 sro_000791.evio.00002.v6│ +│   │ │├── 📄 sro_000791.evio.00002.v6.lz4│ +│   │ │├── 📄 sro_000791.evio.00003.v6│ +│   │ │└── 📄 sro_000791.evio.00003.v6.lz4│ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ next +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ ││ +│   │ │Enter opens · Backspace goes to the parent folder · Ctrl+A shows every file · Escape cancels│ +│   │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +│   ├── ▶ event 1 — tag 0xFF60 stream frame, 45347 words   │ +▋│ + n Next words  p Previous words  o Open  1 Overview  2 Plots  3 Records  4 Structure  q Quit ▏^p palette + + + diff --git a/docs/images/console_gui_overview.svg b/docs/images/console_gui_overview.svg new file mode 100644 index 0000000..fd35e3a --- /dev/null +++ b/docs/images/console_gui_overview.svg @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + pyevio + + + + + + + + + + ⭘pyevio — /data/evio-lib/corpus/sro_000791.evio.00000.v6 +sro_000791.evio.00000.v6 — analysis done (7,891 records, cached) +OverviewPlotsRecordsStructure +╸━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +/data/evio-lib/corpus/sro_000791.evio.00000.v6 — analysis from cache, stats version 3 + +Totals +  records               7,891 + 1 trailer +  events                86,794 (86,794 stream frames, 0 other, 0 out of bounds) +  fADC250 hits          764,009,774 +  hit rate              134,316,473.3 Hz +  hits per frame        8,802.564 +  duration / live time  22.752 s / 5.688 s +  modules               fADC250 (764,009,774 words), DCRB (3,065,574,754 words) +  decode errors         0 +  decode warnings       0 + +What stands out +all checks pass + +Checks                                                                                                                                   +┏━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +┃check                   ┃ status ┃ value  ┃ detail                                                                                    ┃ +┡━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ +│header layout           │ info   │ v6     │ EVIO, version 6                                                                           │ +│trailer                 │ ok     │ yes    │                                                                                           │▃▃ +│record errors           │ ok     │ 0      │ records whose event walk stopped early                                                    │ +│events found            │ ok     │ 86,794 │ headers declare 86794                                                                     │ +│last-record bit         │ info   │ 0      │ records with the last-record bit set                                                      │ +│compressed records      │ info   │ 0      │                                                                                           │ +│records without index   │ info   │ 0      │                                                                                           │ +│out-of-bounds events    │ ok     │ 0      │ events whose range runs past the file                                                     │ +│stream frames           │ info   │ 86,794 │ of 86794 events; 0 other                                                                  │ +│frame stride            │ info   │ 4      │ most common frame-number step (4 = one stripe of a 4-way run)                             │ +│missing frames          │ ok     │ 0      │ counted in steps of the stride                                                            │ +│duplicate frames        │ ok     │ 0      │                                                                                           │ +│out-of-order frames     │ ok     │ 0      │                                                                                           │ +│timestamp-step outliers │ ok     │ 0      │ median step 65536 ticks                                                                   │ +│decode errors           │ ok     │ 0      │                                                                                           │ +│decode warnings         │ ok     │ 0      │                                                                                           │ +│flag words              │ ok     │ 0      │ hit words with bit 31 set                                                                 │ +│unmapped payload banks  │ ok     │ 0      │ ports absent from the 0x41 payload map, decoded as fADC250                                │ +│AIS entries identical   │ info   │ 86,794 │ of 86794 frames with an aggregation info segment: the segment repeats one stream's entry  │ +│                        │        │        │ in every slot; it does not list the streams                                               │ + o Open  1 Overview  2 Plots  3 Records  4 Structure  q Quit ▏^p palette + + + diff --git a/docs/images/console_gui_plots_file.svg b/docs/images/console_gui_plots_file.svg new file mode 100644 index 0000000..c62e398 --- /dev/null +++ b/docs/images/console_gui_plots_file.svg @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + pyevio + + + + + + + + + + ⭘pyevio — /data/evio-lib/corpus/sro_000791.evio.00000.v6 +sro_000791.evio.00000.v6 — analysis done (7,891 records, cached) +OverviewPlotsRecordsStructure +━━━━━━━━━━╸━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ +▊File▼▎▊ROC 7 (fADC250)▼▎▊port 1▼▎▊ch 0▼▎f/r/c level · ]/[ ROC · }/{ port · +/- channel · Tab next plot · s save PNG · S save all +▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ +╭─ Hits per frame — file ──────────────────────────────────────────────────────────────────────────────────────────────────────╮╭─ Hit rate — file ─────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐││┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐│ +│30k┤│││150M┤││ +││││││⠤⠤⠤⠤⠤⢤⣀⡤⠤⠤⠤⠤⢤⣀⡤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⣄⣀⣀⣀⣠⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤⣄⣠⠤⣄⣠⢤⣀⡤⠤⠤⠤⠤⠤⠤⠤⠤⠤⠤││ +││█████││││││ +││█████││││││ +││█████││││││ +│20k┤█████│││100M┤││ +││█████││││││ +││█████││││││ +││█████████││││││ +│10k┤█████████│││ 50M┤││ +││█████████││││││ +││█████████││││││ +││█████████████││││││ +││█████████████││││││ +│  0┤                                                                                                █████████████████████████│││   0┤││ +│└┬───────────────────────┬────────────────────────┬───────────────────────┬───────────────────────┬───────────────────────┘││└┬─────────────────────────┬──────────────────────────┬─────────────────────────┬─────────────────────────┬───────────────┘│ +│02000400060008000││05101520│ +│frameshits per frame││hit rate (Hz)time since the first frame (s)│ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Occupancy (ROC × port) — file ──────────────────────────────────────────────────────────────────────────────────────────────╮ +│┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐│ +││││ +│ROC 7┤964k1.33M2.11M762k905k1.02M2.06M1.01M2.07M730k857k1.42M2.43M02.1M0││ +│ROC 9┤1.66M372k1.26M824k935k1.14M678k1.38M1.66M–1.51M–2.95M–1.88M–││ +│ROC 25┤917k804k1.8M281k1.01M666k1.55M970k1.05M858k578k1.96M1.54M02.55M0││ +│ROC 27┤1.67M373k1.26M813k946k1.11M591k1.53M1.59M–1.58M–3.15M–1.92M–││ +│ROC 29┤23.2M25.7M22.8M26.2M11.7M21.6M10.5M22.1M48.4M–71.1M–38.1M–36.9M–││ +│ROC 44┤––00000000000000││ +│ROC 45┤––00000000000000││ +│ROC 46┤––00000000000000││ +│ROC 53┤––00000000000000││ +│ROC 54┤––00000000000000││ +│ROC 55┤––00000000000000││ +│ROC 84┤24.3M24M22.7M25.2M10.6M23.1M9.52M22.6M45.3M–52.1M–39M–38M–││ +││││ +││││ +│└──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┘│ +│12345678910111213141516│ +│ROCpayload port│ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + f File  r ROC  c Channel  s Save PNG  S Save all  o Open  1 Overview  2 Plots  3 Records  4 Structure  q Quit ▏^p palette + + + diff --git a/docs/images/console_gui_plots_roc.svg b/docs/images/console_gui_plots_roc.svg new file mode 100644 index 0000000..3547bd7 --- /dev/null +++ b/docs/images/console_gui_plots_roc.svg @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + pyevio + + + + + + + + + + ⭘pyevio — /data/evio-lib/corpus/sro_000791.evio.00000.v6 +sro_000791.evio.00000.v6 — analysis done (7,891 records, cached) +OverviewPlotsRecordsStructure +━━━━━━━━━━╸━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎ +▊ROC▼▎▊ROC 7 (fADC250)▼▎▊port 1▼▎▊ch 0▼▎f/r/c level · ]/[ ROC · }/{ port · +/- channel · Tab next plot · s save PNG · S save all +▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎ +╭─ Occupancy (port × channel) — ROC 7 ─────────────────────────────────────────────────────────────────────────────────────────╮╭─ Hit rate — ROC 7 ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│port 1┤49.1k066.6k73.7k42.6k72.7k64.6k70.8k66.2k87.6k41.9k56.9k52.4k110k57.7k51.6k│││┌───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐│ +│port 2┤88.1k62.5k80.9k84.8k77.1k75.8k65.4k85.7k102k102k78.4k71.3k90.6k114k82.4k71.5k│││4M┤││ +│port 3┤147k143k145k110k283k279k190k235k324k242k14.7k00000││││⣀⣀⢀⣀⡀⢀⣀⡀⢀⣀⡀⣀⣀⣀⡀⣀⣀⢀⣀⡀⣀⣀⣀⡀⢀⣀⡀⢀⣀⡀⢀⣀⣀⣀⣀⣀⣀⣀⣀⣀││ +│port 4┤57.7k112k84.3k103k1.83k11.3k20.8k17.6k19.5k32.3k40.7k62.8k57.2k46.2k50.2k44.9k││││⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠁⠈⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠁⠈⠉⠉⠉⠉⠁⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠁⠈⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠁⠉⠉⠉⠉⠉⠉⠉⠉⠉⠉⠈⠉⠉⠉⠉⠉⠉⠉⠉⠁⠈⠉⠁⠈⠉⠉⠉⠉⠁││ +│port 5┤17k20.9k48.3k17.6k47.8k47.9k37.9k41.1k51.3k75.1k82.8k92.5k55.7k102k77.6k89.4k│││3M┤││ +│port 6┤40.3k45k48.7k52.7k56.7k50.6k94k58.9k52.5k63.6k71.1k97.9k62.5k66.9k70.4k87.6k││││││ +│port 7┤256k270k259k241k259k275k234k188k14.1k1.89k4.94k5.25k8.39k13.3k17.9k11.6k││││││ +│port 8┤93.6k144k119k95.7k82.4k157k122k107k010.2k13.8k9.18k11.1k13k19.1k15.8k││││││ +│port 9┤55k39k78.9k84.1k110k126k145k97.1k143k121k278k173k202k101k105k214k│││2M┤││ +│port 10┤23.4k24.4k35.6k34.1k39.4k34.4k37.3k42.8k44.2k46.5k48.8k55.8k65.8k57.1k70.5k70.2k││││││ +│port 11┤131k144k163k07495.01k1.95k11.9k18.6k32.7k48.7k56.1k49.5k65.8k53.2k75.4k││││││ +│port 12┤80.7k97.6k128k93.9k193k117k105k130k168k181k131k00000│││1M┤││ +│port 13┤157k111k123k131k165k172k136k184k142k141k152k173k173k152k165k146k││││││ +│port 14┤0000000000000000││││││ +│port 15┤051.9k61.2k58.2k179k215k196k204k161k192k128k188k89.9k151k111k109k││││││ +│port 16┤0000000000000000│││ 0┤││ +│└─────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┘││└┬──────────────────────────┬──────────────────────────┬─────────────────────────┬──────────────────────────┬───────────────┘│ +│0123456789101112131415││05101520│ +│payload portchannel││hit rate (Hz)time since the first frame (s)│ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ Charge — ROC 7 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────╮╭─ Time in frame — ROC 7 ───────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐││┌─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐│ +│100M┤│││400k┤││ +││███││││││ +│ 10M┤███││││││ +│  1M┤███│││300k┤█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████││ +││█████││││█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████││ +│100k┤███████││││█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████││ +││████████││││█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████││ +│ 10k┤████████████│││200k┤█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████││ +││████████████████││││█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████││ +│  1k┤████████████████████││││█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████││ +│ 100┤███████████████████████│││100k┤█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████││ +││███████████████████████████││││█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████││ +│  10┤█████████████████████████████││││█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████││ +│   1┤██████████████████████████████████││││█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████││ +││█████████████████████████████████                                                                                       │││   0┤█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████││ +│└┬────────────────────────────┬────────────────────────────┬────────────────────────────┬────────────────────────────┬───┘││└┬────────────────────────────────────┬───────────────────────────────────┬────────────────────────────────────┬──────────┘│ +│02000400060008000││020k40k60k│ +│hits (log)charge (13-bit ADC units)││hitsns from the frame start│ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + f File  r ROC  c Channel  s Save PNG  S Save all  o Open  1 Overview  2 Plots  3 Records  4 Structure  q Quit ▏^p palette + + + diff --git a/docs/images/console_gui_records.svg b/docs/images/console_gui_records.svg new file mode 100644 index 0000000..89df9b0 --- /dev/null +++ b/docs/images/console_gui_records.svg @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + pyevio + + + + + + + + + + ⭘pyevio — /data/evio-lib/corpus/sro_000791.evio.00000.v6 +sro_000791.evio.00000.v6 — analysis done (7,891 records, cached) +OverviewPlotsRecordsStructure +━━━━━━━━━━━━━━━━━╸━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +rows 0—499 of 7,892 records  ·  Enter opens the record  ·  ] next page  [ previous page + #    offset      size (B)   events decl / found  type  compression  last-record bit  index  truncated  header quirk  errors  +   0  0x00000038  2,028,000  11 / 11              0     none         —                44 B   —          —             —       +   1  0x001EF218  1,984,692  11 / 11              0     none         —                44 B   —          —             —       +   2  0x003D3ACC  1,995,068  11 / 11              0     none         —                44 B   —          —             —      ▇▇ +   3  0x005BAC08  1,973,420  11 / 11              0     none         —                44 B   —          —             —       +   4  0x0079C8B4  1,970,972  11 / 11              0     none         —                44 B   —          —             —       +   5  0x0097DBD0  1,995,460  11 / 11              0     none         —                44 B   —          —             —       +   6  0x00B64E94  1,996,516  11 / 11              0     none         —                44 B   —          —             —       +   7  0x00D4C578  1,969,436  11 / 11              0     none         —                44 B   —          —             —       +   8  0x00F2D294  1,966,640  11 / 11              0     none         —                44 B   —          —             —       +   9  0x0110D4C4  1,973,984  11 / 11              0     none         —                44 B   —          —             —       +  10  0x012EF3A4  1,956,088  11 / 11              0     none         —                44 B   —          —             —       +  11  0x014CCC9C  1,953,812  11 / 11              0     none         —                44 B   —          —             —       +  12  0x016A9CB0  1,975,084  11 / 11              0     none         —                44 B   —          —             —       +  13  0x0188BFDC  1,955,728  11 / 11              0     none         —                44 B   —          —             —       +  14  0x01A6976C  1,988,448  11 / 11              0     none         —                44 B   —          —             —       +  15  0x01C4EECC  1,983,492  11 / 11              0     none         —                44 B   —          —             —       +  16  0x01E332D0  1,944,884  11 / 11              0     none         —                44 B   —          —             —       +  17  0x0200E004  1,994,800  11 / 11              0     none         —                44 B   —          —             —       +  18  0x021F5034  1,973,148  11 / 11              0     none         —                44 B   —          —             —       +  19  0x023D6BD0  1,957,604  11 / 11              0     none         —                44 B   —          —             —       +  20  0x025B4AB4  1,956,204  11 / 11              0     none         —                44 B   —          —             —       +  21  0x02792420  1,952,712  11 / 11              0     none         —                44 B   —          —             —       +  22  0x0296EFE8  1,945,124  11 / 11              0     none         —                44 B   —          —             —       +  23  0x02B49E0C  1,999,924  11 / 11              0     none         —                44 B   —          —             —       +  24  0x02D32240  1,970,052  11 / 11              0     none         —                44 B   —          —             —       +  25  0x02F131C4  1,980,108  11 / 11              0     none         —                44 B   —          —             —       +  26  0x030F6890  1,990,636  11 / 11              0     none         —                44 B   —          —             —       +  27  0x032DC87C  1,976,200  11 / 11              0     none         —                44 B   —          —             —       +  28  0x034BF004  1,975,888  11 / 11              0     none         —                44 B   —          —             —       +  29  0x036A1654  2,015,524  11 / 11              0     none         —                44 B   —          —             —       +  30  0x0388D778  1,960,308  11 / 11              0     none         —                44 B   —          —             —       +  31  0x03A6C0EC  1,976,624  11 / 11              0     none         —                44 B   —          —             —       +  32  0x03C4EA1C  1,967,952  11 / 11              0     none         —                44 B   —          —             —       +  33  0x03E2F16C  1,974,444  11 / 11              0     none         —                44 B   —          —             —       +  34  0x04011218  1,956,644  11 / 11              0     none         —                44 B   —          —             —       +  35  0x041EED3C  1,980,808  11 / 11              0     none         —                44 B   —          —             —       +  36  0x043D26C4  1,966,092  11 / 11              0     none         —                44 B   —          —             —       +  37  0x045B26D0  1,928,136  11 / 11              0     none         —                44 B   —          —             —       + ] Next page  [ Previous page  o Open  1 Overview  2 Plots  3 Records  4 Structure  q Quit ▏^p palette + + + diff --git a/docs/images/console_gui_structure.svg b/docs/images/console_gui_structure.svg new file mode 100644 index 0000000..5d2110a --- /dev/null +++ b/docs/images/console_gui_structure.svg @@ -0,0 +1,249 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + pyevio + + + + + + + + + + ⭘pyevio — /data/evio-lib/corpus/sro_000791.evio.00000.v6 +sro_000791.evio.00000.v6 — analysis done (7,891 records, cached) +OverviewPlotsRecordsStructure +━━━━━━━━━━━━━━━━━━━━━━━━━━╸━━━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +▼ file — v6, 7891 records + trailer│node 'r0/e0/b120/b128/b140' +├── ▼ record 0 — 11 events, 2,028,000 B│kind          bank +│   ├── ▼ event 0 — tag 0xFF60 stream frame, 44956 words▁▁│structure     Segment +│   │   ├── ▶ stream info — tag 0xFF31  type 0x20  num 0  3│tag           0x0041 (65) +│   │   ├── ▼ ROC bank 44 stream 1 — tag 0x002C  type 0x10 │data_type     0x05 +│   │   │   ├── ▼ ROC stream info — tag 0xFF30  type 0x20  │num           — +│   │   │   │   ├── time slice — tag 0x0031  type 0x01  3 w│pad           2 +│   │   │   │   └── payload-port map — tag 0x0041  type 0x0│length        4 +│   │   │   ├── payload port 3 — tag 0x0003  type 0x00  num│offset        0x0140 (320) +│   │   │   ├── payload port 5 — tag 0x0005  type 0x00  num│size          20 +│   │   │   ├── payload port 7 — tag 0x0007  type 0x00  num│type_name     Unknown +│   │   │   ├── payload port 9 — tag 0x0009  type 0x00  num│is_container  no +│   │   │   ├── payload port 11 — tag 0x000B  type 0x00  nu│role          payload_map +│   │   │   ├── payload port 13 — tag 0x000D  type 0x00  nu│ +│   │   │   └── payload port 15 — tag 0x000F  type 0x00  nu│payload-port map +│   │   ├── ▶ ROC bank 44 stream 2 — tag 0x002C  type 0x10 │entry  port  module id  module +│   │   ├── ▶ ROC bank 45 stream 1 — tag 0x002D  type 0x10 │    0     3          1  DCRB +│   │   ├── ▶ ROC bank 45 stream 2 — tag 0x002D  type 0x10 │    1     5          1  DCRB +│   │   ├── ▶ ROC bank 46 stream 1 — tag 0x002E  type 0x10 │    2     7          1  DCRB +│   │   ├── ▶ ROC bank 46 stream 2 — tag 0x002E  type 0x10 │    3     9          1  DCRB +│   │   ├── ▶ ROC bank 53 stream 1 — tag 0x0035  type 0x10 │    4    11          1  DCRB +│   │   ├── ▶ ROC bank 53 stream 2 — tag 0x0035  type 0x10 │    5    13          1  DCRB +│   │   ├── ▶ ROC bank 54 stream 1 — tag 0x0036  type 0x10 │    6    15          1  DCRB +│   │   ├── ▶ ROC bank 54 stream 2 — tag 0x0036  type 0x10 │ +│   │   ├── ▶ ROC bank 55 stream 1 — tag 0x0037  type 0x10 │raw words +│   │   ├── ▶ ROC bank 55 stream 2 — tag 0x0037  type 0x10 │0x00000140  41850004 01850183 01890187 018d018b 0000018f +│   │   ├── ▶ ROC bank 7 stream 1 — tag 0x0007  type 0x10  │words 0–4 of 5, little-endian, offsets in the buffer of record 0  (n: next +│   │   ├── ▶ ROC bank 7 stream 2 — tag 0x0007  type 0x10  │64 words, p: previous) +│   │   ├── ▶ ROC bank 9 stream 1 — tag 0x0009  type 0x10  │ +│   │   ├── ▶ ROC bank 9 stream 2 — tag 0x0009  type 0x10  │ +│   │   ├── ▶ ROC bank 84 stream 1 — tag 0x0054  type 0x10 │ +│   │   ├── ▶ ROC bank 84 stream 2 — tag 0x0054  type 0x10 │ +│   │   ├── ▶ ROC bank 25 stream 1 — tag 0x0019  type 0x10 │ +│   │   ├── ▶ ROC bank 25 stream 2 — tag 0x0019  type 0x10 │ +│   │   ├── ▶ ROC bank 27 stream 1 — tag 0x001B  type 0x10 │ +│   │   ├── ▶ ROC bank 27 stream 2 — tag 0x001B  type 0x10 │ +│   │   ├── ▶ ROC bank 29 stream 1 — tag 0x001D  type 0x10 │ +│   │   └── ▶ ROC bank 29 stream 2 — tag 0x001D  type 0x10 │ +│   ├── ▶ event 1 — tag 0xFF60 stream frame, 45347 words│ +▋│ + n Next words  p Previous words  o Open  1 Overview  2 Plots  3 Records  4 Structure  q Quit ▏^p palette + + + diff --git a/docs/images/web_viewer_overview.png b/docs/images/web_viewer_overview.png new file mode 100644 index 0000000..165d877 Binary files /dev/null and b/docs/images/web_viewer_overview.png differ diff --git a/docs/images/web_viewer_plots_channel.png b/docs/images/web_viewer_plots_channel.png new file mode 100644 index 0000000..38645c8 Binary files /dev/null and b/docs/images/web_viewer_plots_channel.png differ diff --git a/docs/images/web_viewer_plots_file.png b/docs/images/web_viewer_plots_file.png new file mode 100644 index 0000000..4eaa75c Binary files /dev/null and b/docs/images/web_viewer_plots_file.png differ diff --git a/docs/images/web_viewer_plots_roc.png b/docs/images/web_viewer_plots_roc.png new file mode 100644 index 0000000..3614747 Binary files /dev/null and b/docs/images/web_viewer_plots_roc.png differ diff --git a/docs/images/web_viewer_records.png b/docs/images/web_viewer_records.png new file mode 100644 index 0000000..ceb8719 Binary files /dev/null and b/docs/images/web_viewer_records.png differ diff --git a/docs/images/web_viewer_structure.png b/docs/images/web_viewer_structure.png new file mode 100644 index 0000000..9c0670d Binary files /dev/null and b/docs/images/web_viewer_structure.png differ diff --git a/docs/stream_decoding.md b/docs/stream_decoding.md new file mode 100644 index 0000000..dcf15ef --- /dev/null +++ b/docs/stream_decoding.md @@ -0,0 +1,125 @@ +# Streaming fADC250 decoding + +How pyevio reads CODA streaming fADC250 files: the frame layout, the hit word, the time units, the striping of a run across files, the outputs of `stream_stats` and the quirks of the files the JLab SRO hardware writes. The decoder is `pyevio/decoders/fadc250_stream.py`, the statistics module is `pyevio/stream_stats.py`, and `pyevio stats FILE` runs the latter from the command line. + +Every number quoted here was measured on run 791 (`sro_000791.evio.00000` and `.00001`, 15.5 GB each) and on the same data converted to clean EVIO v6, with readers that share no code with pyevio: a standalone walker, the evio C++ library, and the jana4ml4fpga `evio_sro_parser`. + +## Frame layout + +A streaming time frame is one EVIO event. The event is a bank of banks with tag 0xFF60; its first child is the stream info bank (SIB), the rest are ROC banks, one per stream of each crate. + +``` +event bank tag 0xFF60, type 0x10 (bank of banks), num 0 + SIB tag 0xFF31, type 0x20 (bank of segments) + TSS segment tag 0x32, length 3: {frame number, timestamp low, timestamp high} + AIS segment tag 0x42, length 24: aggregated-stream entries + ROC bank tag = ROC id, type 0x10, num = stream status byte + stream-info tag 0xFF30, type 0x20 (bank of segments) + TSS segment tag 0x31, length 3: the same frame number and timestamp as the SIB + payload map tag 0x41: uint16 entries, one per payload port + payload bank tag = payload port, type 0x00, num 0, one word per hit + payload bank tag = payload port, length 1, num 1: an empty port + ... + ROC bank ... +``` + +Facts the decoder relies on, each measured on both raw files with no exception: + +- Every event has tag 0xFF60, type 0x10, num 0 (173,587 events). +- The SIB starts with the TSS segment: header word `0x32010003`, then frame number, timestamp low word, timestamp high word. The decoder reads the frame number and the 64-bit timestamp from here; `stream_stats.scan_frames` reads only these words when it scans a file for its time axis. +- Every ROC bank's stream-info TSS (tag 0x31) carries the same frame number and timestamp as the SIB (0 mismatches). +- Each crate sends two streams, so each ROC id appears as two ROC banks per frame. The `num` byte of the ROC bank is the stream status: bits 6—4 hold the stream count (2) and bits 3—0 the stream mask (1 or 2), so the two banks carry num 0x21 (33) and 0x22 (34). The mask is the stream identity. The port split between the two streams is odd/even in 8 of the 12 crates of run 791 and 5, 7, 9, 11, 13, 15 against 1, 2, 3, 4, 6, 8 in ROC ids 9, 27, 29 and 84, so port parity identifies nothing. The decoder keeps the mask as the `stream` column; `stream_stats` merges both streams of a crate by ROC id. +- Run 791 has 12 crates, so every frame holds 24 ROC banks. +- The payload map (segment 0x41) holds uint16 entries, two per word, read in memory order (low half first in a little-endian file); pad value 2 in the segment header means an odd entry count. An entry is `module << 8 | port`: bits 11—8 module id, bit 7 bond, bits 6—5 lane, bits 4—0 port. Module 0 is fADC250, module 1 is DCRB. Every payload bank's tag appears in its stream's map (0 exceptions in 14.2 million payload banks per file). +- Every payload bank declares data type 0x00. A bank with data has num 0; an empty bank has length 1 and num 1. Both are valid. File 00000 holds 357,735 empty banks among 14,233,888 payload banks (2.51%), all on ROC ids 7, 9, 25 and 27; ports 14 and 16 of ROC ids 7 and 25 are mapped and never carry a hit. + +The decoder walks this tree by length words and trusts none of them: every bank end is checked against its container, and a bad length stops the walk of that event with a counted error (`errors`, `error_messages`, the first 50 messages kept) instead of an exception. A ROC bank without a stream-info bank, or a port absent from the map, is decoded as fADC250 and counted in `n_unmapped_payload_banks`. The decoder reads the AIS segment only to count its entries and their distinct values (`n_ais_entries`, `n_ais_distinct` per frame): ROC banks come from the walk. A malformed SIB segment after the time slice segment (a length past the SIB end, or an AIS with no entries) touches no hit, so it is a warning (`warnings`, `warning_messages`, per frame and in total), not an error, and `validate()` passes. + +## Hit word + +One `uint32` per hit in an fADC250 payload bank: + +| bits | field | range | +|---|---|---| +| 31 | flag | never set in the SRO data (0 words in either raw file); `n_flag_words` counts it | +| 30—17 | time from the frame start, in 4 ns ticks | 0—16,383 ticks = 0—65,532 ns | +| 16—13 | fADC250 channel | 0—15 | +| 12—0 | charge | 0—8,191; 8,191 means overflow | + +`unpack_hit_words` splits the columns; `t_ns = time_raw × 4`. `validate_hit_words` rejects words whose raw time field exceeds 14 bits, which happens exactly when bit 31 is set. + +Evidence for the layout: the jana4ml4fpga `evio_sro_parser`, written independently of pyevio, decodes the same bit positions, and its hit counts per (ROC id, port) agree with pyevio for all 76 instrumented pairs in both raw files (763,992,122 hits in the complete blocks of file 00000, 764,052,526 in file 00001). The port totals are also fixed by the bank structure: hits = Σ(payload length − 1) over fADC250 ports. The per-channel split rests on the bit layout alone, and the DAQ group has not confirmed it. + +## Module types + +The module id of a port comes from the payload map of its ROC bank. In run 791 the map says fADC250 (module 0) on ROC ids 7, 9, 25, 27, 29 and 84, and DCRB (module 1) on ROC ids 44, 45, 46, 53, 54 and 55. ROC ids 7, 9, 25 and 27 map 16 ports, 29 and 84 map 12 (ports 10, 12, 14 and 16 absent), and the DCRB crates map ports 3—16. + +Only fADC250 payload words become hits. Payload words of any other module are counted: per ROC bank in `roc_banks["non_fadc_words"]`, per bank in the `non_fadc_banks` table, and in the total `non_fadc_words`. DCRB payload words are two-word channel masks (the independent parser reads word 0 as a 3-bit channel group and a 29-bit mask, word 1 as a 19-bit mask continuation and an 11-bit time); pyevio decodes nothing from them. `StreamHits.validate()` checks `n_hits + non_fadc_words == n_payload_words` and that the per-frame hit counts add up. + +Record 0 of the converted file 00000 decodes to 499,660 payload words in 264 ROC banks and 1,804 payload banks (45 empty). + +## Time + +Two tick lengths are in play: + +- The hit time inside a frame is a 14-bit field in 4 ns ticks, the fADC250 sample period (`HIT_TIME_TICK_NS = 4`, fixed). The largest value is 16,383 × 4 ns = 65,532 ns. +- The frame timestamp (TSS) counts ticks whose length is the knob `tick_ns` of `stream_stats` (`compute_stream_stats(..., tick_ns=1.0)`, `pyevio stats --tick-ns 1`), default 1 ns. The timestamp step per frame number, measured as median = min = max on all four files, is 65,536 ticks, which equals 2^14 hit ticks × 4 ns at a 1 ns timestamp tick. The DAQ group has not confirmed the tick length; a different length goes into the knob. + +With the default, one frame lasts 65.536 µs. `stream_stats` derives the frame period as the median timestamp step per frame number (`checks.frame_period_ticks`), so a wrong knob scales every time quantity by one factor: + +- `duration_s`: first to last timestamp plus one frame period — the span of the run the file covers. +- `live_time_s`: frames in the file × frame period — the time the file records. +- `rate_hz(...)`: hits per time bin divided by the frames the bin covers × frame period, so a partial bin, a gap and a stripe of a striped run all read right. + +On file 00000 the span is 22.752 s and the live time 5.688 s: the file holds one frame in four. + +## Striping across files + +A run is written round-robin by frame over its files: `.evio.0000N` holds frames N, N + 4, N + 8, … for a four-way split. Measured on stripes 0 and 1 of run 791: first frames 0 and 1, every frame delta 4, 0 missing, 0 duplicate, 0 out of order, 0 timestamp steps other than 65,536 per frame; frame 0 with timestamp 0 is a real frame. + +`stream_stats` reads the stride from the data as the most common positive frame delta between consecutive frames (`checks.stride`) and counts missing frames against it: `(last − first) / stride + 1 − distinct frames`, so a stripe of a split run reports 0 missing frames instead of three in four. The other frame checks: `duplicate_frames` (frame numbers seen more than once), `out_of_order` (consecutive frames whose number decreases) and `dts_outliers` (consecutive frames whose timestamp step per frame deviates from the median by more than 1%). + +Statistics are per file. For a whole run, the charge, time-in-frame, occupancy and empty-bank histograms of its stripes add, once their ROC and port axes are aligned. Each file's rate axis runs from its own first to its last timestamp, so rate series of different stripes are compared bin by bin only when the files cover the same span. + +## `stream_stats` outputs + +`compute_stream_stats(path, workers=None, rate_bins=256, tick_ns=1.0, max_records=None, chunk_records=8)` makes two passes: a serial scan of every event's SIB for frame numbers and timestamps, which fixes the time axis and the frame checks, then a parallel decode by record ranges (`workers` processes, default cores minus two; `workers=1` runs in-process). Each worker opens the file itself and returns partial histograms whose axes grow with the ROC and port sets it meets; the partials add. + +`StreamStats` fields: + +| field | shape | content | +|---|---|---| +| `rocs`, `ports` | (R,), (P,) | sorted axes of every histogram; `roc_index(roc)` maps an id to its row | +| `module_id` | (R, P) | module behind each port, 0xFF where the port never appeared | +| `module_ids`, `module_words` | (M,), (M,) | payload words per module id; fADC250 hits count as words | +| `rate_counts`, `rate_edges_ns`, `frames_per_rate_bin` | (R, P, 16, B), (B + 1,), (B,) | hits per time bin from the first to the last timestamp, B = `rate_bins`; `rate_hz()` normalizes | +| `occupancy` | (R, P, 16) | hits per channel | +| `charge_hist`, `charge_edges` | (R, P, 16, 128), (129,) | 13-bit charge in 128 bins of 64 | +| `time_hist`, `time_edges_ns` | (R, P, 16, 64), (65,) | 14-bit tick field in 64 bins of 256 ticks = 1,024 ns | +| `hits_per_frame_hist`, `hits_per_frame_edges` | (64,), (65,) | hits per frame in 64 linear bins from 0 to the largest count | +| `empty_banks` | (R, P) | empty payload banks per port | +| `non_fadc_words_per_port` | (R, P) | words of other modules per port | +| `frames` | (F,) structured | per frame: event index, frame number, timestamp, hits, other-module words, errors, warnings, AIS entries and distinct values; sorted by event index | +| `checks` | `FrameChecks` | frame count, first and last frame and timestamp, stride, frame period, missing, duplicate, out-of-order frames, timestamp-step outliers | + +Scalars: `n_records` (data records), `n_trailer_records`, `n_events`, `n_frames`, `n_other_events` (events that are no 0xFF60 frame), `n_out_of_bounds_events` (events whose range runs past their record; counted and skipped), `n_hits`, `n_payload_banks`, `n_empty_payload_banks`, `n_unmapped_payload_banks`, `n_payload_words`, `non_fadc_words`, `n_flag_words`, `errors`, `error_messages` (first 50), `warnings`, `warning_messages` (first 50), `frames_with_ais`, `frames_with_degenerate_ais`, plus `path`, `file_size`, `bytes_processed`, `tick_ns`, `workers`, `wall_seconds`. `per_roc(hist)` sums a (R, P, 16, bins) histogram to (R, bins); `hits_per_roc()` returns `{roc: hits}`. + +`save(path)` writes a compressed `.npz`: one entry `meta`, a JSON string with the scalar fields, `version` and the `checks` dictionary, and one array entry per array field. `StreamStats.load(path)` opens it with `allow_pickle=False` and raises `ValueError` unless `version` equals the `STATS_VERSION` of the running pyevio (`"3"`). The full run-791 file produces a 1.6 MB `.npz`. + +`pyevio stats` prints the totals, the payload words per module, the frame checks, span against live time, the fADC250 hit rate of live time, the wall time with MB/s, and a per-ROC table (module, ports, hits, other words, empty banks); `--out FILE.npz` saves the result, `--records N` stops after N records. + +Measured on run 791: the converted file 00000 (15.55 GB) through `pyevio stats` with 22 workers in 30.5 s (509 MB/s), the raw file with 20 workers in 25.6 s (607 MB/s); 86,794 frames; 764,009,774 fADC250 hits = the independent reference for the complete blocks (763,992,122) + the 17,652 hits of the two frames salvaged from the cut tail, exact per ROC id; 3,065,574,754 DCRB words; 357,743 empty banks. The decoder alone runs at 966 MB/s on record 0 and 686 MB/s over 200 records on one core. + +## Known file quirks and how pyevio reports them + +The raw files of the SRO hardware deviate from the evio-library output in five ways. Each is read as the hardware writes it. + +| quirk | what the data shows | what pyevio does | +|---|---|---| +| 8-word block headers, no file header | every block header is `{length, block number, 8, event count, 0, 4, 0, 0xc0da0100}`: version word 4 with no bit-info bits | `EvioFile.header_layout == "sro-8word"`; `header` is a stand-in `FileHeader` with `version` 6 and `container_version` 4; record headers have `header_length == 8`; `first_record_offset` is 0 | +| no last-record bit, no trailer | the bit is set on no block; the file ends after its last block | the record scan continues while a record header follows and ends at the end of the file; `EvioFile.trailer` is `None` | +| truncated tail | 3 of the 4 files end in a block cut mid-event: file 00000 declares 1,967,400 bytes and holds 531,760 (2 complete events); file 00001 declares 1,960,136 and holds 1,939,708 (10 complete events) | the block stays the last record with `is_truncated` set; its complete events are read; `EvioFile.checks` holds `("truncated_tail", declared, present, salvaged)` — `(1967400, 531760, 2)` and `(1960136, 1939708, 10)`; `record_count` counts the tail, `get_total_event_count()` counts its salvaged events (86,794 and 86,805) | +| AIS repeated entry | the AIS segment (tag 0x42) repeats one entry 24 times in every event instead of listing 24 streams (4 distinct values per file) | the decoder counts the entries and their distinct values per frame (`n_ais_entries`, `n_ais_distinct`); `stream_stats` counts `frames_with_ais` and `frames_with_degenerate_ais` (several entries, one value); the file check "AIS entries identical" shows the degenerate-frame count as `info`, `ok` when it is 0. ROC banks come from the walk. | +| empty payload banks | length 1, num 1 on ports with no hit in the frame | counted per (ROC, stream, port) in `empty_banks`; `stream_stats.empty_banks` sums them per (ROC, port) | + +Two more checks exist for any file: `("bad_record_header", offset, message)` in `EvioFile.checks` when the scan meets bytes that are no record header (a byte-swapped header and a second file header are named), and `Record.errors` when an event length runs past its record. `stream_stats` counts events past their record in `n_out_of_bounds_events`, decode errors in `errors` and decode warnings in `warnings`; the viewer shows both as file checks ("decode errors" at error severity, "decode warnings" at warn). diff --git a/docs/web_viewer.md b/docs/web_viewer.md new file mode 100644 index 0000000..0a2936d --- /dev/null +++ b/docs/web_viewer.md @@ -0,0 +1,95 @@ +# Web viewer + +`pyevio web` opens a page for browsing an EVIO v6 file: file checks, channel occupancy, rate, charge and timing plots, a record table and a structure tree from the file header down to single fADC hit words. pyevio reads and decodes the file on the server; the browser receives JSON (histograms, record headers, tree nodes, requested words) and renders it. The file never leaves the machine it is on, and a 15 GB file produces the same few kilobytes per response as a 90 MB one. + +Plots are written for CODA streaming fADC250 files (0xFF60 built time frames). Any other EVIO v6 file shows the record table and the structure tree. + +## Install and start + +The server needs FastAPI and uvicorn, installed as the optional extra: + +```bash +pip install 'pyevio[web]' +``` + +```bash +pyevio web # serve the current folder +pyevio web /path/to/runs # serve another folder +pyevio web /path/to/run.evio # serve the file's folder and open the file at once +pyevio web /path/to/runs --port 9000 --no-browser --workers 8 --cache-dir /scratch/pyevio-cache +``` + +The command prints a URL of the form `http://127.0.0.1:8765/?token=…` and opens it in a browser (unless `--no-browser`). The token is random per start and required on every API request: another user on the same machine cannot read your files by guessing the port. Open the printed URL, including the token; a page without it shows nothing. + +Only files under ROOT (the folder given on the command line, default the current folder) can be listed and opened. Paths are resolved with symlinks before the check, so a link that points outside ROOT is refused. + +### Analysis and cache + +Opening a file starts one analysis job on the server (`pyevio stats` on all cores minus two, or `--workers N`); the page shows its progress and the estimated time to completion. Opening another file cancels the running job. Results are cached as `.npz` under `~/.cache/pyevio/stats` (or `--cache-dir DIR`), keyed by the file path, size and modification time, so a second open of an unchanged file is immediate. An entry written by an older statistics version is recomputed and overwritten in place, so a pyevio upgrade leaves no stale files behind. The cache folder can be deleted at any time. + +### Files on a remote machine + +On a farm node, run the server there and forward the port over SSH: + +```bash +# on the node +pyevio web /path/to/runs --no-browser +# on your machine +ssh -L 8765:localhost:8765 user@node +``` + +Then open the URL the server printed on the node, token included, in a local browser. The server listens on 127.0.0.1 only and rejects requests whose `Host` header is not `localhost` or `127.0.0.1`, which also blocks web pages that reach the port through DNS rebinding. + +`--verbose` logs every request; by default only errors are logged. + +## What it shows + +The page is five static files (`index.html`, `style.css`, `app.js`, `charts.js`, `tree.js`) served by the package; no build step and no external resources, so it works on a farm node without internet. Pick a file in the folder browser at the top; the status strip shows the analysis progress (records done, time left) and the tabs enable when the analysis is done. Opening another file cancels the running analysis and switches the page to the new file. Errors from the server (a file that is not EVIO, a failed analysis, a lost connection) appear in red under the status line. + +### Overview + +Totals (records, events, stream frames, hits, hit rate, hits per frame, duration, decode errors), the file checks with their status, "what stands out" (the failed and warning checks first), the file facts (header layout, byte order, trailer position as the file header stores it, salvaged events of a truncated tail) and one row per ROC. Ratios with a zero denominator print as `n/a`. + +![Overview tab](images/web_viewer_overview.png) + +### Plots + +All histograms are computed by `pyevio stats` on the server; the browser draws the bins it receives. + +**File:** hits per frame, total hit rate over time (seconds from the first frame; empty bins are frames without hits or missing frames) and an occupancy heatmap of hits per ROC and payload port. Grey cells are ports the ROC's 0x41 map does not list. + +![File-level plots](images/web_viewer_plots_file.png) + +**Per ROC:** pick a ROC (or click a cell in the occupancy map): its port × channel occupancy, hit rate over time, charge spectrum and hit time within the frame. + +![Per-ROC plots](images/web_viewer_plots_roc.png) + +**Per channel:** pick port and channel, step with Prev / Next, or click a cell in the ROC map. + +![Per-channel plots](images/web_viewer_plots_channel.png) + +### Records + +A paged table of the record headers: offset, size, events declared and found, header type, compression, last-record bit, index, truncation, header quirks and walk errors. Click a row to open the record in the Structure tab. + +![Records tab](images/web_viewer_records.png) + +### Structure + +The tree pyevio builds: file → records → events → banks and segments → hit words, loaded as nodes are expanded (pages of 500 children). Inside a stream frame the nodes are labeled by role: stream info with its time-slice and aggregation-info (AIS) segments, ROC banks with ROC id and stream, ROC stream info with the payload-port map (its entries decoded by pyevio), payload banks with port, module and hit count. Selecting a node shows its header fields and its raw words; events of LZ4 or gzip records are read from the decompressed record buffer. + +![Structure tab](images/web_viewer_structure.png) + +## Data format + +The stream format (0xFF60 built time frames, 0xFF31/0xFF30 stream-info banks, the 0x41 payload-port map, the hit word `t = bits[30:17] × 4 ns, ch = bits[16:13], q = bits[12:0]`) is decoded in one place, `pyevio/decoders/fadc250_stream.py`; the viewer holds no copy of it. See [stream_decoding.md](stream_decoding.md). + +## Convert to Parquet + +[examples/fadc250_stream_to_parquet.py](../examples/fadc250_stream_to_parquet.py) decodes the same hits with pyevio and numpy into two Parquet tables, for analysis outside the browser (requires `pip install pyarrow`): + +```bash +python examples/fadc250_stream_to_parquet.py run.evio # → out/run.hits.parquet, out/run.frames.parquet +``` + +Hits (`frame, roc, port, ch, t_ns, q`) are sorted by `(frame, t_ns)`. Rows `hit_offset … hit_offset + n_hits` of the hits table belong to that frame. diff --git a/examples/fadc250_stream_to_parquet.py b/examples/fadc250_stream_to_parquet.py new file mode 100644 index 0000000..d124a62 --- /dev/null +++ b/examples/fadc250_stream_to_parquet.py @@ -0,0 +1,145 @@ +"""Decode CODA streaming fADC250 hits from an EVIO v6 file into Parquet. + +Container access goes through pyevio; the frame walk and the hit word +layout live in pyevio.decoders.fadc250_stream. Requires pyarrow +(pip install pyarrow). Browse the same files with `pyevio web`. + +Usage: + python fadc250_stream_to_parquet.py run.evio # writes out/run.hits.parquet + out/run.frames.parquet + python fadc250_stream_to_parquet.py runs/*.evio --out out --force + +Read the output: + hits = pq.read_table("out/run.hits.parquet") # frame, roc, stream, port, ch, t_ns, q (sorted by frame, event, t_ns) + frames = pq.read_table("out/run.frames.parquet") # frame, timestamp_ns, n_hits, hit_offset (sorted by frame, event) + +Every hit of a file stays in memory until it is written, so a 15 GB SRO file +needs more RAM than most hosts have; this example is for files up to a few GB. +""" +import argparse +import sys +import time +from pathlib import Path + +import numpy as np +from pyevio import EvioFile +from pyevio.decoders import fadc250_stream as fs + +DECODER_VERSION = "2" +HIT_FORMAT = fs.HIT_FORMAT + + +# ---------------------------------------------------------------- decoding +def decode(path): + """Return (hits dict of numpy arrays, frames dict, stats dict, StreamHits). + + Hits are sorted by (frame, event index, t_ns) and frames by (frame, event index), + so the hit ranges in the frames table stay right when a frame number repeats. + """ + with EvioFile(str(path)) as evio_file: + endian = evio_file.header.endian + chunks = [] + out_of_bounds = 0 + event_index = 0 + for record_i in range(evio_file.record_count): + record = evio_file.get_record(record_i) + events = [] + # Event offsets point into record.buffer (the decompressed bytes for LZ4/gzip + # records), never into the file mmap. + buffer = record.buffer + for offset, length in record.get_event_offsets()[:record.header.event_count]: + if offset + length > len(buffer): + out_of_bounds += 1 + continue + events.append(fs.event_words(buffer, offset, length, endian)) + chunks.append(fs.decode_frames(events, first_event_index=event_index)) + event_index += len(events) + stream_hits = fs.concatenate_stream_hits(chunks) + # Drop every view of the mmap before the `with` block closes it (EvioFile.close + # raises BufferError while a numpy view is alive). + del chunks, events, buffer + record_count = evio_file.record_count + + order = np.lexsort((stream_hits.t_ns, stream_hits.event_index, stream_hits.frame)) + hits = { + "frame": stream_hits.frame[order], + "roc": stream_hits.roc[order], + "stream": stream_hits.stream[order], + "port": stream_hits.port[order], + "ch": stream_hits.channel[order], + "t_ns": stream_hits.t_ns[order], + "q": stream_hits.charge[order], + } + frames = stream_hits.frames + out_of_order = int((np.diff(frames["frame"].astype(np.int64)) < 0).sum()) + frame_order = np.lexsort((frames["event_index"], frames["frame"])) + frames = frames[frame_order] + n_hits = frames["n_hits"].astype(np.uint64) + offsets = np.concatenate([[0], np.cumsum(n_hits)[:-1]]) if len(n_hits) else np.empty(0) + frame_tab = {"frame": frames["frame"].astype(np.uint32), "timestamp_ns": frames["timestamp"].astype(np.uint64), + "n_hits": frames["n_hits"].astype(np.uint32), "hit_offset": offsets.astype(np.uint64)} + stats = {"records": record_count, "frames": len(frames), "hits": stream_hits.n_hits, + "skipped_events": stream_hits.n_other_events + out_of_bounds, "out_of_order": out_of_order, + "payload_words": stream_hits.n_payload_words, "non_fadc_words": stream_hits.non_fadc_words, + "empty_payload_banks": stream_hits.n_empty_payload_banks, "decode_errors": stream_hits.errors, + "decode_warnings": stream_hits.warnings} + return hits, frame_tab, stats, stream_hits + + +def validate(stream_hits, frames): + """Self-consistency checks; raises ValueError on failure and returns the frame checks.""" + stream_hits.validate() # decode errors, hit words with bit 31 set, hits + non-fADC words vs payload words + if int(frames["n_hits"].sum()) != stream_hits.n_hits: + raise ValueError("per-frame hit counts do not add up") + frame_numbers = frames["frame"] + if len(frame_numbers): + n_unique = len(np.unique(frame_numbers)) + return {"missing_frames": int(frame_numbers[-1] - frame_numbers[0] + 1 - n_unique), + "duplicate_frames": int(len(frame_numbers) - n_unique)} + return {} + + +def convert(path, out_dir, force=False): + import pyarrow as pa + import pyarrow.parquet as pq + + path, out_dir = Path(path), Path(out_dir) + hits_p = out_dir / f"{path.stem}.hits.parquet" + frames_p = out_dir / f"{path.stem}.frames.parquet" + if not force and hits_p.exists() and hits_p.stat().st_mtime > path.stat().st_mtime: + print(f"skip {path.name} (already converted)") + return + t0 = time.time() + hits, frames, stats, stream_hits = decode(path) + checks = validate(stream_hits, frames) + for message in stream_hits.warning_messages: # malformed SIB segments; no hit depends on them + print(f"warn {path.name}: {message}", file=sys.stderr) + meta = {"source_file": path.name, "source_bytes": str(path.stat().st_size), "decoder_version": DECODER_VERSION, + "hit_format": HIT_FORMAT, **{k: str(v) for k, v in {**stats, **checks}.items()}} + out_dir.mkdir(parents=True, exist_ok=True) + for table, p in ((hits, hits_p), (frames, frames_p)): + t = pa.table(table) + t = t.replace_schema_metadata({k.encode(): v.encode() for k, v in meta.items()}) + pq.write_table(t, p, compression="zstd") + print(f"done {path.name}: {stats['frames']:,} frames, {stats['hits']:,} hits, " + f"{stats['out_of_order']:,} out-of-order, {checks} -> {hits_p.name} " + f"({hits_p.stat().st_size / 1e6:.1f} MB) in {time.time() - t0:.1f} s") + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("files", nargs="+", help=".evio files to convert") + ap.add_argument("--out", default="out", help="output folder (default: ./out)") + ap.add_argument("--force", action="store_true", help="re-convert even if the Parquet is up to date") + args = ap.parse_args(argv) + failed = 0 + for p in args.files: + try: + convert(p, args.out, args.force) + except Exception as e: # keep going with the other files + failed += 1 + print(f"FAIL {p}: {e}", file=sys.stderr) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyevio/__init__.py b/pyevio/__init__.py index 2d986db..a67d4ac 100644 --- a/pyevio/__init__.py +++ b/pyevio/__init__.py @@ -13,5 +13,4 @@ from pyevio.utils import make_hex_dump -__version__ = "0.2.0" - +__version__ = "1.0.0" diff --git a/pyevio/bank.py b/pyevio/bank.py index d5a5464..26b943b 100644 --- a/pyevio/bank.py +++ b/pyevio/bank.py @@ -28,6 +28,14 @@ class Bank(BankHeader): - Word 2: tag (16 bits) | pad (2 bits) | type (6 bits) | num (8 bits) Banks can contain data or other banks, depending on the data_type. + + Every length word counts the words that follow it, so a structure with + length N occupies N + 1 words. Children are typed by the parent's data + type: a bank of banks (0xe/0x10) holds ``Bank`` children, a bank of + segments (0xd/0x20) holds ``Segment`` children (1-word header) and a bank + of tagsegments (0xc) holds ``TagSegment`` children (1-word header). + Reading a segment with the 2-word bank header takes its first payload + word as a bank header. """ # Data type constants @@ -65,38 +73,60 @@ class Bank(BankHeader): TYPE_INT32: np.int32 } - def __init__(self, mm: mmap.mmap, offset: int, endian: str = '<'): + # Header words of this structure kind; subclasses with a 1-word header override it. + HEADER_WORDS = 2 + STRUCTURE_NAME = "Bank" + + def __init__(self, mm: mmap.mmap, offset: int, endian: str = '<', limit: Optional[int] = None): """ Initialize a Bank object. Args: - mm: Memory-mapped file containing the bank - offset: Byte offset in the file where the bank starts + mm: Memory-mapped file (or bytes buffer) containing the bank + offset: Byte offset in the buffer where the bank starts endian: Endianness ('<' for little endian, '>' for big endian) + limit: Byte offset this bank must not reach past (the end of its + parent, event or buffer). Default: the end of the buffer. + A declared length past the limit is recorded in ``errors`` and + the bank is clamped to the limit, so no read runs past it. """ super().__init__() self.mm = mm self.offset = offset self.endian = endian - # Parse bank header + # Why a child walk stopped early or this bank was clamped (short + # strings with offsets); empty when every length fit. + self.errors: List[str] = [] + + self.header_size = self.HEADER_WORDS * 4 + if offset < 0 or offset + self.header_size > len(mm): + raise ValueError(f"{self.STRUCTURE_NAME} header at 0x{offset:X} needs {self.header_size} bytes, " + f"buffer ends at 0x{len(mm):X}") self._parse_header() - # Calculate data offset and size - self.header_size = 8 # 2 words * 4 bytes + # The length word counts the words that follow it, so a structure with + # length N occupies N + 1 words. Counting the length word itself drops + # the last payload word of every leaf. self.data_offset = self.offset + self.header_size - self.data_length = (self.length * 4) - self.header_size - self.size = self.length * 4 - - # End offset for this bank + self.size = (self.length + 1) * 4 self.end_offset = self.offset + self.size + if limit is None: + limit = len(mm) + if self.end_offset > limit: + self.errors.append(f"{self.STRUCTURE_NAME} at 0x{self.offset:X} declares {self.length} words, " + f"ends at 0x{self.end_offset:X} past its limit 0x{limit:X}") + self.end_offset = limit + self.size = max(0, limit - self.offset) + + self.data_length = max(0, self.end_offset - self.data_offset) + # Child banks (will be parsed on demand) self._children = None def _parse_header(self): - """Parse the bank header from the buffer.""" - # Parse 2-word header + """Parse the 2-word bank header from the buffer.""" self.length = struct.unpack(self.endian + 'I', self.mm[self.offset:self.offset+4])[0] bank_info = struct.unpack(self.endian + 'I', self.mm[self.offset+4:self.offset+8])[0] @@ -117,7 +147,12 @@ def _parse_header(self): self.type_name = "PhysicsEvent" @classmethod - def from_buffer(cls, buffer: mmap.mmap, offset: int, endian: str = '<') -> 'Bank': + def read_length(cls, mm, offset: int, endian: str) -> int: + """The length field (words that follow the length word) of the structure at offset.""" + return struct.unpack(endian + 'I', mm[offset:offset + 4])[0] + + @classmethod + def from_buffer(cls, buffer: mmap.mmap, offset: int, endian: str = '<', limit: Optional[int] = None) -> 'Bank': """ Create a Bank object from a memory-mapped buffer. @@ -125,11 +160,12 @@ def from_buffer(cls, buffer: mmap.mmap, offset: int, endian: str = '<') -> 'Bank buffer: Memory-mapped buffer containing the bank offset: Byte offset in the buffer where the bank starts endian: Endianness ('<' for little endian, '>' for big endian) + limit: Byte offset the bank must not reach past (see ``__init__``) Returns: Bank object """ - return cls(buffer, offset, endian) + return cls(buffer, offset, endian, limit) def is_container(self) -> bool: """ @@ -138,7 +174,18 @@ def is_container(self) -> bool: Returns: True if this bank contains other banks, False otherwise """ - return self.data_type in (self.TYPE_BANK, self.TYPE_BANK2, self.TYPE_SEGMENT, self.TYPE_SEGMENT2) + return self.data_type in (self.TYPE_BANK, self.TYPE_BANK2, self.TYPE_SEGMENT, self.TYPE_SEGMENT2, + self.TYPE_TAGSEGMENT) + + def child_class(self) -> Optional[type]: + """The structure class of this container's children, or None for a leaf.""" + if self.data_type in (self.TYPE_BANK, self.TYPE_BANK2): + return Bank + if self.data_type in (self.TYPE_SEGMENT, self.TYPE_SEGMENT2): + return Segment + if self.data_type == self.TYPE_TAGSEGMENT: + return TagSegment + return None def get_children(self) -> List['Bank']: """ @@ -155,64 +202,31 @@ def get_children(self) -> List['Bank']: if self._children is not None: return self._children - # Initialize empty children list self._children = [] - - # Start from the beginning of the data section + child_class = self.child_class() + + # Walk the children by their length words. Every length is checked + # against this bank's end before it is trusted: a child that has no + # room for its header words, declares length 0 with a 2-word header + # (no room for the second header word) or runs past the parent is + # corrupt data. The walk stops there, keeps the children found so far + # and records why. current_offset = self.data_offset - end_offset = self.end_offset + while current_offset < self.end_offset: + if current_offset + child_class.HEADER_WORDS * 4 > self.end_offset: + self.errors.append(f"{child_class.STRUCTURE_NAME} header at 0x{current_offset:X} needs " + f"{child_class.HEADER_WORDS * 4} bytes, parent ends at 0x{self.end_offset:X}") + break - while current_offset < end_offset: - # We need at least 4 bytes to read a length - if current_offset + 4 > end_offset: + child_length = child_class.read_length(self.mm, current_offset, self.endian) + child_end = current_offset + (child_length + 1) * 4 + if child_length + 1 < child_class.HEADER_WORDS or child_end > self.end_offset: + self.errors.append(f"{child_class.STRUCTURE_NAME} at 0x{current_offset:X} declares {child_length} words, " + f"ends at 0x{child_end:X}, parent ends at 0x{self.end_offset:X}") break - try: - # Read bank length - bank_length = struct.unpack(self.endian + 'I', - self.mm[current_offset:current_offset+4])[0] - - # Basic sanity check: bank shouldn't exceed parent boundary - if current_offset + (bank_length * 4) > end_offset: - # If we're near the end (just a word or two left), this might be data padding - # Skip to the next word and continue - current_offset += 4 - continue - - # Even a length of 1 is valid for data banks (just the length word itself) - # In this case the bank has no additional data beyond the length word - - # Check if we have enough space for this bank - if bank_length == 0: - # Zero-length banks are invalid - skip this word - current_offset += 4 - continue - - if current_offset + (bank_length * 4) > end_offset: - # Bank goes beyond parent boundary - skip this word - current_offset += 4 - continue - - # Try to create bank object - try: - child = Bank.from_buffer(self.mm, current_offset, self.endian) - - # Add to children - self._children.append(child) - - # Move to next bank by adding this bank's length - current_offset += bank_length * 4 - - except Exception as e: - # Error creating bank - skip ahead - if self._children and hasattr(self, 'verbose') and self.verbose: - print(f"Error parsing bank at 0x{current_offset:X}: {str(e)}") - current_offset += 4 - continue - - except Exception as e: - # Error reading/processing data - skip ahead - current_offset += 4 + self._children.append(child_class(self.mm, current_offset, self.endian, self.end_offset)) + current_offset = child_end return self._children @@ -246,7 +260,7 @@ def to_numpy(self) -> Optional[np.ndarray]: # Create NumPy array from buffer return np.frombuffer( self.mm[self.data_offset:self.data_offset + (num_elements * element_size)], - dtype=dtype + dtype=np.dtype(dtype).newbyteorder(self.endian) ) def to_string(self) -> Optional[str]: @@ -290,9 +304,9 @@ def get_hex_dump(self, max_bytes: int = 64, title: Optional[str] = None) -> str: def __str__(self) -> str: """Return string representation of the bank header.""" - return f"""Bank (0x{self.tag:04x}, {self.type_name}): + return f"""{self.STRUCTURE_NAME} (0x{self.tag:04x}, {self.type_name}): Offset: 0x{self.offset:08x} - Length: {self.length} words ({self.length * 4} bytes) + Length: {self.length} words ({self.size} bytes with the length word) Tag: 0x{self.tag:04x} Pad: {self.pad} Data Type: 0x{self.data_type:02x} @@ -300,4 +314,49 @@ def __str__(self) -> str: def __repr__(self) -> str: """Return a string representation for debugging.""" - return f"Bank(offset=0x{self.offset:X}, tag=0x{self.tag:04X}, type=0x{self.data_type:02X})" \ No newline at end of file + return f"{self.STRUCTURE_NAME}(offset=0x{self.offset:X}, tag=0x{self.tag:04X}, type=0x{self.data_type:02X})" + + +class Segment(Bank): + """A segment: one header word ``tag (8) | pad (2) | type (6) | length (16)``, no num. + + Children of a bank of segments (data type 0xd/0x20). Shares every + accessor with ``Bank``; ``num`` is None. + """ + + HEADER_WORDS = 1 + STRUCTURE_NAME = "Segment" + + def _parse_header(self): + header = struct.unpack(self.endian + 'I', self.mm[self.offset:self.offset + 4])[0] + self.tag = (header >> 24) & 0xFF + self.pad = (header >> 22) & 0x3 + self.data_type = (header >> 16) & 0x3F + self.length = header & 0xFFFF + self.num = None + + @classmethod + def read_length(cls, mm, offset: int, endian: str) -> int: + return struct.unpack(endian + 'I', mm[offset:offset + 4])[0] & 0xFFFF + + +class TagSegment(Bank): + """A tagsegment: one header word ``tag (12) | type (4) | length (16)``, no pad, no num. + + Children of a bank of tagsegments (data type 0xc). + """ + + HEADER_WORDS = 1 + STRUCTURE_NAME = "TagSegment" + + def _parse_header(self): + header = struct.unpack(self.endian + 'I', self.mm[self.offset:self.offset + 4])[0] + self.tag = (header >> 20) & 0xFFF + self.pad = 0 + self.data_type = (header >> 16) & 0xF + self.length = header & 0xFFFF + self.num = None + + @classmethod + def read_length(cls, mm, offset: int, endian: str) -> int: + return struct.unpack(endian + 'I', mm[offset:offset + 4])[0] & 0xFFFF diff --git a/pyevio/buffer_reader.py b/pyevio/buffer_reader.py index 81f90d2..4a87610 100644 --- a/pyevio/buffer_reader.py +++ b/pyevio/buffer_reader.py @@ -55,8 +55,7 @@ def _detect_endianness(self) -> str: raise ValueError(f"Buffer too small or invalid at offset {magic_pos}: {e}") # If we get here, neither endianness matched - print(self.hex_dump()) - raise ValueError(f"Invalid magic number: 0x{magic:08X}, expected 0xc0da0100") + raise ValueError(f"Invalid magic number at offset 0x{magic_pos:X}: 0x{magic:08X}, expected 0xc0da0100") def read_uint32(self, offset_words: int = 0) -> int: """ diff --git a/pyevio/cli/__init__.py b/pyevio/cli/__init__.py index c63d425..ef98910 100644 --- a/pyevio/cli/__init__.py +++ b/pyevio/cli/__init__.py @@ -1,8 +1,7 @@ -import os -import sys - import click +from pyevio import __version__ + # Import and register commands from pyevio.cli.info import info_command from pyevio.cli.dump import dump_command @@ -10,12 +9,29 @@ from pyevio.cli.record import record_command from pyevio.cli.event import event_command from pyevio.cli.hex import hex_command -from pyevio.cli.ui import ui_command +from pyevio.cli.gui import gui_command from pyevio.cli.ana import ana_command +from pyevio.cli.web import web_command +from pyevio.cli.stats import stats_command + + +class EvioCli(click.Group): + """The pyevio command group: a file pyevio cannot read ends the command with its message. + ``EvioFile`` raises ``ValueError`` for a file that is not EVIO or an EVIO + version pyevio does not read. Converting it here, once, turns the message + into click's ``Error: ...`` on stderr with exit code 1 for every command. + """ -@click.group(invoke_without_command=True) -@click.version_option(version="0.1.0") + def invoke(self, ctx): + try: + return super().invoke(ctx) + except ValueError as error: + raise click.ClickException(str(error)) from error + + +@click.group(cls=EvioCli) +@click.version_option(version=__version__) @click.option('--verbose', '-v', is_flag=True, help="Enable verbose output") @click.pass_context def cli(ctx, verbose): @@ -24,13 +40,6 @@ def cli(ctx, verbose): ctx.ensure_object(dict) ctx.obj['VERBOSE'] = verbose - # If no command was provided, and there are arguments, check if the first one is a file - if ctx.invoked_subcommand is None and len(sys.argv) > 1: - potential_file = sys.argv[1] - if os.path.isfile(potential_file): - # Treat this as 'info' command - ctx.invoke(info_command, filename=potential_file, verbose=verbose) - # Register commands with the CLI cli.add_command(info_command) @@ -39,8 +48,10 @@ def cli(ctx, verbose): cli.add_command(record_command) cli.add_command(event_command) cli.add_command(hex_command) -cli.add_command(ui_command) +cli.add_command(gui_command) cli.add_command(ana_command) +cli.add_command(web_command) +cli.add_command(stats_command) # Entry point for the CLI diff --git a/pyevio/cli/debug.py b/pyevio/cli/debug.py index 36516e9..03a666d 100644 --- a/pyevio/cli/debug.py +++ b/pyevio/cli/debug.py @@ -1,31 +1,18 @@ import click from rich.console import Console -from rich.panel import Panel -from rich.syntax import Syntax from rich import box from rich.table import Table -from rich.tree import Tree -import struct -from datetime import datetime -from pyevio.evio_file import EvioFile -from pyevio.roc_time_slice_bank import RocTimeSliceBank -from pyevio.utils import make_hex_dump, print_offset_hex - -import click -from rich.console import Console -from rich.panel import Panel -from rich.syntax import Syntax -from rich import box -from rich.table import Table -from rich.tree import Tree -import struct -from datetime import datetime +import numpy as np from pyevio.evio_file import EvioFile -from pyevio.bank import Bank +from pyevio.decoders import fadc250_stream +from pyevio.display import offset_scope_note from pyevio.roc_time_slice_bank import RocTimeSliceBank -from pyevio.utils import make_hex_dump, print_offset_hex +from pyevio.utils import print_offset_hex + +# Hit words printed per payload bank before the output is cut; SRO payload banks hold thousands. +MAX_HIT_WORDS_SHOWN = 32 def display_record_info(console, record_obj, record_index): @@ -33,6 +20,9 @@ def display_record_info(console, record_obj, record_index): console.print(f"[bold cyan]Record #{record_index} Analysis[/bold cyan]") console.print(f"[bold]Offset: [green]0x{record_obj.offset:X}[{record_obj.offset//4}][/green], Length: [green]{record_obj.header.record_length}[/green] words[/bold]") console.print(f"[bold]Type: [green]{record_obj.header.event_type}[/green], Events: [green]{record_obj.event_count}[/green][/bold]") + note = offset_scope_note(record_obj) + if note: + console.print(note) def display_bank_header(console, bank, title=None): @@ -120,16 +110,15 @@ def display_child_banks(console, parent_bank, evio_file, verbose, hexdump, level # Use print_offset_hex for displaying hex data if hexdump: - print_offset_hex(evio_file.mm, child.offset, min(16, child.length), + print_offset_hex(child.mm, child.offset, min(16, child.length), f"Child #{i} at offset 0x{child.offset:X}[{child.offset//4}]") # Show length details for better debugging console.print(f"[dim]Length: {child.length} words, Data Length: {child.data_length} bytes[/dim]") - console.print(f"[dim]Offset Range: 0x{child.offset:X} - 0x{child.offset + (child.length * 4):X}[/dim]") + # The length word counts the words after it: a bank spans (length + 1) words. + console.print(f"[dim]Offset Range: 0x{child.offset:X} - 0x{child.offset + (child.length + 1) * 4:X}[/dim]") - # Check if this is a data bank (length 1 or 2) - if child.length <= 2 and not child.is_container(): - # This is likely a data bank + if not child.is_container(): display_data_bank(console, child, evio_file, hexdump) # Recurse for container banks elif child.is_container() and level < max_level: @@ -158,35 +147,29 @@ def display_data_bank(console, bank, evio_file, hexdump=False): console.print(f"[bold]Data Bank (Length 1, No Data)[/bold]") return - # Get all data words from the bank - data_words = [] - for i in range(bank.data_offset, bank.data_offset + bank.data_length, 4): - if i + 4 <= bank.offset + bank.size: - word = struct.unpack(bank.endian + 'I', evio_file.mm[i:i+4])[0] - data_words.append(word) + # The length word counts the words that follow it, so the payload is (length - 1) words after the 2-word header. + data_offset = bank.offset + 8 + data_end = min(data_offset + 4 * (bank.length - 1), len(bank.mm)) + data_words = fadc250_stream.event_words(bank.mm, data_offset, max(data_end - data_offset, 0), bank.endian) - if not data_words: + if len(data_words) == 0: console.print(f"[bold]Data Bank (No Valid Data)[/bold]") return - # Display data information console.print(f"[bold]Data Bank (Data Words: {len(data_words)})[/bold]") - # Display raw values - for i, word in enumerate(data_words): - console.print(f" Word {i}: 0x{word:08X}") - - # Try to interpret FADC hit data - chan = (word >> 13) & 0x000F - charge = word & 0x1FFF - time = ((word >> 17) & 0x3FFF) * 4 - - # Display decoded fields - console.print(f" [dim]Channel: {chan}, Charge: {charge}, Time: {time}[/dim]") + # Interpret the words as streaming fADC250 hits; the layout lives in the decoder module. + shown = data_words[:MAX_HIT_WORDS_SHOWN] + channels, times_raw, charges = fadc250_stream.unpack_hit_words(shown) + for word_i, (word, chan, time_raw, charge) in enumerate(zip(shown.tolist(), channels, times_raw, charges)): + console.print(f" Word {word_i}: 0x{word:08X}") + console.print(f" [dim]Channel: {chan}, Charge: {charge}, Time: {int(time_raw) * fadc250_stream.HIT_TIME_TICK_NS}[/dim]") + if len(data_words) > len(shown): + console.print(f" [dim]... {len(data_words) - len(shown)} more words[/dim]") # Show hexdump if requested if hexdump and bank.data_length > 0: - print_offset_hex(evio_file.mm, bank.data_offset, min(bank.data_length // 4, 16), + print_offset_hex(bank.mm, bank.data_offset, min(bank.data_length // 4, 16), f"Data Bank at 0x{bank.offset:X}[{bank.offset//4}]") def display_roc_timeslice_info(console, bank, evio_file, payload_filter=None, hexdump=False): @@ -264,24 +247,60 @@ def display_roc_timeslice_info(console, bank, evio_file, payload_filter=None, he # Show hexdump of waveform data if hexdump: - print_offset_hex(evio_file.mm, payload_bank.data_offset, min(16, payload_bank.data_length//4), + print_offset_hex(payload_bank.mm, payload_bank.data_offset, min(16, payload_bank.data_length//4), f"Payload {p_idx} Data at 0x{payload_bank.data_offset:X}[{payload_bank.data_offset//4}]") except Exception as e: console.print(f"[red]Error analyzing waveform data: {str(e)}[/red]") +def display_stream_frame(console, evio_file, event_obj): + """Decode a streaming time frame (0xFF60) and show its hits per ROC bank.""" + words = fadc250_stream.event_words(event_obj.mm, event_obj.offset, event_obj.length, event_obj.endian) + frame = fadc250_stream.decode_frame(words) + console.print(f"[bold]Streaming time frame {frame.frame_number}[/bold], timestamp {frame.timestamp}, " + f"{frame.n_hits} hits in {frame.n_roc_banks} ROC banks, " + f"{frame.n_payload_banks} payload banks ({frame.n_empty_payload_banks} empty), " + f"{frame.non_fadc_words} words of other modules") + for message in frame.error_messages: + console.print(f"[red]Decode error: {message}[/red]") + for message in frame.warning_messages: + console.print(f"[yellow]Decode warning: {message}[/yellow]") + + module_names = {fadc250_stream.MODULE_FADC250: "fADC250", fadc250_stream.MODULE_DCRB: "DCRB", + fadc250_stream.MODULE_UNKNOWN: "unmapped"} + roc_table = Table(box=box.SIMPLE) + roc_table.add_column("ROC", style="cyan") + roc_table.add_column("Stream", style="green") + roc_table.add_column("Module", style="white") + roc_table.add_column("Payload banks", style="blue") + roc_table.add_column("Empty", style="yellow") + roc_table.add_column("Hits", style="magenta") + roc_table.add_column("Other words", style="magenta") + roc_table.add_column("Ports", style="white") + for roc_bank in frame.roc_banks: + same_bank = lambda table: table[(table["roc"] == roc_bank["roc"]) & (table["stream"] == roc_bank["stream"])] + hit_slice = slice(int(roc_bank["first_hit_index"]), int(roc_bank["first_hit_index"] + roc_bank["n_hits"])) + ports = sorted(set(frame.port[hit_slice].tolist()) | set(same_bank(frame.empty_banks)["port"].tolist()) + | set(same_bank(frame.non_fadc_banks)["port"].tolist())) + module_id = int(roc_bank["module_id"]) + roc_table.add_row(str(roc_bank["roc"]), str(roc_bank["stream"]), module_names.get(module_id, str(module_id)), + str(roc_bank["n_payload_banks"]), str(roc_bank["n_empty_payload_banks"]), str(roc_bank["n_hits"]), + str(roc_bank["non_fadc_words"]), ", ".join(map(str, ports))) + console.print(roc_table) + + def display_event_info(console, evio_file, record_obj, event_idx, payload_filter, hexdump, verbose): """Display detailed information about an event.""" console.print(f"[bold yellow]Event #{event_idx}[/bold yellow]") try: - event_obj = record_obj.get_record_and_event(event_idx) + event_obj = record_obj.get_event(event_idx) console.print(f"[bold]Offset: [green]0x{event_obj.offset:X}[{event_obj.offset//4}][/green], Size: [green]{event_obj.length}[/green] bytes[/bold]") # Show hexdump if requested if hexdump: console.print() - print_offset_hex(evio_file.mm, event_obj.offset, min(30, event_obj.length//4), f"Event #{event_idx} at 0x{event_obj.offset:X}[{event_obj.offset//4}]") + print_offset_hex(event_obj.mm, event_obj.offset, min(30, event_obj.length//4), f"Event #{event_idx} at 0x{event_obj.offset:X}[{event_obj.offset//4}]") # Get bank information @@ -289,6 +308,9 @@ def display_event_info(console, evio_file, record_obj, event_idx, payload_filter if bank_info: console.print(f"[bold]Bank Type: {bank_info.get('bank_type', 'Unknown')} (Tag: 0x{bank_info.get('tag', 0):04X})[/bold]") + if bank_info and bank_info.get("tag") == fadc250_stream.STREAM_EVENT_TAG: + display_stream_frame(console, evio_file, event_obj) + # Try to parse the event as a bank try: @@ -316,7 +338,7 @@ def display_event_info(console, evio_file, record_obj, event_idx, payload_filter # Show hexdump of bank data if requested if hexdump: - print_offset_hex(evio_file.mm, bank.data_offset, min(16, bank.data_length//4), + print_offset_hex(bank.mm, bank.data_offset, min(16, bank.data_length//4), f"Bank Data at 0x{bank.data_offset:X}[{bank.data_offset//4}]") except Exception as e: diff --git a/pyevio/cli/dump.py b/pyevio/cli/dump.py index eb4a23e..51b9ba4 100644 --- a/pyevio/cli/dump.py +++ b/pyevio/cli/dump.py @@ -182,14 +182,15 @@ def dump_command(ctx, filename, count, start, record, format_as, only, verbose): verbose = verbose or ctx.obj.get('VERBOSE', False) console = Console() - try: - with EvioFile(filename, verbose) as evio_file: - # Validate record if specified - if record is not None: - if record < 0 or record >= evio_file.record_count: - console.print(f"[red]Record {record} out of range (0-{evio_file.record_count-1})[/red]") - return 1 + # The open sits outside the catch-all: a file pyevio cannot read raises + # ValueError, which the command group reports with exit code 1. + with EvioFile(filename, verbose) as evio_file: + # Validated before the catch-all below, which would otherwise swallow the click error. + if record is not None and (record < 0 or record >= evio_file.record_count): + raise click.BadParameter(f"Record {record} out of range (0-{evio_file.record_count-1})") + try: + if record is not None: # If record is specified and start is default (2), adjust to 0 if start == 2 and not ctx.params.get("start"): start = 0 @@ -212,8 +213,8 @@ def dump_command(ctx, filename, count, start, record, format_as, only, verbose): rendered_output = render_rows(formatted_rows) print(rendered_output) - except Exception as e: - console.print(f"[red]Error: {str(e)}[/red]") - if verbose: - import traceback - console.print(traceback.format_exc()) \ No newline at end of file + except Exception as e: + console.print(f"[red]Error: {str(e)}[/red]") + if verbose: + import traceback + console.print(traceback.format_exc()) \ No newline at end of file diff --git a/pyevio/cli/event.py b/pyevio/cli/event.py index b7710b6..1f6227d 100644 --- a/pyevio/cli/event.py +++ b/pyevio/cli/event.py @@ -10,7 +10,7 @@ import matplotlib.pyplot as plt from datetime import datetime -from pyevio.display import create_bank_tree +from pyevio.display import create_bank_tree, offset_scope_note from pyevio.evio_file import EvioFile from pyevio.roc_time_slice_bank import RocTimeSliceBank from pyevio.utils import make_hex_dump, print_offset_hex @@ -32,16 +32,21 @@ def display_event(console, evio_file, record_obj, event_obj, record_idx, """ # Display event header information console.print(f"[bold]Record #{record_idx} Event #{event_obj.index}[/bold]") + note = offset_scope_note(record_obj) + if note: + console.print(note) console.print(f"[bold]Offset: [green]0x{event_obj.offset:X}[{event_obj.offset//4}][/green], Length: [green]{event_obj.length}[/green] bytes[/bold]") - # Show hexdump of the full event if requested + # Show hexdump of the full event if requested. The event's buffer is the + # file for an uncompressed record and the decompressed bytes otherwise; + # event_obj.offset is valid only in that buffer. if hexdump: console.print() print_offset_hex( - evio_file.mm, + event_obj.mm, event_obj.offset, event_obj.length // 4, # Convert bytes to words - f"Record #{record_idx} Event rel#{event_obj.index}, Length: {event_obj.length} bytes)" + f"Record #{record_idx} Event rel#{event_obj.index}, Length: {event_obj.length} bytes" ) console.print() @@ -87,8 +92,7 @@ def event_command(ctx, filename, event_index, record_index, hexdump, verbose): else: # Record-specific event indexing record = evio_file.get_record(record_index) - event = evio_file.get_event(event_index) - record_index = evio_file.get_records().index(record) + event = record.get_event(event_index) # Display the event information display_event( @@ -96,7 +100,7 @@ def event_command(ctx, filename, event_index, record_index, hexdump, verbose): evio_file, record, event, - record_index, + record.index, hexdump=hexdump, verbose=verbose ) diff --git a/pyevio/cli/gui.py b/pyevio/cli/gui.py new file mode 100644 index 0000000..64c28c5 --- /dev/null +++ b/pyevio/cli/gui.py @@ -0,0 +1,32 @@ +import sys +from pathlib import Path + +import click + + +@click.command(name="gui") +@click.argument("path", type=click.Path(exists=True), default=".") +@click.option("--cache-dir", type=click.Path(file_okay=False), default=None, + help="Where analysis results (.npz) are cached (default: ~/.cache/pyevio/stats, shared with pyevio web)") +@click.option("--workers", "-w", type=int, default=None, help="Worker processes for the analysis (default: cores minus two)") +def gui_command(path, cache_dir, workers): + """Browse an EVIO file in the terminal: checks, plots, records and structure. + + PATH is a file to open, or a folder (default: the current one) to pick a + file from. The analysis runs in the background and its result is cached, + so a file analyzed here opens at once in `pyevio web`, and the other way round. + """ + try: + from pyevio.gui.app import PyEvioGui + except ImportError: + from pyevio.gui import INSTALL_HINT + click.echo(INSTALL_HINT, err=True) + sys.exit(1) + from pyevio.gui.session import Session + from pyevio.stream_stats import start_worker_services + + start_worker_services() # before Textual captures stderr: the resource tracker needs a real one to start + target = Path(path) + session = Session(cache_dir=Path(cache_dir) if cache_dir else None, workers=workers) + app = PyEvioGui(session, start_path=target if target.is_file() else None, start_dir=target if target.is_dir() else target.parent) + app.run() diff --git a/pyevio/cli/info.py b/pyevio/cli/info.py index 69a6ccb..291748a 100644 --- a/pyevio/cli/info.py +++ b/pyevio/cli/info.py @@ -38,8 +38,12 @@ def info_command(ctx, filename, verbose, hexdump, full): console.print() # Add header fields to the table + table.add_row("Layout", header.layout) table.add_row("Magic Number", f"EVIO (0x{header.magic_number:08X})") - table.add_row("Format Version", str(header.version)) + version = str(header.version) + if header.container_version != header.version: + version += f" (content; block headers say {header.container_version})" + table.add_row("Format Version", version) table.add_row("Endianness", "Little" if header.endian == '<' else "Big") table.add_row("Record Count", str(evio_file.record_count)) table.add_row("Index Array Size", f"{header.index_array_length // 8} entries") @@ -97,4 +101,23 @@ def info_command(ctx, filename, verbose, hexdump, full): console.print(f"Total Records: {evio_file.record_count}") total_events = evio_file.get_total_event_count() console.print(f"Total Events: {total_events}") - console.print(f"File Size: {evio_file.file_size / (1024*1024):.2f} MB") \ No newline at end of file + console.print(f"File Size: {evio_file.file_size / (1024*1024):.2f} MB") + + console.print("\n[bold]File Checks:[/bold]") + if not evio_file.checks: + console.print("none") + for check in evio_file.checks: + console.print(describe_check(check)) + + +def describe_check(check: tuple) -> str: + """One line for an EvioFile.checks entry (see EvioFile for the tuple shapes).""" + name = check[0] + if name == "truncated_tail": + _, declared, present, salvaged = check + return (f"truncated_tail: the last record is cut by the end of the file — " + f"declared {declared} bytes, {present} present, {salvaged} complete events kept") + if name == "bad_record_header": + _, offset, reason = check + return f"bad_record_header: record scan stopped at offset 0x{offset:X} — {reason}" + return f"{name}: {check[1:]}" \ No newline at end of file diff --git a/pyevio/cli/stats.py b/pyevio/cli/stats.py new file mode 100644 index 0000000..adc2ab8 --- /dev/null +++ b/pyevio/cli/stats.py @@ -0,0 +1,73 @@ +import click +from rich.console import Console +from rich import box +from rich.table import Table + +from pyevio.decoders import fadc250_stream +from pyevio.stream_stats import compute_stream_stats, default_workers, DEFAULT_RATE_BINS, DEFAULT_TICK_NS + +MODULE_NAMES = fadc250_stream.MODULE_NAMES + + +def print_stats_summary(console, stats): + """Print totals, frame checks and a per-ROC table.""" + checks = stats.checks + totals = Table(box=box.SIMPLE, show_header=False) + totals.add_column("", style="bold") + totals.add_column("") + totals.add_row("File", f"{stats.path} ({stats.file_size / 1e9:.2f} GB)") + totals.add_row("Records", f"{stats.n_records:,} ({stats.n_trailer_records} trailer)") + totals.add_row("Events", f"{stats.n_events:,} ({stats.n_frames:,} stream frames, {stats.n_other_events:,} other, " + f"{stats.n_out_of_bounds_events:,} out of bounds)") + totals.add_row("fADC250 hits", f"{stats.n_hits:,}") + totals.add_row("Payload words", ", ".join(f"{MODULE_NAMES.get(int(m), str(m))}: {int(w):,}" + for m, w in zip(stats.module_ids, stats.module_words)) or "none") + totals.add_row("Payload banks", f"{stats.n_payload_banks:,} ({stats.n_empty_payload_banks:,} empty, " + f"{stats.n_unmapped_payload_banks:,} unmapped)") + totals.add_row("Frames", f"{checks.first_frame:,} — {checks.last_frame:,}, stride {checks.stride}, " + f"{checks.missing_frames:,} missing, {checks.duplicate_frames:,} duplicated, " + f"{checks.out_of_order:,} out of order, {checks.dts_outliers:,} timestamp-step outliers") + totals.add_row("Frame period", f"{checks.frame_period_ticks:,.0f} ticks = {stats.frame_period_ns / 1000:,.3f} µs " + f"at {stats.tick_ns} ns per tick") + totals.add_row("Time", f"span {stats.duration_s:,.3f} s, live {stats.live_time_s:,.3f} s " + f"(stride {checks.stride}: this file holds 1 frame in {max(checks.stride, 1)})") + if stats.live_time_s > 0: + totals.add_row("Hit rate", f"{stats.n_hits / stats.live_time_s / 1e6:,.2f} MHz of live time (fADC250)") + totals.add_row("Decode errors", f"{stats.errors:,}" + (f" — first: {stats.error_messages[0]}" if stats.error_messages else "")) + totals.add_row("Decode warnings", f"{stats.warnings:,}" + (f" — first: {stats.warning_messages[0]}" if stats.warning_messages else "")) + # The SRO hardware fills every AIS slot with one stream's entry; the same check the viewer shows. + totals.add_row("AIS", f"{stats.frames_with_ais:,} frames with an aggregation info segment, " + f"{stats.frames_with_degenerate_ais:,} repeat one entry in every slot") + totals.add_row("Wall time", f"{stats.wall_seconds:.1f} s with {stats.workers} workers for " + f"{stats.bytes_processed / 1e6:,.1f} MB ({stats.bytes_processed / max(stats.wall_seconds, 1e-9) / 1e6:,.0f} MB/s)") + console.print(totals) + + per_roc = Table(box=box.SIMPLE, title="Per ROC") + for column in ("ROC", "Module", "Ports", "Hits", "Other words", "Empty banks"): + per_roc.add_column(column, justify="right" if column not in ("Module",) else "left") + hits_per_roc = stats.hits_per_roc() + for roc_i, roc in enumerate(stats.rocs): + modules = {int(m) for m in stats.module_id[roc_i] if m != fadc250_stream.MODULE_UNKNOWN} + ports = [int(p) for p, m in zip(stats.ports, stats.module_id[roc_i]) if m != fadc250_stream.MODULE_UNKNOWN] + per_roc.add_row(str(roc), ", ".join(MODULE_NAMES.get(m, str(m)) for m in sorted(modules)) or "unmapped", + str(len(ports)), f"{hits_per_roc[int(roc)]:,}", f"{int(stats.non_fadc_words_per_port[roc_i].sum()):,}", + f"{int(stats.empty_banks[roc_i].sum()):,}") + console.print(per_roc) + + +@click.command(name="stats") +@click.argument("filename", type=click.Path(exists=True)) +@click.option("--out", "-o", "out_path", type=click.Path(), help="Save the statistics as .npz") +@click.option("--workers", "-w", type=int, default=None, help=f"Worker processes (default: cores minus two = {default_workers()})") +@click.option("--rate-bins", type=int, default=DEFAULT_RATE_BINS, show_default=True, help="Time bins of the rate series") +@click.option("--tick-ns", type=float, default=DEFAULT_TICK_NS, show_default=True, help="Timestamp tick length in ns") +@click.option("--records", "-n", type=int, default=None, help="Stop after this many records") +@click.pass_context +def stats_command(ctx, filename, out_path, workers, rate_bins, tick_ns, records): + """Histogram the streaming fADC250 hits of a file and check its frame sequence.""" + console = Console() + stats = compute_stream_stats(filename, workers=workers, rate_bins=rate_bins, tick_ns=tick_ns, max_records=records) + print_stats_summary(console, stats) + if out_path: + stats.save(out_path) + console.print(f"Saved {out_path}") diff --git a/pyevio/cli/ui.py b/pyevio/cli/ui.py deleted file mode 100644 index 08c185e..0000000 --- a/pyevio/cli/ui.py +++ /dev/null @@ -1,24 +0,0 @@ -import click -from rich.console import Console -from rich.table import Table -from rich import box -from datetime import datetime - -from pyevio.evio_file import EvioFile -from pyevio.utils import make_hex_dump - -@click.command(name="ui") -@click.argument("filename", type=click.Path(exists=True)) -@click.option('--verbose', '-v', is_flag=True, help="Enable verbose output") -@click.pass_context -def ui_command(ctx, filename, verbose): - """Launch the textual UI for EVIO file inspection.""" - try: - from pyevio.ui.app import PyEvioApp - except ImportError: - print("Error: The textual library is required for the UI.") - print("Please install it with: pip install textual>=0.30.0") - return - - app = PyEvioApp(filename) - app.run() \ No newline at end of file diff --git a/pyevio/cli/web.py b/pyevio/cli/web.py new file mode 100644 index 0000000..84382db --- /dev/null +++ b/pyevio/cli/web.py @@ -0,0 +1,28 @@ +import sys + +import click + + +@click.command(name="web") +@click.argument("path", type=click.Path(exists=True), default=".") +@click.option("--port", "-p", default=8765, show_default=True, help="Port on 127.0.0.1") +@click.option("--no-browser", is_flag=True, help="Do not open a browser tab") +@click.option("--cache-dir", type=click.Path(file_okay=False), default=None, + help="Where analysis results (.npz) are cached (default: ~/.cache/pyevio/stats)") +@click.option("--workers", "-w", type=int, default=None, help="Worker processes for the analysis (default: cores minus two)") +@click.option("--verbose", "-v", is_flag=True, help="Log every request") +def web_command(path, port, no_browser, cache_dir, workers, verbose): + """Browse EVIO files in a web page: checks, plots, records and structure. + + PATH is a folder to browse (default: the current folder) or a file: then its + folder is served and the page opens the file at once. The server listens on + 127.0.0.1 and prints a URL with a one-time token; only files under the served + folder can be opened. + """ + try: + from pyevio.web.app import serve + except ImportError: + from pyevio.web import INSTALL_HINT + click.echo(INSTALL_HINT, err=True) + sys.exit(1) + serve(path, port, open_browser=not no_browser, cache_dir=cache_dir, workers=workers, verbose=verbose) diff --git a/pyevio/decoders/fadc250_stream.py b/pyevio/decoders/fadc250_stream.py new file mode 100644 index 0000000..577776a --- /dev/null +++ b/pyevio/decoders/fadc250_stream.py @@ -0,0 +1,634 @@ +"""Decode CODA streaming fADC250 time frames into numpy hit arrays. + +A streaming time frame is one EVIO event. The decoder takes the words of +one event (or of many events) as a numpy ``uint32`` array and returns hit +columns plus per-frame, per-ROC-bank and per-empty-bank tables. File +access stays in pyevio core: :func:`event_words` slices one event out of a +memory map, and the CLI, the examples and the web server call this module. + +Frame layout, measured on /data/evio-lib/corpus/sro_000791.evio.00000.v6 +(record 0):: + + event bank tag 0xFF60, type 0x10 (bank of banks), num 0 + SIB tag 0xFF31, type 0x20 (bank of segments) + TSS segment tag 0x32, len 3: {frame number, timestamp lo, timestamp hi} + AIS segment tag 0x42, len 24: aggregated-stream entries (counted, never walked) + ROC bank tag = ROC id, type 0x10, num = stream status byte + stream-info tag 0xFF30, type 0x20: TSS 0x31 + payload-port map 0x41 + payload bank tag = payload port, type 0x00, one word per hit + payload bank ... + ROC bank ... + +Each crate sends two streams, so the same ROC id appears as two ROC banks +per frame. The banks differ in the num byte: bits 6—4 hold the stream +count (2) and bits 3—0 the stream mask (1 or 2). The decoder keeps that +mask as the ``stream`` column. The port split between the two streams is +mostly odd/even but not always (ROC 9 stream 2 carries ports 1, 2, 3, 4, +6, 8), so the num byte is the only reliable stream identity. + +The payload-port map (segment 0x41, uint16 entries ``module << 8 | port``, +two entries per word, odd counts padded) names the module behind each +port: 0 = fADC250, 1 = DCRB. Only fADC250 payload words are hits. DCRB +payload words are two-word channel masks; the decoder counts them per +ROC bank as ``non_fadc_words`` and decodes nothing from them. A +port absent from the map (or a ROC bank without a stream-info bank) is +decoded as fADC250 and counted in ``n_unmapped_payload_banks``. + +The AIS segment should list the aggregated streams, one ``(rocid << 16) | status`` +entry per stream. The SRO hardware writes one stream's entry into every slot, a +value that changes from frame to frame. The decoder records per frame how many +entries the segment has and how many distinct values (``n_ais_entries``, +``n_ais_distinct``); :func:`ais_degenerate_mask` names the repeated case. ROC +banks come from the bank walk alone; the AIS never locates them. + +An empty payload bank has length 1 and num 1; a data bank has num 0. Both +are valid. The walk trusts no length word: every bank end is checked +against its container, a bad length stops the walk and lands in +``errors`` and ``error_messages`` instead of an exception. A malformed +segment after the time slice segment of the SIB (an AIS with a length past +the SIB end, or with no entries) touches no hit: it lands in ``warnings`` +and ``warning_messages`` and the walk goes on. + +Hit word layout (one ``uint32`` per hit), the single copy for pyevio. +Evidence: every earlier decoder in this repository, the viewer and the +independent greenfield SRO parser agree on the bit positions and on the +4 ns tick, the fADC250 sample period (250 MSPS). 2^14 ticks × 4 ns = +65,536 ns equals the 65,536-tick timestamp step per frame only if the +timestamp tick is 1 ns. The tick length is unconfirmed, so ``stream_stats`` +keeps it as the ``tick_ns`` knob:: + + bit 31 flag, never set in the SRO data (Jon Zarling's decoder skips such words) + bits 30—17 time in 4 ns ticks from the frame start (14 bits, 0—16383) + bits 16—13 fADC250 channel (4 bits, 0—15) + bits 12—0 charge (13 bits, 8191 = overflow) +""" +import sys +from dataclasses import dataclass, field +from typing import Iterable, List + +import numpy as np + +# ---------------------------------------------------------------- hit word layout +HIT_FLAG_BIT = 31 +HIT_TIME_SHIFT = 17 +HIT_TIME_MASK = 0x3FFF +HIT_TIME_BITS = 14 +HIT_CHANNEL_SHIFT = 13 +HIT_CHANNEL_MASK = 0xF +HIT_CHARGE_MASK = 0x1FFF +HIT_CHARGE_OVERFLOW = 0x1FFF +HIT_TIME_TICK_NS = 4 +HIT_FORMAT = "fadc250-stream-v1: t=bits[30:17]*4ns ch=bits[16:13] q=bits[12:0]" + +# ---------------------------------------------------------------- bank tags and payload map +STREAM_EVENT_TAG = 0xFF60 +SIB_TAG = 0xFF31 +SIB_TSS_SEGMENT_TAG = 0x32 +SIB_AIS_SEGMENT_TAG = 0x42 +ROC_STREAM_INFO_TAG = 0xFF30 +ROC_TSS_SEGMENT_TAG = 0x31 +ROC_PAYLOAD_PORT_SEGMENT_TAG = 0x41 +BANK_OF_BANKS_TYPES = (0x10, 0x0E) + +# Payload-port map entry (uint16): bits 11—8 module id, bit 7 bond, bits 6—5 lane, bits 4—0 port. +MAP_MODULE_SHIFT = 8 +MAP_MODULE_MASK = 0xF +MAP_PORT_MASK = 0x1F +MODULE_FADC250 = 0 +MODULE_DCRB = 1 +MODULE_UNKNOWN = 0xFF # port not in the map, or no stream-info bank +MODULE_NAMES = {MODULE_FADC250: "fADC250", MODULE_DCRB: "DCRB", MODULE_UNKNOWN: "unmapped"} + +# Only the first error messages of a batch are kept; the count is always exact. +MAX_ERROR_MESSAGES = 50 + +FRAME_DTYPE = np.dtype([ + ("frame", np.uint32), ("timestamp", np.uint64), ("n_hits", np.uint32), ("first_hit_index", np.uint64), + ("event_index", np.uint32), ("n_roc_banks", np.uint16), ("n_payload_banks", np.uint16), + ("n_empty_payload_banks", np.uint16), ("non_fadc_words", np.uint32), ("errors", np.uint16), + ("warnings", np.uint16), ("n_ais_entries", np.uint16), ("n_ais_distinct", np.uint16), +]) +ROC_BANK_DTYPE = np.dtype([ + ("event_index", np.uint32), ("frame", np.uint32), ("roc", np.uint16), ("stream", np.uint8), + ("stream_status", np.uint8), ("module_id", np.uint8), ("n_payload_banks", np.uint16), + ("n_empty_payload_banks", np.uint16), ("n_hits", np.uint32), ("first_hit_index", np.uint64), + ("non_fadc_words", np.uint32), +]) +EMPTY_BANK_DTYPE = np.dtype([ + ("event_index", np.uint32), ("roc", np.uint16), ("stream", np.uint8), ("port", np.uint16), ("module_id", np.uint8), +]) +# One row per payload bank of a module other than fADC250 (DCRB channel masks); its words are never hits. +NON_FADC_BANK_DTYPE = np.dtype([ + ("event_index", np.uint32), ("frame", np.uint32), ("roc", np.uint16), ("stream", np.uint8), ("port", np.uint16), + ("module_id", np.uint8), ("n_words", np.uint32), +]) + + +def event_words(mm, offset: int, length: int, endian: str) -> np.ndarray: + """Return the words of one event as a ``uint32`` view of ``mm`` in the file's byte order. + + ``offset`` and ``length`` are in bytes, as ``Record.get_event_offsets`` returns them; + they index ``Record.buffer`` (the file mmap for a plain record, the decompressed bytes + for an LZ4 or gzip record), so pass that buffer, never the file mmap. + Raises ``ValueError`` when the range runs past the buffer, so a caller never reads + past the file because an index word said so. + """ + if offset < 0 or length < 0 or offset + length > len(mm): + raise ValueError(f"event at byte {offset} with length {length} runs past the buffer end ({len(mm)} bytes)") + return np.frombuffer(mm, dtype=np.dtype(endian + "u4"), count=length // 4, offset=offset) + + +def payload_map_entries(data_words, pad: int, big_endian: bool) -> list: + """``[(port, module id)]`` from the data words of a 0x41 payload-port segment, in entry order. + + Entries are uint16, two per word: the low half first in a little-endian file, the + high half first in a big-endian one. Pad bits 2 in the segment header mean an odd + entry count. Entries with port 0 stay in the list — the caller decides whether a zero + entry is a map slot (the decoder skips it, the viewer shows it). + """ + n_entries = 2 * len(data_words) - (1 if pad == 2 else 0) + entries = [] + for entry_i in range(n_entries): + word = int(data_words[entry_i // 2]) + first_half = (word >> 16) if big_endian else (word & 0xFFFF) + second_half = (word & 0xFFFF) if big_endian else (word >> 16) + entry = first_half if entry_i % 2 == 0 else second_half + entries.append((entry & MAP_PORT_MASK, (entry >> MAP_MODULE_SHIFT) & MAP_MODULE_MASK)) + return entries + + +def unpack_hit_words(hit_words: np.ndarray): + """Split hit words into ``(channel, time_raw, charge)`` columns. + + ``time_raw`` is the 14-bit tick field; multiply by ``HIT_TIME_TICK_NS`` for nanoseconds. + The flag bit is masked away; count it with :func:`count_flag_words` or reject it + with :func:`validate_hit_words`. + """ + hit_words = np.asarray(hit_words, dtype=np.uint32) + channel = ((hit_words >> HIT_CHANNEL_SHIFT) & HIT_CHANNEL_MASK).astype(np.uint8) + time_raw = ((hit_words >> HIT_TIME_SHIFT) & HIT_TIME_MASK).astype(np.uint16) + charge = (hit_words & HIT_CHARGE_MASK).astype(np.uint16) + return channel, time_raw, charge + + +def count_flag_words(hit_words: np.ndarray) -> int: + """Count hit words with bit 31 set.""" + return int(np.count_nonzero(np.asarray(hit_words, dtype=np.uint32) >> HIT_FLAG_BIT)) + + +def validate_hit_words(hit_words: np.ndarray) -> None: + """Raise ``ValueError`` when the raw time field does not fit its 14 bits. + + The check reads the field without the mask: ``word >> 17`` exceeds ``2**14 - 1`` + exactly when the flag bit is set. A masked field can never fail, which is why the + check runs on the raw words and before any unit conversion. + """ + raw_time_field = np.asarray(hit_words, dtype=np.uint32) >> HIT_TIME_SHIFT + n_bad = int(np.count_nonzero(raw_time_field > HIT_TIME_MASK)) + if n_bad: + raise ValueError(f"{n_bad} hit words have a time field outside {HIT_TIME_BITS} bits (bit 31 set)") + + +def ais_degenerate_mask(frames: np.ndarray) -> np.ndarray: + """True per frame whose AIS segment holds several entries with one value: one stream repeated in every slot. + + ``frames`` is a table with ``n_ais_entries`` and ``n_ais_distinct`` columns (``FRAME_DTYPE`` + or ``stream_stats.FRAME_STATS_DTYPE``). A frame without an AIS (0 entries) is never degenerate. + """ + return (frames["n_ais_entries"] > 1) & (frames["n_ais_distinct"] == 1) + + +def _is_big_endian(words: np.ndarray) -> bool: + byteorder = words.dtype.byteorder + return byteorder == ">" or (byteorder == "=" and sys.byteorder == "big") + + +@dataclass +class StreamHits: + """Hits of many decoded events, concatenated in event order. + + Hit columns share one index and hold fADC250 hits only. ``frames`` has one row + per stream frame (``first_hit_index`` + ``n_hits`` gives the hit range); + ``roc_banks`` one row per ROC bank with its hit range, stream identity and + module; ``empty_banks`` one row per empty payload bank; ``non_fadc_banks`` one + row per payload bank of another module with its word count. Events that are + not 0xFF60 stream frames count in ``n_other_events`` and get no row. + """ + frame: np.ndarray + event_index: np.ndarray + roc: np.ndarray + stream: np.ndarray + port: np.ndarray + channel: np.ndarray + time_raw: np.ndarray + t_ns: np.ndarray + charge: np.ndarray + frames: np.ndarray + roc_banks: np.ndarray + empty_banks: np.ndarray + non_fadc_banks: np.ndarray + n_events: int = 0 + n_stream_frames: int = 0 + n_other_events: int = 0 + n_hits: int = 0 + n_payload_banks: int = 0 + n_empty_payload_banks: int = 0 + n_unmapped_payload_banks: int = 0 + n_payload_words: int = 0 + non_fadc_words: int = 0 + n_flag_words: int = 0 + errors: int = 0 + error_messages: List[str] = field(default_factory=list) + warnings: int = 0 # malformed SIB segments after the TSS; no hit depends on them + warning_messages: List[str] = field(default_factory=list) + + HIT_COLUMNS = ("frame", "event_index", "roc", "stream", "port", "channel", "time_raw", "t_ns", "charge") + + def hit_columns(self) -> dict: + """Return the hit columns as ``{name: array}``.""" + return {name: getattr(self, name) for name in self.HIT_COLUMNS} + + def validate(self) -> None: + """Raise ``ValueError`` on any inconsistency the decode found. + + Detects: walk errors (bad lengths), hit words with bit 31 set, and a hit + count that differs from the fADC250 payload word count or the per-frame sums. + """ + if self.errors: + first = "; ".join(self.error_messages[:3]) + raise ValueError(f"{self.errors} decode errors, first: {first}") + if self.n_flag_words: + raise ValueError(f"{self.n_flag_words} hit words have a time field outside {HIT_TIME_BITS} bits (bit 31 set)") + if self.n_hits + self.non_fadc_words != self.n_payload_words: + raise ValueError(f"hit count {self.n_hits} + other-module words {self.non_fadc_words} " + f"!= sum of payload bank words {self.n_payload_words}") + if int(self.frames["n_hits"].sum()) != self.n_hits: + raise ValueError("per-frame hit counts do not add up to the hit count") + + +@dataclass +class FrameHits: + """Hits of one decoded event, with the frame metadata as scalars.""" + frame_number: int + timestamp: int + is_stream_frame: bool + roc: np.ndarray + stream: np.ndarray + port: np.ndarray + channel: np.ndarray + time_raw: np.ndarray + t_ns: np.ndarray + charge: np.ndarray + roc_banks: np.ndarray + empty_banks: np.ndarray + non_fadc_banks: np.ndarray + n_hits: int + n_roc_banks: int + n_payload_banks: int + n_empty_payload_banks: int + n_unmapped_payload_banks: int + n_payload_words: int + non_fadc_words: int + n_flag_words: int + errors: int + error_messages: List[str] + n_ais_entries: int = 0 # entries of the SIB's aggregation info segment (0x42); 0 without one + n_ais_distinct: int = 0 # distinct values among them; 1 with several entries means one stream repeated + warnings: int = 0 + warning_messages: List[str] = field(default_factory=list) + + +def concatenate_stream_hits(chunks: Iterable[StreamHits]) -> StreamHits: + """Join the results of several :func:`decode_frames` calls into one, keeping event order. + + Hit indices in ``frames`` and ``roc_banks`` are shifted so they index the joined columns; + ``event_index`` values are kept as the chunks produced them. + """ + chunks = list(chunks) + if not chunks: + return decode_frames([]) + columns = {name: np.concatenate([getattr(c, name) for c in chunks]) for name in StreamHits.HIT_COLUMNS} + frames = np.concatenate([c.frames for c in chunks]) + roc_banks = np.concatenate([c.roc_banks for c in chunks]) + hit_offsets = np.cumsum([0] + [c.n_hits for c in chunks[:-1]], dtype=np.uint64) + frames["first_hit_index"] += np.repeat(hit_offsets, [len(c.frames) for c in chunks]) + roc_banks["first_hit_index"] += np.repeat(hit_offsets, [len(c.roc_banks) for c in chunks]) + messages = [m for c in chunks for m in c.error_messages][:MAX_ERROR_MESSAGES] + warning_messages = [m for c in chunks for m in c.warning_messages][:MAX_ERROR_MESSAGES] + return StreamHits( + **columns, frames=frames, roc_banks=roc_banks, + empty_banks=np.concatenate([c.empty_banks for c in chunks]), + non_fadc_banks=np.concatenate([c.non_fadc_banks for c in chunks]), + n_events=sum(c.n_events for c in chunks), n_stream_frames=sum(c.n_stream_frames for c in chunks), + n_other_events=sum(c.n_other_events for c in chunks), n_hits=sum(c.n_hits for c in chunks), + n_payload_banks=sum(c.n_payload_banks for c in chunks), + n_empty_payload_banks=sum(c.n_empty_payload_banks for c in chunks), + n_unmapped_payload_banks=sum(c.n_unmapped_payload_banks for c in chunks), + n_payload_words=sum(c.n_payload_words for c in chunks), + non_fadc_words=sum(c.non_fadc_words for c in chunks), + n_flag_words=sum(c.n_flag_words for c in chunks), + errors=sum(c.errors for c in chunks), error_messages=messages, + warnings=sum(c.warnings for c in chunks), warning_messages=warning_messages, + ) + + +class _Walk: + """Accumulates what the bank walk finds over one or more events, before any numpy work. + + Hit words are collected as slices of the event arrays and concatenated once at the end, + which keeps the per-event cost at a few hundred Python-level header reads. + """ + + def __init__(self): + self.hit_slices = [] + # One entry per fADC250 payload bank with data: its event, frame, roc, stream, port and word count. + self.bank_event, self.bank_frame, self.bank_roc, self.bank_stream, self.bank_port, self.bank_n = [], [], [], [], [], [] + self.frame_rows, self.roc_bank_rows, self.empty_rows, self.non_fadc_rows = [], [], [], [] + self.n_events = self.n_stream_frames = self.n_other_events = 0 + self.n_hits = self.n_payload_banks = self.n_empty_payload_banks = self.n_unmapped_payload_banks = 0 + self.n_payload_words = self.non_fadc_words = 0 + self.errors = 0 + self.error_messages = [] + self.warnings = 0 + self.warning_messages = [] + + def error(self, event_index: int, word_i: int, reason: str) -> None: + self.errors += 1 + if len(self.error_messages) < MAX_ERROR_MESSAGES: + self.error_messages.append(f"event {event_index} word {word_i}: {reason}") + + def warning(self, event_index: int, word_i: int, reason: str) -> None: + self.warnings += 1 + if len(self.warning_messages) < MAX_ERROR_MESSAGES: + self.warning_messages.append(f"event {event_index} word {word_i}: {reason}") + + def walk_event(self, words: np.ndarray, event_index: int) -> None: + """Walk one event; a bad length stops the walk and is recorded, never raised.""" + self.n_events += 1 + big_endian = _is_big_endian(words) + w = np.ascontiguousarray(words, dtype=np.uint32) # a no-op view when the dtype already matches + n = len(w) + read = w.item # returns Python ints; faster than indexing a numpy scalar + errors_before, warnings_before = self.errors, self.warnings + if n < 2: + self.n_other_events += 1 + self.error(event_index, 0, f"event has {n} words, a bank header needs 2") + return + if (read(1) >> 16) != STREAM_EVENT_TAG: + self.n_other_events += 1 + return + self.n_stream_frames += 1 + event_end = read(0) + 1 + if event_end > n: + self.error(event_index, 0, f"event length {event_end} words runs past the {n} words given") + event_end = n + + frame_number, timestamp, sib_end, n_ais_entries, n_ais_distinct = self._read_sib(w, read, event_end, event_index) + hits_before, payload_before, empty_before = self.n_hits, self.n_payload_banks, self.n_empty_payload_banks + other_before, roc_banks_before = self.non_fadc_words, len(self.roc_bank_rows) + + roc_bank_i = sib_end + while roc_bank_i < event_end: + if roc_bank_i + 1 >= event_end: + self.error(event_index, roc_bank_i, "ROC bank header runs past the event end") + break + roc_end = roc_bank_i + read(roc_bank_i) + 1 + if roc_end > event_end: + self.error(event_index, roc_bank_i, f"ROC bank length {read(roc_bank_i)} runs past the event end {event_end}") + break + roc_header = read(roc_bank_i + 1) + if ((roc_header >> 8) & 0x3F) not in BANK_OF_BANKS_TYPES: + self.error(event_index, roc_bank_i, f"ROC bank 0x{roc_header:08X} is not a bank of banks") + roc_bank_i = roc_end + continue + self._walk_roc_bank(w, read, roc_bank_i + 2, roc_end, roc_header, event_index, frame_number, big_endian) + roc_bank_i = roc_end + + self.frame_rows.append(( + frame_number, timestamp, self.n_hits - hits_before, hits_before, event_index, + len(self.roc_bank_rows) - roc_banks_before, self.n_payload_banks - payload_before, + self.n_empty_payload_banks - empty_before, self.non_fadc_words - other_before, self.errors - errors_before, + self.warnings - warnings_before, n_ais_entries, n_ais_distinct, + )) + + def _read_sib(self, w, read, event_end: int, event_index: int): + """Return ``(frame_number, timestamp, sib_end, n_ais_entries, n_ais_distinct)`` from the SIB at word 2. + + Zeros on a bad SIB. ``sib_end`` comes from the SIB length word alone, so the ROC bank + walk that starts there never depends on the AIS segment or its contents. + """ + if 3 >= event_end: + self.error(event_index, 2, "no room for the stream info bank") + return 0, 0, event_end, 0, 0 + sib_end = 2 + read(2) + 1 + if sib_end > event_end: + self.error(event_index, 2, f"SIB length {read(2)} runs past the event end {event_end}") + return 0, 0, event_end, 0, 0 + if (read(3) >> 16) != SIB_TAG: + self.error(event_index, 3, f"expected SIB tag 0x{SIB_TAG:04X}, found 0x{read(3) >> 16:04X}") + return 0, 0, sib_end, 0, 0 + # The TSS is the first segment of the SIB: header word tag<<24 | pad<<22 | type<<16 | length. + if 7 >= sib_end or (read(4) >> 24) != SIB_TSS_SEGMENT_TAG or (read(4) & 0xFFFF) < 3: + self.error(event_index, 4, "SIB does not start with a time slice segment (tag 0x32, length 3)") + return 0, 0, sib_end, 0, 0 + n_ais_entries, n_ais_distinct = self._count_ais_entries(w, read, 4 + (read(4) & 0xFFFF) + 1, sib_end, event_index) + return read(5), read(6) | (read(7) << 32), sib_end, n_ais_entries, n_ais_distinct + + def _count_ais_entries(self, w, read, segment_i: int, sib_end: int, event_index: int): + """Return ``(entries, distinct values)`` of the 0x42 segment among the SIB segments from ``segment_i``. + + The entries are uint32 words. Three cases yield ``(0, 0)``: no 0x42 segment (nothing + recorded), a 0x42 segment with no entries (a warning), and a segment whose length runs + past the SIB end (a warning; the search ends there). No hit depends on these segments, + so none of them is an error. + """ + while segment_i < sib_end: + header = read(segment_i) + segment_len = header & 0xFFFF + segment_end = segment_i + segment_len + 1 + if segment_end > sib_end: + self.warning(event_index, segment_i, f"SIB segment length {segment_len} runs past the SIB end {sib_end}") + return 0, 0 + if (header >> 24) == SIB_AIS_SEGMENT_TAG: + if segment_len == 0: + self.warning(event_index, segment_i, "AIS segment holds no entries") + return 0, 0 + return segment_len, len(set(w[segment_i + 1:segment_end].tolist())) # a set beats np.unique on 24 words + segment_i = segment_end + return 0, 0 + + def _read_payload_port_map(self, read, info_i: int, info_end: int, event_index: int, big_endian: bool) -> dict: + """Return ``{port: module id}`` from the 0x41 segment of the stream-info bank at ``info_i``. + + Entries are uint16, two per word: the low half first in a little-endian file, the + high half first in a big-endian one. Pad bits 2 in the segment header mean an odd + entry count. A missing or malformed segment yields an empty map (recorded as an error + only when a segment is malformed). + """ + port_map = {} + segment_i = info_i + 2 + while segment_i < info_end: + header = read(segment_i) + segment_len = header & 0xFFFF + segment_end = segment_i + segment_len + 1 + if segment_end > info_end: + self.error(event_index, segment_i, f"stream-info segment length {segment_len} runs past the bank end {info_end}") + break + if (header >> 24) == ROC_PAYLOAD_PORT_SEGMENT_TAG: + data_words = [read(segment_i + 1 + word_i) for word_i in range(segment_len)] + for port, module in payload_map_entries(data_words, (header >> 22) & 0x3, big_endian): + if port: + port_map[port] = module + break + segment_i = segment_end + return port_map + + def _walk_roc_bank(self, w, read, child_i: int, roc_end: int, roc_header: int, event_index: int, + frame_number: int, big_endian: bool) -> None: + roc = roc_header >> 16 + stream_status = roc_header & 0xFF + stream = stream_status & 0xF + hits_before, payload_before, empty_before = self.n_hits, self.n_payload_banks, self.n_empty_payload_banks + other_before = self.non_fadc_words + port_map = {} + # The stream-info bank comes first; its 0x41 segment says which module sits behind each port. + if child_i + 1 < roc_end and (read(child_i + 1) >> 16) == ROC_STREAM_INFO_TAG: + info_end = min(child_i + read(child_i) + 1, roc_end) + port_map = self._read_payload_port_map(read, child_i, info_end, event_index, big_endian) + child_i = info_end + roc_module = MODULE_UNKNOWN + while child_i < roc_end: + if child_i + 1 >= roc_end: + self.error(event_index, child_i, "payload bank header runs past the ROC bank end") + break + payload_len = read(child_i) + if payload_len == 0 or child_i + payload_len + 1 > roc_end: + self.error(event_index, child_i, f"payload bank length {payload_len} runs past the ROC bank end {roc_end}") + break + port = read(child_i + 1) >> 16 + module = port_map.get(port, MODULE_UNKNOWN) + if module == MODULE_UNKNOWN: + self.n_unmapped_payload_banks += 1 + if roc_module == MODULE_UNKNOWN: + roc_module = module + n_words = payload_len - 1 + self.n_payload_banks += 1 + self.n_payload_words += n_words + if n_words == 0: + self.n_empty_payload_banks += 1 + self.empty_rows.append((event_index, roc, stream, port, module)) + elif module in (MODULE_FADC250, MODULE_UNKNOWN): + self.hit_slices.append(w[child_i + 2:child_i + 2 + n_words]) + self.bank_event.append(event_index) + self.bank_frame.append(frame_number) + self.bank_roc.append(roc) + self.bank_stream.append(stream) + self.bank_port.append(port) + self.bank_n.append(n_words) + self.n_hits += n_words + else: + self.non_fadc_words += n_words + self.non_fadc_rows.append((event_index, frame_number, roc, stream, port, module, n_words)) + child_i += payload_len + 1 + self.roc_bank_rows.append(( + event_index, frame_number, roc, stream, stream_status, roc_module, self.n_payload_banks - payload_before, + self.n_empty_payload_banks - empty_before, self.n_hits - hits_before, hits_before, + self.non_fadc_words - other_before, + )) + + def to_stream_hits(self) -> StreamHits: + """Build the numpy columns: one concatenation of hit words, then vectorized unpacking.""" + if self.hit_slices: + hit_words = np.concatenate(self.hit_slices) + else: + hit_words = np.empty(0, dtype=np.uint32) + bank_n = np.asarray(self.bank_n, dtype=np.int64) + channel, time_raw, charge = unpack_hit_words(hit_words) + return StreamHits( + frame=np.repeat(np.asarray(self.bank_frame, dtype=np.uint32), bank_n), + event_index=np.repeat(np.asarray(self.bank_event, dtype=np.uint32), bank_n), + roc=np.repeat(np.asarray(self.bank_roc, dtype=np.uint16), bank_n), + stream=np.repeat(np.asarray(self.bank_stream, dtype=np.uint8), bank_n), + port=np.repeat(np.asarray(self.bank_port, dtype=np.uint16), bank_n), + channel=channel, time_raw=time_raw, + t_ns=(time_raw * np.uint16(HIT_TIME_TICK_NS)).astype(np.uint16), # max 16383 × 4 = 65,532 fits + charge=charge, + frames=np.array(self.frame_rows, dtype=FRAME_DTYPE), + roc_banks=np.array(self.roc_bank_rows, dtype=ROC_BANK_DTYPE), + empty_banks=np.array(self.empty_rows, dtype=EMPTY_BANK_DTYPE), + non_fadc_banks=np.array(self.non_fadc_rows, dtype=NON_FADC_BANK_DTYPE), + n_events=self.n_events, n_stream_frames=self.n_stream_frames, n_other_events=self.n_other_events, + n_hits=self.n_hits, n_payload_banks=self.n_payload_banks, + n_empty_payload_banks=self.n_empty_payload_banks, n_unmapped_payload_banks=self.n_unmapped_payload_banks, + n_payload_words=self.n_payload_words, non_fadc_words=self.non_fadc_words, + n_flag_words=count_flag_words(hit_words), errors=self.errors, error_messages=self.error_messages, + warnings=self.warnings, warning_messages=self.warning_messages, + ) + + +def decode_frames(events: Iterable[np.ndarray], first_event_index: int = 0) -> StreamHits: + """Decode many events (each a ``uint32`` word array) into one :class:`StreamHits`. + + Events are numbered from ``first_event_index`` in iteration order; the number is the + ``event_index`` of their hits and table rows. The result holds every hit of the batch + in memory, so a caller streams a large file in chunks (for example one record at a time) + and joins or reduces the chunks itself. + """ + walk = _Walk() + for event_i, words in enumerate(events, start=first_event_index): + walk.walk_event(words, event_i) + return walk.to_stream_hits() + + +def decode_frame(words: np.ndarray, event_index: int = 0) -> FrameHits: + """Decode one event into a :class:`FrameHits`. + + A non-stream event (tag other than 0xFF60) returns ``is_stream_frame=False`` with empty + columns and no error; malformed lengths return the hits found before the bad length + and describe it in ``error_messages``. + """ + stream_hits = decode_frames([words], first_event_index=event_index) + is_stream_frame = stream_hits.n_stream_frames == 1 + frame_row = stream_hits.frames[0] if is_stream_frame else None + return FrameHits( + frame_number=int(frame_row["frame"]) if is_stream_frame else 0, + timestamp=int(frame_row["timestamp"]) if is_stream_frame else 0, + is_stream_frame=is_stream_frame, + roc=stream_hits.roc, stream=stream_hits.stream, port=stream_hits.port, channel=stream_hits.channel, + time_raw=stream_hits.time_raw, t_ns=stream_hits.t_ns, charge=stream_hits.charge, + roc_banks=stream_hits.roc_banks, empty_banks=stream_hits.empty_banks, non_fadc_banks=stream_hits.non_fadc_banks, + n_hits=stream_hits.n_hits, n_roc_banks=len(stream_hits.roc_banks), + n_payload_banks=stream_hits.n_payload_banks, n_empty_payload_banks=stream_hits.n_empty_payload_banks, + n_unmapped_payload_banks=stream_hits.n_unmapped_payload_banks, + n_payload_words=stream_hits.n_payload_words, non_fadc_words=stream_hits.non_fadc_words, + n_flag_words=stream_hits.n_flag_words, errors=stream_hits.errors, error_messages=stream_hits.error_messages, + n_ais_entries=int(frame_row["n_ais_entries"]) if is_stream_frame else 0, + n_ais_distinct=int(frame_row["n_ais_distinct"]) if is_stream_frame else 0, + warnings=stream_hits.warnings, warning_messages=stream_hits.warning_messages, + ) + + +def empty_banks_per_port(empty_banks: np.ndarray) -> dict: + """Count empty payload banks per ``(roc, stream, port)`` from an ``empty_banks`` table.""" + if len(empty_banks) == 0: + return {} + keys, counts = np.unique(empty_banks[["roc", "stream", "port"]], return_counts=True) + return {(int(k["roc"]), int(k["stream"]), int(k["port"])): int(c) for k, c in zip(keys, counts)} + + +def words_per_roc(roc_banks: np.ndarray, column: str = "non_fadc_words") -> dict: + """Sum a ``roc_banks`` column per ROC id, for example ``non_fadc_words`` or ``n_hits``.""" + if len(roc_banks) == 0: + return {} + rocs, inverse = np.unique(roc_banks["roc"], return_inverse=True) + sums = np.bincount(inverse, weights=roc_banks[column].astype(np.float64), minlength=len(rocs)) + return {int(roc): int(total) for roc, total in zip(rocs, sums)} + + +def non_fadc_words_per_port(non_fadc_banks: np.ndarray) -> dict: + """Sum the words of non-fADC250 payload banks per ``(roc, port)``.""" + if len(non_fadc_banks) == 0: + return {} + keys, inverse = np.unique(non_fadc_banks[["roc", "port"]], return_inverse=True) + sums = np.bincount(inverse, weights=non_fadc_banks["n_words"].astype(np.float64), minlength=len(keys)) + return {(int(k["roc"]), int(k["port"])): int(total) for k, total in zip(keys, sums)} diff --git a/pyevio/display.py b/pyevio/display.py index 95c7f84..c31a3f5 100644 --- a/pyevio/display.py +++ b/pyevio/display.py @@ -2,6 +2,7 @@ Display utilities for rendering EVIO structures in a user-friendly way. """ from rich.tree import Tree +from pyevio.record import Record from pyevio.roc_time_slice_bank import RocTimeSliceBank @@ -125,3 +126,22 @@ def create_bank_tree(bank, title="Bank Structure"): tree = Tree(f"[bold]{title}[/bold]") display_bank_tree(tree, bank) return tree + + +COMPRESSION_NAMES = {Record.COMPRESSION_LZ4: "LZ4", Record.COMPRESSION_LZ4_BEST: "LZ4_BEST", + Record.COMPRESSION_GZIP: "gzip"} + + +def offset_scope_note(record): + """The line that says what the event and bank offsets of ``record`` refer to. + + For a compressed record every event and bank offset is an offset in the + decompressed record buffer (see ``Record``), so a reader who takes it for + a file offset lands on compressed bytes. Returns ``None`` for an + uncompressed record, whose offsets are file offsets. + """ + if record.compression_type == record.COMPRESSION_NONE: + return None + name = COMPRESSION_NAMES.get(record.compression_type, f"type {record.compression_type}") + # Kept under 80 columns so rich prints it on one line in a default terminal. + return f"[yellow]{name} compressed record: offsets below are in the decompressed record buffer[/yellow]" diff --git a/pyevio/event.py b/pyevio/event.py index a7dc170..4df279a 100644 --- a/pyevio/event.py +++ b/pyevio/event.py @@ -95,8 +95,9 @@ def get_bank(self)->Bank: Bank object for this event's root bank """ if self._root_bank is None: - # Parse the root bank - self._root_bank = Bank.from_buffer(self.mm, self.offset, self.endian) + # The root bank may not reach past the event's bytes (its length + # word is file data); the event end is the limit. + self._root_bank = Bank.from_buffer(self.mm, self.offset, self.endian, self.end_offset) # For special bank types, return appropriate subclass bank_info = self.get_bank_info() @@ -104,7 +105,7 @@ def get_bank(self)->Bank: if bank_info.get("bank_type") == "ROC Time Slice Bank": from pyevio.roc_time_slice_bank import RocTimeSliceBank try: - self._root_bank = RocTimeSliceBank(self.mm, self.offset, self.endian) + self._root_bank = RocTimeSliceBank(self.mm, self.offset, self.endian, self.end_offset) except Exception as e: # If parsing as specialized bank fails, fall back to generic Bank pass diff --git a/pyevio/evio_file.py b/pyevio/evio_file.py index 0b71c4b..3b85a51 100644 --- a/pyevio/evio_file.py +++ b/pyevio/evio_file.py @@ -4,8 +4,9 @@ from typing import List, Tuple, Optional, Dict, Any, Iterator from datetime import datetime -from pyevio.file_header import FileHeader -from pyevio.record import Record +from pyevio.file_header import FileHeader, detect_endian, file_type_name, unsupported_version_error +from pyevio.record import Record, padded_to_words +from pyevio.record_header import RecordHeader from pyevio.event import Event @@ -15,6 +16,36 @@ class EvioFile: Manages file resources and provides methods for navigating through the file structure using Records and Events. + + Records: ``record_count``, ``get_record()`` and ``iter_records()`` cover + the data records. The trailer record that closes a file (header species + 3) holds no events and is exposed separately as ``trailer`` (None when + the file ends in a plain record). + + Layouts: ``header_layout`` names how the file starts. ``"v6"`` is a + 14-word file header followed by 14-word record headers. ``"v6-records"`` + is the same record stream with no file header (a buffer written by the + evio writers); ``header`` is then a stand-in ``FileHeader``. ``"sro-8word"`` + is the JLab SRO hardware stream: no file header, 8-word block headers + with version word exactly 4 (see ``FileHeader``); ``header`` is then a + stand-in ``FileHeader`` with ``layout == "sro-8word"``, each record + header has ``header_length == 8``, ``trailer`` is None, and a final + block cut by the end of the file is the last record with its complete + events (``checks`` reports the byte counts). Any other file without a + file header (EVIO v4 blocks with bit-info bits set, v1—3 blocks) raises + ``ValueError`` naming the version at open. + + Checks: ``checks`` lists what the record scan found wrong with the file + as tuples whose first item names the check: ``("truncated_tail", + declared_bytes, present_bytes, events_salvaged)`` for a final record + that runs past the end of the file (its complete events are kept), + ``("bad_record_header", offset, message)`` where the scan stopped on + bytes that are no record header. + + Resources: ``close()`` (or the ``with`` block) releases the mmap and the + file. A numpy array or memoryview that still refers to the mmap makes + ``close()`` raise ``BufferError`` after closing the file; drop the views + and call ``close()`` again. """ def __init__(self, filename: str, verbose: bool = False): @@ -24,6 +55,9 @@ def __init__(self, filename: str, verbose: bool = False): Args: filename: Path to the EVIO file verbose: Enable verbose output + + Raises: + ValueError: the file is not EVIO, or an EVIO version pyevio does not read """ self.verbose = verbose self.filename = filename @@ -33,38 +67,117 @@ def __init__(self, filename: str, verbose: bool = False): # Memory map the file for efficient access self.mm = mmap.mmap(self.file.fileno(), 0, access=mmap.ACCESS_READ) - # Parse file header - self.header = FileHeader.from_buffer(self.mm, 0) - - # Calculate initial offset after file header - self.first_record_offset = self.header.header_length * 4 - - # Skip index array if present - if self.header.index_array_length > 0: - self.first_record_offset += self.header.index_array_length + self.header_layout, self.header = self._detect_layout() - # Skip user header if present - if self.header.user_header_length > 0: - self.first_record_offset += self.header.user_header_length + # The first record follows the file header, its index array and its + # user header (dictionary, first event), which is stored padded to + # whole words although the header gives the unpadded byte count. + self.first_record_offset = (self.header.header_length * 4 + + self.header.index_array_length + + padded_to_words(self.header.user_header_length)) - # Record objects (will be created on demand) - self._records = {} + # One Record is cached: the one accessed last. A Record owns its + # decompressed buffer and Event objects, so caching every record + # would hold a whole compressed file in memory. + self._cached_record: Optional[Record] = None + self._trailer = None # Scan record offsets + self._trailer_offset: Optional[int] = None + self.checks: List[tuple] = [] self._record_offsets = self._scan_record_offsets() + if self.header_layout != FileHeader.LAYOUT_V6: + self.header.record_count = len(self._record_offsets) # Total event count cache self._total_event_count = None + def _detect_layout(self) -> Tuple[str, FileHeader]: + """Decide how the file starts and parse its header. + + Every EVIO header layout carries the magic word at word 7, so the byte + order comes from there. A file type id in word 0 means a v6 file + header. Anything else is a bare header chain at offset 0: a 14-word + version-6 header starts a bare v6 record stream; the SRO hardware + stream (every block header version 4 with no bit-info bits) is read + as 8-word blocks; any other version, or a v4 chain with bit-info + bits set, raises the one unsupported-version error. + """ + endian = detect_endian(self.mm, 0) + if endian is None: + raise ValueError(f"Not an EVIO file: no magic number 0x{RecordHeader.MAGIC_NUMBER:08x} " + f"at word 7 of {self.filename}") + + first_word = struct.unpack(endian + 'I', self.mm[0:4])[0] + if file_type_name(first_word) is not None: + return FileHeader.LAYOUT_V6, FileHeader.from_buffer(self.mm, 0) + + header_length = struct.unpack(endian + 'I', self.mm[8:12])[0] + version = struct.unpack(endian + 'I', self.mm[20:24])[0] & 0xFF + if header_length == 14 and version == 6: + return FileHeader.LAYOUT_V6_RECORDS, FileHeader.for_record_stream(endian) + + self._require_sro_block_chain(endian) + return FileHeader.LAYOUT_SRO, FileHeader.for_sro_stream(endian) + + def _require_sro_block_chain(self, endian: str) -> None: + """Raise the unsupported-version error unless every block header has the SRO shape. + + The first block of a v4 file written by the evio library is identical + to an SRO block (version 4, bit-info 0, header length 8), so the + whole chain is walked: a v4 file sets the last-block bit (0x200) on + its final block or the dictionary bit (0x100) on its first. The walk + reads 8 words per block and stops at the end of the file, at a block + cut by it, or at bytes that are no block header (the record scan + reports those). + """ + offset = 0 + header_count = 0 + while offset + 32 <= self.file_size: + words = struct.unpack(endian + '8I', self.mm[offset:offset + 32]) + length_words, _, header_length, _, _, bit_info_version, _, magic = words + if magic != RecordHeader.MAGIC_NUMBER or header_length != 8: + break + version = bit_info_version & 0xFF + if version != 4: + raise unsupported_version_error(version, f"block header at offset 0x{offset:X}, no file header: {self.filename}") + if not RecordHeader.is_sro_block_word(bit_info_version): + raise unsupported_version_error(4, f"block header at offset 0x{offset:X} has bit-info 0x{bit_info_version >> 8:X}, " + f"an evio-library v4 file rather than an SRO stream: {self.filename}") + header_count += 1 + if length_words < 8: + break + offset += length_words * 4 + if header_count == 0: + block_version = struct.unpack(endian + 'I', self.mm[20:24])[0] & 0xFF + raise unsupported_version_error(block_version, f"block header at offset 0, no file header: {self.filename}") + + def close(self) -> None: + """Release the mmap and the file. + + The file is closed whatever happens to the mmap. ``mmap.close()`` + raises ``BufferError`` while a numpy array or memoryview exported + from it is alive; that error is re-raised with the cause named, and + the mmap stays open until those views are dropped and ``close()`` is + called again. + """ + mm = getattr(self, 'mm', None) + file = getattr(self, 'file', None) + try: + if mm is not None and not mm.closed: + mm.close() + except BufferError as error: + raise BufferError(f"cannot close the mmap of {self.filename}: a numpy array or memoryview " + f"still refers to it; drop those views, then call close() again") from error + finally: + if file is not None and not file.closed: + file.close() + def __del__(self): - """Cleanup resources when object is destroyed""" + """Release resources at garbage collection; errors are silent here.""" try: - if hasattr(self, 'mm') and self.mm and not getattr(self.mm, 'closed', True): - self.mm.close() - if hasattr(self, 'file') and self.file and not self.file.closed: - self.file.close() - except (ValueError, AttributeError): - # Ignore errors during cleanup + self.close() + except Exception: pass def __enter__(self): @@ -72,8 +185,8 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - """Cleanup when exiting context""" - self.__del__() + """Release resources; a BufferError from a live view propagates (see close).""" + self.close() def _scan_record_offsets(self) -> List[int]: """ @@ -86,27 +199,94 @@ def _scan_record_offsets(self) -> List[int]: offset = self.first_record_offset while offset < self.file_size: - try: - # Store this record's position - record_offsets.append(offset) - - # Create a temporary Record object to get its size - record = Record(self.mm, offset, self.header.endian) - - # Move to next record - offset += record.size - - # Stop if this was the last record - if record.header.is_last_record: - break - - except Exception as e: - if self.verbose: - print(f"Error scanning record at offset 0x{offset:X}: {e}") - raise + # Bytes that are no record header (garbage, a second file header + # of a concatenated file, a header the scan cannot step over) end + # the scan with a check instead of a record. + problem = self.record_header_problem(offset) + if problem is None: + try: + record = Record(self.mm, offset, self.header.endian) + except ValueError as error: + problem = str(error) + if problem is not None: + self.checks.append(("bad_record_header", offset, problem)) + break + + # The trailer closes the file: it lists records, holds no events + # and is exposed as `trailer`, never as a data record. + if record.header.is_trailer: + self._trailer_offset = offset + break + + record_offsets.append(offset) + + # A final record cut by the end of the file: keep it, walk its + # complete events and report the byte counts. + if record.is_truncated: + record.index = len(record_offsets) - 1 + salvaged = len(record.scan_events()) + self.checks.append(("truncated_tail", record.declared_end - offset, self.file_size - offset, salvaged)) + break + + offset += record.size + + # Stop if this was the last record. Some CODA writers set the + # last-record bit on every record, so keep going if another + # record header follows. + if record.header.is_last_record and not self._is_record_at(offset): + break return record_offsets + @property + def trailer(self) -> Optional[Record]: + """The trailer record that closes the file, or None when the file ends in a plain record.""" + if self._trailer_offset is None: + return None + if self._trailer is None: + self._trailer = Record(self.mm, self._trailer_offset, self.header.endian) + return self._trailer + + def record_header_problem(self, offset: int) -> Optional[str]: + """Why no record header can start at offset, or None when one can. + + A record header is 8 or 14 words with the magic at word 7, a record + length of at least the header, and no file type id in word 0 (that + is a file header, as in two files concatenated). The record length + may run past the end of the file: that is a truncated tail, which + the scan keeps as the last record. + + The record scan calls this before building each ``Record``; the + reason of the header that ended the scan is in ``checks`` as + ``("bad_record_header", offset, reason)``. Call it on any offset + (a record table's "header quirks" column, a hex view) to get the + same wording for that position. + """ + if offset + 32 > self.file_size: + return f"{self.file_size - offset} bytes left, a record header needs 32" + words = struct.unpack(self.header.endian + '8I', self.mm[offset:offset + 32]) + length_words, _, header_length, _, _, _, _, magic = words + if file_type_name(words[0]) is not None: + return f"file header ({file_type_name(words[0])}) where a record header should start" + if magic != RecordHeader.MAGIC_NUMBER: + swapped = int.from_bytes(magic.to_bytes(4, "little"), "big") + hint = " (byte-swapped record header)" if swapped == RecordHeader.MAGIC_NUMBER else "" + return f"no magic number at word 7 (0x{magic:08X}){hint}" + if header_length not in (8, 14): + return f"header length {header_length}, expected 8 or 14" + if offset + header_length * 4 > self.file_size: + return f"{self.file_size - offset} bytes left, the {header_length}-word header needs {header_length * 4}" + if length_words < header_length: + return f"record length {length_words} words is shorter than its {header_length}-word header" + return None + + def _is_record_at(self, offset: int) -> bool: + """Whether a whole record (valid header, length inside the file) starts at offset.""" + if self.record_header_problem(offset) is not None: + return False + length_words = struct.unpack(self.header.endian + 'I', self.mm[offset:offset + 4])[0] + return offset + length_words * 4 <= self.file_size + @property def record_count(self) -> int: """Get the number of records in this file.""" @@ -128,12 +308,10 @@ def get_record(self, index: int) -> Record: if index < 0 or index >= len(self._record_offsets): raise IndexError(f"Record index {index} out of range (0-{len(self._record_offsets)-1})") - # Check if record is already cached - if index not in self._records: - offset = self._record_offsets[index] - self._records[index] = Record(self.mm, offset, self.header.endian, index=index) + if self._cached_record is None or self._cached_record.index != index: + self._cached_record = Record(self.mm, self._record_offsets[index], self.header.endian, index=index) - return self._records[index] + return self._cached_record def get_records(self) -> List[Record]: """ @@ -162,12 +340,17 @@ def get_total_event_count(self) -> int: Total event count """ if self._total_event_count is None: - self._total_event_count = 0 - for record in self.iter_records(): - self._total_event_count += record.event_count + self._total_event_count = sum(self.record_event_count(record) for record in self.iter_records()) return self._total_event_count + @staticmethod + def record_event_count(record: Record) -> int: + """Events a record contributes: the header count, or the complete events present in a truncated tail.""" + if record.is_truncated: + return len(record.scan_events()) + return record.event_count + def get_record_and_event(self, global_index: int) -> Tuple[Record, Event]: """ Get an event by global index across all records. @@ -189,7 +372,7 @@ def get_record_and_event(self, global_index: int) -> Tuple[Record, Event]: for record_idx in range(len(self._record_offsets)): record = self.get_record(record_idx) - event_count = record.event_count + event_count = self.record_event_count(record) # Check if this event is in the current record if current_index + event_count > global_index: @@ -213,7 +396,7 @@ def get_event(self, global_index: int) -> Event: Returns: found events """ - _, _, event, _ = self.get_record_and_event(global_index) + _, event = self.get_record_and_event(global_index) return event def iter_events(self) -> Iterator[Tuple[Record, Event]]: diff --git a/pyevio/file_header.py b/pyevio/file_header.py index 36ee7f1..366df85 100644 --- a/pyevio/file_header.py +++ b/pyevio/file_header.py @@ -1,27 +1,97 @@ import mmap -import os import struct -from typing import List, Tuple, Optional, Dict, Any -from datetime import datetime +from typing import Optional from pyevio.utils import make_hex_dump +# File type ids in word 0 of a v6 file header. The evio writers store the id +# as a 32-bit word in the file's byte order; HIPO-flavored writers store the +# ASCII bytes in order whatever the byte order, so the id must be accepted in +# both orders (see file_type_name). +FILE_TYPE_EVIO = 0x4556494F # "EVIO" +FILE_TYPE_HIPO = 0x4849504F # "HIPO" +FILE_TYPE_NAMES = {FILE_TYPE_EVIO: "EVIO", FILE_TYPE_HIPO: "HIPO"} + +MAGIC_NUMBER = 0xc0da0100 + + +def file_type_name(word: int) -> Optional[str]: + """Name of a file type id ("EVIO", "HIPO") read in either byte order, else None.""" + swapped = int.from_bytes(word.to_bytes(4, "little"), "big") + return FILE_TYPE_NAMES.get(word) or FILE_TYPE_NAMES.get(swapped) + + +def detect_endian(buffer, offset: int = 0) -> Optional[str]: + """Byte order ('<' or '>') of the header at offset, from the magic word 0xc0da0100 at word 7. + + Both the v6 file header and every record or block header (v1—v6) carry + the magic at word 7, so one probe serves all layouts. Returns None when + neither byte order matches or the buffer is shorter than 8 words. + """ + magic_bytes = buffer[offset + 28:offset + 32] + if len(magic_bytes) < 4: + return None + for endian in ("<", ">"): + if struct.unpack(endian + "I", magic_bytes)[0] == MAGIC_NUMBER: + return endian + return None + + +def unsupported_version_error(version: int, detail: str = "") -> ValueError: + """The one error for files pyevio does not read, naming the EVIO version. + + The golden harness pins this message: it names "version N" and points + at the evio tools. + """ + message = (f"EVIO version {version} file is not supported: pyevio reads EVIO version 6 files " + f"and SRO streaming files. Convert the file with the evio tools (evio2xml, eviocopy) " + f"or read it with the evio library.") + if detail: + message += f" ({detail})" + return ValueError(message) + + class FileHeader: """ Parses and represents an EVIO v6 file header. + + ``layout`` names the container the header describes: + + - ``"v6"`` — the 14-word file header read from the file, followed by + 14-word record headers. + - ``"v6-records"`` — a bare stream of 14-word v6 records with no file + header, as the evio writers produce into a buffer (``.dump`` files). + - ``"sro-8word"`` — the JLab SRO hardware stream: no file header at all, + 8-word block headers ``{length, block number, 8, event count, 0, 4, + 0, magic}`` whose version word says 4 with no bit-info bits. The + events are EVIO v6 streaming content (tag 0xFF60 frames), so + ``version`` reports 6 and ``container_version`` keeps the 4 the block + headers carry. + + For the two layouts without a file header the header is a stand-in: + every field only a file header could supply (file number, user + register, trailer position) is 0 and ``record_count`` is filled from + the record scan. """ # EVIO magic number and bytes MAGIC_BYTES = b'EVIO' - MAGIC_NUMBER = 0xc0da0100 + MAGIC_NUMBER = MAGIC_NUMBER # Size of file header in bytes HEADER_SIZE = 56 # 14 words * 4 bytes + LAYOUT_V6 = "v6" + LAYOUT_V6_RECORDS = "v6-records" + LAYOUT_SRO = "sro-8word" + def __init__(self): """Initialize an empty FileHeader object""" + self.layout = self.LAYOUT_V6 + self.container_version = None self.file_type_id = None + self.file_type = None self.file_number = None self.header_length = None self.record_count = None @@ -52,28 +122,23 @@ def from_buffer(buffer: mmap.mmap, offset: int = 0) -> 'FileHeader': Returns: FileHeader object + + Raises: + ValueError: no magic word, no file type id, or a version other than 6 """ header = FileHeader() - # Try little endian first - endian = '<' + endian = detect_endian(buffer, offset) + if endian is None: + raise ValueError(f"Not an EVIO file: no magic number 0x{MAGIC_NUMBER:08x} at word 7 of offset 0x{offset:X}") + header.endian = endian - # Read header fields header.file_type_id = struct.unpack(endian + 'I', buffer[offset:offset+4])[0] + header.file_type = file_type_name(header.file_type_id) + if header.file_type is None: + raise ValueError(f"Invalid EVIO file header: file_type_id = 0x{header.file_type_id:08x}, " + f"expected 0x{FILE_TYPE_EVIO:08X} (EVIO) or 0x{FILE_TYPE_HIPO:08X} (HIPO)") - # Check if the file type is EVIO (ASCII for "EVIO" is 0x4556494F) - if header.file_type_id != 0x4556494F: - # Try big endian if little endian didn't work - endian = '>' - header.file_type_id = struct.unpack(endian + 'I', buffer[offset:offset+4])[0] - - # If still not EVIO, raise error - if header.file_type_id != 0x4556494F: - raise ValueError(f"Invalid EVIO file: file_type_id = 0x{header.file_type_id:08x}, expected 0x4556494F") - - header.endian = endian - - # Continue parsing with detected endianness header.file_number = struct.unpack(endian + 'I', buffer[offset+4:offset+8])[0] header.header_length = struct.unpack(endian + 'I', buffer[offset+8:offset+12])[0] header.record_count = struct.unpack(endian + 'I', buffer[offset+12:offset+16])[0] @@ -87,22 +152,15 @@ def from_buffer(buffer: mmap.mmap, offset: int = 0) -> 'FileHeader': header.magic_number = struct.unpack(endian + 'I', buffer[offset+28:offset+32])[0] # 64-bit values - if endian == '<': - header.user_register = struct.unpack('Q', buffer[offset+32:offset+40])[0] - header.trailer_position = struct.unpack('>Q', buffer[offset+40:offset+48])[0] + header.user_register = struct.unpack(endian + 'Q', buffer[offset+32:offset+40])[0] + header.trailer_position = struct.unpack(endian + 'Q', buffer[offset+40:offset+48])[0] header.user_integer1 = struct.unpack(endian + 'I', buffer[offset+48:offset+52])[0] header.user_integer2 = struct.unpack(endian + 'I', buffer[offset+52:offset+56])[0] - # Validate header if header.version != 6: - raise ValueError(f"Unsupported EVIO version: {header.version}, expected 6") - - if header.magic_number != FileHeader.MAGIC_NUMBER: - raise ValueError(f"Invalid magic number: 0x{header.magic_number:08x}, expected 0x{FileHeader.MAGIC_NUMBER:08x}") + raise unsupported_version_error(header.version, "file header") + header.container_version = header.version # Parse bit_info fields header.has_dictionary = bool((header.bit_info >> 0) & 1) # Bit 8 @@ -111,6 +169,38 @@ def from_buffer(buffer: mmap.mmap, offset: int = 0) -> 'FileHeader': return header + @staticmethod + def for_sro_stream(endian: str) -> 'FileHeader': + """The stand-in header of an SRO hardware stream (layout ``"sro-8word"``, see the class docstring).""" + return FileHeader._stand_in(FileHeader.LAYOUT_SRO, endian, container_version=4, file_type="SRO") + + @staticmethod + def for_record_stream(endian: str) -> 'FileHeader': + """The stand-in header of a bare v6 record stream (layout ``"v6-records"``, see the class docstring).""" + return FileHeader._stand_in(FileHeader.LAYOUT_V6_RECORDS, endian, container_version=6, file_type="EVIO records") + + @staticmethod + def _stand_in(layout: str, endian: str, container_version: int, file_type: str) -> 'FileHeader': + header = FileHeader() + header.layout = layout + header.endian = endian + header.file_type_id = 0 + header.file_type = file_type + header.file_number = 0 + header.header_length = 0 + header.record_count = 0 + header.index_array_length = 0 + header.bit_info = 0 + header.version = 6 + header.container_version = container_version + header.user_header_length = 0 + header.magic_number = MAGIC_NUMBER + header.user_register = 0 + header.trailer_position = 0 + header.user_integer1 = 0 + header.user_integer2 = 0 + return header + def get_hex_dump(self, buffer: mmap.mmap, offset: int = 0) -> str: """ Generate a hex dump of the raw header bytes. @@ -130,10 +220,11 @@ def __str__(self) -> str: endian_str = "Little Endian" if self.endian == '<' else "Big Endian" return f"""EVIO File Header: + Layout: {self.layout} Magic Number: 0x{self.magic_number:08x} - Version: {self.version} + Version: {self.version} (container {self.container_version}) Endianness: {endian_str} - File Type ID: 0x{self.file_type_id:08x} ({"EVIO" if self.file_type_id == 0x4556494F else "Unknown"}) + File Type ID: 0x{self.file_type_id:08x} ({self.file_type or "Unknown"}) File Number: {self.file_number} Header Length: {self.header_length} words ({self.header_length * 4} bytes) Record Count: {self.record_count} diff --git a/pyevio/gui/__init__.py b/pyevio/gui/__init__.py new file mode 100644 index 0000000..8143712 --- /dev/null +++ b/pyevio/gui/__init__.py @@ -0,0 +1,22 @@ +"""Console viewer for EVIO files: a Textual app in the terminal. + +``pyevio gui [PATH]`` shows the same things as the browser viewer, in the +terminal: file checks and totals, plots, the record table and the structure +tree down to single hit words. It reads through the same model modules as +``pyevio web`` (``pyevio.web.report``, ``pyevio.web.structure``, +``pyevio.web.jobs``, ``pyevio.web.cache``; none of them imports FastAPI), so +the two viewers agree on every number and share the statistics cache. + +Modules, one job each: + +- ``session``: the open file, its analysis job and the cached statistics. No Textual. +- ``app``: the Textual app, the status strip and the file-open screen. +- ``overview``, ``plots``, ``records``, ``structure``: one pane per tab. +- ``export``: the current plot as a PNG through matplotlib. + +Textual and textual-plotext are the optional extra ``pyevio[gui]``; this +package imports them only inside the widget modules, so ``pyevio.gui.session`` +works without them. +""" + +INSTALL_HINT = "The console viewer needs Textual and textual-plotext: pip install 'pyevio[gui]'" diff --git a/pyevio/gui/app.py b/pyevio/gui/app.py new file mode 100644 index 0000000..3c3b556 --- /dev/null +++ b/pyevio/gui/app.py @@ -0,0 +1,295 @@ +"""The Textual app: status strip, four tabs, the file-open screen and the progress poll. + +Threading: the analysis runs in the session's job thread and writes only its own +fields; this app reads them on a timer (``_poll``) from the main thread and +drives the panes. No widget is ever touched from the job thread. +""" +from pathlib import Path +from typing import Optional + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import DirectoryTree, Footer, Header, Input, Label, ProgressBar, Static, TabbedContent, TabPane + +from pyevio.gui.overview import OverviewPane +from pyevio.gui.plots import PlotsPane +from pyevio.gui.records import RecordsPane +from pyevio.gui.session import STATE_EMPTY, Session +from pyevio.gui.structure import StructurePane +from pyevio.web import ApiError, is_evio_name +from pyevio.web.jobs import STATE_DONE, STATE_ERROR, STATE_CANCELLED + +POLL_INTERVAL_S = 0.2 +TAB_IDS = ("overview", "plots", "records", "structure") + + +class EvioDirectoryTree(DirectoryTree): + """Folders and EVIO-named files; ``show_all`` lists every file.""" + + def __init__(self, path, show_all: bool = False, **kwargs): + super().__init__(path, **kwargs) + self.show_all = show_all + + def filter_paths(self, paths): + for path in paths: + if path.name.startswith("."): + continue + if path.is_dir() or self.show_all or is_evio_name(path.name): + yield path + + +class OpenScreen(ModalScreen[Optional[Path]]): + """Pick a file: type a path (a folder re-roots the tree), or select one in the tree. Escape cancels.""" + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("backspace", "go_up", "Parent folder"), + Binding("ctrl+a", "toggle_all", "All files"), + ] + DEFAULT_CSS = """ + OpenScreen { + align: center middle; + } + OpenScreen > Vertical { + width: 90%; + height: 90%; + border: round $accent; + background: $surface; + padding: 0 1; + } + OpenScreen Input { + margin-bottom: 1; + } + OpenScreen EvioDirectoryTree { + height: 1fr; + } + OpenScreen #open-hint { + color: $text-muted; + } + """ + + def __init__(self, root: Path): + super().__init__() + self.root = Path(root).expanduser().resolve() + + def compose(self) -> ComposeResult: + with Vertical(): + yield Label("Open an EVIO file — type a path and press Enter, or pick one below") + yield Input(value=str(self.root), placeholder="path to a file or folder", id="open-path") + yield EvioDirectoryTree(self.root, id="open-tree") + yield Label("Enter opens · in the tree (Tab): Backspace goes to the parent folder, Ctrl+A shows every file · Escape cancels", id="open-hint") + + def reroot(self, folder: Path) -> None: + self.root = folder + self.query_one("#open-path", Input).value = str(folder) + self.query_one("#open-tree", EvioDirectoryTree).path = folder + + def on_input_submitted(self, event: Input.Submitted) -> None: + path = Path(event.value).expanduser() + if path.is_file(): + self.dismiss(path.resolve()) + elif path.is_dir(): + self.reroot(path.resolve()) + else: + self.notify(f"no such file or folder: {event.value}", severity="error") + + def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None: + event.stop() + self.dismiss(Path(event.path).resolve()) + + def action_cancel(self) -> None: + self.dismiss(None) + + def action_go_up(self) -> None: + if self.root.parent != self.root: + self.reroot(self.root.parent) + + def action_toggle_all(self) -> None: + tree = self.query_one("#open-tree", EvioDirectoryTree) + tree.show_all = not tree.show_all + tree.reload() + + +class StatusStrip(Horizontal): + """One line under the header: the file, the analysis state, progress and the last error.""" + + DEFAULT_CSS = """ + StatusStrip { + height: 1; + padding: 0 1; + } + StatusStrip #status-text { + width: 1fr; + } + StatusStrip ProgressBar { + width: 30; + } + StatusStrip #status-error { + color: $error; + width: auto; + margin-left: 2; + } + """ + + def compose(self) -> ComposeResult: + yield Static("No file open — press o", id="status-text") + yield ProgressBar(total=100, show_eta=False, id="status-progress") + yield Static("", id="status-error") + + def show(self, text: str, fraction: Optional[float] = None) -> None: + self.query_one("#status-text", Static).update(text) + bar = self.query_one("#status-progress", ProgressBar) + bar.display = fraction is not None + if fraction is not None: + bar.update(progress=fraction * 100) + + def show_error(self, text: str) -> None: + self.query_one("#status-error", Static).update(text) + + +class PyEvioGui(App): + """``pyevio gui``: the console viewer.""" + + TITLE = "pyevio" + CSS = """ + TabbedContent { + height: 1fr; + } + """ + BINDINGS = [ + Binding("o", "open_file", "Open"), + Binding("1", "tab('overview')", "Overview"), + Binding("2", "tab('plots')", "Plots"), + Binding("3", "tab('records')", "Records"), + Binding("4", "tab('structure')", "Structure"), + Binding("q", "quit", "Quit"), + ] + + def __init__(self, session: Session, start_path: Optional[Path] = None, start_dir: Optional[Path] = None): + super().__init__() + self.session = session + self.start_path = Path(start_path) if start_path else None + self.start_dir = Path(start_dir) if start_dir else (self.start_path.parent if self.start_path else Path.cwd()) + self._poll_timer = None + # Direct references: TabbedContent mounts its panes after the app's own mount, so a query can come too early. + self.overview = OverviewPane(session, id="overview") + self.plots = PlotsPane(session, id="plots") + self.records = RecordsPane(session, id="records") + self.structure = StructurePane(session, id="structure") + self.panes = [self.overview, self.plots, self.records, self.structure] + + def compose(self) -> ComposeResult: + yield Header() + yield StatusStrip(id="status") + with TabbedContent(id="tabs"): + with TabPane("Overview", id="tab-overview"): + yield self.overview + with TabPane("Plots", id="tab-plots"): + yield self.plots + with TabPane("Records", id="tab-records"): + yield self.records + with TabPane("Structure", id="tab-structure"): + yield self.structure + yield Footer() + + def on_mount(self) -> None: + self.query_one("#status", StatusStrip).show("No file open — press o", None) + self.call_after_refresh(self._start) # the panes are mounted by then + + def _start(self) -> None: + if self.start_path is not None: + self.open_path(self.start_path) + else: + self.action_open_file() + + def on_unmount(self) -> None: + # The panes' children are gone by now; only the session needs closing (the job thread is cancelled). + try: + self.session.close() + except BufferError: + pass # a pane kept a view; the process ends anyway + + # ---------------------------------------------------------------- opening files + def action_open_file(self) -> None: + self.push_screen(OpenScreen(self.session.path.parent if self.session.path else self.start_dir), callback=self.open_path) + + def open_path(self, path: Optional[Path]) -> None: + """Open ``path`` in the session and reset every pane; a failure keeps the current file and shows the reason.""" + if path is None: + return + status = self.query_one("#status", StatusStrip) + status.show_error("") + if self._poll_timer is not None: + self._poll_timer.stop() + self._poll_timer = None + for pane in self.panes: + pane.file_closed() + try: + self.session.open(path) + except (ValueError, OSError, BufferError) as error: + self.show_error(f"{path}: {error}") + if self.session.is_open: + for pane in self.panes: + pane.file_opened() + self._poll() + return + self.sub_title = str(path) + self.start_dir = Path(path).parent + for pane in self.panes: + pane.file_opened() + self._poll() + if self.session.state != STATE_DONE: + self._poll_timer = self.set_interval(POLL_INTERVAL_S, self._poll) + + def show_error(self, message: str) -> None: + self.query_one("#status", StatusStrip).show_error(message) + self.notify(message, severity="error", timeout=8) + + # ---------------------------------------------------------------- analysis progress + def _poll(self) -> None: + status = self.query_one("#status", StatusStrip) + progress = self.session.progress() + state = progress["state"] + name = self.session.path.name if self.session.path else "" + if state == STATE_EMPTY: + status.show("No file open — press o", None) + return + if state == STATE_DONE: + if self._poll_timer is not None: + self._poll_timer.stop() + self._poll_timer = None + source = "cached" if progress["cached"] else f"{progress['elapsed_s']:.1f} s" + status.show(f"{name} — analysis done ({progress['records_total']:,} records, {source})", None) + try: + summary = self.session.summary() + except ApiError as error: + self.show_error(error.message) + return + for pane in self.panes: + pane.stats_ready(summary) + return + if state in (STATE_ERROR, STATE_CANCELLED): + if self._poll_timer is not None: + self._poll_timer.stop() + self._poll_timer = None + status.show(f"{name} — analysis {state}", None) + if progress["error"]: + self.show_error(progress["error"]) + return + if not progress["records_total"]: + status.show(f"{name} — scanning the records…", None) + return + eta = f", {progress['eta_s']:.0f} s left" if progress["eta_s"] else "" + status.show(f"{name} — analyzing {progress['records_done']:,} of {progress['records_total']:,} records{eta}", + progress["fraction"]) + + # ---------------------------------------------------------------- navigation + def action_tab(self, name: str) -> None: + self.query_one("#tabs", TabbedContent).active = f"tab-{name}" + + def show_structure_node(self, node_id: str) -> None: + """Switch to the Structure tab and select the node (``r3`` a record, ``r3/e1`` an event, …).""" + self.action_tab("structure") + self.structure.show_node(node_id) diff --git a/pyevio/gui/export.py b/pyevio/gui/export.py new file mode 100644 index 0000000..d8f5712 --- /dev/null +++ b/pyevio/gui/export.py @@ -0,0 +1,295 @@ +"""Histogram dicts as plot series, and one histogram as a PNG through matplotlib. + +Everything here is pure: the input is a dict of :func:`pyevio.web.report.histogram` +(lists of numbers), the output a dict, a string or a file. No Textual, so the +plots pane and the tests share one reading of the five histogram shapes: + +- ``hits_per_frame``, ``charge``: ``counts`` over ``edges``; +- ``time_in_frame``: ``counts`` over ``edges_ns``; +- ``rate``: ``rate_hz`` over ``edges_ns``, with ``counts`` and ``frames_per_bin`` per bin; +- ``occupancy``: ``counts`` at three depths by selection — (roc, port, channel) for the + file, (port, channel) for a ROC, one channel for a channel — plus ``module_id`` + (roc, port), 255 where the ROC's map does not list the port. + +matplotlib is imported inside :func:`save_histogram_png` only, with the ``Agg`` +backend: a farm node has no display, and the viewer must start without matplotlib. +""" +import math +from pathlib import Path +from typing import Optional + +from pyevio.decoders.fadc250_stream import MODULE_UNKNOWN + +NS_PER_S = 1e-9 +PLOT_COLOR = "#1f6fb5" # one hue for every single-series plot +DEAD_COLOR = "#8a8a8a" # a port the ROC's map does not list + + +# ---------------------------------------------------------------- ticks and labels +def nice_ticks(low: float, high: float, count: int) -> list: + """Round tick positions covering ``[low, high]``: 1, 2 or 5 times a power of ten (as the web charts do).""" + span = (high - low) or 1.0 + rough = span / max(count, 1) + power = 10 ** math.floor(math.log10(rough)) + step = next(candidate * power for candidate in (1, 2, 5, 10) if candidate * power >= rough) + first = math.ceil(low / step) * step + ticks = [] + tick = first + while tick <= high + step * 1e-9: + ticks.append(round(tick, 12)) + tick += step + return ticks + + +def format_compact(value: float) -> str: + """``1.2k``, ``3.4M``; integers below 1000 as they are.""" + if value == 0: + return "0" + magnitude = abs(value) + for limit, suffix in ((1e9, "G"), (1e6, "M"), (1e3, "k")): + if magnitude >= limit: + return f"{value / limit:.3g}{suffix}" + if float(value).is_integer(): + return str(int(value)) + return f"{value:.3g}" + + +def format_tick(value: float, step: float) -> str: + if value == 0: + return "0" + if abs(value) >= 1e4: + return format_compact(value) + decimals = 0 if step >= 1 else min(9, math.ceil(-math.log10(step))) + return f"{value:.{decimals}f}" + + +def tick_labels(ticks: list) -> list: + step = ticks[1] - ticks[0] if len(ticks) > 1 else (abs(ticks[0]) if ticks and ticks[0] else 1.0) + return [format_tick(tick, step) for tick in ticks] + + +# ---------------------------------------------------------------- the dicts as series +def selection_label(selection: Optional[dict]) -> str: + """``file``, ``ROC 7``, ``ROC 7 port 1 ch 3``.""" + selection = selection or {} + if selection.get("roc") is None: + return "file" + parts = [f"ROC {selection['roc']}"] + if selection.get("port") is not None: + parts.append(f"port {selection['port']}") + if selection.get("ch") is not None: + parts.append(f"ch {selection['ch']}") + return " ".join(parts) + + +HISTOGRAM_TITLES = {"hits_per_frame": "Hits per frame", "rate": "Hit rate", "occupancy": "Occupancy", + "charge": "Charge", "time_in_frame": "Time in frame"} + + +def histogram_title(hist: dict) -> str: + """``Charge — ROC 7 port 1 ch 3``; the occupancy names its axes.""" + name = HISTOGRAM_TITLES.get(hist["name"], hist["name"]) + if hist["name"] == "occupancy": + depth = occupancy_depth(hist) + name += " (ROC × port)" if depth == 3 else " (port × channel)" if depth == 2 else "" + return f"{name} — {selection_label(hist.get('selection'))}" + + +def series(hist: dict) -> dict: + """A one-dimensional histogram as ``{edges, values, x_label, y_label, log_y, empty}`` for drawing. + + ``values[i]`` belongs to ``[edges[i], edges[i + 1])``. The rate is drawn in seconds + from the first frame (the dict has nanoseconds); ``empty`` names why there is nothing + to draw, or is ``None``. + """ + name = hist["name"] + if name == "rate": + values = list(hist["rate_hz"]) + edges = [edge * NS_PER_S for edge in hist["edges_ns"]] + x_label, y_label, log_y = "time since the first frame (s)", "hit rate (Hz)", False + counts = hist["counts"] + elif name in ("hits_per_frame", "charge", "time_in_frame"): + values = list(hist["counts"]) + edges = list(hist["edges"] if name != "time_in_frame" else hist["edges_ns"]) + x_label, y_label, log_y = hist["x_unit"], hist["y_unit"], name == "charge" + counts = values + else: + raise ValueError(f"{name!r} is not a one-dimensional histogram") + if not values or len(edges) != len(values) + 1: + empty = "no stream frames" + elif not any(count > 0 for count in counts): + empty = "no stream frames" if name == "hits_per_frame" else "no hits" + else: + empty = None + return {"edges": edges, "values": values, "x_label": x_label, "y_label": y_label, "log_y": log_y, "empty": empty} + + +def rebin(hist: dict, max_bins: int) -> dict: + """The same histogram with adjacent bins merged so that at most ``max_bins`` remain. + + Counts add up. The rate of a merged bin is the frames-per-bin weighted mean of the + rates, which equals the merged counts over the merged live time, so no frame period + is needed. Returns ``hist`` itself when it already fits. + """ + name = hist["name"] + key_values = "rate_hz" if name == "rate" else "counts" + key_edges = "edges_ns" if name in ("rate", "time_in_frame") else "edges" + n_bins = len(hist[key_values]) + if max_bins < 1 or n_bins <= max_bins: + return hist + factor = math.ceil(n_bins / max_bins) + edges = hist[key_edges] + starts = list(range(0, n_bins, factor)) + new_edges = [edges[start] for start in starts] + [edges[n_bins]] + merged = dict(hist) + merged[key_edges] = new_edges + merged["counts"] = [sum(hist["counts"][start:start + factor]) for start in starts] + if name == "rate": + frames = [sum(hist["frames_per_bin"][start:start + factor]) for start in starts] + weighted = [sum(rate * weight for rate, weight in zip(hist["rate_hz"][start:start + factor], + hist["frames_per_bin"][start:start + factor])) + for start in starts] + merged["frames_per_bin"] = frames + merged["rate_hz"] = [total / count if count else 0.0 for total, count in zip(weighted, frames)] + return merged + + +def occupancy_depth(hist: dict) -> int: + """3 for the file (roc, port, channel), 2 for a ROC (port, channel), 1 for one channel. + + Read from the selection, not from the shape of ``counts``: a file without stream + frames has no ROCs, and its empty file-level list looks like any other depth. + """ + selection = hist.get("selection") or {} + if selection.get("roc") is None: + return 3 + if selection.get("port") is None: + return 2 + return 1 if selection.get("ch") is not None else 2 + + +def occupancy_grid(hist: dict) -> dict: + """The occupancy as a grid: ``rows``/``cols`` labels, ``values[row][col]``, ``dead[row][col]``, titles, ``empty``. + + File level: ROC × port, the channels summed. ROC level: port × channel. Channel + level: one cell. ``dead`` marks ports the ROC's map does not list (module 255). + """ + selection = hist.get("selection") or {} + rocs, ports, module_id, channels = hist["rocs"], hist["ports"], hist["module_id"], hist["channels"] + roc, port, channel = selection.get("roc"), selection.get("port"), selection.get("ch") + if roc is None: + values = [[sum(per_channel) for per_channel in per_port] for per_port in hist["counts"]] + dead = [[module == MODULE_UNKNOWN for module in row] for row in module_id] + rows, cols = [f"ROC {roc}" for roc in rocs], [str(port) for port in ports] + row_title, col_title = "ROC", "payload port" + else: + roc_i = rocs.index(roc) if roc in rocs else 0 + modules = module_id[roc_i] if roc_i < len(module_id) else [MODULE_UNKNOWN] * len(ports) + row_title, col_title = "payload port", "channel" + if port is None: # port × channel + values = [list(per_channel) for per_channel in hist["counts"]] + dead = [[module == MODULE_UNKNOWN] * channels for module in modules] + rows, cols = [f"port {port}" for port in ports], [str(channel) for channel in range(channels)] + else: # one port: its channels, or the one channel + port_i = ports.index(port) if port in ports else 0 + is_dead = port_i < len(modules) and modules[port_i] == MODULE_UNKNOWN + values = [list(hist["counts"])] + dead = [[is_dead] * len(values[0])] + rows = [f"port {port}"] + cols = [str(channel)] if channel is not None else [str(index) for index in range(len(values[0]))] + empty = None + if not rows or not cols: + empty = "no stream frames" + elif not any(value > 0 for row, dead_row in zip(values, dead) for value, is_dead in zip(row, dead_row) if not is_dead): + empty = "no hits" + return {"rows": rows, "cols": cols, "values": values, "dead": dead, "row_title": row_title, "col_title": col_title, + "value_label": hist.get("y_unit", "hits"), "empty": empty} + + +# ---------------------------------------------------------------- PNG +def default_png_name(file_path, hist_name: str, selection: Optional[dict] = None) -> str: + """``synthetic_charge_roc7_port1_ch3.png``: the file's stem, the histogram, the selection that is set.""" + stem = Path(str(file_path)).stem if file_path else "histogram" + parts = [stem, hist_name] + selection = selection or {} + for key, label in (("roc", "roc"), ("port", "port"), ("ch", "ch")): + if selection.get(key) is not None: + parts.append(f"{label}{selection[key]}") + return "_".join(parts) + ".png" + + +def save_histogram_png(hist: dict, path, title: str = "") -> Path: + """Draw one histogram dict (any of the five names) with matplotlib and write ``path`` as a PNG.""" + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib.colors import PowerNorm + + path = Path(path) + figure, axes = plt.subplots(figsize=(8, 4.5), dpi=120) + try: + if hist["name"] == "occupancy": + _draw_occupancy_png(axes, figure, occupancy_grid(hist), PowerNorm) + else: + _draw_series_png(axes, series(hist)) + axes.set_title(title or histogram_title(hist), loc="left", fontsize=11) + figure.tight_layout() + figure.savefig(path, format="png") + finally: + plt.close(figure) + return path + + +def _draw_series_png(axes, data: dict) -> None: + if data["empty"]: + axes.text(0.5, 0.5, data["empty"], ha="center", va="center", transform=axes.transAxes, color=DEAD_COLOR) + axes.set_xticks([]) + axes.set_yticks([]) + return + axes.stairs(data["values"], data["edges"], fill=True, color=PLOT_COLOR, alpha=0.85, linewidth=0) + axes.set_xlim(data["edges"][0], data["edges"][-1]) + if data["log_y"]: + axes.set_yscale("log") + else: + axes.set_ylim(bottom=0) + axes.set_xlabel(data["x_label"]) + axes.set_ylabel(data["y_label"]) + axes.grid(True, axis="y", color="#dddddd", linewidth=0.6) + axes.set_axisbelow(True) + for side in ("top", "right"): + axes.spines[side].set_visible(False) + + +def _draw_occupancy_png(axes, figure, grid: dict, PowerNorm) -> None: + import numpy as np + + if grid["empty"] == "no stream frames": + axes.text(0.5, 0.5, grid["empty"], ha="center", va="center", transform=axes.transAxes, color=DEAD_COLOR) + axes.set_xticks([]) + axes.set_yticks([]) + return + values = np.array(grid["values"], dtype=float) + dead = np.array(grid["dead"], dtype=bool) + masked = np.ma.masked_where(dead, values) + maximum = float(masked.max()) if masked.count() else 0.0 + colormap = plt_colormap("Blues").with_extremes(bad=DEAD_COLOR) + image = axes.imshow(masked, cmap=colormap, norm=PowerNorm(0.5, vmin=0, vmax=maximum or 1), aspect="auto", + interpolation="nearest") + axes.set_xticks(range(len(grid["cols"])), grid["cols"]) + axes.set_yticks(range(len(grid["rows"])), grid["rows"]) + axes.set_xlabel(grid["col_title"]) + axes.set_ylabel(grid["row_title"]) + for row_i, (row, dead_row) in enumerate(zip(grid["values"], grid["dead"])): + for col_i, (value, is_dead) in enumerate(zip(row, dead_row)): + label = "–" if is_dead else format_compact(value) + dark = (not is_dead) and maximum > 0 and math.sqrt(value / maximum) > 0.55 + axes.text(col_i, row_i, label, ha="center", va="center", fontsize=7, color="white" if dark else "black") + bar = figure.colorbar(image, ax=axes) + bar.set_label(f"{grid['value_label']} (square-root scale); grey: port not in the ROC's map") + + +def plt_colormap(name: str): + import matplotlib + + return matplotlib.colormaps[name] diff --git a/pyevio/gui/overview.py b/pyevio/gui/overview.py new file mode 100644 index 0000000..ac13d35 --- /dev/null +++ b/pyevio/gui/overview.py @@ -0,0 +1,199 @@ +"""Overview tab: totals, what stands out, the file checks, file facts and one row per ROC. + +Everything here is rendered from the ``build_summary`` dict the app passes to +:meth:`OverviewPane.stats_ready`; the pane keeps that dict and nothing else. +Six ``Static`` widgets in a ``VerticalScroll``: the state line, totals, "what +stands out", the checks table, the file facts and the per-ROC table. Text +sections are Textual markup (values escaped); the two tables are Rich +``Table`` renderables with the status word coloured by a Rich style. +""" +import math +from typing import List + +from rich.table import Table +from rich.text import Text +from textual.containers import VerticalScroll +from textual.markup import escape +from textual.widgets import Static + +from pyevio.gui.panes import Pane + +STATUS_STYLE = {"error": "red", "warn": "yellow", "info": "blue", "ok": "green"} +STANDOUT_ORDER = ("error", "warn") # the web viewer's rule: info checks stay in the checks table +NO_FILE_TEXT = "No file open — press o to open an EVIO file." +SECTION_IDS = ("overview-totals", "overview-standout", "overview-checks", "overview-file", "overview-rocs") + + +def fmt(value) -> str: + """One value as the web viewer prints it: ``n/a`` as given, lists joined, booleans as yes/no, numbers with separators.""" + if value is None: + return "—" + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, str): + return value + if isinstance(value, (list, tuple)): + return ", ".join(fmt(item) for item in value) if value else "none" + if isinstance(value, float): + return f"{value:,.3f}".rstrip("0").rstrip(".") if math.isfinite(value) else str(value) + return f"{value:,}" + + +def seconds(value) -> str: + return value if isinstance(value, str) else f"{value:.3f} s" + + +def facts_markup(title: str, rows: List[tuple]) -> str: + """A titled key/value list; the keys are padded to one column.""" + width = max((len(name) for name, _ in rows), default=0) + lines = [f"[b]{escape(title)}[/b]"] + lines += [f" {escape(name):<{width}} {escape(fmt(value))}" for name, value in rows] + return "\n".join(lines) + + +def status_text(status: str) -> Text: + return Text(status, style=STATUS_STYLE.get(status, "")) + + +class OverviewPane(Pane): + """See the module docstring; the hooks are those of :class:`~pyevio.gui.panes.Pane`.""" + + DEFAULT_CSS = """ + OverviewPane VerticalScroll { + height: 1fr; + } + OverviewPane Static { + margin-bottom: 1; + } + OverviewPane #overview-state { + color: $text-muted; + } + """ + + def compose(self): + with VerticalScroll(id="overview-scroll"): + yield Static(NO_FILE_TEXT, id="overview-state") + for section_id in SECTION_IDS: + yield Static("", id=section_id) + + # ---------------------------------------------------------------- hooks + def file_closed(self) -> None: + super().file_closed() + self._set_state(NO_FILE_TEXT) + self._clear_sections() + + def file_opened(self) -> None: + name = self.session.path.name if self.session.path else "the file" + self._set_state(f"Analyzing {escape(name)} — the totals and checks appear when the analysis is done.") + self._clear_sections() + + def stats_ready(self, summary: dict) -> None: + super().stats_ready(summary) + stats = summary["stats"] + source = "from cache" if stats["cached"] else f"computed in {stats['wall_seconds']:.1f} s on {stats['workers']} workers" + self._set_state(f"{escape(summary['file']['path'])} — analysis {source}, stats version {stats['version']}") + self.query_one("#overview-totals", Static).update(self.totals_markup(summary)) + self.query_one("#overview-standout", Static).update(self.standout_markup(summary["checks"], summary["totals"].get("error_messages", []))) + self.query_one("#overview-checks", Static).update(self.checks_table(summary["checks"])) + self.query_one("#overview-file", Static).update(self.file_markup(summary)) + self.query_one("#overview-rocs", Static).update(self.roc_table(summary["rocs"])) + self.query_one("#overview-scroll", VerticalScroll).scroll_home(animate=False) + + # ---------------------------------------------------------------- sections + def _set_state(self, markup: str) -> None: + self.query_one("#overview-state", Static).update(markup) + + def _clear_sections(self) -> None: + for section_id in SECTION_IDS: + self.query_one(f"#{section_id}", Static).update("") + + @staticmethod + def totals_markup(summary: dict) -> str: + totals = summary["totals"] + modules = ", ".join(f"{module['name']} ({fmt(module['words'])} words)" for module in totals["modules"]) or "none" + first_error = f" — first: {totals['error_messages'][0]}" if totals["error_messages"] else "" + first_warning = f" — first: {totals['warning_messages'][0]}" if totals["warning_messages"] else "" + rows = [ + ("records", f"{fmt(totals['n_records'])} + {fmt(totals['n_trailer_records'])} trailer"), + ("events", f"{fmt(totals['n_events'])} ({fmt(totals['n_frames'])} stream frames, {fmt(totals['n_other_events'])} other, " + f"{fmt(totals['n_out_of_bounds_events'])} out of bounds)"), + ("fADC250 hits", totals["n_hits"]), + ("hit rate", totals["hit_rate_hz"] if isinstance(totals["hit_rate_hz"], str) else f"{totals['hit_rate_hz']:,.1f} Hz"), + ("hits per frame", totals["hits_per_frame"]), + ("duration / live time", f"{seconds(totals['duration_s'])} / {seconds(totals['live_time_s'])}"), + ("modules", modules), + ("decode errors", f"{fmt(totals['errors'])}{first_error}"), + ("decode warnings", f"{fmt(totals['warnings'])}{first_warning}"), + ] + return facts_markup("Totals", rows) + + @staticmethod + def standout_markup(checks: List[dict], messages: List[str] = ()) -> str: + """The error checks, then the warn checks, then the first decode messages — the web viewer's rule.""" + lines = ["[b]What stands out[/b]"] + notable = [check for status in STANDOUT_ORDER for check in checks if check["status"] == status] + for check in notable: + style = STATUS_STYLE[check["status"]] + detail = f" — {escape(check['detail'])}" if check["detail"] else "" + lines.append(f" [{style}]{check['status']:<5}[/{style}] {escape(check['name'])}: {escape(fmt(check['value']))}{detail}") + for message in list(messages)[:3]: + lines.append(f" [red]{escape(message)}[/red]") + if len(lines) == 1: + lines.append(" [green]all checks pass[/green]") + return "\n".join(lines) + + @staticmethod + def checks_table(checks: List[dict]) -> Table: + table = Table(title="Checks", title_justify="left", expand=False, pad_edge=False) + for column in ("check", "status", "value", "detail"): + table.add_column(column) + for check in checks: + table.add_row(check["name"], status_text(check["status"]), fmt(check["value"]), check["detail"] or "") + return table + + @staticmethod + def file_markup(summary: dict) -> str: + file = summary["file"] + rows = [ + ("path", file["path"]), + ("size", f"{fmt(file['size'])} B ({file['size'] / 1e6:.1f} MB)"), + ("header layout", file["header_layout"]), + ("byte order", "little-endian" if file["endian"] == "<" else "big-endian"), + ("EVIO version", file["version"]), + ("file type", file["file_type"]), + ("records", f"{fmt(file['header_record_count'])} declared in the file header, {fmt(file['record_count'])} scanned"), + ("trailer", f"{'present' if file['has_trailer'] else 'absent'}, position {fmt(file['trailer_position'])} in the file header"), + ("first record offset", file["first_record_offset"]), + ("index array length", f"{fmt(file['index_array_length'])} B"), + ("user header length", f"{fmt(file['user_header_length'])} B"), + ] + for words in (OverviewPane.scan_check_words(check) for check in summary["scan_checks"]): + rows.append(("record scan", words)) + if not summary["scan_checks"]: + rows.append(("record scan", "every record header read cleanly")) + return facts_markup("File", rows) + + @staticmethod + def scan_check_words(check: list) -> str: + """An ``EvioFile.checks`` tuple in words.""" + if check[0] == "truncated_tail": + _, declared, present, salvaged = check + return (f"truncated tail: the last record declares {fmt(declared)} B but {fmt(present)} B are present; " + f"{fmt(salvaged)} complete events kept") + if check[0] == "bad_record_header": + _, offset, reason = check + return f"bad record header at 0x{offset:X}: the scan stopped there ({reason})" + return f"{check[0]}: {', '.join(str(item) for item in check[1:])}" + + @staticmethod + def roc_table(rocs: List[dict]) -> Table: + table = Table(title="Per ROC", title_justify="left", expand=False, pad_edge=False) + for column, justify in (("ROC", "right"), ("modules", "left"), ("ports", "left"), ("hits", "right"), + ("other-module words", "right"), ("empty banks", "right")): + table.add_column(column, justify=justify) + for roc in rocs: + ports = f"{len(roc['ports'])}: {fmt(roc['ports'])}" if roc["ports"] else "none" + table.add_row(str(roc["roc"]), fmt(roc["modules"]), ports, fmt(roc["hits"]), fmt(roc["non_fadc_words"]), fmt(roc["empty_banks"])) + if not rocs: + table.add_row("—", "no ROC banks in the stream frames", "", "", "", "") + return table diff --git a/pyevio/gui/panes.py b/pyevio/gui/panes.py new file mode 100644 index 0000000..7677663 --- /dev/null +++ b/pyevio/gui/panes.py @@ -0,0 +1,46 @@ +"""The base of the four tab panes and the contract between the app and a pane. + +The app owns the :class:`~pyevio.gui.session.Session` and drives every pane +through three hooks, always from the main thread: + +- :meth:`Pane.file_closed` — the previous file is going away: drop every row, node + and plot that refers to it. Called before the session closes the file. +- :meth:`Pane.file_opened` — a file is open; its analysis may still be running. + Records and Structure load now, because they read the file, not the statistics. +- :meth:`Pane.stats_ready` — the analysis finished; ``summary`` is the dict of + ``pyevio.web.report.build_summary``. Overview and Plots load now. + +A pane keeps only what the session's model functions return (dicts, lists, +numbers), never a ``Record``, ``Event`` or ``Bank``: a live view of the file +mmap makes ``EvioFile.close()`` raise ``BufferError`` on the next file switch. +Anything slow runs through the session on demand (a page of 500 rows, one node's +children, one window of words), never the whole file. +""" +from typing import Optional + +from textual.containers import Vertical + + +class Pane(Vertical): + """One tab's content; see the module docstring for the hooks.""" + + DEFAULT_CSS = """ + Pane { + height: 1fr; + padding: 0 1; + } + """ + + def __init__(self, session, **kwargs): + super().__init__(**kwargs) + self.session = session + self.summary: Optional[dict] = None # the last build_summary dict, None until stats_ready + + def file_closed(self) -> None: + self.summary = None + + def file_opened(self) -> None: + pass + + def stats_ready(self, summary: dict) -> None: + self.summary = summary diff --git a/pyevio/gui/plots.py b/pyevio/gui/plots.py new file mode 100644 index 0000000..eeba608 --- /dev/null +++ b/pyevio/gui/plots.py @@ -0,0 +1,601 @@ +"""Plots tab: hits per frame, rate, occupancy, charge and time in frame, at file, ROC and channel level. + +The pane mirrors the browser's Plots tab. A control row picks the level (file, +ROC, channel) and the ROC, port and channel; a 2 × 2 grid of +:class:`HistogramPlot` widgets draws the histograms of that selection: + +- file: hits per frame, hit rate over time, occupancy heatmap of ROC × port; +- ROC: occupancy heatmap of port × channel, hit rate, charge spectrum, time in frame; +- channel: hit rate, charge and time in frame of the one channel. + +Keys, on the pane (they need the focus inside it; the pane takes it when its tab +is shown): ``f`` / ``r`` / ``c`` switch the level, ``]`` / ``[`` step the ROC, +``}`` / ``{`` the port, ``+`` / ``-`` the channel (rolling over into the next +port, as the browser's Prev / Next do), Tab moves between the plots, ``s`` saves +the focused plot (else the first one) as a PNG in the working directory and ``S`` +saves every plot of the level. + +Each plot holds only the histogram dict and rebuilds its plotext figure in +``render``, so a resize or the focus border redraws it at the new size; the +dicts are lists of numbers, never a view of the file. + +The occupancy heatmap is not a plotext figure: :func:`render_heatmap` lays the +cells out as a Rich text itself, a whole number of lines per row and of columns +per cell, so every row is the same height and every printed count is exact. +plotext's ``matrix_plot`` resamples the matrix onto its canvas and, when the +canvas is lower than the matrix has rows, merges rows: two ports then share a +line, and their ``text()`` labels, centred on different lengths, overstrike each +other (``93.6k`` under ``55k`` read ``955kk``). +""" +import math +from pathlib import Path +from typing import Optional + +from rich.color import Color +from rich.style import Style +from rich.text import Text +from textual import events, on +from textual.binding import Binding +from textual.containers import Grid, Horizontal +from textual.widgets import Select, Static +from textual_plotext import PlotextPlot + +from pyevio.gui.export import (default_png_name, format_compact, histogram_title, nice_ticks, occupancy_grid, rebin, + save_histogram_png, series, tick_labels) +from pyevio.gui.panes import Pane +from pyevio.stream_stats import N_CHANNELS +from pyevio.web import ApiError + +LEVEL_FILE, LEVEL_ROC, LEVEL_CHANNEL = "file", "roc", "channel" +LEVELS = (("File", LEVEL_FILE), ("ROC", LEVEL_ROC), ("Channel", LEVEL_CHANNEL)) +PLOT_COUNT = 4 +MESSAGE_NO_FILE = "Open a file to see its plots." +MESSAGE_ANALYZING = "The analysis is running — the plots appear when it is done." +HINT = "f/r/c level · ]/[ ROC · }/{ port · +/- channel · Tab next plot · s save PNG · S save all" + +# plotext colours: one hue for the series, a light-to-dark ramp of the same hue for the heatmap +SERIES_COLOR = (31, 111, 181) +RAMP_LOW = (28, 42, 68) +RAMP_HIGH = (120, 200, 255) +DEAD_CELL = (90, 90, 90) +TEXT_LIGHT, TEXT_DARK = (240, 240, 240), (20, 20, 20) +MIN_PLOT_WIDTH, MIN_PLOT_HEIGHT = 16, 6 +HEATMAP_OVERHEAD = 3 # lines under the cells: the x axis, its tick labels, the axis titles +HALF_BLOCK = "▀" # two rows on one line: the upper row is the glyph's colour, the lower one its background +DEAD_LABEL = "–" +PAD = Style(dim=True) # blanks before a visible run, as a run of their own (see render_heatmap) + + +def heat_color(value: float, maximum: float) -> tuple: + """The ramp colour of ``value``; square root, so a few hot channels do not flatten the rest (as the web does).""" + fraction = (value / maximum) ** 0.5 if maximum > 0 and value > 0 else 0.0 + return tuple(int(round(low + (high - low) * fraction)) for low, high in zip(RAMP_LOW, RAMP_HIGH)) + + +def text_color_on(background: tuple) -> tuple: + luminance = 0.299 * background[0] + 0.587 * background[1] + 0.114 * background[2] + return TEXT_DARK if luminance > 150 else TEXT_LIGHT + + +def cell_style(foreground: tuple, background: Optional[tuple] = None) -> Style: + return Style(color=Color.from_rgb(*foreground), bgcolor=Color.from_rgb(*background) if background else None) + + +def heatmap_layout(grid: dict, width: int, height: int) -> dict: + """Where the cells of ``grid`` go in a plot of ``width`` × ``height`` characters; ``fits`` is False when they cannot. + + The row labels and the axis take the left columns, the right frame the last one; + the three lines under the cells are the axis, its tick labels and the axis titles. + Each cell is ``cell_w`` columns by ``cell_h`` lines, whole numbers, and the + leftover columns and lines are margins (one spare line is the top frame), so + every row has the same height and every column the same width. When the lines + are fewer than the rows but at least half as many, ``halves`` is set: two rows + share a line, drawn as half blocks, and the counts are not printed. + """ + rows, cols = len(grid["rows"]), len(grid["cols"]) + label_w = max(len(label) for label in grid["rows"]) + canvas_w, canvas_h = width - label_w - 2, height - HEATMAP_OVERHEAD + halves = canvas_h < rows + lines_needed = -(-rows // 2) if halves else rows + if canvas_w < cols or canvas_h < lines_needed: + return {"fits": False, "min_width": cols + label_w + 2, "min_height": -(-rows // 2) + HEATMAP_OVERHEAD} + cell_w, cell_h = canvas_w // cols, 1 if halves else canvas_h // rows + spare_w, spare_h = canvas_w - cell_w * cols, canvas_h - cell_h * lines_needed + top_frame = spare_h >= 1 + top_margin = (spare_h - top_frame) // 2 + left_margin = spare_w // 2 + longest = max((len(format_compact(value)) for row, dead_row in zip(grid["values"], grid["dead"]) + for value, is_dead in zip(row, dead_row) if not is_dead), default=0) + longest_col_label = max(len(label) for label in grid["cols"]) + return {"fits": True, "label_w": label_w, "canvas_w": canvas_w, "canvas_h": canvas_h, "cell_w": cell_w, "cell_h": cell_h, + "halves": halves, "top_frame": top_frame, "top_margin": top_margin, + "bottom_margin": spare_h - top_frame - top_margin, "left_margin": left_margin, + "with_values": not halves and longest + 2 <= cell_w, # a margin of a column on each side + "x_step": max(1, -(-(longest_col_label + 2) // cell_w))} # tick labels that do not touch + + +def render_heatmap(grid: dict, width: int, height: int) -> Text: + """The occupancy ``grid`` (of :func:`occupancy_grid`) as ``height`` lines of ``width`` characters. + + The cells are coloured by the square-root ramp, unmapped ports grey with a dash, + and the counts are printed in the cells when the longest one fits with a margin + (see :func:`heatmap_layout`). A plot too small for the rows and columns says so. + """ + if grid["empty"] == "no stream frames": + return Text(grid["empty"], style="dim") + layout = heatmap_layout(grid, width, height) + if not layout["fits"]: + return Text(f"{len(grid['rows'])} × {len(grid['cols'])} cells need a plot of {layout['min_width']} × {layout['min_height']}; " + f"this one is {width} × {height}", style="dim") + rows, cols = len(grid["rows"]), len(grid["cols"]) + label_w, canvas_w, cell_w, cell_h = layout["label_w"], layout["canvas_w"], layout["cell_w"], layout["cell_h"] + maximum = max((value for row, dead_row in zip(grid["values"], grid["dead"]) + for value, is_dead in zip(row, dead_row) if not is_dead), default=0) + colors = [[DEAD_CELL if is_dead else heat_color(value, maximum) for value, is_dead in zip(row, dead_row)] + for row, dead_row in zip(grid["values"], grid["dead"])] + # Blanks before a visible run get a style of their own (PAD): the SVG screenshots give every run its own x, and some + # SVG renderers (rsvg) drop the non-breaking spaces at the start of a run, which would shift the run left. + left = " " * layout["left_margin"] + right = " " * (canvas_w - layout["left_margin"] - cell_w * cols) + + def line_with(label: Optional[str], axis_char: str) -> Text: + """A line's start: the row label (or blanks) right-justified, the axis character, the left margin.""" + line = Text() + if label is None: + line.append(" " * label_w, style=PAD) + else: + line.append(" " * (label_w - len(label)), style=PAD) + line.append(label) + line.append(axis_char) + line.append(left, style=PAD) + return line + + def cell(text: str, row_i: int, col_i: int) -> list: + background = colors[row_i][col_i] + pad, label = Style(bgcolor=Color.from_rgb(*background)), cell_style(text_color_on(background), background) + before = (cell_w - len(text)) // 2 + return [(" " * before, pad), (text, label), (" " * (cell_w - before - len(text)), pad)] + + def blank() -> Text: + line = line_with(None, "│") + line.append(" " * (canvas_w - layout["left_margin"]), style=PAD) + line.append("│") + return line + + lines = [] + if layout["top_frame"]: + lines.append(Text.assemble((" " * label_w, PAD), "┌" + "─" * canvas_w + "┐")) + lines += [blank() for _ in range(layout["top_margin"])] + if layout["halves"]: + for line_i in range(-(-rows // 2)): + upper, lower = 2 * line_i, 2 * line_i + 1 + line = line_with(grid["rows"][upper], "┤") + for col_i in range(cols): + below = colors[lower][col_i] if lower < rows else None + line.append(HALF_BLOCK * cell_w, style=cell_style(colors[upper][col_i], below)) + line.append(right, style=PAD) + line.append("│") + lines.append(line) + else: + for row_i, (row, dead_row) in enumerate(zip(grid["values"], grid["dead"])): + for line_i in range(cell_h): + labelled = line_i == (cell_h - 1) // 2 + line = line_with(grid["rows"][row_i], "┤") if labelled else line_with(None, "│") + for col_i, (value, is_dead) in enumerate(zip(row, dead_row)): + text = "" + if labelled and (is_dead or layout["with_values"]): + text = DEAD_LABEL if is_dead else format_compact(value) + for part, style in cell(text, row_i, col_i): + line.append(part, style=style) + line.append(right, style=PAD) + line.append("│") + lines.append(line) + lines += [blank() for _ in range(layout["bottom_margin"])] + # The axis with a tick under the centre of every labelled column, the tick labels centred on their cells, the titles. + axis = ["─"] * canvas_w + ticks = Text.assemble((" " * (label_w + 1), PAD)) + column = 0 # the next free column of the tick-label line, from the canvas start + for col_i in range(0, cols, layout["x_step"]): + start = layout["left_margin"] + col_i * cell_w + axis[start + (cell_w - 1) // 2] = "┬" + label = grid["cols"][col_i] + label_start = start + (cell_w - len(label)) // 2 # centred on the cell, wider than it when the cell is narrow + if label_start < column or label_start + len(label) > canvas_w: + continue # no room: the label is left out whole, never cut to a wrong number + ticks.append(" " * (label_start - column), style=PAD) + ticks.append(label) + column = label_start + len(label) + lines.append(Text.assemble((" " * label_w, PAD), "└" + "".join(axis) + "┘")) + lines.append(ticks) + title_start = max(len(grid["row_title"]) + 1, label_w + 1 + canvas_w // 2 - len(grid["col_title"]) // 2) + lines.append(Text.assemble(grid["row_title"], (" " * (title_start - len(grid["row_title"])), PAD), grid["col_title"])) + text = Text("\n").join(lines) + text.no_wrap, text.overflow = True, "crop" + return text + + +class ReleaseFocusOnHide: + """Give the focus up when hidden, instead of passing it to a sibling. + + Textual's ``blur`` moves the focus to a visible sibling; when the Plots tab is + switched away the sibling is another widget of this pane, its focus makes + ``TabbedContent`` activate the Plots tab again, and the tab switch is undone. + """ + + def _on_hide(self, event: events.Hide) -> None: + if self.has_focus: + self.screen.set_focus(None) + super()._on_hide(event) + + +class PlotsSelect(ReleaseFocusOnHide, Select): + pass + + +class HistogramPlot(ReleaseFocusOnHide, PlotextPlot, can_focus=True): + """One histogram dict drawn with plotext; the figure is rebuilt from the dict at every render.""" + + DEFAULT_CSS = """ + HistogramPlot { + border: round $panel-lighten-2; + padding: 0; + } + HistogramPlot:focus { + border: round $accent; + } + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.hist: Optional[dict] = None + self.title = "" + self.placeholder = "" + + def show(self, hist: Optional[dict], title: str = "", placeholder: str = "") -> None: + self.hist = hist + self.title = title or (histogram_title(hist) if hist else "") + self.placeholder = placeholder + self.border_title = self.title + self.refresh() + + def clear(self, placeholder: str = "") -> None: + self.show(None, "", placeholder) + + def render(self): + if self.hist is None: + return Text(self.placeholder, style="dim") + width, height = self.size.width, self.size.height + if width < MIN_PLOT_WIDTH or height < MIN_PLOT_HEIGHT: + return Text("") + try: + if self.hist["name"] == "occupancy": + return render_heatmap(occupancy_grid(self.hist), width, height) + self.plt.clear_figure() + empty = self._build(width, height) + if empty: + return Text(empty, style="dim") + return super().render() + except Exception as error: # a plotext failure on odd data must never take the app down + return Text(f"cannot draw {self.title}: {error}", style="dim") + + # ---------------------------------------------------------------- building the plotext figure + def _build(self, width: int, height: int) -> Optional[str]: + """Draw the one-dimensional ``self.hist`` into ``self.plt``; returns the reason when there is nothing to draw.""" + hist = self.hist + data = series(rebin(hist, max(8, width - 12))) + if data["empty"]: + return data["empty"] + plt = self.plt + edges, values = data["edges"], data["values"] + y_label = data["y_label"] + if data["log_y"]: + # The charge spectrum, logarithmic as in the browser and the PNG. Not plotext's own log scale: it takes the + # log of every value and raises on the empty bins, and reads ``ylim`` in log units. log10(1 + count) keeps + # empty bins at zero height; the ticks sit at the real counts 1, 10, 100, ... and are labelled with them. + top_count = max(values) + values = [math.log10(1 + value) if value > 0 else 0.0 for value in values] + decades = max(0, math.ceil(math.log10(top_count))) + step = max(1, -(-(decades + 1) // max(2, (height - 4) * 2 // 3))) # fewer ticks than the canvas has lines + tick_counts = [10 ** power for power in range(0, decades + 1, step)] + y_ticks, y_labels = [math.log10(1 + count) for count in tick_counts], [format_compact(count) for count in tick_counts] + y_label += " (log)" + if hist["name"] == "rate": + xs, ys = [], [] + for i, value in enumerate(values): # a step line: the bin's value from its left edge to its right one + xs += [edges[i], edges[i + 1]] + ys += [value, value] + plt.plot(xs, ys, marker="braille", color=SERIES_COLOR) + else: + centers = [(edges[i] + edges[i + 1]) / 2 for i in range(len(values))] + plt.bar(centers, values, width=1, color=SERIES_COLOR) + top = max(values) + if data["log_y"]: + top = max(top, y_ticks[-1]) + elif hist["name"] != "rate" and top <= 4: + y_ticks = list(range(int(top) + 1)) # counts: whole numbers only + y_labels = tick_labels(y_ticks) + else: + y_ticks = nice_ticks(0, top, 4) + if y_ticks and y_ticks[-1] < top: + y_ticks.append(round(y_ticks[-1] + (y_ticks[1] - y_ticks[0] if len(y_ticks) > 1 else top), 12)) + y_labels = tick_labels(y_ticks) + if y_ticks: + top = y_ticks[-1] + x_ticks = [tick for tick in nice_ticks(edges[0], edges[-1], 5) if edges[0] <= tick <= edges[-1]] + plt.xlim(edges[0], edges[-1]) + plt.ylim(0, top) + if x_ticks: + plt.xticks(x_ticks, tick_labels(x_ticks)) + if y_ticks: + plt.yticks(y_ticks, y_labels) + plt.xlabel(data["x_label"]) + plt.ylabel(y_label) + return None + + +class PlotsPane(Pane): + """The Plots tab; see the module docstring for the keys and the levels.""" + + BINDINGS = [ + Binding("f", "level('file')", "File"), + Binding("r", "level('roc')", "ROC"), + Binding("c", "level('channel')", "Channel"), + # The step keys stay out of the footer (the hint line in the pane lists them) so the footer keeps the level and save keys. + Binding("right_square_bracket", "step('roc', 1)", "Next ROC", key_display="]", show=False), + Binding("left_square_bracket", "step('roc', -1)", "Prev ROC", key_display="[", show=False), + Binding("right_curly_bracket", "step('port', 1)", "Next port", key_display="}", show=False), + Binding("left_curly_bracket", "step('port', -1)", "Prev port", key_display="{", show=False), + Binding("plus", "step('channel', 1)", "Next channel", key_display="+", show=False), + Binding("equals_sign", "step('channel', 1)", "Next channel", show=False), + Binding("minus", "step('channel', -1)", "Prev channel", key_display="-", show=False), + Binding("s", "save", "Save PNG"), + Binding("S", "save_all", "Save all", key_display="S"), + ] + DEFAULT_CSS = """ + PlotsPane #plots-message { + padding: 1 0; + } + PlotsPane #plots-controls { + height: 3; + } + PlotsPane #plots-controls Select { + width: 16; + } + PlotsPane #plots-controls #plots-roc { + width: 24; + } + PlotsPane #plots-hint { + width: 1fr; + color: $text-muted; + padding: 1 1 0 1; + } + PlotsPane #plots-grid { + height: 1fr; + grid-size: 2 2; + grid-gutter: 0 1; + } + """ + + def __init__(self, session, **kwargs): + super().__init__(session, **kwargs) + self.level = LEVEL_FILE + self.roc: Optional[int] = None + self.port: Optional[int] = None + self.channel: int = 0 + self._updating = False # setting a Select programmatically posts Changed; ignored while this is set + + def compose(self): + yield Static(MESSAGE_NO_FILE, id="plots-message") + with Horizontal(id="plots-controls"): + yield PlotsSelect(LEVELS, value=LEVEL_FILE, allow_blank=False, id="plots-level") + yield PlotsSelect([], prompt="ROC", id="plots-roc") + yield PlotsSelect([], prompt="port", id="plots-port") + yield PlotsSelect([(f"ch {channel}", channel) for channel in range(N_CHANNELS)], prompt="channel", id="plots-channel") + yield Static(HINT, id="plots-hint") + with Grid(id="plots-grid"): + for index in range(PLOT_COUNT): + yield HistogramPlot(id=f"plot-{index}") + + # ---------------------------------------------------------------- pane hooks + def file_closed(self) -> None: + super().file_closed() + self.roc = self.port = None + self.channel = 0 + for plot in self.plots: + plot.clear() + self._show_message(MESSAGE_NO_FILE) + + def file_opened(self) -> None: + self._show_message(MESSAGE_ANALYZING) + + def stats_ready(self, summary: dict) -> None: + super().stats_ready(summary) + rocs = summary["rocs"] + self.roc = rocs[0]["roc"] if rocs else None + self.port = rocs[0]["ports"][0] if rocs and rocs[0]["ports"] else None + self.channel = 0 + self.level = LEVEL_FILE + self._sync_controls() + self._show_message(None) + self.replot() + + def _show_message(self, message: Optional[str]) -> None: + self.query_one("#plots-message", Static).update(message or "") + self.query_one("#plots-message", Static).display = message is not None + self.query_one("#plots-controls", Horizontal).display = message is None + self.query_one("#plots-grid", Grid).display = message is None + + def on_show(self, event: events.Show) -> None: + """Take the focus when the tab is shown, so the pane's keys work at once.""" + focused = self.app.focused + if focused is None or not any(node is self for node in focused.ancestors_with_self): + self.plots[0].focus() + + # ---------------------------------------------------------------- selection + @property + def plots(self) -> list: + return [self.query_one(f"#plot-{index}", HistogramPlot) for index in range(PLOT_COUNT)] + + @property + def visible_plots(self) -> list: + return [plot for plot in self.plots if plot.display and plot.hist is not None] + + def titles(self) -> list: + """The titles of the plots on show, for the tests and the status.""" + return [plot.title for plot in self.plots if plot.display] + + def roc_row(self, roc: Optional[int]) -> Optional[dict]: + if self.summary is None or roc is None: + return None + return next((row for row in self.summary["rocs"] if row["roc"] == roc), None) + + def _sync_controls(self) -> None: + """Set the Select widgets to the pane's state without replotting.""" + self._updating = True + try: + rocs = self.summary["rocs"] if self.summary else [] + roc_select = self.query_one("#plots-roc", Select) + roc_select.set_options([(f"ROC {row['roc']} ({', '.join(row['modules']) or 'no modules'})", row["roc"]) for row in rocs]) + if self.roc is not None: + roc_select.value = self.roc + row = self.roc_row(self.roc) + ports = row["ports"] if row else [] + port_select = self.query_one("#plots-port", Select) + port_select.set_options([(f"port {port}", port) for port in ports]) + if self.port in ports: + port_select.value = self.port + self.query_one("#plots-channel", Select).value = self.channel + self.query_one("#plots-level", Select).value = self.level + finally: + self._updating = False + + @on(Select.Changed) + def _select_changed(self, event: Select.Changed) -> None: + event.stop() + if self._updating or event.value is Select.NULL or event.value != event.select.value: + return # programmatic, blank, or stale: the Select already shows another value (fast key repeats) + select_id = event.select.id + if select_id == "plots-level" and event.value != self.level: + self.level = event.value + elif select_id == "plots-roc" and event.value != self.roc: + self._set_roc(event.value) + self._sync_controls() + elif select_id == "plots-port" and event.value != self.port: + self.port = event.value + elif select_id == "plots-channel" and event.value != self.channel: + self.channel = event.value + else: + return + self.replot() + + def _set_roc(self, roc: int) -> None: + self.roc = roc + row = self.roc_row(roc) + self.port = row["ports"][0] if row and row["ports"] else None + self.channel = 0 + + def action_level(self, level: str) -> None: + if level == self.level or self.summary is None: + return + self.level = level + self._sync_controls() + self.replot() + + def action_step(self, what: str, direction: int) -> None: + if self.summary is None: + return + if what == "roc": + rocs = [row["roc"] for row in self.summary["rocs"]] + if not rocs: + return + index = (rocs.index(self.roc) if self.roc in rocs else 0) + direction + if not 0 <= index < len(rocs): + return + self._set_roc(rocs[index]) + else: + row = self.roc_row(self.roc) + ports = row["ports"] if row else [] + if not ports: + return + port_i = ports.index(self.port) if self.port in ports else 0 + if what == "port": + port_i += direction + if not 0 <= port_i < len(ports): + return + self.port = ports[port_i] + else: # the channel, rolling over into the next or previous port + flat = port_i * N_CHANNELS + self.channel + direction + if not 0 <= flat < len(ports) * N_CHANNELS: + return + self.port, self.channel = ports[flat // N_CHANNELS], flat % N_CHANNELS + self._sync_controls() + self.replot() + + # ---------------------------------------------------------------- plotting + def _histogram(self, name: str, **selection) -> Optional[dict]: + try: + return self.session.histogram(name, **selection) + except ApiError as error: + self.app.show_error(error.message) + return None + + def replot(self) -> None: + """Fetch the histograms of the current level and selection and hand them to the plots.""" + if self.summary is None: + return + plots = self.plots + requests, placeholder = [], "" + if self.level == LEVEL_FILE: + requests = [("hits_per_frame", {}), ("rate", {}), ("occupancy", {})] + elif self.roc is None: + placeholder = "no ROCs in the file" + elif self.level == LEVEL_ROC: + selection = {"roc": self.roc} + requests = [("occupancy", selection), ("rate", selection), ("charge", selection), ("time_in_frame", selection)] + elif self.port is None: + placeholder = f"ROC {self.roc} has no mapped ports" + else: + selection = {"roc": self.roc, "port": self.port, "channel": self.channel} + requests = [("rate", selection), ("charge", selection), ("time_in_frame", selection)] + for index, plot in enumerate(plots): + if index < len(requests): + name, selection = requests[index] + hist = self._histogram(name, **selection) + plot.show(hist, placeholder=f"{name}: not available" if hist is None else "") + plot.display = True + else: + plot.clear(placeholder) + plot.display = bool(placeholder) and index == 0 + + # ---------------------------------------------------------------- PNG export + def current_plot(self) -> Optional[HistogramPlot]: + """The focused plot, else the first plot on show.""" + focused = self.app.focused + if isinstance(focused, HistogramPlot) and focused.hist is not None and focused.display: + return focused + visible = self.visible_plots + return visible[0] if visible else None + + def _save(self, plot: HistogramPlot) -> Optional[Path]: + path = Path.cwd() / default_png_name(self.session.path, plot.hist["name"], plot.hist.get("selection")) + try: + return save_histogram_png(plot.hist, path, plot.title) + except Exception as error: # matplotlib missing, an unwritable folder: tell, do not crash + self.app.show_error(f"cannot save {path.name}: {error}") + return None + + def action_save(self) -> None: + plot = self.current_plot() + if plot is None: + self.app.notify("nothing to save", severity="warning") + return + path = self._save(plot) + if path is not None: + self.app.notify(f"saved {path}") + + def action_save_all(self) -> None: + paths = [path for plot in self.visible_plots for path in [self._save(plot)] if path is not None] + if paths: + self.app.notify(f"saved {len(paths)} PNG{'s' if len(paths) != 1 else ''} in {paths[0].parent}") + else: + self.app.notify("nothing to save", severity="warning") diff --git a/pyevio/gui/records.py b/pyevio/gui/records.py new file mode 100644 index 0000000..dbfa91d --- /dev/null +++ b/pyevio/gui/records.py @@ -0,0 +1,197 @@ +"""Records tab: a paged table of the record headers; Enter opens the record in the Structure tab. + +The table holds one page of ``PAGE_SIZE`` rows of ``session.records(start, count)`` +at a time, so a file of a million records costs one page of strings. The row +key is the record's node id (``r12``, ``t`` for the trailer), which is all the +Structure tab needs. Paging: ``]`` and ``[`` replace the page; moving the cursor +down past the last row (down, PageDown) loads the next page with the cursor on +its first row, and up past the first row loads the previous page with the cursor +on its last row. +""" +from typing import Optional + +from rich.text import Text +from textual.binding import Binding +from textual.message import Message +from textual.widgets import DataTable, Static + +from pyevio.gui.panes import Pane + +PAGE_SIZE = 500 +COLUMNS = ("#", "offset", "size (B)", "events decl / found", "type", "compression", "last-record bit", "index", + "truncated", "header quirk", "errors") +NO_FILE_TEXT = "No file open." + + +def row_cells(row: dict) -> list: + """The cells of one ``describe_record`` dict; the problem cells are coloured. Every cell is a Text, never markup.""" + bad, warn = "red", "yellow" + events = Text(f"{row['event_count']} / {row['events_found']}", style=bad if row["events_found"] != row["event_count"] else "") + kind = "trailer" if row["is_trailer"] else str(row["header_type"]) + errors = Text("—") + if row["errors"]: + errors = Text(f"{len(row['errors'])}: {row['errors'][0]}", style=bad) + return [ + Text(str(row["index"]), justify="right"), + Text(f"0x{row['offset']:08X}"), + Text(f"{row['size']:,}", justify="right"), + events, + Text(kind), + Text(row["compression"]), + Text("set" if row["bit_info"]["is_last_record"] else "—"), + Text(f"{row['index_array_length']} B" if row["index_array_length"] else "none"), + Text("yes", style=bad) if row["is_truncated"] else Text("—"), + Text(str(row["header_quirk"]), style=warn) if row["header_quirk"] else Text("—"), + errors, + ] + + +class RecordsTable(DataTable): + """A row-cursor table that asks its pane for the next or previous page at the edges.""" + + DEFAULT_CSS = """ + RecordsTable { + height: 1fr; + } + """ + + class PageRequested(Message): + """The cursor tried to leave the page: ``step`` is +1 (next page) or -1 (previous page).""" + + def __init__(self, step: int) -> None: + super().__init__() + self.step = step + + def action_cursor_down(self) -> None: + if self.row_count and self.cursor_row >= self.row_count - 1: + self.post_message(self.PageRequested(+1)) + else: + super().action_cursor_down() + + def action_page_down(self) -> None: + if self.row_count and self.cursor_row >= self.row_count - 1: + self.post_message(self.PageRequested(+1)) + else: + super().action_page_down() + + def action_cursor_up(self) -> None: + if self.row_count and self.cursor_row <= 0: + self.post_message(self.PageRequested(-1)) + else: + super().action_cursor_up() + + def action_page_up(self) -> None: + if self.row_count and self.cursor_row <= 0: + self.post_message(self.PageRequested(-1)) + else: + super().action_page_up() + + +class RecordsPane(Pane): + """See the module docstring; the hooks are those of :class:`~pyevio.gui.panes.Pane`.""" + + BINDINGS = [ + Binding("]", "next_page", "Next page"), + Binding("[", "previous_page", "Previous page"), + ] + DEFAULT_CSS = """ + RecordsPane #records-status { + height: auto; + color: $text-muted; + } + """ + + def __init__(self, session, **kwargs): + super().__init__(session, **kwargs) + self.start = 0 # index of the first row in the table + self.loaded = 0 # rows in the table + self.total = 0 # rows in the file (records + trailer) + self.ends_with_trailer = False # the last row of the page is the trailer + + def compose(self): + yield Static(NO_FILE_TEXT, id="records-status", markup=False) + yield RecordsTable(cursor_type="row", zebra_stripes=True, id="records-table") + + def on_mount(self) -> None: + self._ensure_columns() + + def on_show(self) -> None: + if self.session.is_open: + self.table.focus() + + @property + def table(self) -> RecordsTable: + return self.query_one("#records-table", RecordsTable) + + def _ensure_columns(self) -> None: + table = self.table + if not table.columns: + for column in COLUMNS: + table.add_column(column, key=column) + + # ---------------------------------------------------------------- hooks + def file_closed(self) -> None: + super().file_closed() + self.start = self.loaded = self.total = 0 + self.ends_with_trailer = False + self.table.clear() + self._set_status(NO_FILE_TEXT) + + def file_opened(self) -> None: + self.load_page(0) + + # ---------------------------------------------------------------- paging + def load_page(self, start: int, cursor_row: Optional[int] = 0) -> None: + """Replace the table with the page at ``start``; ``cursor_row`` is where the cursor lands (None: the last row).""" + self._ensure_columns() + page = self.session.records(max(0, start), PAGE_SIZE) + table = self.table + table.clear() + for row in page["rows"]: + table.add_row(*row_cells(row), key=row["id"]) + self.start, self.loaded, self.total = page["start"], len(page["rows"]), page["total"] + self.ends_with_trailer = bool(page["rows"]) and page["rows"][-1]["is_trailer"] + if self.loaded: + table.move_cursor(row=self.loaded - 1 if cursor_row is None else min(cursor_row, self.loaded - 1), animate=False) + self._set_status(self.status_line()) + + def status_line(self) -> str: + if not self.total: + return "0 records in this file" + first, last = self.start, self.start + self.loaded - 1 + trailer = " (the trailer is the last row)" if self.ends_with_trailer else "" + hint = " · ] next page [ previous page" if self.total > PAGE_SIZE else "" + return f"rows {first}—{last} of {self.total:,} records{trailer} · Enter opens the record{hint}" + + def _set_status(self, text: str) -> None: + self.query_one("#records-status", Static).update(text) + + @property + def has_next_page(self) -> bool: + return self.start + self.loaded < self.total + + @property + def has_previous_page(self) -> bool: + return self.start > 0 + + def action_next_page(self) -> None: + if self.has_next_page: + self.load_page(self.start + self.loaded, cursor_row=0) + + def action_previous_page(self) -> None: + if self.has_previous_page: + self.load_page(self.start - PAGE_SIZE, cursor_row=0) + + def on_records_table_page_requested(self, event: RecordsTable.PageRequested) -> None: + event.stop() + if event.step > 0 and self.has_next_page: + self.load_page(self.start + self.loaded, cursor_row=0) + elif event.step < 0 and self.has_previous_page: + self.load_page(self.start - PAGE_SIZE, cursor_row=None) + + # ---------------------------------------------------------------- selection + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + event.stop() + node_id = event.row_key.value + if node_id: + self.app.show_structure_node(node_id) diff --git a/pyevio/gui/session.py b/pyevio/gui/session.py new file mode 100644 index 0000000..c6faa4f --- /dev/null +++ b/pyevio/gui/session.py @@ -0,0 +1,138 @@ +"""The one open file of the console viewer: its ``EvioFile``, its analysis job and its statistics. + +No Textual here. The app calls :meth:`Session.open` from its main thread, polls +:meth:`Session.progress` on a timer while the analysis runs in the job's own +thread, and reads everything else (summary, tree nodes, record rows, raw +words, histograms) through the same functions the web server uses, so the two +viewers show the same numbers for the same file. + +The job thread never touches the ``EvioFile`` of the session: ``compute_stream_stats`` +opens the file itself in its workers. Every pane holds only the dicts the model +functions return, never a ``Record`` or ``Bank``; a live numpy view would make +``EvioFile.close()`` raise ``BufferError`` on the next file switch. +""" +import time +from pathlib import Path +from typing import Optional + +from pyevio.evio_file import EvioFile +from pyevio.stream_stats import StreamStats +from pyevio.web import ApiError +from pyevio.web.cache import StatsCache, cache_key +from pyevio.web.jobs import STATE_DONE, STATE_QUEUED, AnalysisJob +from pyevio.web.report import build_summary, histogram +from pyevio.web.structure import children_of, record_rows, words_at + +STATE_EMPTY = "empty" # no file open + + +class Session: + """Owns the open file; see the module docstring for the threading rules.""" + + def __init__(self, cache_dir: Optional[Path] = None, workers: Optional[int] = None): + self.cache = StatsCache(cache_dir) + self.workers = workers + self.path: Optional[Path] = None + self.evio_file: Optional[EvioFile] = None + self.key: Optional[str] = None + self.job: Optional[AnalysisJob] = None + self.stats: Optional[StreamStats] = None + self.from_cache = False + self.opened_at: Optional[float] = None + + # ---------------------------------------------------------------- open / close + def open(self, path) -> None: + """Open ``path``; the previous file closes and its running analysis is cancelled. + + A file pyevio cannot read raises ``ValueError`` (or ``OSError``) before the + previous file is touched, so the viewer keeps showing it. The cached statistics + are loaded when present; otherwise the analysis starts in its thread. + """ + path = Path(path).expanduser().resolve() + new_file = EvioFile(str(path)) + self.close() + self.path, self.evio_file, self.opened_at = path, new_file, time.monotonic() + self.key = cache_key(path) + self.stats = self.cache.load(self.key) + self.from_cache = self.stats is not None + if self.stats is None: + self.job = AnalysisJob(self.key[:16], path, self.key, self.cache, self.workers) + self.job.start() + + def close(self) -> None: + """Cancel the running analysis and close the file. Raises ``BufferError`` while a view of the mmap is alive.""" + if self.job is not None and self.job.is_active: + self.job.cancel() + self.job = None + self.stats = None + self.from_cache = False + if self.evio_file is not None: + evio_file, self.evio_file = self.evio_file, None + evio_file.close() + self.path = self.key = None + + @property + def is_open(self) -> bool: + return self.evio_file is not None + + # ---------------------------------------------------------------- analysis state + @property + def state(self) -> str: + """``empty``, or the job state: ``queued``, ``running``, ``done``, ``error``, ``cancelled``.""" + if self.evio_file is None: + return STATE_EMPTY + if self.stats is not None: + return STATE_DONE + if self.job is None: + return STATE_QUEUED + if self.job.state == STATE_DONE and self.job.stats is not None: + self.stats = self.job.stats # pick the finished result up on the first poll after the thread set it + return STATE_DONE + return self.job.state + + def progress(self) -> dict: + """Progress as the web ``/progress`` endpoint reports it, plus ``cached`` and ``state``.""" + state = self.state + if state == STATE_EMPTY: + return {"state": state, "fraction": 0.0, "records_done": 0, "records_total": 0, "elapsed_s": 0.0, + "eta_s": None, "error": None, "cached": False} + if self.job is None or (state == STATE_DONE and self.from_cache): + n_records = self.stats.n_records if self.stats is not None else 0 + return {"state": STATE_DONE, "fraction": 1.0, "records_done": n_records, "records_total": n_records, + "elapsed_s": 0.0, "eta_s": None, "error": None, "cached": True} + snapshot = self.job.snapshot() + snapshot["cached"] = False + return snapshot + + @property + def error(self) -> Optional[str]: + return self.job.error if self.job is not None else None + + def require_stats(self) -> StreamStats: + if self.state != STATE_DONE or self.stats is None: + raise ApiError(409, f"the analysis of {self.path.name if self.path else 'the file'} is {self.state}") + return self.stats + + def require_file(self) -> EvioFile: + if self.evio_file is None: + raise ApiError(409, "no file is open") + return self.evio_file + + # ---------------------------------------------------------------- model, as the web API serves it + def summary(self) -> dict: + """The Overview data (``pyevio.web.report.build_summary``); needs the finished analysis.""" + return build_summary(self.require_file(), self.require_stats(), str(self.path), self.from_cache) + + def histogram(self, name: str, roc: Optional[int] = None, port: Optional[int] = None, channel: Optional[int] = None) -> dict: + return histogram(self.require_stats(), name, roc, port, channel) + + def records(self, start: int = 0, count: int = 500) -> dict: + """A page of record rows; works before the analysis is done.""" + return record_rows(self.require_file(), start, count) + + def children(self, node: str = "", start: int = 0, count: int = 500) -> dict: + """A structure node and a page of its children (``pyevio.web.structure.children_of``).""" + return children_of(self.require_file(), node, start, count) + + def words(self, offset: int, count: int = 64, record_index: Optional[int] = None) -> dict: + return words_at(self.require_file(), offset, count, record_index) diff --git a/pyevio/gui/structure.py b/pyevio/gui/structure.py new file mode 100644 index 0000000..9345ed9 --- /dev/null +++ b/pyevio/gui/structure.py @@ -0,0 +1,461 @@ +"""Structure tab: the lazy tree file → records → events → banks → hit words, with header fields and raw words. + +The tree on the left is a Textual ``Tree`` whose nodes carry one dict each: the +node dict the session returned (``pyevio.web.structure.children_of``) plus the +paging state of its children and, for events and banks, the index and +compression of the record they live in, so their words are read from that +record's buffer (``words_at``). Children load one page of 500 when a node is +expanded; a page that does not reach the end ends with a "… N more" child whose +selection loads the next page in place. + +The panel on the right shows the highlighted node: its fields, its errors, the +decoded payload-port map when it has one, and a window of its raw words (``n`` / +``p`` move the window). Nothing here holds a ``Record``, ``Bank`` or a view of the +file; everything runs on the main thread. +""" +from typing import List, Optional + +from rich.text import Text +from textual.binding import Binding +from textual.containers import Horizontal, VerticalScroll +from textual.widgets import Static, Tree +from textual.widgets.tree import TreeNode + +from pyevio.decoders import fadc250_stream as fs +from pyevio.gui.panes import Pane +from pyevio.web import ApiError + +PAGE_SIZE = 500 +HEX_WINDOW = 64 +# What a session call can raise: ApiError from the model, ValueError/OSError from core on malformed data, +# BufferError when the file is closing. Each is reported in the status strip; none takes the app down. +PANE_ERRORS = (ApiError, ValueError, OSError, BufferError) +HEX_PER_LINE = 8 +KIND_MORE = "more" # the "… N more" child; its data["id"] is the parent's id + +# The same role names as the browser's tree.js. +ROLE_LABELS = { + "stream_info": "stream info", "roc_bank": "ROC bank", "roc_stream_info": "ROC stream info", "payload_hits": "payload", + "payload_empty": "payload (empty)", "payload_words": "payload", "payload_unmapped": "payload (unmapped port)", + "time_slice": "time slice", "aggregation_info": "aggregation info (AIS)", "payload_map": "payload-port map", +} +# Fields shown elsewhere in the panel (path line, bit-info rows, errors, entries table) or of no interest. +HIDDEN_FIELDS = {"id", "has_children", "entries", "bit_info", "errors"} +HEX_FIELDS = {"tag", "offset", "declared_end"} + + +def hex_value(value: int, digits: int = 4) -> str: + return f"0x{value:0{digits}X}" + + +# ---------------------------------------------------------------- labels +def node_label(node: dict) -> str: + """One tree line for a node dict, labeled by kind and role as the browser does.""" + kind = node.get("kind") + if kind == "file": + trailer = " + trailer" if node.get("has_trailer") else "" + return f"file — {node.get('header_layout')}, {node.get('record_count')} records{trailer}" + if kind == "trailer": + return f"trailer — {node.get('size', 0):,} B at {hex_value(node.get('offset', 0), 8)}" + if kind == "record": + return f"record {node['index']} — {record_meta(node)}" + if kind == "event": + tag = node.get("tag", 0) + name = "stream frame" if tag == fs.STREAM_EVENT_TAG else f"type {hex_value(node.get('data_type', 0), 2)} num {node.get('num')}" + return f"event {node['index']} — tag {hex_value(tag)} {name}, {node.get('length', 0) + 1} words" + if kind == "hit": + flag = " FLAG" if node.get("flag") else "" + return f"hit {node['index']} — ch {node['channel']} t {node['t_ns']} ns q {node['charge']} {node['word']}{flag}" + if kind == KIND_MORE: + return f"… {node.get('remaining', 0):,} more (press Enter to load)" + return f"{bank_name(node)} — {bank_meta(node)}" + + +def record_meta(node: dict) -> str: + parts = [f"{node.get('event_count', 0)} events", f"{node.get('size', 0):,} B"] + if node.get("compression") not in (None, "none"): + parts.append(node["compression"]) + if node.get("is_truncated"): + parts.append("TRUNCATED") + if node.get("errors"): + parts.append(f"{len(node['errors'])} errors") + return ", ".join(parts) + + +def bank_name(node: dict) -> str: + role = ROLE_LABELS.get(node.get("role")) + if node.get("role") == "roc_bank": + return f"{role} {node.get('roc')} stream {node.get('stream')}" + if "port" in node: + return f"{role} port {node['port']}" + return role or f"{node.get('structure', 'bank')} {hex_value(node.get('tag', 0))}" + + +def bank_meta(node: dict) -> str: + parts = [f"tag {hex_value(node.get('tag', 0))}", f"type {hex_value(node.get('data_type', 0), 2)}"] + if node.get("num") is not None: + parts.append(f"num {node['num']}") + parts.append(f"{node.get('length', 0)} words") + if node.get("module"): + parts.append(node["module"]) + if node.get("role") == "payload_hits" and "n_words" in node: + parts.append(f"{node['n_words']} hits") + if node.get("entries"): + parts.append(f"{len(node['entries'])} entries") + return " ".join(parts) + + +def field_text(name: str, value) -> str: + if value is None: + return "—" + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, (list, tuple)): + return ", ".join(str(item) for item in value) + if isinstance(value, int): + if name in HEX_FIELDS: + return f"{hex_value(value)} ({value:,})" + if name == "data_type": + return hex_value(value, 2) + return f"{value:,}" + return str(value) + + +def fields_lines(node: dict) -> List[str]: + """``name value`` rows for the fields table; bit-info fields are prefixed ``bit_info.``.""" + rows = [(name, field_text(name, value)) for name, value in node.items() if name not in HIDDEN_FIELDS and not name.startswith("_")] + for name, value in (node.get("bit_info") or {}).items(): + rows.append((f"bit_info.{name}", field_text(name, value))) + width = max((len(name) for name, _ in rows), default=0) + return [f"{name:<{width}} {value}" for name, value in rows] + + +def entries_lines(entries: List[dict]) -> List[str]: + lines = ["payload-port map", f"{'entry':>5} {'port':>4} {'module id':>9} module"] + lines += [f"{i:>5} {entry['port']:>4} {entry['module_id']:>9} {entry['module']}" for i, entry in enumerate(entries)] + return lines + + +def hex_lines(words: List[str], base_offset: int) -> List[str]: + """``HEX_PER_LINE`` words per line, the line's byte offset first.""" + lines = [] + for start in range(0, len(words), HEX_PER_LINE): + lines.append(f"{hex_value(base_offset + start * 4, 8)} " + " ".join(words[start:start + HEX_PER_LINE])) + return lines + + +def words_request(data: dict) -> Optional[dict]: + """Which words a node has: record headers at file offsets, events and banks inside their record's buffer; hits and the file none.""" + kind = data.get("kind") + if kind in ("file", "hit", KIND_MORE) or data.get("offset") is None: + return None + total_words = int(data.get("size", 0)) // 4 + if kind in ("record", "trailer"): + return {"offset": data["offset"], "record": None, "total_words": total_words, "compressed": False} + return {"offset": data["offset"], "record": data.get("_record"), "total_words": total_words, + "compressed": data.get("_compression") not in (None, "none")} + + +# ---------------------------------------------------------------- the pane +class DetailPanel(VerticalScroll, can_focus=False): + """The scrolling detail column. Not focusable on purpose: when the tab switches away while the Tree has the + focus, Textual blurs the hidden Tree onto a focusable sibling, and a programmatic focus inside a TabPane + re-activates that tab (``TabbedContent._on_tab_pane_focused``), so the number keys could never leave the + Structure tab. With no focusable sibling the focus is simply dropped. The mouse wheel still scrolls it.""" + + +class StructurePane(Pane): + """The structure tree and the detail panel; see the module docstring.""" + + BINDINGS = [ + Binding("n", "next_words", "Next words"), + Binding("p", "previous_words", "Previous words"), + ] + DEFAULT_CSS = """ + StructurePane Horizontal { + height: 1fr; + } + StructurePane #structure-tree { + width: 45%; + height: 1fr; + border-right: solid $panel; + } + StructurePane #structure-detail { + width: 1fr; + height: 1fr; + padding: 0 1; + } + StructurePane #structure-path { + text-style: bold; + } + StructurePane #structure-errors { + color: $error; + } + StructurePane #structure-hex-title { + margin-top: 1; + text-style: bold; + } + StructurePane #structure-hex-note { + color: $text-muted; + } + """ + + def __init__(self, session, **kwargs): + super().__init__(session, **kwargs) + self._tree: Tree = Tree("no file open", data={"id": "", "kind": "file", "has_children": False, "_loaded": 0, "_total": 0}, id="structure-tree") + self._tree.root.allow_expand = False + self._generation = 0 # bumped on every rebuild; TreeNode ids restart from 0 after Tree.clear() + self._shown: Optional[tuple] = None # (generation, TreeNode.id) of the node the panel shows + self.detail: Optional[dict] = None # the node dict the panel shows (tests read it) + self.words_page: Optional[dict] = None # the last words_at dict (tests read it) + self._hex: Optional[dict] = None # the words request of the shown node plus "start", the window's first word + + def compose(self): + with Horizontal(): + yield self._tree + with DetailPanel(id="structure-detail"): + yield Static("Open a file to browse its structure.", id="structure-path") + yield Static("", id="structure-fields", markup=False) + yield Static("", id="structure-errors", markup=False) + yield Static("", id="structure-entries", markup=False) + yield Static("", id="structure-hex-title", markup=False) + yield Static("", id="structure-hex", markup=False) + yield Static("", id="structure-hex-note", markup=False) + + # ---------------------------------------------------------------- pane hooks + def file_closed(self) -> None: + super().file_closed() + self._generation += 1 + self._shown = None + self.detail = self.words_page = self._hex = None + self._tree.reset("no file open", {"id": "", "kind": "file", "has_children": False, "_loaded": 0, "_total": 0}) + self._tree.root.allow_expand = False + self._clear_panel("Open a file to browse its structure.") + + def file_opened(self) -> None: + self._generation += 1 + self._shown = None + self._tree.reset("file", {"id": "", "kind": "file", "has_children": True, "_loaded": 0, "_total": None}) + self._tree.root.allow_expand = True + try: + self._load_page(self._tree.root) + except PANE_ERRORS as error: + self._report(error) + return + self._tree.root.expand() + self._build_lines() + self._tree.move_cursor(self._tree.root) + self._show_detail(self._tree.root) + + # ---------------------------------------------------------------- public + def show_node(self, node_id: str) -> None: + """Expand the path to the node (``""`` the file, ``r3`` a record, ``r3/e1`` an event, …), select it and show it.""" + try: + target = self._walk_to(node_id) + except PANE_ERRORS as error: + self._report(error) + return + if target is None: + return + if target.allow_expand and not target.is_expanded: + target.expand() + self._build_lines() + self._tree.move_cursor(target) + self._tree.scroll_to_node(target, animate=False) + self._show_detail(target) + self._tree.focus() + + def _build_lines(self) -> None: + """Lay the tree lines out now: a node added or expanded this turn has no line (``_line == -1``) until the + tree rebuilds, and ``move_cursor``/``scroll_to_node`` read it directly. Textual 8.2 rebuilds lazily on idle + through this private property; nothing public triggers it synchronously.""" + self._tree._tree_lines # noqa: B018 + + # ---------------------------------------------------------------- tree events + def on_show(self) -> None: + self._tree.focus() # the tab became active: arrows, Enter, n and p go to the tree without a Tab first + + def on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: + event.stop() + data = event.node.data + if data is None or data.get("_total") is not None or not self.session.is_open: + return + try: + self._load_page(event.node) + except PANE_ERRORS as error: + self._report(error) + + def on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + if event.node.data is None or event.node.data.get("kind") != KIND_MORE or event.node.parent is None: + return + event.stop() + try: + self._load_page(event.node.parent) + except PANE_ERRORS as error: + self._report(error) + + def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None: + event.stop() + try: + self._show_detail(event.node) + except PANE_ERRORS as error: + self._report(error) + + # ---------------------------------------------------------------- words window + def action_next_words(self) -> None: + self._move_window(HEX_WINDOW) + + def action_previous_words(self) -> None: + self._move_window(-HEX_WINDOW) + + def _move_window(self, step: int) -> None: + if self._hex is None: + return + start = max(0, min(self._hex["start"] + step, max(0, self._hex["total_words"] - 1))) + if start == self._hex["start"]: + return + self._hex["start"] = start + try: + self._show_words() + except PANE_ERRORS as error: + self._report(error) + + # ---------------------------------------------------------------- loading + def _load_page(self, tree_node: TreeNode) -> None: + """Append the next page of children to ``tree_node``, replacing its "more" child; raises ApiError.""" + data = tree_node.data + start = data.get("_loaded", 0) + page = self.session.children(data["id"], start, PAGE_SIZE) + more = tree_node.children[-1] if tree_node.children and (tree_node.children[-1].data or {}).get("kind") == KIND_MORE else None + if more is not None: + more.remove() + if start == 0: + if data["kind"] == "file": + data.update(page["node"]) + tree_node.set_label(node_label(data)) + if page.get("errors") is not None and data["kind"] != "file": + data["errors"] = list(page["errors"]) # the node's own walk errors (bank.errors, record.errors) + if data["kind"] == "record": + tree_node.set_label(node_label(data)) + record = data.get("_record") + compression = data.get("_compression") + for child in page["children"]: + child_data = dict(child) + if child_data["kind"] == "record": + child_data["_record"], child_data["_compression"] = child_data["index"], child_data.get("compression") + elif child_data["kind"] != "trailer": + child_data["_record"], child_data["_compression"] = record, compression + child_data["_loaded"], child_data["_total"] = 0, None if child_data.get("has_children") else 0 + tree_node.add(node_label(child_data), data=child_data, allow_expand=bool(child_data.get("has_children"))) + data["_loaded"] = start + len(page["children"]) + data["_total"] = page["total"] + if data["_total"] > data["_loaded"]: + remaining = data["_total"] - data["_loaded"] + tree_node.add(node_label({"kind": KIND_MORE, "remaining": remaining}), + data={"id": data["id"], "kind": KIND_MORE, "has_children": False, "remaining": remaining}, allow_expand=False) + if self._shown == (self._generation, tree_node.id): + self._show_detail(tree_node, force=True) # the errors arrived with the page + + def _walk_to(self, node_id: str) -> Optional[TreeNode]: + """The tree node for ``node_id``, loading pages and expanding ancestors on the way; None (reported) when absent.""" + if not self.session.is_open: + self._report("no file is open") + return None + root = self._tree.root + if root.data.get("_total") is None: + self._load_page(root) + parts = [part for part in node_id.strip("/").split("/") if part] + node = root + for depth, _ in enumerate(parts): + target_id = "/".join(parts[:depth + 1]) + if node.data.get("_total") is None: + self._load_page(node) + child = self._find_child(node, target_id) + while child is None and node.data["_total"] > node.data["_loaded"]: + self._load_page(node) + child = self._find_child(node, target_id) + if child is None: + self._report(f"no node {target_id!r} in the tree") + return None + if not node.is_expanded: + node.expand() + node = child + return node + + @staticmethod + def _find_child(node: TreeNode, child_id: str) -> Optional[TreeNode]: + for child in node.children: + if child.data is not None and child.data.get("kind") != KIND_MORE and child.data.get("id") == child_id: + return child + return None + + # ---------------------------------------------------------------- detail panel + def _show_detail(self, tree_node: TreeNode, force: bool = False) -> None: + data = tree_node.data + if data is None or (not force and self._shown == (self._generation, tree_node.id)): + return + self._shown = (self._generation, tree_node.id) + if data.get("kind") == KIND_MORE: + self.detail = None + self._hex = None + self._clear_panel(f"{data['remaining']:,} more children of {data['id'] or 'file'!r} — press Enter to load them") + return + self.detail = data + self.query_one("#structure-path", Static).update(f"node {data.get('id', '')!r}" if data.get("id") else "node '' (file)") + self.query_one("#structure-fields", Static).update("\n".join(fields_lines(data))) + errors = data.get("errors") or [] + self.query_one("#structure-errors", Static).update(Text("\n".join(errors), style="bold red") if errors else "") + self.query_one("#structure-entries", Static).update("\n".join(entries_lines(data["entries"])) if data.get("entries") else "") + request = words_request(data) + self._hex = dict(request, start=0) if request and request["total_words"] > 0 else None + if self._hex is None: + self.words_page = None + self.query_one("#structure-hex-title", Static).update("") + self.query_one("#structure-hex", Static).update("") + self.query_one("#structure-hex-note", Static).update("") + return + try: + self._show_words() + except PANE_ERRORS as error: + self.query_one("#structure-hex", Static).update("") + self.query_one("#structure-hex-note", Static).update(self._error_text(error)) + self._report(error) + + def _show_words(self) -> None: + """Fetch and show the current window of the shown node's words; raises ApiError.""" + request = self._hex + start = request["start"] + count = min(HEX_WINDOW, request["total_words"] - start) + page = self.session.words(request["offset"] + start * 4, count, request["record"]) + self.words_page = page + self.query_one("#structure-hex-title", Static).update("raw words") + self.query_one("#structure-hex", Static).update("\n".join(hex_lines(page["words"], page["offset"]))) + endian = "little" if page["endian"] == "<" else "big" + where = "in the file" if request["record"] is None else f"in the buffer of record {request['record']}" + note = f"words {start:,}–{start + page['count'] - 1:,} of {request['total_words']:,}, {endian}-endian, offsets {where}" + if request["compressed"]: + note += "; offsets index the decompressed record buffer" + note += " (n: next 64 words, p: previous)" + self.query_one("#structure-hex-note", Static).update(note) + + def _clear_panel(self, heading: str) -> None: + self.query_one("#structure-path", Static).update(heading) + for widget_id in ("fields", "errors", "entries", "hex-title", "hex", "hex-note"): + self.query_one(f"#structure-{widget_id}", Static).update("") + + @staticmethod + def _error_text(error) -> str: + if isinstance(error, ApiError): + return error.message + if isinstance(error, str): + return error + return f"{type(error).__name__}: {error}" + + def _report(self, error) -> None: + """Show an error (an exception or a message) in the app's status strip; never raise.""" + message = self._error_text(error) + show_error = getattr(self.app, "show_error", None) + if show_error is not None: + show_error(message) + else: + self.notify(message, severity="error") diff --git a/pyevio/record.py b/pyevio/record.py index dbfdd63..8825e73 100644 --- a/pyevio/record.py +++ b/pyevio/record.py @@ -1,11 +1,18 @@ import mmap import struct -from typing import List, Tuple, Optional, Dict, Any -from datetime import datetime -from typing import Optional, List, Tuple, Dict, Any +import zlib +from typing import List, Tuple, Optional, Union + +import lz4.block +import numpy as np + from pyevio.record_header import RecordHeader from pyevio.utils import make_hex_dump -import numpy as np + + +def padded_to_words(length_bytes: int) -> int: + """A byte length rounded up to whole 32-bit words, as user headers are stored.""" + return (length_bytes + 3) & ~3 class Record: @@ -15,8 +22,21 @@ class Record: A record contains a header followed by an event index array, optional user header, and multiple events. This class provides methods to access and parse the record structure efficiently. + + Buffers: ``mm`` is the file; ``buffer`` is where the index array, user + header and events live. For an uncompressed record ``buffer`` is ``mm`` + and all offsets are file offsets. For a compressed record (LZ4, LZ4_BEST + or gzip, header word 10) ``buffer`` is the decompressed bytes, built on + first access, and ``index_array_start``, ``data_start``, ``data_end`` and + every event offset are offsets into that buffer. Read event bytes through + ``buffer``, never through ``mm``. """ + COMPRESSION_NONE = 0 + COMPRESSION_LZ4 = 1 + COMPRESSION_LZ4_BEST = 2 + COMPRESSION_GZIP = 3 + def __init__(self, mm: mmap.mmap, offset: int, endian: str = '<', index=-1): """ Initialize a Record object. @@ -25,56 +45,170 @@ def __init__(self, mm: mmap.mmap, offset: int, endian: str = '<', index=-1): mm: Memory-mapped file containing the record offset: Byte offset in the file where the record starts endian: Endianness ('<' for little endian, '>' for big endian) + index: Record index within the file (for reference) """ self.mm = mm self.offset = offset self.endian = endian + self.index = index # Parse the record header self.header = RecordHeader.parse(mm, offset) - # Calculate key positions in the record self.header_size = self.header.header_length * 4 - self.index_array_start = self.offset + self.header_size - self.index_array_end = self.index_array_start + self.header.index_array_length - self.data_start = self.index_array_end + self.header.user_header_length - self.data_end = self.offset + (self.header.record_length * 4) self.size = self.header.record_length * 4 - self.index = index + self.compression_type = self.header.compression_type + + # Declared record end against the file: a record that runs past the + # end of the file is truncated, and reads stop at the present bytes. + self.declared_end = self.offset + self.size + self.file_end = min(self.declared_end, len(mm)) + self.is_truncated = self.declared_end > len(mm) + + # Why an event walk stopped early (short strings with offsets); empty + # when every event length fit inside the record. + self.errors: List[str] = [] + + # The user header is stored padded to whole words; the unpadded length + # in the header word shifts every event by the padding otherwise. + user_header_padded = padded_to_words(self.header.user_header_length) + + self._buffer = None + if self.compression_type == self.COMPRESSION_NONE: + self._buffer = mm + self.index_array_start = self.offset + self.header_size + self.index_array_end = self.index_array_start + self.header.index_array_length + self.data_start = self.index_array_end + user_header_padded + self.data_end = self.file_end + else: + # Layout of the decompressed buffer: index array, user header, events. + self.index_array_start = 0 + self.index_array_end = self.header.index_array_length + self.data_start = self.index_array_end + user_header_padded + self.data_end = self.data_start + self.header.uncompressed_data_length # Cache for events (will be populated on demand) self._events = None - self._event_count = None + self._event_info = None + + @property + def buffer(self) -> Union[mmap.mmap, bytes]: + """The buffer that holds the index array, user header and events (see class docstring).""" + if self._buffer is None: + self._buffer = self._decompress() + return self._buffer + + def release(self) -> None: + """Drop the decompressed buffer and the Event cache; the next access rebuilds them.""" + if self.compression_type != self.COMPRESSION_NONE: + self._buffer = None + self._events = None + + def _decompress(self) -> bytes: + """Decompress index array + user header + events into one bytes buffer. + + Header facts (verified on the evio C++ and Java writers): the + compressed region starts right after the header; word 10 holds the + compression type in bits 28—31 and the compressed length in words + below; the last word carries pad3 (bits 24—25 of word 5) filler bytes + that are not part of the stream. LZ4 and LZ4_BEST are the LZ4 block + format (no frame) and need the decompressed size up front: word 9 + counts the events only, so the index array and padded user header are + added to it. + """ + compressed_words = self.header.compressed_data_length + compressed_start = self.offset + self.header_size + compressed_end = compressed_start + compressed_words * 4 - self.header.pad3 + if compressed_end > self.file_end: + self.errors.append(f"compressed data declares {compressed_words} words at 0x{compressed_start:X}, " + f"record ends at 0x{self.file_end:X}") + compressed_end = self.file_end + compressed = self.mm[compressed_start:compressed_end] + + try: + if self.compression_type in (self.COMPRESSION_LZ4, self.COMPRESSION_LZ4_BEST): + decompressed = lz4.block.decompress(compressed, uncompressed_size=self.data_end) + elif self.compression_type == self.COMPRESSION_GZIP: + decompressed = zlib.decompress(compressed, wbits=47) # gzip or zlib wrapper + else: + self.errors.append(f"unknown compression type {self.compression_type}") + decompressed = b"" + except (lz4.block.LZ4BlockError, zlib.error, ValueError) as error: + self.errors.append(f"decompression failed (type {self.compression_type}): {error}") + decompressed = b"" + + if len(decompressed) < self.data_end: + self.errors.append(f"decompressed {len(decompressed)} bytes, header declares {self.data_end}") + self.data_end = len(decompressed) + return decompressed @property def event_count(self) -> int: - """Get the number of events in this record.""" - if self._event_count is None: - self._event_count = self.header.event_count - return self._event_count + """Number of events the header declares. A trailer declares 0.""" + return self.header.event_count + + @property + def is_trailer(self) -> bool: + """True for the trailer record that closes a file; it holds no events.""" + return self.header.is_trailer def scan_events(self) -> List[Tuple[int, int]]: """ Scan and parse all events in this record. Returns: - List of tuples (offset, length) for all events in the record + List of tuples (offset, length) for all events in the record; + offsets are into ``buffer`` (file offsets for uncompressed records) """ - event_info = [] + if self._event_info is None: + self._event_info = self._scan_events() + return self._event_info - if self.header.index_array_length > 0: - # Parse events from index array - event_count = self.header.index_array_length // 4 - current_offset = self.data_start + def _scan_events(self) -> List[Tuple[int, int]]: + # A trailer's index array lists records, never events: reading it as + # event lengths yields ghost events past the end of the file. + if self.header.is_trailer: + return [] - for i in range(event_count): - length_offset = self.index_array_start + (i * 4) - event_length = struct.unpack(self.endian + 'I', self.mm[length_offset:length_offset+4])[0] + buffer = self.buffer + event_info = [] + current_offset = self.data_start - # Store event offset and length + if self.header.index_array_length > 0: + # The index array holds one byte length per event. The header's + # event count and the index size are two claims about the same + # number; trust neither beyond the other, nor beyond the bytes + # present (a truncated record may cut the index array itself). + index_entries = self.header.index_array_length // 4 + index_present = max(0, min(self.index_array_end, len(buffer)) - self.index_array_start) // 4 + event_count = min(self.header.event_count, index_entries, index_present) + + for event_i in range(event_count): + length_offset = self.index_array_start + event_i * 4 + event_length = struct.unpack(self.endian + 'I', buffer[length_offset:length_offset + 4])[0] + if event_length < 8 or current_offset + event_length > self.data_end: + self.errors.append(f"event {event_i} at 0x{current_offset:X} declares {event_length} bytes, " + f"record data ends at 0x{self.data_end:X}") + break event_info.append((current_offset, event_length)) + current_offset += event_length - # Update cumulative offset for next event + else: + # No index array: walk events by the length word of each event + # bank (length + 1 words, see Bank). Stop at the header's event + # count or at the end of the record data, whichever comes first. + while len(event_info) < self.header.event_count: + if current_offset + 8 > self.data_end: + self.errors.append(f"event {len(event_info)} at 0x{current_offset:X} has no room for a bank header, " + f"record data ends at 0x{self.data_end:X}") + break + bank_length = struct.unpack(self.endian + 'I', buffer[current_offset:current_offset + 4])[0] + event_length = (bank_length + 1) * 4 + if bank_length == 0 or current_offset + event_length > self.data_end: + self.errors.append(f"event {len(event_info)} at 0x{current_offset:X} declares {event_length} bytes, " + f"record data ends at 0x{self.data_end:X}") + break + event_info.append((current_offset, event_length)) current_offset += event_length return event_info @@ -93,10 +227,9 @@ def get_events(self, start_event: Optional[int] = None, end_event: Optional[int] # Scan events only if needed if self._events is None: from pyevio.event import Event # Import here to avoid circular import - event_info = self.scan_events() self._events = [ - Event(self.mm, offset, length, self.endian, i) - for i, (offset, length) in enumerate(event_info) + Event(self.buffer, offset, length, self.endian, i) + for i, (offset, length) in enumerate(self.scan_events()) ] # Apply range filter @@ -121,7 +254,7 @@ def get_event_offsets(self, start_event: Optional[int] = None, end_event: Option end_event: Ending event index, exclusive (default: all events) Returns: - List of tuples (offset, length) for each event + List of tuples (offset, length) for each event; offsets are into ``buffer`` """ event_info = self.scan_events() @@ -138,6 +271,9 @@ def get_event_offsets(self, start_event: Optional[int] = None, end_event: Option return event_info[start_event:end_event] + def _second_word(self, offset: int) -> int: + return struct.unpack(self.endian + 'I', self.buffer[offset + 4:offset + 8])[0] + def events_to_numpy_direct(self, start_event: Optional[int] = None, end_event: Optional[int] = None, signature: Optional[int] = None, event_size_words: Optional[int] = None) -> np.ndarray: """ @@ -157,51 +293,32 @@ def events_to_numpy_direct(self, start_event: Optional[int] = None, end_event: O # Get event offsets and lengths event_info = self.get_event_offsets(start_event, end_event) - if not event_info: - return np.array([], dtype=np.uint32) - if signature is not None: - # Filter events by signature - filtered_info = [] - signature_mask = signature & 0xFFFF # Use only top 16 bits - - for offset, length in event_info: - if length >= 8: # Need at least 2 words - # Get the second word - second_word = struct.unpack(self.endian + 'I', self.mm[offset + 4:offset + 8])[0] - if (second_word >> 16) == signature_mask: - filtered_info.append((offset, length)) - - event_info = filtered_info + # Filter events by the tag in the second word (every event has 8+ bytes) + signature_mask = signature & 0xFFFF + event_info = [(offset, length) for offset, length in event_info + if (self._second_word(offset) >> 16) == signature_mask] if not event_info: return np.array([], dtype=np.uint32) + dtype = np.dtype(np.uint32).newbyteorder(self.endian) + buffer = self.buffer + if event_size_words is not None: # All events have the same size, we can create a regular 2D array - num_events = len(event_info) - result = np.zeros((num_events, event_size_words), dtype=np.uint32) + result = np.zeros((len(event_info), event_size_words), dtype=np.uint32) - for i, (offset, _) in enumerate(event_info): - # Read event data directly into result array with correct endianness - event_data = np.frombuffer( - self.mm[offset:offset + (event_size_words * 4)], - dtype=np.dtype(np.uint32).newbyteorder('>' if self.endian == '>' else '<') - ) - result[i, :len(event_data)] = event_data + for i, (offset, length) in enumerate(event_info): + # Never read past the event: a shorter event leaves zeros. + word_count = min(event_size_words, length // 4) + result[i, :word_count] = np.frombuffer(buffer, dtype=dtype, count=word_count, offset=offset) return result else: # Variable-sized events, create a list of arrays - event_arrays = [] - - for offset, length in event_info: - word_count = length // 4 - event_data = np.frombuffer( - self.mm[offset:offset + length], - dtype=np.uint32 - ) - event_arrays.append(event_data) + event_arrays = [np.frombuffer(buffer, dtype=dtype, count=length // 4, offset=offset) + for offset, length in event_info] # Try to determine if all events are the same size after filtering if all(len(arr) == len(event_arrays[0]) for arr in event_arrays): @@ -231,15 +348,9 @@ def get_event(self, index: int): return events[index] - import numpy as np - def events_to_numpy(self, signature: int = 0xFF60, start_event: Optional[int] = None, end_event: Optional[int] = None, dtype=np.uint32) -> np.ndarray: """ - Convert events with a specific signature to a NumPy array. - - This method efficiently converts events where the second word matches the given signature - to a NumPy array. It avoids slow event-by-event Python loops by taking a vectorized approach - where possible. + Concatenate the words of every event whose tag matches the signature into one NumPy array. Args: signature: Integer signature to match in the second word of events (default: 0xFF60) @@ -250,64 +361,26 @@ def events_to_numpy(self, signature: int = 0xFF60, start_event: Optional[int] = Returns: NumPy array containing all event data matching the signature """ - # Get all events or subset based on start/end parameters - events = self.get_events(start_event, end_event) + signature_mask = signature & 0xFFFF + matching = [(offset, length) for offset, length in self.get_event_offsets(start_event, end_event) + if (self._second_word(offset) >> 16) == signature_mask] - if not events: + if not matching: return np.array([], dtype=dtype) - # Extract offsets and lengths for all events - offsets = np.array([event.offset for event in events], dtype=np.int64) - lengths = np.array([event.length for event in events], dtype=np.int64) - - # Filter out events that are too small - valid_indices = lengths >= 8 - offsets = offsets[valid_indices] - lengths = lengths[valid_indices] - - if len(offsets) == 0: - return np.array([], dtype=dtype) - - # Check which events match the signature - signatures = np.zeros(len(offsets), dtype=np.uint32) - - for i in range(len(offsets)): - # Extract the second word (bytes 4-8) - second_word = int.from_bytes( - self.mm[offsets[i] + 4:offsets[i] + 8], - byteorder='little' if self.endian == '<' else 'big' - ) - signatures[i] = second_word >> 16 - - # Create a mask for events that match our signature - mask = signatures == (signature & 0xFFFF) - matching_offsets = offsets[mask] - matching_lengths = lengths[mask] - - if len(matching_offsets) == 0: - return np.array([], dtype=dtype) - - # Calculate the total size and preallocate array - total_words = np.sum(matching_lengths) // 4 + total_words = sum(length // 4 for _, length in matching) result = np.zeros(total_words, dtype=dtype) + buffer = self.buffer + source_dtype = np.dtype(np.uint32).newbyteorder(self.endian) - # Copy each matching event's data into the result array current_idx = 0 - for i in range(len(matching_offsets)): - offset = matching_offsets[i] - length = matching_lengths[i] + for offset, length in matching: words = length // 4 - - # Use np.frombuffer for efficient memory-to-array conversion - event_data = np.frombuffer( - self.mm[offset:offset + length], - dtype=dtype - ) - - result[current_idx:current_idx + words] = event_data + result[current_idx:current_idx + words] = np.frombuffer(buffer, dtype=source_dtype, count=words, offset=offset) current_idx += words return result + def get_hex_dump(self, word_count: int = 14, title: Optional[str] = None) -> str: """ Generate a hex dump of the record header. @@ -324,8 +397,9 @@ def get_hex_dump(self, word_count: int = 14, title: Optional[str] = None) -> str def __str__(self) -> str: """Return a string representation of this record.""" - return f"Record at offset 0x{self.offset:X} with {self.event_count} events" + kind = "Trailer" if self.is_trailer else "Record" + return f"{kind} at offset 0x{self.offset:X} with {self.event_count} events" def __repr__(self) -> str: """Return a string representation for debugging.""" - return f"Record(offset=0x{self.offset:X}, size={self.size}, events={self.event_count})" \ No newline at end of file + return f"Record(offset=0x{self.offset:X}, size={self.size}, events={self.event_count})" diff --git a/pyevio/record_header.py b/pyevio/record_header.py index 272d824..438c12d 100644 --- a/pyevio/record_header.py +++ b/pyevio/record_header.py @@ -2,14 +2,34 @@ class RecordHeader: - """Parses and represents an EVIO v6 record header.""" + """Parses and represents an EVIO v6 record header. + + Word layout (word 5 holds the header species, flags and version): + + 0 record length (words) 7 magic 0xc0da0100 + 1 record number 8 uncompressed data length (bytes, events only) + 2 header length (words) 9 compression type (bits 28—31) | compressed length (words) + 3 event count 10—11 user register 1 + 4 index array length (bytes) 12—13 user register 2 + 5 species (bits 28—31) | bit-info (bits 8—27) | version (bits 0—7) + 6 user header length (bytes) + + A 14-word header carries all of these. An 8-word header (the SRO hardware + block layout, see ``EvioFile``) stops after the magic word; the remaining + fields read as 0. + """ # EVIO record header magic number MAGIC_NUMBER = 0xc0da0100 - # Size of record header in bytes + # Size of a full record header in bytes HEADER_SIZE = 56 # 14 words * 4 bytes + # Header species, bits 28—31 of word 5. + TYPE_RECORD = 0 + TYPE_FILE = 1 + TYPE_TRAILER = 3 + def __init__(self): """ Initialize an empty RecordHeader object @@ -22,73 +42,108 @@ def __init__(self): self.index_array_length = -1 self.version = -1 self.bit_info = -1 + self.header_type = -1 + self.pad1 = 0 + self.pad2 = 0 + self.pad3 = 0 self.user_header_length = -1 self.magic_number = -1 - self.uncompressed_data_length = -1 - self.compression_type = -1 - self.compressed_data_length = -1 - self.user_register1 = -1 - self.user_register2 = -1 - self.version = -1 - self.magic_number = -1 + self.uncompressed_data_length = 0 + self.compression_type = 0 + self.compressed_data_length = 0 + self.user_register1 = 0 + self.user_register2 = 0 self.has_dictionary = False self.is_last_record = False self.has_first_event = False self.event_type = f"NeverInitialized" + # Word 5 of an SRO hardware block header: version 4 and nothing else. A + # v4 file written by the evio library sets bit-info bits somewhere in its + # chain (last-block 0x200, dictionary 0x100), which is how the two are + # told apart (EvioFile._detect_layout). + SRO_BLOCK_WORD = 0x00000004 + + @staticmethod + def is_sro_block_word(bit_info_version: int) -> bool: + """True when word 5 is exactly version 4 with no bit-info bits.""" + return bit_info_version == RecordHeader.SRO_BLOCK_WORD + + @property + def is_trailer(self) -> bool: + """True for the trailer record that closes a file (header species 3). + + A trailer's index array lists (record length, event count) pairs of the + records before it, never events. + """ + return self.header_type == RecordHeader.TYPE_TRAILER + @staticmethod def parse(buffer, offset=0): """ Parse a RecordHeader from a memory-mapped buffer. - + Args: buffer: Memory-mapped buffer containing EVIO data offset: Byte offset where the record header starts - + Returns: RecordHeader object with parsed data """ header = RecordHeader() + if offset + 8 * 4 > len(buffer): + raise ValueError(f"Record header at offset 0x{offset:X} needs 8 words, " + f"buffer ends at 0x{len(buffer):X}") reader = BufferReader(buffer, offset) - # Basic header fields (already validated in reader initialization) + # The first eight words exist in every header layout. header.record_length = reader.read_uint32(0) header.record_number = reader.read_uint32(1) header.header_length = reader.read_uint32(2) header.endian = reader.endian - - # Read remaining fields header.event_count = reader.read_uint32(3) header.index_array_length = reader.read_uint32(4) bit_info_version = reader.read_uint32(5) header.version = bit_info_version & 0xFF - header.bit_info = (bit_info_version >> 8) & 0xFFFFFF + header.bit_info = (bit_info_version >> 8) & 0xFFFFF + header.header_type = (bit_info_version >> 28) & 0xF + # Padding byte counts: pad1 after the user header, pad2 after the + # events, pad3 after the compressed data (bits 20—25 of word 5). + header.pad1 = (bit_info_version >> 20) & 0x3 + header.pad2 = (bit_info_version >> 22) & 0x3 + header.pad3 = (bit_info_version >> 24) & 0x3 header.user_header_length = reader.read_uint32(6) header.magic_number = reader.read_uint32(7) - header.uncompressed_data_length = reader.read_uint32(8) + # Words 8—13 exist only in the 14-word layout; reading them from an + # 8-word header would take the first event's words as header fields. + if header.header_length >= 14: + if offset + 14 * 4 > len(buffer): + raise ValueError(f"Record header at offset 0x{offset:X} declares 14 words, " + f"buffer ends at 0x{len(buffer):X}") + header.uncompressed_data_length = reader.read_uint32(8) - compression_data = reader.read_uint32(9) - header.compression_type = (compression_data >> 28) & 0xF - header.compressed_data_length = compression_data & 0x0FFFFFFF + compression_data = reader.read_uint32(9) + header.compression_type = (compression_data >> 28) & 0xF + header.compressed_data_length = compression_data & 0x0FFFFFFF - # 64-bit values - header.user_register1 = reader.read_uint64(10) # Words 10-11 - header.user_register2 = reader.read_uint64(12) # Words 12-13 - - # Validate header - if header.version != 6: - print(f"Record version validation failed: got {header.version}, expected 6") - print(f"bit_info_version raw value: 0x{bit_info_version:08X}") - print(reader.hex_dump()) - raise ValueError(f"Unsupported EVIO version in record: {header.version}, expected 6") + header.user_register1 = reader.read_uint64(10) # Words 10-11 + header.user_register2 = reader.read_uint64(12) # Words 12-13 if header.magic_number != RecordHeader.MAGIC_NUMBER: - print(reader.hex_dump()) raise ValueError(f"Invalid record magic number: 0x{header.magic_number:08x}, expected 0x{RecordHeader.MAGIC_NUMBER:08x}") + # Version 6 is the record header; the SRO hardware block header is + # the one other shape read: exactly 8 words, version 4, no bit-info + # bits (see RecordHeader.is_sro_block_word). Any other version is a + # legacy block that EvioFile refuses at open. + if header.version != 6 and not (header.header_length == 8 + and RecordHeader.is_sro_block_word(bit_info_version)): + raise ValueError(f"Unsupported EVIO version in record at offset 0x{offset:X}: " + f"version {header.version}, expected 6") + # Parse bit_info fields header.has_dictionary = bool((header.bit_info >> 0) & 1) # Bit 8 header.is_last_record = bool((header.bit_info >> 1) & 1) # Bit 9 @@ -111,4 +166,4 @@ def parse(buffer, offset=0): header.has_first_event = bool((header.bit_info >> 6) & 1) # Bit 14 - return header \ No newline at end of file + return header diff --git a/pyevio/roc_time_slice_bank.py b/pyevio/roc_time_slice_bank.py index fc9d0ad..a297f0d 100644 --- a/pyevio/roc_time_slice_bank.py +++ b/pyevio/roc_time_slice_bank.py @@ -65,8 +65,8 @@ def _parse_time_slice_segment(self): # Big endian: high word first, then low word self.timestamp = (timestamp_high << 32) | timestamp_low - # Store TSS end offset for later use - self.tss_end_offset = tss_offset + (self.tss_length * 4) + # A segment's length counts the words after its one header word. + self.tss_end_offset = tss_offset + (self.tss_length + 1) * 4 def _parse_aggregation_info_segment(self): """Parse the Aggregation Info Segment (AIS).""" @@ -279,7 +279,7 @@ class RocTimeSliceBank(Bank): # Tag value for ROC Time Slice Banks TAG = 0xFF30 - def __init__(self, mm: mmap.mmap, offset: int, endian: str = '<'): + def __init__(self, mm: mmap.mmap, offset: int, endian: str = '<', limit: Optional[int] = None): """ Initialize a RocTimeSliceBank parser. @@ -287,8 +287,9 @@ def __init__(self, mm: mmap.mmap, offset: int, endian: str = '<'): mm: Memory-mapped buffer offset: Byte offset where the bank starts endian: Endianness ('<' for little endian, '>' for big endian) + limit: Byte offset the bank must not reach past (see Bank) """ - super().__init__(mm, offset, endian) + super().__init__(mm, offset, endian, limit) # Validate ROC Time Slice Bank if self.data_type != 0x10: @@ -311,7 +312,7 @@ def _parse_payload_banks(self): # Start after Stream Info Bank current_offset = self.sib.ais_end_offset - end_offset = self.offset + (self.length * 4) + end_offset = self.end_offset # Match payload banks with payload infos for i, payload_info in enumerate(self.sib.payload_infos): @@ -324,7 +325,7 @@ def _parse_payload_banks(self): self.payload_banks.append(payload_bank) # Move to next bank - current_offset += payload_bank.length * 4 + current_offset += payload_bank.size except Exception as e: # If there's an error parsing a payload bank, log it and continue if self.payload_banks: diff --git a/pyevio/stream_stats.py b/pyevio/stream_stats.py new file mode 100644 index 0000000..fda21c0 --- /dev/null +++ b/pyevio/stream_stats.py @@ -0,0 +1,566 @@ +"""One pass over the streaming frames of an EVIO file: histograms and file checks. + +Built on :mod:`pyevio.decoders.fadc250_stream`. ``compute_stream_stats`` opens +the file, scans every frame header once (frame number, timestamp) for the +frame checks and the time axis, then decodes all records in parallel by +record ranges, each worker opening the file itself and returning numpy +partials that add. The result, :class:`StreamStats`, is small (counts in +fixed bins, sized by the ROC and port sets found in the data) and round-trips +through ``.npz`` with a version field. + +Bins: + +- ``rate``: hits per time bin from the first to the last frame timestamp, + per (ROC, port, channel). ``rate_hz()`` normalizes by the frames each bin + covers, so a partial bin or a missing stripe reads right. Timestamps are + in ticks; ``tick_ns`` converts them (knob, default 1 ns). +- ``charge``: 13-bit charge in 128 bins of 64; ``time_in_frame``: 14-bit + tick field in 64 bins of 256 ticks (1,024 ns); both per (ROC, port, channel), + with per-ROC views summed on demand. +- ``hits_per_frame``: histogram of hits per frame in 64 linear bins. + +Trailer records (header type 3) are skipped; events whose range runs past +the file are counted, never read. +""" +import io +import json +import multiprocessing +import os +import sys +import struct +import time +from dataclasses import dataclass, field, asdict, fields +from typing import Callable, List, Optional + +import numpy as np + +from pyevio.evio_file import EvioFile +from pyevio.decoders import fadc250_stream as fs + +STATS_VERSION = "3" # 2: AIS entry counts per frame and the AIS totals; 3: decode warnings per frame and in total +N_CHANNELS = 16 +CHARGE_BIN_SHIFT = 6 # 13-bit charge >> 6 → 128 bins of 64 +N_CHARGE_BINS = (fs.HIT_CHARGE_MASK + 1) >> CHARGE_BIN_SHIFT +TIME_BIN_SHIFT = 8 # 14-bit tick field >> 8 → 64 bins of 256 ticks +N_TIME_BINS = (fs.HIT_TIME_MASK + 1) >> TIME_BIN_SHIFT +N_HITS_PER_FRAME_BINS = 64 +DEFAULT_RATE_BINS = 256 +DEFAULT_TICK_NS = 1.0 +DEFAULT_CHUNK_RECORDS = 8 +RECORD_TYPE_TRAILER = 3 +# A frame whose timestamp step per frame deviates by more than this fraction from the median is an outlier. +DTS_OUTLIER_TOLERANCE = 0.01 +MAX_ERROR_MESSAGES = 50 + +# Hooks of compute_stream_stats: progress(records_done, records_total) is called after the +# header scan and after every decoded task; cancel() is polled between chunks and tasks. +ProgressCallback = Callable[[int, int], None] +CancelCheck = Callable[[], bool] + + +class StatsCancelled(Exception): + """Raised by compute_stream_stats when its cancel check returns True.""" + + +FRAME_SCAN_DTYPE = np.dtype([("record", np.uint32), ("event", np.uint32), ("frame", np.uint32), ("timestamp", np.uint64)]) +FRAME_STATS_DTYPE = np.dtype([ + ("event_index", np.uint32), ("frame", np.uint32), ("timestamp", np.uint64), ("n_hits", np.uint32), + ("non_fadc_words", np.uint32), ("errors", np.uint16), ("warnings", np.uint16), + ("n_ais_entries", np.uint16), ("n_ais_distinct", np.uint16), +]) + + +def record_type(record) -> int: + """Record header type: bits 28—31 of the bit-info/version word (3 = trailer).""" + return (record.header.bit_info >> 20) & 0xF + + +def default_workers() -> int: + return max(1, (os.cpu_count() or 2) - 2) + + +# ---------------------------------------------------------------- frame scan (serial, headers only) +@dataclass +class FrameScan: + """Frame numbers and timestamps of every stream frame, in file order, plus record bookkeeping.""" + frames: np.ndarray # FRAME_SCAN_DTYPE + n_records: int + n_trailer_records: int + n_events: int + n_other_events: int + n_out_of_bounds_events: int + events_before_record: np.ndarray # per record: global index of its first event (trailers hold the running count) + + +def scan_frames(evio_file: EvioFile, max_records: Optional[int] = None) -> FrameScan: + """Read frame number and timestamp from the SIB of every event without decoding hits.""" + endian = evio_file.header.endian + rows = [] + n_records = n_trailers = n_events = n_other = n_out_of_bounds = 0 + events_before = [] + record_limit = evio_file.record_count if max_records is None else min(max_records, evio_file.record_count) + for record_i in range(record_limit): + record = evio_file.get_record(record_i) + events_before.append(n_events) + n_records += 1 + if record_type(record) == RECORD_TYPE_TRAILER: + n_trailers += 1 + continue + buffer = record.buffer # event offsets are buffer offsets (decompressed bytes for LZ4/gzip records) + for event_i, (offset, length) in enumerate(record.get_event_offsets()[:record.header.event_count]): + if offset + length > len(buffer): + n_out_of_bounds += 1 + continue + n_events += 1 + if length < 32: + n_other += 1 + continue + words = struct.unpack_from(endian + "8I", buffer, offset) + if (words[1] >> 16) != fs.STREAM_EVENT_TAG or (words[3] >> 16) != fs.SIB_TAG or (words[4] >> 24) != fs.SIB_TSS_SEGMENT_TAG: + n_other += 1 + continue + rows.append((record_i, event_i, words[5], words[6] | (words[7] << 32))) + # EvioFile.record_count covers data records only; the trailer is a separate + # attribute and counts as seen once the scan has covered the whole file. + if record_limit == evio_file.record_count and evio_file.trailer is not None: + n_trailers += 1 + return FrameScan(np.array(rows, dtype=FRAME_SCAN_DTYPE), n_records, n_trailers, n_events, n_other, n_out_of_bounds, + np.asarray(events_before, dtype=np.int64)) + + +@dataclass +class FrameChecks: + """What the frame sequence says about the file: stride, gaps, duplicates and timestamp steps.""" + n_frames: int = 0 + first_frame: int = 0 + last_frame: int = 0 + first_timestamp: int = 0 + last_timestamp: int = 0 + stride: int = 0 # most common positive frame delta between consecutive frames (4 for a 4-way striped run) + frame_period_ticks: float = 0.0 # median timestamp step per frame number + missing_frames: int = 0 # frames absent between first and last, counted in steps of the stride + duplicate_frames: int = 0 + out_of_order: int = 0 # consecutive frames whose number decreases + dts_outliers: int = 0 # consecutive frames whose timestamp step per frame deviates from the median + + +def frame_checks(frames: np.ndarray) -> FrameChecks: + """Compute :class:`FrameChecks` from a frame scan table in file order.""" + checks = FrameChecks(n_frames=len(frames)) + if len(frames) == 0: + return checks + frame = frames["frame"].astype(np.int64) + timestamp = frames["timestamp"].astype(np.int64) + unique_frames = np.unique(frame) + checks.first_frame, checks.last_frame = int(unique_frames[0]), int(unique_frames[-1]) + checks.first_timestamp, checks.last_timestamp = int(timestamp.min()), int(timestamp.max()) + checks.duplicate_frames = int(len(frame) - len(unique_frames)) + d_frame = np.diff(frame) + d_timestamp = np.diff(timestamp) + checks.out_of_order = int(np.count_nonzero(d_frame < 0)) + forward = d_frame > 0 + if forward.any(): + deltas, counts = np.unique(d_frame[forward], return_counts=True) + checks.stride = int(deltas[np.argmax(counts)]) + step_per_frame = d_timestamp[forward] / d_frame[forward] + checks.frame_period_ticks = float(np.median(step_per_frame)) + if checks.frame_period_ticks > 0: + deviation = np.abs(step_per_frame - checks.frame_period_ticks) / checks.frame_period_ticks + checks.dts_outliers = int(np.count_nonzero(deviation > DTS_OUTLIER_TOLERANCE)) + checks.missing_frames = int((checks.last_frame - checks.first_frame) // checks.stride + 1 - len(unique_frames)) + return checks + + +# ---------------------------------------------------------------- accumulation +class Accumulator: + """Histogram partial over some events; axes (ROC ids, ports) grow with the data and partials add.""" + + ARRAYS_3D = ("rate", "charge", "time") # (roc, port, channel, bins) + ARRAYS_2D = ("empty_banks", "non_fadc_words_per_port") # (roc, port) + # Totals summed from the StreamHits attribute of the same name; n_frames and the AIS totals are added apart. + HITS_TOTALS = ("n_events", "n_other_events", "n_hits", "n_payload_banks", "n_empty_payload_banks", + "n_unmapped_payload_banks", "n_payload_words", "non_fadc_words", "n_flag_words", "errors", "warnings") + + def __init__(self, t_first: int, t_last: int, n_rate_bins: int): + self.t_first, self.t_last, self.n_rate_bins = int(t_first), int(t_last), int(n_rate_bins) + self.rocs = np.empty(0, dtype=np.uint16) + self.ports = np.empty(0, dtype=np.uint16) + self.rate = np.zeros((0, 0, N_CHANNELS, n_rate_bins), dtype=np.int64) + self.charge = np.zeros((0, 0, N_CHANNELS, N_CHARGE_BINS), dtype=np.int64) + self.time = np.zeros((0, 0, N_CHANNELS, N_TIME_BINS), dtype=np.int64) + self.empty_banks = np.zeros((0, 0), dtype=np.int64) + self.non_fadc_words_per_port = np.zeros((0, 0), dtype=np.int64) + self.module_id = np.full((0, 0), fs.MODULE_UNKNOWN, dtype=np.uint8) + self.words_per_module = {} # module id → payload words (fADC250 hits count as words) + self.frame_rows = [] # FRAME_STATS_DTYPE arrays, concatenated at the end + self.totals = {name: 0 for name in self.HITS_TOTALS + ("n_frames", "frames_with_ais", "frames_with_degenerate_ais")} + self.error_messages: List[str] = [] + self.warning_messages: List[str] = [] + + def _grow(self, rocs: np.ndarray, ports: np.ndarray) -> None: + """Extend the axes to the union with ``rocs``/``ports``, re-indexing every array.""" + new_rocs = np.union1d(self.rocs, rocs).astype(np.uint16) + new_ports = np.union1d(self.ports, ports).astype(np.uint16) + if len(new_rocs) == len(self.rocs) and len(new_ports) == len(self.ports): + return + roc_pos = np.searchsorted(new_rocs, self.rocs) + port_pos = np.searchsorted(new_ports, self.ports) + for name in self.ARRAYS_3D + self.ARRAYS_2D + ("module_id",): + old = getattr(self, name) + fill = fs.MODULE_UNKNOWN if name == "module_id" else 0 + new = np.full((len(new_rocs), len(new_ports)) + old.shape[2:], fill, dtype=old.dtype) + new[np.ix_(roc_pos, port_pos)] = old + setattr(self, name, new) + self.rocs, self.ports = new_rocs, new_ports + + def _time_bin(self, timestamps: np.ndarray) -> np.ndarray: + span = max(self.t_last - self.t_first, 1) + bins = (timestamps.astype(np.int64) - self.t_first) * self.n_rate_bins // span + return np.clip(bins, 0, self.n_rate_bins - 1) + + def absorb(self, hits: fs.StreamHits) -> None: + """Add the hits and tables of one decoded batch.""" + seen_rocs = np.unique(np.concatenate([hits.roc_banks["roc"], hits.empty_banks["roc"], hits.non_fadc_banks["roc"]])) + seen_ports = np.unique(np.concatenate([hits.port, hits.empty_banks["port"], hits.non_fadc_banks["port"]])) + self._grow(seen_rocs, seen_ports) + n_ports = len(self.ports) + n_cells = len(self.rocs) * n_ports * N_CHANNELS + + if hits.n_hits: + base = int(hits.frames["event_index"].min()) + n_events_span = int(hits.frames["event_index"].max()) - base + 1 + bin_of_event = np.zeros(n_events_span, dtype=np.int64) + bin_of_event[hits.frames["event_index"].astype(np.int64) - base] = self._time_bin(hits.frames["timestamp"]) + hit_bin = bin_of_event[hits.event_index.astype(np.int64) - base] + hit_roc_i = np.searchsorted(self.rocs, hits.roc).astype(np.int64) + hit_port_i = np.searchsorted(self.ports, hits.port).astype(np.int64) + cell = (hit_roc_i * n_ports + hit_port_i) * N_CHANNELS + hits.channel + self.rate += np.bincount(cell * self.n_rate_bins + hit_bin, minlength=n_cells * self.n_rate_bins).reshape(self.rate.shape) + self.charge += np.bincount(cell * N_CHARGE_BINS + (hits.charge >> CHARGE_BIN_SHIFT), + minlength=n_cells * N_CHARGE_BINS).reshape(self.charge.shape) + self.time += np.bincount(cell * N_TIME_BINS + (hits.time_raw >> TIME_BIN_SHIFT), + minlength=n_cells * N_TIME_BINS).reshape(self.time.shape) + self.module_id[hit_roc_i, hit_port_i] = fs.MODULE_FADC250 + + for table, target, weights in ((hits.empty_banks, "empty_banks", None), + (hits.non_fadc_banks, "non_fadc_words_per_port", "n_words")): + if len(table) == 0: + continue + roc_i = np.searchsorted(self.rocs, table["roc"]).astype(np.int64) + port_i = np.searchsorted(self.ports, table["port"]).astype(np.int64) + counts = np.bincount(roc_i * n_ports + port_i, weights=None if weights is None else table[weights].astype(np.float64), + minlength=len(self.rocs) * n_ports) + setattr(self, target, getattr(self, target) + counts.astype(np.int64).reshape(len(self.rocs), n_ports)) + self.module_id[roc_i, port_i] = table["module_id"] + + self.words_per_module[fs.MODULE_FADC250] = self.words_per_module.get(fs.MODULE_FADC250, 0) + hits.n_hits + if len(hits.non_fadc_banks): + modules, inverse = np.unique(hits.non_fadc_banks["module_id"], return_inverse=True) + sums = np.bincount(inverse, weights=hits.non_fadc_banks["n_words"].astype(np.float64), minlength=len(modules)) + for module, words in zip(modules.tolist(), sums.tolist()): + self.words_per_module[int(module)] = self.words_per_module.get(int(module), 0) + int(words) + + frame_rows = np.empty(len(hits.frames), dtype=FRAME_STATS_DTYPE) + for name in FRAME_STATS_DTYPE.names: + frame_rows[name] = hits.frames[name] + self.frame_rows.append(frame_rows) + for name in self.HITS_TOTALS: + self.totals[name] += getattr(hits, name) + self.totals["n_frames"] += hits.n_stream_frames + self.totals["frames_with_ais"] += int(np.count_nonzero(hits.frames["n_ais_entries"])) + self.totals["frames_with_degenerate_ais"] += int(np.count_nonzero(fs.ais_degenerate_mask(hits.frames))) + self.error_messages = (self.error_messages + hits.error_messages)[:MAX_ERROR_MESSAGES] + self.warning_messages = (self.warning_messages + hits.warning_messages)[:MAX_ERROR_MESSAGES] + + def merge(self, other: "Accumulator") -> None: + """Add another partial (built with the same time axis) into this one.""" + self._grow(other.rocs, other.ports) + roc_pos = np.searchsorted(self.rocs, other.rocs) + port_pos = np.searchsorted(self.ports, other.ports) + index = np.ix_(roc_pos, port_pos) + for name in self.ARRAYS_3D + self.ARRAYS_2D: + getattr(self, name)[index] += getattr(other, name) + known = other.module_id != fs.MODULE_UNKNOWN + self.module_id[index] = np.where(known, other.module_id, self.module_id[index]) + for module, words in other.words_per_module.items(): + self.words_per_module[module] = self.words_per_module.get(module, 0) + words + self.frame_rows.extend(other.frame_rows) + for name in self.totals: + self.totals[name] += other.totals[name] + self.error_messages = (self.error_messages + other.error_messages)[:MAX_ERROR_MESSAGES] + self.warning_messages = (self.warning_messages + other.warning_messages)[:MAX_ERROR_MESSAGES] + + +# ---------------------------------------------------------------- workers +_worker_file: Optional[EvioFile] = None + + +def start_worker_services() -> None: + """Start the forkserver and the multiprocessing resource tracker, with a real stderr if the current one has none. + + The resource tracker hands ``sys.stderr.fileno()`` to the process it spawns. A program that + captures stderr (Textual replaces it with an object whose ``fileno()`` is -1 while the app + runs) makes that spawn fail with ``ValueError: bad value(s) in fds_to_keep`` on the first + pool. Both services start once per process and stay up, so ``pyevio gui`` calls this + before the app starts; ``compute_stream_stats`` calls it too, pointing ``sys.stderr`` at the + process's original stderr for the few milliseconds the start takes when the current one is + unusable. Harmless when the services already run. + """ + from multiprocessing import forkserver, resource_tracker + + try: + usable = sys.stderr is not None and sys.stderr.fileno() >= 0 + except (AttributeError, OSError, ValueError, io.UnsupportedOperation): + usable = False + captured, original = sys.stderr, sys.__stderr__ + if not usable and original is not None: + sys.stderr = original + try: + resource_tracker.ensure_running() + forkserver.ensure_running() + finally: + if not usable and original is not None: + sys.stderr = captured + + +def _init_worker(path: str) -> None: + global _worker_file + _worker_file = EvioFile(path) + + +def _decode_records(evio_file: EvioFile, record_range, events_before_record: np.ndarray, t_first: int, t_last: int, + n_rate_bins: int, chunk_records: int, progress: Optional[ProgressCallback] = None, + cancel: Optional[CancelCheck] = None) -> Accumulator: + """Decode a range of records in chunks and accumulate histograms; trailers and out-of-bounds events are skipped. + + ``progress`` receives (records done in this range, records in this range) after each chunk; + ``cancel`` is polled before each chunk and raises :class:`StatsCancelled`. + """ + accumulator = Accumulator(t_first, t_last, n_rate_bins) + endian = evio_file.header.endian + record_list = list(record_range) + for chunk_start in range(0, len(record_list), chunk_records): + if cancel is not None and cancel(): + raise StatsCancelled(f"cancelled after {chunk_start} of {len(record_list)} records") + chunk = record_list[chunk_start:chunk_start + chunk_records] + events = [] + first_event_index = int(events_before_record[chunk[0]]) + for record_i in chunk: + record = evio_file.get_record(record_i) + if record_type(record) == RECORD_TYPE_TRAILER: + continue + # Event offsets point into record.buffer: the mmap for a plain record, the + # decompressed bytes for an LZ4/gzip record. Never read them from the mmap. + buffer = record.buffer + for offset, length in record.get_event_offsets()[:record.header.event_count]: + if offset + length <= len(buffer): + events.append(fs.event_words(buffer, offset, length, endian)) + record.release() # drop a decompressed buffer early; the views above keep it alive while needed + accumulator.absorb(fs.decode_frames(events, first_event_index=first_event_index)) + if progress is not None: + progress(chunk_start + len(chunk), len(record_list)) + return accumulator + + +def _process_range(args): + """Worker entry: decode one record range; returns the range too because results arrive unordered.""" + start, end, events_before_record, t_first, t_last, n_rate_bins, chunk_records = args + partial = _decode_records(_worker_file, range(start, end), events_before_record, t_first, t_last, n_rate_bins, chunk_records) + return start, end, partial + + +# ---------------------------------------------------------------- result +@dataclass +class StreamStats: + """Histograms and checks of one file. Arrays are indexed by position in ``rocs``/``ports`` and channel 0—15.""" + version: str + path: str + file_size: int + bytes_processed: int # end of the last record read; equals file_size unless a record limit was given + tick_ns: float + workers: int + wall_seconds: float + n_records: int + n_trailer_records: int + n_events: int + n_frames: int + n_other_events: int + n_out_of_bounds_events: int + n_hits: int + n_payload_banks: int + n_empty_payload_banks: int + n_unmapped_payload_banks: int + n_payload_words: int + non_fadc_words: int + n_flag_words: int + errors: int + error_messages: List[str] + warnings: int # malformed SIB segments after the TSS (no hit depends on them) + warning_messages: List[str] + frames_with_ais: int # frames whose SIB holds an aggregation info segment (0x42) + frames_with_degenerate_ais: int # of those, frames whose entries all hold one value + checks: FrameChecks + rocs: np.ndarray + ports: np.ndarray + module_id: np.ndarray # (roc, port); MODULE_UNKNOWN where a port never appeared + module_ids: np.ndarray # module ids with payload words, aligned with module_words + module_words: np.ndarray + frames: np.ndarray # FRAME_STATS_DTYPE, sorted by event index + rate_counts: np.ndarray # (roc, port, channel, rate bin) + rate_edges_ns: np.ndarray # rate bin edges in ns from the first timestamp (len rate bins + 1) + frames_per_rate_bin: np.ndarray + occupancy: np.ndarray # (roc, port, channel) + charge_hist: np.ndarray # (roc, port, channel, 128) + charge_edges: np.ndarray + time_hist: np.ndarray # (roc, port, channel, 64) + time_edges_ns: np.ndarray + hits_per_frame_hist: np.ndarray + hits_per_frame_edges: np.ndarray + empty_banks: np.ndarray # (roc, port) + non_fadc_words_per_port: np.ndarray # (roc, port) + + SCALAR_FIELDS = ("version", "path", "file_size", "bytes_processed", "tick_ns", "workers", "wall_seconds", "n_records", "n_trailer_records", + "n_events", "n_frames", "n_other_events", "n_out_of_bounds_events", "n_hits", "n_payload_banks", + "n_empty_payload_banks", "n_unmapped_payload_banks", "n_payload_words", "non_fadc_words", + "n_flag_words", "errors", "error_messages", "warnings", "warning_messages", + "frames_with_ais", "frames_with_degenerate_ais") + + @property + def frame_period_ns(self) -> float: + return self.checks.frame_period_ticks * self.tick_ns + + @property + def duration_s(self) -> float: + """Time span of the frames: first to last timestamp plus one frame period.""" + return ((self.checks.last_timestamp - self.checks.first_timestamp) * self.tick_ns + self.frame_period_ns) * 1e-9 + + @property + def live_time_s(self) -> float: + """Time the frames in this file record: frames × frame period. + + A file holding one stripe of a 4-way striped run records a quarter of ``duration_s``; + rates are hits per second of live time, as ``rate_hz`` computes them. + """ + return self.n_frames * self.frame_period_ns * 1e-9 + + def roc_index(self, roc: int) -> int: + position = int(np.searchsorted(self.rocs, roc)) + if position >= len(self.rocs) or self.rocs[position] != roc: + raise KeyError(f"ROC {roc} not in the file") + return position + + def rate_hz(self, roc: Optional[int] = None, port: Optional[int] = None, channel: Optional[int] = None) -> np.ndarray: + """Hit rate per time bin in Hz, normalized by the frames each bin covers (0 where a bin has no frame).""" + counts = self.rate_counts + if roc is not None: + counts = counts[self.roc_index(roc)] + if port is not None: + counts = counts[int(np.searchsorted(self.ports, port))] + if channel is not None: + counts = counts[channel] + summed = counts.reshape(-1, self.rate_counts.shape[-1]).sum(axis=0) + seconds = self.frames_per_rate_bin * self.frame_period_ns * 1e-9 + return np.divide(summed, seconds, out=np.zeros(len(summed)), where=seconds > 0) + + def hits_per_roc(self) -> dict: + return {int(roc): int(total) for roc, total in zip(self.rocs, self.occupancy.sum(axis=(1, 2)))} + + def per_roc(self, hist: np.ndarray) -> np.ndarray: + """Sum a (roc, port, channel, bins) histogram over ports and channels → (roc, bins).""" + return hist.sum(axis=(1, 2)) + + def save(self, path: str) -> None: + meta = {name: getattr(self, name) for name in self.SCALAR_FIELDS} + meta["checks"] = asdict(self.checks) + arrays = {f.name: getattr(self, f.name) for f in fields(self) if f.name not in self.SCALAR_FIELDS and f.name != "checks"} + np.savez_compressed(path, meta=json.dumps(meta), **arrays) + + @classmethod + def load(cls, path: str) -> "StreamStats": + with np.load(path, allow_pickle=False) as data: + meta = json.loads(str(data["meta"])) + if meta.get("version") != STATS_VERSION: + raise ValueError(f"{path}: stats version {meta.get('version')!r}, this pyevio writes {STATS_VERSION!r}") + checks = FrameChecks(**meta.pop("checks")) + arrays = {name: data[name] for name in data.files if name != "meta"} + return cls(checks=checks, **meta, **arrays) + + +def compute_stream_stats(path: str, workers: Optional[int] = None, rate_bins: int = DEFAULT_RATE_BINS, + tick_ns: float = DEFAULT_TICK_NS, max_records: Optional[int] = None, + chunk_records: int = DEFAULT_CHUNK_RECORDS, progress: Optional[ProgressCallback] = None, + cancel: Optional[CancelCheck] = None) -> StreamStats: + """Scan, decode and histogram a streaming file. ``workers=1`` runs in-process. + + ``progress(records_done, records_total)`` is called after the header scan (0 done) and after + every decoded chunk or task. ``cancel()`` is polled between chunks and tasks; True raises + :class:`StatsCancelled` and, with a worker pool, terminates the workers. + """ + t_start = time.perf_counter() + workers = default_workers() if workers is None else max(1, int(workers)) + with EvioFile(path) as evio_file: + file_size = evio_file.file_size + scan = scan_frames(evio_file, max_records) + checks = frame_checks(scan.frames) + n_records = scan.n_records + if progress is not None: + progress(0, n_records) + last_record = evio_file.get_record(n_records - 1) if n_records else None + bytes_processed = last_record.offset + last_record.size if last_record else 0 + if workers == 1: + partial = _decode_records(evio_file, range(n_records), scan.events_before_record, + checks.first_timestamp, checks.last_timestamp, rate_bins, chunk_records, + progress=progress, cancel=cancel) + if workers > 1: + n_tasks = min(n_records, workers * 4) or 1 + bounds = np.linspace(0, n_records, n_tasks + 1, dtype=np.int64) + tasks = [(int(bounds[i]), int(bounds[i + 1]), scan.events_before_record, checks.first_timestamp, + checks.last_timestamp, rate_bins, chunk_records) for i in range(n_tasks) if bounds[i] < bounds[i + 1]] + partial = Accumulator(checks.first_timestamp, checks.last_timestamp, rate_bins) + records_done = 0 + # forkserver: a forked copy of a multi-threaded parent (numpy, pytest) may deadlock; workers re-import pyevio instead. + context = multiprocessing.get_context("forkserver") + start_worker_services() + # Leaving the `with` block (also by StatsCancelled) terminates the workers. + with context.Pool(workers, initializer=_init_worker, initargs=(path,)) as pool: + for start, end, result in pool.imap_unordered(_process_range, tasks): + partial.merge(result) + records_done += end - start + if progress is not None: + progress(records_done, n_records) + if cancel is not None and cancel(): + raise StatsCancelled(f"cancelled after {records_done} of {n_records} records") + + frames = np.concatenate(partial.frame_rows) if partial.frame_rows else np.empty(0, dtype=FRAME_STATS_DTYPE) + frames = frames[np.argsort(frames["event_index"], kind="stable")] + span = max(checks.last_timestamp - checks.first_timestamp, 1) + rate_edges_ns = np.linspace(0, span, rate_bins + 1) * tick_ns + frames_per_rate_bin = np.bincount(partial._time_bin(frames["timestamp"]), minlength=rate_bins) if len(frames) else np.zeros(rate_bins, dtype=np.int64) + max_hits = int(frames["n_hits"].max()) if len(frames) else 0 + hits_per_frame_edges = np.linspace(0, max(max_hits, 1), N_HITS_PER_FRAME_BINS + 1) + hits_per_frame_hist, _ = np.histogram(frames["n_hits"], bins=hits_per_frame_edges) + module_ids = np.array(sorted(partial.words_per_module), dtype=np.uint8) + module_words = np.array([partial.words_per_module[m] for m in module_ids], dtype=np.int64) + + return StreamStats( + version=STATS_VERSION, path=os.path.abspath(path), file_size=file_size, bytes_processed=bytes_processed, + tick_ns=float(tick_ns), workers=workers, + wall_seconds=time.perf_counter() - t_start, + n_records=n_records, n_trailer_records=scan.n_trailer_records, n_events=scan.n_events, + n_frames=partial.totals["n_frames"], n_other_events=partial.totals["n_other_events"], + n_out_of_bounds_events=scan.n_out_of_bounds_events, n_hits=partial.totals["n_hits"], + n_payload_banks=partial.totals["n_payload_banks"], n_empty_payload_banks=partial.totals["n_empty_payload_banks"], + n_unmapped_payload_banks=partial.totals["n_unmapped_payload_banks"], n_payload_words=partial.totals["n_payload_words"], + non_fadc_words=partial.totals["non_fadc_words"], n_flag_words=partial.totals["n_flag_words"], + errors=partial.totals["errors"], error_messages=partial.error_messages, + warnings=partial.totals["warnings"], warning_messages=partial.warning_messages, + frames_with_ais=partial.totals["frames_with_ais"], frames_with_degenerate_ais=partial.totals["frames_with_degenerate_ais"], + checks=checks, + rocs=partial.rocs, ports=partial.ports, module_id=partial.module_id, module_ids=module_ids, module_words=module_words, + frames=frames, rate_counts=partial.rate, rate_edges_ns=rate_edges_ns, frames_per_rate_bin=frames_per_rate_bin, + occupancy=partial.charge.sum(axis=3), charge_hist=partial.charge, + charge_edges=np.arange(N_CHARGE_BINS + 1) << CHARGE_BIN_SHIFT, + time_hist=partial.time, time_edges_ns=(np.arange(N_TIME_BINS + 1) << TIME_BIN_SHIFT) * fs.HIT_TIME_TICK_NS, + hits_per_frame_hist=hits_per_frame_hist, hits_per_frame_edges=hits_per_frame_edges, + empty_banks=partial.empty_banks, non_fadc_words_per_port=partial.non_fadc_words_per_port, + ) diff --git a/pyevio/ui/__init__.py b/pyevio/ui/__init__.py deleted file mode 100644 index 19bad55..0000000 --- a/pyevio/ui/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""UI module for pyevio.""" -from .app import PyEvioApp - -__all__ = ["PyEvioApp"] \ No newline at end of file diff --git a/pyevio/ui/app.py b/pyevio/ui/app.py deleted file mode 100644 index eb46b67..0000000 --- a/pyevio/ui/app.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Main application class for the pyevio UI.""" -import os -from rich.text import Text -from textual.app import App -from textual.widgets import Header, Footer, Tree, RichLog -from textual.containers import Container, Horizontal -from textual.binding import Binding - -class PyEvioApp(App): - """The main pyevio UI application.""" - - BINDINGS = [ - Binding(key="f2", action="view", description="View"), - Binding(key="f10", action="exit", description="Exit"), - ] - - CSS = """ - #main-container { - height: 100%; - } - - #left-panel { - width: 30%; - min-width: 20; - border: solid $accent; - } - - #right-panel { - width: 70%; - min-width: 50; - background: $surface; - border: solid $accent; - overflow-y: auto; - } - - Tree { - padding: 1; - } - - RichLog { - padding: 1; - } - """ - - def __init__(self, file_path: str): - """Initialize the application with a file path.""" - super().__init__() - self.file_path = file_path - self.file_size = 0 # Will be set during setup - - def compose(self): - """Compose the UI layout.""" - # Calculate file size - try: - self.file_size = os.path.getsize(self.file_path) - # Create the title - title = f"{self.file_path} ({self.file_size/1024:.2f} KB)" - except OSError as e: - title = f"{self.file_path} (Error: {str(e)})" - - yield Header(title) - - with Horizontal(id="main-container"): - # Left panel with tree - tree = Tree("File Structure", id="file-tree") - tree.root.expand() - - # Yield the container first - left_panel = Container(tree, id="left-panel") - yield left_panel - - # Right panel with text (using RichLog for better scrolling) - right_log = RichLog(id="detail-view") - right_log.write("Select an item in the tree to view details.") - yield Container(right_log, id="right-panel") - - yield Footer() - - def on_mount(self): - """Called when the app is mounted.""" - self.build_tree() - - def build_tree(self): - """Build the initial file structure tree with sample data.""" - # Get the tree widget - tree = self.query_one("#file-tree", Tree) - - # Add the file header - file_node = tree.root.add("File Header") - file_node.add("Magic Number: EVIO") - file_node.add("Version: 6") - file_node.add("Endianness: Little Endian") - - # Add records - records_node = tree.root.add("Records") - # Add sample data for now - for i in range(5): - record_node = records_node.add(f"Record #{i}") - record_node.add(f"Length: {100 + i*10} words") - record_node.add(f"Events: {i+1}") - - # Add sample events - events_node = record_node.add("Events") - for j in range(i+1): - event_node = events_node.add(f"Event #{j}") - event_node.add(f"Length: {50 + j*5} bytes") - event_node.add(f"Type: Physics") - - def on_tree_node_highlighted(self, event): - """Handle tree node highlighting.""" - # Update the right panel with information about the highlighted node - node = event.node - - # Get the text view widget - text_view = self.query_one("#detail-view", RichLog) - - # Clear previous content - text_view.clear() - - if "File Header" in node.label: - text_view.write("# File Header Information\n") - text_view.write("\nMagic Number: EVIO (0x4556494F)") - text_view.write("\nVersion: 6") - text_view.write("\nEndianness: Little") - text_view.write("\nRecord Count: 5") - text_view.write("\nIndex Array Length: 0 bytes") - text_view.write("\nUser Header Length: 0 bytes") - text_view.write("\nTrailer Position: 0x0") - - elif "Record #" in node.label: - record_num = node.label.split("#")[1] - text_view.write(f"# Record {record_num} Information\n") - text_view.write(f"\nOffset: 0x{1000 + int(record_num) * 100:X}") - text_view.write(f"\nLength: {100 + int(record_num) * 10} words") - text_view.write(f"\nEvents: {int(record_num) + 1}") - text_view.write(f"\nType: Physics") - text_view.write(f"\nCompression: None") - - elif node.label.plain.startswith("Event #"): - event_num = node.label.split("#")[1] - text_view.write(f"# Event {event_num} Information\n") - text_view.write(f"\nOffset: 0x{2000 + int(event_num) * 50:X}") - text_view.write(f"\nLength: {50 + int(event_num) * 5} bytes") - text_view.write(f"\nType: Physics") - text_view.write(f"\nBank Tag: 0xFF60") - text_view.write(f"\nBank Type: 0x10") - - elif "Magic Number" in node.label: - text_view.write("# Magic Number\n") - text_view.write("\nThe magic number identifies this file as an EVIO file.") - text_view.write("\nValue: 0x4556494F (ASCII: 'EVIO')") - text_view.write("\nLocation: First word of file header") - - elif "Version" in node.label: - text_view.write("# Version Information\n") - text_view.write("\nThis file uses EVIO format version 6.") - text_view.write("\nVersion 6 supports data compression and enhanced metadata.") - - elif "Endianness" in node.label: - text_view.write("# Endianness\n") - text_view.write("\nThis file uses little endian byte ordering.") - text_view.write("\nThis means the least significant byte is stored first.") - - elif "Length" in node.label: - text_view.write("# Length Information\n") - length_val = node.label.split(": ")[1].split(" ")[0] - text_view.write(f"\nThe length is {length_val} words/bytes.") - text_view.write("\nFor records, length is measured in 32-bit words.") - text_view.write("\nFor events, length is typically measured in bytes.") - - elif "Events" in node.label and ": " in node.label: - text_view.write("# Event Count\n") - count = node.label.split(": ")[1] - text_view.write(f"\nThis record contains {count} events.") - text_view.write("\nEach event contains a bank structure with data.") - - elif node.label == "Events": - text_view.write("# Events\n") - text_view.write("\nEvents contain the actual physics data.") - text_view.write("\nEach event has a hierarchical bank structure.") - text_view.write("\nSelect an event to see more details.") - - else: - text_view.write(f"# {node.label}\n") - text_view.write("\nNo detailed information available.") - - def action_view(self): - """Handle the view action (F2).""" - self.notify("Detail view mode - Press F10 to exit") - - def action_exit(self): - """Handle the exit action (F10).""" - self.exit() \ No newline at end of file diff --git a/pyevio/web/__init__.py b/pyevio/web/__init__.py new file mode 100644 index 0000000..d47004a --- /dev/null +++ b/pyevio/web/__init__.py @@ -0,0 +1,46 @@ +"""Web server for the EVIO viewer: the browser renders what pyevio computes. + +``pyevio web ROOT`` serves the viewer page and a JSON API on 127.0.0.1. The API +lists files under ROOT, runs :func:`pyevio.stream_stats.compute_stream_stats` +as a background job per opened file, caches the result as ``.npz`` and serves +the file structure, record headers, raw words and histograms on demand. The +browser never receives the file. + +Modules, one job each: + +- ``app``: the FastAPI factory, logging and ``serve()``. +- ``auth``: Host check, token check and path confinement to ROOT. +- ``jobs``: opened files, the analysis job thread and its progress. +- ``cache``: ``.npz`` result cache keyed by path, size and mtime; an entry of another stats version is recomputed in place. +- ``structure``: tree nodes, record pages and raw words from pyevio core. +- ``report``: summary and histogram JSON from ``StreamStats``. +- ``routes``: the ``/api`` endpoints. + +FastAPI and uvicorn are the optional extra ``pyevio[web]``; this package +imports them only inside ``app`` and ``routes`` so the core library stays free +of web dependencies. +""" +from pathlib import Path + + +class ApiError(Exception): + """A request the API rejects; ``status`` is the HTTP status, ``message`` the JSON ``error`` text.""" + + def __init__(self, status: int, message: str): + super().__init__(message) + self.status = status + self.message = message + + +EVIO_NAME_MARKERS = (".evio", ".dat", ".ev") + + +def is_evio_name(name: str) -> bool: + """True for file names the folder browsers list by default (a marker anywhere in the name: ``run.evio.00000.v6`` counts).""" + lower = name.lower() + return any(marker in lower for marker in EVIO_NAME_MARKERS) + + +WEB_DIR = Path(__file__).resolve().parent +INDEX_HTML = WEB_DIR / "index.html" +INSTALL_HINT = "The web viewer needs FastAPI and uvicorn: pip install 'pyevio[web]'" diff --git a/pyevio/web/app.js b/pyevio/web/app.js new file mode 100644 index 0000000..7857822 --- /dev/null +++ b/pyevio/web/app.js @@ -0,0 +1,616 @@ +// app.js — state, API client, tab routing and the four tab renderers of the pyevio viewer. +// The browser is a thin client: pyevio reads and decodes the file on the server, this page renders the JSON. +// One file is active at a time; responses that arrive for an earlier file are dropped (see fileApi). + +import { emptyChart, formatNumber, heatmap, heatmapScale, stepChart } from './charts.js'; +import { createTree, renderDetail, renderRecordsTable } from './tree.js'; + +const POLL_INTERVAL_MS = 500; +const RECORDS_PAGE = 200; +const TABS = ['overview', 'plots', 'records', 'structure']; + +const $ = id => document.getElementById(id); + +// ---------------------------------------------------------------- API client +// The token comes from the URL `pyevio web` printed (?token=…) and goes out as the X-Token header on every +// /api call: static files need none, and API URLs stay free of secrets in logs and bookmarks. +const TOKEN = new URLSearchParams(location.search).get('token') || ''; +// `pyevio web path/to/file` adds ?open=; the page opens it on load. +const START_FILE = new URLSearchParams(location.search).get('open') || ''; + +class ApiError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +/** Thrown when a response belongs to a file that is no longer the active one; callers drop it silently. */ +class StaleResponse extends Error {} + +async function apiFetch(path, options = {}) { + let response; + try { + response = await fetch(path, { ...options, headers: { 'X-Token': TOKEN, 'Content-Type': 'application/json' } }); + } catch (error) { + throw new ApiError(0, `cannot reach the server (${error.message}); is \`pyevio web\` still running?`); + } + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new ApiError(response.status, body.error || `${response.status} ${response.statusText}`); + return body; +} + +/** + * GET an endpoint of one file. The result is checked against the active file after the response arrives: + * two opens in flight must not let a slow earlier file overwrite the newer one. + * A 409 means the analysis is not done; polling resumes and the caller's render is abandoned. + */ +async function fileApi(fileId, path) { + try { + const data = await apiFetch(`/api/files/${fileId}${path}`); + if (fileId !== state.fileId) throw new StaleResponse(); + return data; + } catch (error) { + if (error instanceof ApiError && error.status === 409 && fileId === state.fileId) { + startPolling(fileId); + throw new StaleResponse(); + } + throw error; + } +} + +/** Run a renderer; stale responses are dropped, API errors are shown in the status strip. */ +async function guarded(action) { + try { + await action(); + } catch (error) { + if (error instanceof StaleResponse) return; + showError(error.message); + if (!(error instanceof ApiError)) console.error(error); + } +} + +// ---------------------------------------------------------------- state +const state = freshState(); + +function freshState(fileId = null, path = '') { + return { + fileId, path, summary: null, pollTimer: null, hists: new Map(), rendered: new Set(), + recordsStart: 0, tree: null, treeLoaded: false, roc: null, port: null, channel: 0, channels: 0, + }; +} + +function resetState(fileId, path) { + stopPolling(); + Object.assign(state, freshState(fileId, path)); +} + +// ---------------------------------------------------------------- status strip +function showError(message) { + $('stErr').textContent = message; + $('stErr').hidden = !message; +} + +function showMessage(message) { + $('stMsg').textContent = message; +} + +function showProgress(progress) { + const bar = $('prog'); + bar.hidden = false; + bar.firstElementChild.style.width = `${Math.round((progress.fraction || 0) * 100)}%`; + const done = formatNumber(progress.records_done); + const total = progress.records_total ? ` / ${formatNumber(progress.records_total)}` : ''; + const eta = progress.eta_s === null || progress.eta_s === undefined ? '' : `, about ${formatSeconds(progress.eta_s)} left`; + showMessage(`Analyzing — ${done}${total} records${eta} (${formatSeconds(progress.elapsed_s)} elapsed)`); +} + +function formatSeconds(seconds) { + if (seconds < 60) return `${Math.round(seconds)} s`; + return `${Math.floor(seconds / 60)} min ${Math.round(seconds % 60)} s`; +} + +// ---------------------------------------------------------------- opening a file and polling its analysis +let openSerial = 0; // counts clicks on files; the latest click owns the page, whichever /open answers last + +async function openFile(path) { + const serial = ++openSerial; + showError(''); + setTabsEnabled(false); + hideViews(); + $('stFile').textContent = path; + showMessage('Opening…'); + try { + const opened = await apiFetch('/api/open', { method: 'POST', body: JSON.stringify({ path }) }); + if (serial !== openSerial) return; // a later click already took over; this answer is for an older file + resetState(opened.file_id, path); + showBrowser(false); + handleProgress(opened); + } catch (error) { + if (serial !== openSerial) return; + $('prog').hidden = true; + showMessage('Open failed.'); + showError(error.message); + } +} + +/** Route one /progress (or /open) answer: keep polling, finish, or report the failure. */ +function handleProgress(progress) { + if (progress.state === 'queued' || progress.state === 'running') { + showProgress(progress); + startPolling(state.fileId); + return; + } + stopPolling(); + $('prog').hidden = true; + if (progress.state === 'done') { + showMessage(progress.cached ? 'Analysis loaded from the cache.' : `Analysis done in ${formatSeconds(progress.elapsed_s)}.`); + guarded(onAnalysisDone); + } else if (progress.state === 'cancelled') { + showMessage('Analysis cancelled: another file was opened.'); + } else { + showMessage('Analysis failed.'); + showError(progress.error || `analysis ended in state ${progress.state}`); + } +} + +// /summary and /hist answer 409 until the job is done, so the page polls /progress and renders after "done". +function startPolling(fileId) { + if (state.pollTimer || fileId !== state.fileId) return; + state.pollTimer = setTimeout(() => guarded(async () => { + state.pollTimer = null; + const progress = await fileApi(fileId, '/progress'); + handleProgress(progress); + }), POLL_INTERVAL_MS); +} + +function stopPolling() { + clearTimeout(state.pollTimer); + state.pollTimer = null; +} + +async function onAnalysisDone() { + const fileId = state.fileId; + state.summary = await fileApi(fileId, '/summary'); + document.body.dataset.fileId = fileId; // marks "tabs ready for this file" for scripts and tests + setTabsEnabled(true); + showTab(currentTab()); +} + +// ---------------------------------------------------------------- tabs +function currentTab() { + const name = location.hash.replace('#', ''); + return TABS.includes(name) ? name : 'overview'; +} + +function setTabsEnabled(enabled) { + document.querySelectorAll('.tab').forEach(button => { button.disabled = !enabled; }); +} + +/** Hide the tab views and drop the previous file's content, so nothing stale shows while the next file loads. */ +function hideViews() { + TABS.forEach(name => { $(`v-${name}`).hidden = true; }); + ['tiles', 'checks', 'standout', 'fileFacts', 'recordFacts', 'rocTable', 'recordsTable', 'tree', 'detail', 'recPageInfo', 'occupancyScale'] + .forEach(id => $(id).replaceChildren()); + document.querySelectorAll('svg.chart').forEach(svg => svg.replaceChildren()); + delete document.body.dataset.fileId; +} + +function showTab(name) { + if (!state.summary) return; + document.querySelectorAll('.tab').forEach(button => button.setAttribute('aria-selected', button.dataset.tab === name)); + TABS.forEach(tab => { $(`v-${tab}`).hidden = tab !== name; }); + if (location.hash !== `#${name}`) history.replaceState(null, '', `#${name}`); + if (state.rendered.has(name)) return; + state.rendered.add(name); + guarded(() => RENDERERS[name]()); +} + +// ---------------------------------------------------------------- Overview +const TILE_FIELDS = [ + ['n_records', 'records'], ['n_events', 'events'], ['n_frames', 'stream frames'], ['n_hits', 'hits'], + ['hit_rate_hz', 'hit rate (Hz)'], ['hits_per_frame', 'hits per frame'], ['duration_s', 'duration (s)'], ['errors', 'decode errors'], +]; + +/** Ratios arrive formatted by the server: "n/a" when a denominator is zero, printed as given. */ +function formatValue(value) { + if (value === null || value === undefined) return '—'; + if (typeof value === 'boolean') return value ? 'yes' : 'no'; + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.length ? value.join(', ') : 'none'; + return formatNumber(value); +} + +function renderOverview() { + const summary = state.summary; + renderTiles(summary.totals); + renderChecks(summary.checks); + renderStandout(summary); + renderFacts($('fileFacts'), fileFacts(summary)); + renderFacts($('recordFacts'), recordFacts(summary)); + renderRocTable(summary.rocs); +} + +function renderTiles(totals) { + const tiles = $('tiles'); + tiles.replaceChildren(); + TILE_FIELDS.forEach(([key, label]) => { + const tile = document.createElement('div'); + tile.className = 'tile'; + tile.dataset.key = key; + tile.dataset.value = JSON.stringify(totals[key]); + tile.innerHTML = `
`; + tile.firstElementChild.textContent = formatValue(totals[key]); + tile.lastElementChild.textContent = label; + tiles.appendChild(tile); + }); +} + +function renderChecks(checks) { + const container = $('checks'); + container.replaceChildren(); + checks.forEach(check => { + const row = document.createElement('div'); + row.className = 'ck'; + row.dataset.status = check.status; + const icon = document.createElement('span'); + icon.className = `ico ${check.status}`; + icon.textContent = { ok: '✓', info: 'i', warn: '!', error: '✕' }[check.status] || '?'; + const body = document.createElement('div'); + body.innerHTML = ` `; + body.querySelector('b').textContent = check.name; + body.querySelector('.val').textContent = formatValue(check.value); + body.querySelector('.d').textContent = check.detail ? `— ${check.detail}` : ''; + row.append(icon, body); + container.appendChild(row); + }); +} + +/** Errors first, then warnings, then the first decode messages; "all checks pass" when there is nothing. */ +function renderStandout(summary) { + const list = $('standout'); + list.replaceChildren(); + const notable = [...summary.checks.filter(check => check.status === 'error'), ...summary.checks.filter(check => check.status === 'warn')]; + notable.forEach(check => { + const item = document.createElement('li'); + item.innerHTML = `: `; + item.firstElementChild.textContent = check.name; + item.lastElementChild.textContent = `${formatValue(check.value)}${check.detail ? ` — ${check.detail}` : ''}`; + list.appendChild(item); + }); + summary.totals.error_messages.slice(0, 3).forEach(message => { + const item = document.createElement('li'); + item.textContent = message; + list.appendChild(item); + }); + if (!list.children.length) { + const item = document.createElement('li'); + item.textContent = summary.totals.n_frames ? 'All checks pass.' : 'No stream frames (0xFF60) in this file; the plots are empty. All file checks pass.'; + list.appendChild(item); + } +} + +function fileFacts(summary) { + const file = summary.file; + const stats = summary.stats; + return [ + ['path', file.path], ['size', `${formatNumber(file.size)} B (${(file.size / 1e6).toFixed(1)} MB)`], + ['header layout', file.header_layout], ['byte order', file.endian === '<' ? 'little-endian' : 'big-endian'], + ['file type, version', `${file.file_type}, ${file.version}`], ['records (scanned)', file.record_count], + ['records (file header)', file.header_record_count], ['trailer', file.has_trailer], ['trailer position', file.trailer_position], + ['first record offset', file.first_record_offset], ['index array length', file.index_array_length], + ['user header length', file.user_header_length], + ['analysis', `${stats.cached ? 'from cache' : `${stats.wall_seconds.toFixed(1)} s on ${stats.workers} workers`}, stats version ${stats.version}`], + ]; +} + +function recordFacts(summary) { + const records = summary.records; + const frames = summary.frames; + const truncated = summary.scan_checks.find(check => check[0] === 'truncated_tail'); + return [ + ['events declared / found', `${formatNumber(records.events)} / ${formatNumber(records.events_found)}`], + ['records with errors', records.with_errors], ['compressed records', records.compressed], + ['records without index', records.without_index], ['truncated records', records.truncated], + ['salvaged events (truncated tail)', truncated ? truncated[3] : 'none'], + ['last-record bit set', records.last_record_bit], + ['record size min / mean / max', `${formatNumber(records.min_size)} / ${formatValue(records.mean_size)} / ${formatNumber(records.max_size)} B`], + ['stream frames', frames.n_frames], ['first / last frame', `${frames.first_frame} / ${frames.last_frame}`], + ['frame stride', frames.stride], ['frame period', `${formatNumber(frames.frame_period_ns)} ns`], + ['missing / duplicate / out-of-order', `${frames.missing_frames} / ${frames.duplicate_frames} / ${frames.out_of_order}`], + ['timestamp-step outliers', frames.dts_outliers], + ['duration / live time', `${formatValue(summary.totals.duration_s)} s / ${formatValue(summary.totals.live_time_s)} s`], + ]; +} + +function renderFacts(table, facts) { + table.replaceChildren(); + facts.forEach(([name, value]) => { + const row = table.insertRow(); + row.insertCell().textContent = name; + row.insertCell().textContent = formatValue(value); + }); +} + +function renderRocTable(rocs) { + const table = $('rocTable'); + table.replaceChildren(); + const head = table.createTHead().insertRow(); + ['ROC', 'modules', 'ports', 'hits', 'non-fADC words', 'empty banks'].forEach((title, column_i) => { + const cell = document.createElement('th'); + cell.textContent = title; + if (column_i < 3) cell.className = 'l'; + head.appendChild(cell); + }); + const body = table.createTBody(); + rocs.forEach(roc => { + const row = body.insertRow(); + [roc.roc, roc.modules.join(', '), roc.ports.join(', '), formatNumber(roc.hits), formatNumber(roc.non_fadc_words), formatNumber(roc.empty_banks)] + .forEach((value, column_i) => { + const cell = row.insertCell(); + cell.textContent = value; + if (column_i < 3) cell.className = 'l'; + }); + }); + if (!rocs.length) body.insertRow().insertCell().textContent = 'no ROC banks'; +} + +// ---------------------------------------------------------------- Plots +const NS_PER_S = 1e-9; +const NS_PER_US = 1e-3; + +/** One histogram, cached per file and selection. */ +async function getHist(name, selection = {}) { + const query = Object.entries(selection).filter(([, value]) => value !== null && value !== undefined) + .map(([key, value]) => `${key}=${value}`).join('&'); + const key = `${name}?${query}`; + if (!state.hists.has(key)) state.hists.set(key, fileApi(state.fileId, `/hist/${name}${query ? `?${query}` : ''}`)); + return state.hists.get(key); +} + +async function renderPlots() { + const [hitsPerFrame, rate, occupancy] = await Promise.all([getHist('hits_per_frame'), getHist('rate'), getHist('occupancy')]); + drawHitsPerFrame(hitsPerFrame); + drawRate($('chartRate'), rate, $('rateSub')); + drawOccupancy(occupancy); + state.channels = occupancy.channels; // channels per port, as the server's histogram axis has it + populateRocSelect(); +} + +function drawHitsPerFrame(hist) { + const frames = hist.counts.reduce((sum, count) => sum + count, 0); + $('hpfSub').textContent = frames ? `${formatNumber(frames)} frames; last bin collects frames at or above its left edge` : ''; + if (!frames) return emptyChart($('chartHitsPerFrame'), 'no stream frames'); + stepChart($('chartHitsPerFrame'), { edges: hist.edges, values: hist.counts, xLabel: hist.x_unit, yLabel: hist.y_unit }); +} + +/** Rate over time: the server's bin edges in ns from the first frame, drawn in seconds (axis title names the unit). */ +function drawRate(svg, hist, subtitle) { + const edgesS = hist.edges_ns.map(edge => edge * NS_PER_S); + if (subtitle) { + const binSeconds = edgesS.length > 1 ? edgesS[1] - edgesS[0] : 0; + subtitle.textContent = hist.counts.length ? `${hist.counts.length} bins of ${formatNumber(binSeconds)} s; empty bins are frames with no hits or missing frames` : ''; + } + if (!hist.counts.length || !state.summary.totals.n_frames) return emptyChart(svg, 'no stream frames'); + if (!hist.counts.some(count => count > 0)) return emptyChart(svg, 'no hits'); + stepChart(svg, { edges: edgesS, values: hist.rate_hz, xLabel: 'time since the first frame (s)', yLabel: 'hit rate (Hz)' }); +} + +function drawCharge(svg, hist) { + if (!hist.counts.some(count => count > 0)) return emptyChart(svg, 'no hits'); + stepChart(svg, { edges: hist.edges, values: hist.counts, xLabel: hist.x_unit, yLabel: hist.y_unit, logY: true }); +} + +function drawTimeInFrame(svg, hist) { + if (!hist.counts.some(count => count > 0)) return emptyChart(svg, 'no hits'); + stepChart(svg, { edges: hist.edges_ns.map(edge => edge * NS_PER_US), values: hist.counts, xLabel: 'time in frame (µs)', yLabel: hist.y_unit }); +} + +/** ROC × port heatmap of the whole file: the per-channel counts of each port summed. */ +function drawOccupancy(hist) { + const svg = $('chartOccupancy'); + if (!hist.rocs.length) { + heatmapScale($('occupancyScale'), 0); + return emptyChart(svg, 'no stream frames'); + } + const values = hist.counts.map(ports => ports.map(channels => channels.reduce((sum, count) => sum + count, 0))); + const maximum = heatmap(svg, { + rows: hist.rocs.map(roc => `ROC ${roc}`), cols: hist.ports, values, rowTitle: 'ROC', colTitle: 'payload port', valueLabel: 'hits', + isDead: (roc_i, port_i) => hist.module_id[roc_i][port_i] === 255, + onCell: (roc_i, port_i) => selectRoc(hist.rocs[roc_i], hist.ports[port_i]), + }); + heatmapScale($('occupancyScale'), maximum, 'hits'); +} + +function populateRocSelect() { + const rocs = state.summary.rocs; + const select = $('selRoc'); + select.replaceChildren(...rocs.map(roc => new Option(`${roc.roc} (${roc.modules.join(', ') || 'no modules'})`, roc.roc))); + $('rocPanel').hidden = $('channelPanel').hidden = !rocs.length; + if (rocs.length) selectRoc(rocs[0].roc, rocs[0].ports[0]); +} + +function rocRow(roc) { + return state.summary.rocs.find(row => row.roc === roc); +} + +/** Select a ROC (and a port): redraws the per-ROC charts and the port/channel selectors. */ +function selectRoc(roc, port = null) { + const row = rocRow(roc); + if (!row) return; + state.roc = roc; + $('selRoc').value = String(roc); + $('rocInfo').textContent = `${formatNumber(row.hits)} hits, ports ${row.ports.join(', ') || 'none'}, ${row.empty_banks} empty banks, ${formatNumber(row.non_fadc_words)} non-fADC words`; + $('selPort').replaceChildren(...row.ports.map(value => new Option(String(value), value))); + $('selCh').replaceChildren(...Array.from({ length: state.channels }, (_, channel) => new Option(String(channel), channel))); + guarded(renderRocCharts); + selectChannel(port !== null && row.ports.includes(port) ? port : row.ports[0], 0); +} + +async function renderRocCharts() { + const roc = state.roc; + const [occupancy, rate, charge, time] = await Promise.all([ + getHist('occupancy', { roc }), getHist('rate', { roc }), getHist('charge', { roc }), getHist('time_in_frame', { roc })]); + if (roc !== state.roc) return; + const rocIndex = occupancy.rocs.indexOf(roc); + heatmap($('chartRocMap'), { + rows: occupancy.ports.map(port => `port ${port}`), cols: Array.from({ length: occupancy.channels }, (_, channel) => channel), + values: occupancy.counts, rowTitle: 'payload port', colTitle: 'channel', valueLabel: 'hits', + isDead: port_i => occupancy.module_id[rocIndex][port_i] === 255, + onCell: (port_i, channel) => selectChannel(occupancy.ports[port_i], channel), + }); + drawRate($('chartRocRate'), rate, $('rocRateSub')); + drawCharge($('chartRocCharge'), charge); + drawTimeInFrame($('chartRocTime'), time); +} + +function selectChannel(port, channel) { + if (port === undefined) return; + state.port = port; + state.channel = channel; + $('selPort').value = String(port); + $('selCh').value = String(channel); + $('chInfo').textContent = `ROC ${state.roc}, port ${port}, channel ${channel}`; + guarded(renderChannelCharts); +} + +async function renderChannelCharts() { + const selection = { roc: state.roc, port: state.port, ch: state.channel }; + const [rate, charge, time] = await Promise.all([getHist('rate', selection), getHist('charge', selection), getHist('time_in_frame', selection)]); + if (selection.roc !== state.roc || selection.port !== state.port || selection.ch !== state.channel) return; + drawRate($('chartChRate'), rate, null); + drawCharge($('chartChCharge'), charge); + drawTimeInFrame($('chartChTime'), time); +} + +/** Step through the ROC's channels: port by port, `state.channels` channels each. */ +function stepChannel(direction) { + const ports = rocRow(state.roc).ports; + const perPort = state.channels; + const flat = ports.indexOf(state.port) * perPort + state.channel + direction; + if (flat < 0 || flat >= ports.length * perPort) return; + selectChannel(ports[Math.floor(flat / perPort)], flat % perPort); +} + +// ---------------------------------------------------------------- Records +async function renderRecords() { + await loadRecordsPage(0); +} + +async function loadRecordsPage(start) { + const page = await fileApi(state.fileId, `/records?from=${start}&count=${RECORDS_PAGE}`); + state.recordsStart = page.start; + renderRecordsTable($('recordsTable'), page.rows, openRecordInStructure); + const last = page.start + page.rows.length; + $('recPageInfo').textContent = page.total ? `records ${formatNumber(page.start)} – ${formatNumber(last - 1)} of ${formatNumber(page.total)}` : 'no records'; + $('recPrev').disabled = page.start === 0; + $('recNext').disabled = last >= page.total; + $('recGo').max = Math.max(0, page.total - 1); +} + +// ---------------------------------------------------------------- Structure +async function renderStructure() { + await ensureTree(); +} + +async function ensureTree() { + if (state.treeLoaded) return; + const fileId = state.fileId; + const root = await fileApi(fileId, '/structure'); + state.tree = createTree($('tree'), { + loadChildren: (nodeId, start) => fileApi(fileId, `/structure?node=${encodeURIComponent(nodeId)}&from=${start}`).catch(dropStale), + onSelect: node => guarded(() => renderDetail($('detail'), node, loadWords(fileId))), + }); + state.tree.reset(root); + state.treeLoaded = true; +} + +const dropStale = error => { if (error instanceof StaleResponse) return null; throw error; }; + +/** /words reader bound to one file; a record index selects that record's buffer (the only view of LZ4/gzip events). */ +function loadWords(fileId) { + return ({ offset, count, record }) => { + const query = `offset=${offset}&count=${count}${record === null ? '' : `&record=${record}`}`; + return fileApi(fileId, `/words?${query}`).catch(dropStale); + }; +} + +async function openRecordInStructure(row) { + state.rendered.add('structure'); + showTab('structure'); + await guarded(async () => { + await ensureTree(); + await state.tree.openRecord(row.id); + }); +} + +const RENDERERS = { overview: renderOverview, plots: renderPlots, records: renderRecords, structure: renderStructure }; + +// ---------------------------------------------------------------- folder browser +let browserDir = ''; +let browserParent = null; + +function showBrowser(visible) { + $('browser').hidden = !visible; + $('browseBtn').setAttribute('aria-expanded', String(visible)); +} + +async function browse(dir) { + const listing = await apiFetch(`/api/ls?dir=${encodeURIComponent(dir)}&all=${$('brAll').checked}`); + browserDir = listing.dir; + browserParent = listing.parent; + $('brPath').textContent = `/${listing.dir}`; + $('brUp').disabled = listing.parent === null; + const list = $('brList'); + list.replaceChildren(); + listing.dirs.forEach(name => list.appendChild(browserItem('dir', name, '', () => guarded(() => browse(joinPath(listing.dir, name)))))); + listing.files.forEach(file => list.appendChild(browserItem('file', file.name, `${(file.size / 1e6).toFixed(1)} MB`, + () => openFile(joinPath(listing.dir, file.name))))); + if (!list.children.length) { + const empty = document.createElement('div'); + empty.className = 'bempty'; + empty.textContent = 'No folders or EVIO files here. Tick "all files" to list every file.'; + list.appendChild(empty); + } +} + +function joinPath(dir, name) { + return dir ? `${dir}/${name}` : name; +} + +function browserItem(kind, name, size, onClick) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `bitem ${kind}`; + button.innerHTML = ``; + button.children[0].textContent = kind; + button.children[1].textContent = name; + button.children[2].textContent = size; + button.addEventListener('click', onClick); + return button; +} + +// ---------------------------------------------------------------- wiring +function init() { + document.querySelectorAll('.tab').forEach(button => button.addEventListener('click', () => showTab(button.dataset.tab))); + addEventListener('hashchange', () => showTab(currentTab())); + $('browseBtn').addEventListener('click', () => showBrowser($('browser').hidden)); + $('brUp').addEventListener('click', () => guarded(() => browse(browserParent))); + $('brAll').addEventListener('change', () => guarded(() => browse(browserDir))); + $('selRoc').addEventListener('change', event => selectRoc(Number(event.target.value))); + $('selPort').addEventListener('change', event => selectChannel(Number(event.target.value), state.channel)); + $('selCh').addEventListener('change', event => selectChannel(state.port, Number(event.target.value))); + $('chPrev').addEventListener('click', () => stepChannel(-1)); + $('chNext').addEventListener('click', () => stepChannel(1)); + $('recPrev').addEventListener('click', () => guarded(() => loadRecordsPage(Math.max(0, state.recordsStart - RECORDS_PAGE)))); + $('recNext').addEventListener('click', () => guarded(() => loadRecordsPage(state.recordsStart + RECORDS_PAGE))); + $('recGo').addEventListener('change', event => guarded(() => loadRecordsPage(Math.max(0, Number(event.target.value) || 0)))); + if (!TOKEN) { + showError('No token in the page URL. Open the URL that `pyevio web` printed (it ends in ?token=…).'); + return; + } + guarded(() => browse('')); + if (START_FILE) guarded(() => openFile(START_FILE)); +} + +init(); diff --git a/pyevio/web/app.py b/pyevio/web/app.py new file mode 100644 index 0000000..f90aa41 --- /dev/null +++ b/pyevio/web/app.py @@ -0,0 +1,123 @@ +"""FastAPI application factory and ``serve()``. + +``create_app`` takes everything a test needs to inject: the served root, the +token, the cache directory and the worker count for the statistics pool. The +uvicorn process count is always 1 because the job registry lives in memory. +""" +import logging +import webbrowser +from pathlib import Path +from typing import Optional, Tuple +from urllib.parse import quote + +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError +from fastapi.responses import FileResponse, JSONResponse + +from pyevio.web import INDEX_HTML, WEB_DIR, ApiError +from pyevio.web.auth import TOKEN_HEADER, TOKEN_QUERY_PARAM, check_request, new_token, resolve_under_root +from pyevio.web.cache import StatsCache +from pyevio.web.jobs import FileRegistry +from pyevio.web.routes import router + +log = logging.getLogger("pyevio.web") + +STATIC_SUFFIXES = (".html", ".js", ".css", ".svg", ".png", ".ico") +START_FILE_QUERY_PARAM = "open" # the page opens this file (relative to the served root) on load; app.js reads it + + +def create_app(root: Path, token: str, cache_dir: Optional[Path] = None, stats_workers: Optional[int] = None) -> FastAPI: + root = Path(root).expanduser().resolve() + if not root.is_dir(): + raise ValueError(f"served root is not a folder: {root}") + app = FastAPI(title="pyevio web", docs_url=None, redoc_url=None, openapi_url=None) + app.state.root = root + app.state.token = token + app.state.registry = FileRegistry(root, StatsCache(cache_dir), stats_workers) + + @app.middleware("http") + async def guard(request: Request, call_next): + token_given = request.query_params.get(TOKEN_QUERY_PARAM) or request.headers.get(TOKEN_HEADER) + try: + check_request(request.headers.get("host"), request.url.path, token_given, token) + except ApiError as error: + log.warning("%d %s %s: %s", error.status, request.method, request.url.path, error.message) + return JSONResponse({"error": error.message}, status_code=error.status) + return await call_next(request) + + @app.exception_handler(ApiError) + async def api_error(request: Request, error: ApiError): + log.warning("%d %s %s: %s", error.status, request.method, request.url.path, error.message) + return JSONResponse({"error": error.message}, status_code=error.status) + + @app.exception_handler(RequestValidationError) + async def bad_parameters(request: Request, error: RequestValidationError): + # One error shape for every failure: {"error": "..."}; FastAPI's default is {"detail": [...]}. + message = "; ".join(f"{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" for item in error.errors()) + log.warning("422 %s %s: %s", request.method, request.url.path, message) + return JSONResponse({"error": message}, status_code=422) + + @app.exception_handler(ValueError) + async def not_readable(request: Request, error: ValueError): + # EvioFile raises ValueError for files it does not read (wrong version, no magic word). + log.warning("400 %s %s: %s", request.method, request.url.path, error) + return JSONResponse({"error": str(error)}, status_code=400) + + @app.exception_handler(OSError) + async def not_accessible(request: Request, error: OSError): + log.warning("404 %s %s: %s", request.method, request.url.path, error) + return JSONResponse({"error": str(error)}, status_code=404) + + @app.get("/", include_in_schema=False) + @app.get("/index.html", include_in_schema=False) + def index(): + return FileResponse(INDEX_HTML) + + @app.get("/favicon.ico", include_in_schema=False) + def favicon(): + """Browsers ask for /favicon.ico before the page names its icon; serve the SVG there as well.""" + return FileResponse(WEB_DIR / "favicon.svg", media_type="image/svg+xml") + + @app.get("/{asset_name}", include_in_schema=False) + def asset(asset_name: str): + """Files beside index.html (scripts, styles); nothing outside the package folder.""" + path = resolve_under_root(WEB_DIR, asset_name) + if path.suffix not in STATIC_SUFFIXES or not path.is_file(): + raise ApiError(404, f"no such page: {asset_name}") + return FileResponse(path) + + app.include_router(router) + return app + + +def served_root_and_start_file(path: Path) -> Tuple[Path, Optional[str]]: + """Split the command-line PATH: a folder is served as is; a file's folder is served and the file opens at start. + + Returns the folder to serve and the file name relative to it (``None`` for a folder). + """ + path = Path(path).expanduser().resolve() + if path.is_dir(): + return path, None + return path.parent, path.name + + +def serve(path: str = ".", port: int = 8765, open_browser: bool = True, cache_dir: Optional[str] = None, + workers: Optional[int] = None, verbose: bool = False) -> None: + """Serve the viewer on 127.0.0.1 until Ctrl+C; prints the URL that carries the token. + + ``path`` is the folder to serve, or a file: then its folder is served and the page opens the file. + """ + import uvicorn + + logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") + token = new_token() + root, start_file = served_root_and_start_file(Path(path)) + app = create_app(root, token, Path(cache_dir) if cache_dir else None, workers) + url = f"http://127.0.0.1:{port}/?{TOKEN_QUERY_PARAM}={token}" + if start_file: + url += f"&{START_FILE_QUERY_PARAM}={quote(start_file)}" + # flush: with stdout redirected to a file (ssh session logs), the URL must appear before uvicorn blocks. + print(f"pyevio web: {url}\nFiles under: {app.state.root}\nCache: {app.state.registry.cache.directory}\n(Ctrl+C to stop)", flush=True) + if open_browser: + webbrowser.open(url) + uvicorn.run(app, host="127.0.0.1", port=port, workers=1, access_log=verbose, log_level="info" if verbose else "warning") diff --git a/pyevio/web/auth.py b/pyevio/web/auth.py new file mode 100644 index 0000000..340798a --- /dev/null +++ b/pyevio/web/auth.py @@ -0,0 +1,71 @@ +"""Request guards: Host check, token check and path confinement to the served root. + +The server binds 127.0.0.1, which keeps other machines out but not other +local users or web pages. Three checks close those holes: + +- A random token, printed once in the start URL and required on every + ``/api`` request, so another local user cannot read the owner's files by + guessing the port. +- A ``Host`` header limited to ``localhost``/``127.0.0.1``: a web page can + reach a local server through DNS rebinding (its hostname resolves to + 127.0.0.1 after the page loaded), and such requests carry the page's host + name. The check rejects them. +- Every path parameter is resolved (symlinks included) and must stay inside + the resolved root, so ``../`` and a symlink to ``~/.ssh`` are rejected. +""" +import secrets +from pathlib import Path +from typing import Optional + +from pyevio.web import ApiError + +ALLOWED_HOSTS = frozenset({"localhost", "127.0.0.1"}) +TOKEN_QUERY_PARAM = "token" +TOKEN_HEADER = "x-token" + + +def new_token() -> str: + return secrets.token_urlsafe(32) + + +def host_allowed(host_header: Optional[str]) -> bool: + """True for ``localhost`` or ``127.0.0.1`` with an optional ``:port``.""" + if not host_header: + return False + host = host_header.rsplit(":", 1)[0] if host_header.count(":") == 1 else host_header + return host.lower() in ALLOWED_HOSTS + + +def check_request(host_header: Optional[str], path: str, token_given: Optional[str], token_expected: str) -> None: + """Raise :class:`ApiError` for a bad Host (403) or, on ``/api`` paths, a missing or wrong token (401). + + The token travels in the URL (``?token=``) because the viewer page is opened + from a printed link; a header (``X-Token``) serves scripted clients. + """ + if not host_allowed(host_header): + raise ApiError(403, f"Host {host_header!r} is not localhost; the viewer serves local pages only") + if path.startswith("/api/"): + if not token_given or not secrets.compare_digest(token_given, token_expected): + raise ApiError(401, "missing or wrong token; use the URL printed by `pyevio web`") + + +def resolve_under_root(root: Path, relative: str) -> Path: + """Resolve ``relative`` against ``root`` and return it; raise :class:`ApiError` (403) when it leaves the root. + + ``root`` must already be resolved. Resolution follows symlinks, so a link that + points outside the root is rejected the same way as ``../``. An absolute + ``relative`` replaces the root in the join and is rejected unless it lies inside. + """ + candidate = (root / relative).resolve() + if candidate != root and root not in candidate.parents: + raise ApiError(403, f"path {relative!r} is outside the served root") + return candidate + + +def inside_root(root: Path, path: Path) -> bool: + """True when ``path`` (resolved) is the root or below it.""" + try: + resolved = path.resolve() + except OSError: + return False + return resolved == root or root in resolved.parents diff --git a/pyevio/web/cache.py b/pyevio/web/cache.py new file mode 100644 index 0000000..a305593 --- /dev/null +++ b/pyevio/web/cache.py @@ -0,0 +1,63 @@ +"""Result cache: one ``.npz`` per analyzed file under the cache directory. + +The key is sha256 over the resolved path, the file size and its mtime (ns), so +a rewritten file misses the cache instead of serving stale numbers. The key +doubles as the viewer's ``file_id``: opening the same unchanged file twice +yields the same id and the cached result. The statistics version is not part +of the key: an entry written by an older pyevio fails ``StreamStats.load``, +counts as a miss and is overwritten at the same key by the recompute, so a +version bump leaves no orphaned files behind. +""" +import hashlib +import logging +import os +from pathlib import Path +from typing import Optional + +from pyevio.stream_stats import StreamStats + +log = logging.getLogger("pyevio.web") + +DEFAULT_CACHE_DIR = Path("~/.cache/pyevio/stats") + + +def cache_key(path: Path) -> str: + """sha256 of (resolved path, size, mtime_ns) as hex.""" + stat = path.stat() + digest = hashlib.sha256() + for part in (str(path), stat.st_size, stat.st_mtime_ns): + digest.update(str(part).encode()) + digest.update(b"\0") + return digest.hexdigest() + + +class StatsCache: + """Load and save :class:`StreamStats` by key; every failure to load counts as a miss.""" + + def __init__(self, directory: Optional[Path] = None): + self.directory = (directory or DEFAULT_CACHE_DIR).expanduser().resolve() + + def path_for(self, key: str) -> Path: + return self.directory / f"{key}.npz" + + def load(self, key: str) -> Optional[StreamStats]: + path = self.path_for(key) + if not path.is_file(): + return None + try: + return StreamStats.load(str(path)) + except ValueError as error: # another stats version, or a damaged file: recomputed over the same name + log.info("cache entry %s not usable (%s); recomputing", path, error) + return None + except (OSError, KeyError) as error: + log.warning("cache entry %s unreadable (%s); recomputing", path, error) + return None + + def save(self, key: str, stats: StreamStats) -> Path: + """Write atomically: numpy appends ``.npz`` to any other suffix, so the temporary name ends in ``.npz`` too.""" + self.directory.mkdir(parents=True, exist_ok=True) + final = self.path_for(key) + partial = self.directory / f"{key}.partial.npz" + stats.save(str(partial)) + os.replace(partial, final) + return final diff --git a/pyevio/web/charts.js b/pyevio/web/charts.js new file mode 100644 index 0000000..783d913 --- /dev/null +++ b/pyevio/web/charts.js @@ -0,0 +1,256 @@ +// charts.js — plain SVG charts drawn from arrays the server sends: a step chart over bin edges and a heatmap. +// Colors come from the CSS custom properties in style.css (var(--…) works inside SVG attributes), so the +// dark-mode variant needs no code here. No library: the viewer must load on a machine without internet. + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const WIDTH = 480; +const HEIGHT = 240; +const MARGIN = { left: 60, right: 12, top: 10, bottom: 42 }; + +// ---------------------------------------------------------------- number formatting +export function formatNumber(value) { + if (typeof value !== 'number') return String(value); + if (Number.isInteger(value)) return value.toLocaleString('en-US'); + const magnitude = Math.abs(value); + if (magnitude !== 0 && (magnitude < 0.001 || magnitude >= 1e15)) return value.toExponential(3); + return Number(value.toPrecision(4)).toLocaleString('en-US', { maximumFractionDigits: 6 }); +} + +export function formatCompact(value) { + if (value >= 1e9) return (value / 1e9).toPrecision(3) + 'G'; + if (value >= 1e6) return (value / 1e6).toPrecision(3) + 'M'; + if (value >= 1e3) return (value / 1e3).toPrecision(3) + 'k'; + return formatNumber(value); +} + +/** A tick label with as many decimals as the tick step needs, and no more. */ +function formatTick(value, step) { + if (value === 0) return '0'; + if (Math.abs(value) >= 1e4) return formatCompact(value); + const decimals = step >= 1 ? 0 : Math.min(9, Math.ceil(-Math.log10(step))); + return value.toFixed(decimals); +} + +/** Round tick positions covering [low, high]: 1, 2 or 5 times a power of ten. */ +function niceTicks(low, high, count) { + const span = high - low || 1; + const roughStep = span / count; + const power = 10 ** Math.floor(Math.log10(roughStep)); + const multiplier = [1, 2, 5, 10].find(candidate => candidate * power >= roughStep); + const step = multiplier * power; + const ticks = []; + for (let tick = Math.ceil(low / step) * step; tick <= high + step * 1e-9; tick += step) ticks.push(Number(tick.toFixed(12))); + return { ticks, step }; +} + +// ---------------------------------------------------------------- SVG helpers +function svgElement(tag, attributes, parent) { + const element = document.createElementNS(SVG_NS, tag); + for (const name in attributes) element.setAttribute(name, attributes[name]); + if (parent) parent.appendChild(element); + return element; +} + +function text(parent, x, y, content, attributes = {}) { + const node = svgElement('text', { x, y, ...attributes }, parent); + node.textContent = content; + return node; +} + +export function clearChart(svg) { + svg.replaceChildren(); + svg.setAttribute('viewBox', `0 0 ${WIDTH} ${HEIGHT}`); + svg.style.width = ''; // a heatmap may have pinned the width in pixels; step charts fill their panel +} + +/** A chart with nothing to draw shows why, instead of an empty frame. */ +export function emptyChart(svg, message) { + clearChart(svg); + text(svg, WIDTH / 2, HEIGHT / 2, message, { 'text-anchor': 'middle', class: 'axis-title' }); +} + +// ---------------------------------------------------------------- tooltip +const tip = document.getElementById('tip'); + +export function showTip(event, html) { + tip.innerHTML = html; + tip.hidden = false; + const box = tip.getBoundingClientRect(); + let x = event.clientX + 14; + let y = event.clientY + 14; + if (x + box.width > innerWidth - 8) x = event.clientX - box.width - 14; + if (y + box.height > innerHeight - 8) y = event.clientY - box.height - 14; + tip.style.left = x + 'px'; + tip.style.top = y + 'px'; +} + +export function hideTip() { + tip.hidden = true; +} + +/** Mouse position in the SVG's own coordinate system. */ +function svgPoint(svg, event) { + const point = svg.createSVGPoint(); + point.x = event.clientX; + point.y = event.clientY; + return point.matrixTransform(svg.getScreenCTM().inverse()); +} + +// ---------------------------------------------------------------- step chart +/** + * Draw `values[i]` as a horizontal step between `edges[i]` and `edges[i + 1]`. + * `edges` has one more entry than `values` and is already in the unit `xLabel` names (the caller scales + * nanoseconds to seconds). `logY` draws a decade axis; zero bins sit on the baseline. + * `formatX` and `formatY` are used for the tooltip; axis ticks use a step-aware default. + */ +export function stepChart(svg, { edges, values, xLabel, yLabel, logY = false, formatX = formatNumber, formatY = formatNumber }) { + clearChart(svg); + if (!values || values.length === 0 || edges.length !== values.length + 1) { + emptyChart(svg, 'no data'); + return; + } + const plot = { x0: MARGIN.left, x1: WIDTH - MARGIN.right, y0: HEIGHT - MARGIN.bottom, y1: MARGIN.top }; + const xLow = edges[0]; + const xHigh = edges[edges.length - 1] === xLow ? xLow + 1 : edges[edges.length - 1]; + const scaleX = value => plot.x0 + (value - xLow) / (xHigh - xLow) * (plot.x1 - plot.x0); + const yAxis = logY ? logAxis(values) : linearAxis(values); + const scaleY = value => plot.y0 - (yAxis.transform(value) - yAxis.low) / (yAxis.high - yAxis.low) * (plot.y0 - plot.y1); + + drawYAxis(svg, plot, yAxis, scaleY, yLabel); + drawXAxis(svg, plot, xLow, xHigh, scaleX, xLabel); + + let path = ''; + values.forEach((value, bin_i) => { + const y = scaleY(value).toFixed(1); + path += `${bin_i ? 'L' : 'M'}${scaleX(edges[bin_i]).toFixed(1)},${y}L${scaleX(edges[bin_i + 1]).toFixed(1)},${y}`; + }); + svgElement('path', { d: path, class: 'series' }, svg); + attachBinHover(svg, plot, { edges, values, scaleX, scaleY, formatX, formatY, xLabel, yLabel }); +} + +/** Linear axis from 0 to the first tick at or above the maximum, so the top of the data has a label. */ +function linearAxis(values) { + const maximum = Math.max(...values) || 1; + const { ticks, step } = niceTicks(0, maximum, 4); + const high = Math.ceil(maximum / step) * step; + if (ticks[ticks.length - 1] < high) ticks.push(high); + return { low: 0, high, ticks, transform: value => value, formatTick: value => formatTick(value, step) }; +} + +/** Decade axis from below the smallest positive value (so a count of 1 is not on the baseline) to above the largest. */ +function logAxis(values) { + const positive = values.filter(value => value > 0); + const lowDecade = positive.length ? Math.floor(Math.log10(Math.min(...positive) * 0.5)) : 0; + const highDecade = Math.max(lowDecade + 1, positive.length ? Math.ceil(Math.log10(Math.max(...positive))) : 1); + const ticks = []; + for (let decade = lowDecade; decade <= highDecade; decade++) ticks.push(10 ** decade); + return { low: lowDecade, high: highDecade, ticks, transform: value => (value > 0 ? Math.log10(value) : lowDecade), + formatTick: value => formatCompact(value) }; +} + +function drawYAxis(svg, plot, axis, scaleY, label) { + axis.ticks.forEach(tick => { + const y = scaleY(tick); + svgElement('line', { x1: plot.x0, x2: plot.x1, y1: y, y2: y, class: 'grid' }, svg); + text(svg, plot.x0 - 6, y + 4, axis.formatTick(tick), { 'text-anchor': 'end' }); + }); + text(svg, 12, (plot.y0 + plot.y1) / 2, label, { class: 'axis-title', 'text-anchor': 'middle', + transform: `rotate(-90 12 ${(plot.y0 + plot.y1) / 2})` }); +} + +function drawXAxis(svg, plot, low, high, scaleX, label) { + const { ticks, step } = niceTicks(low, high, 5); + ticks.forEach(tick => text(svg, scaleX(tick), plot.y0 + 16, formatTick(tick, step), { 'text-anchor': 'middle' })); + svgElement('line', { x1: plot.x0, x2: plot.x1, y1: plot.y0, y2: plot.y0, class: 'axis' }, svg); + text(svg, (plot.x0 + plot.x1) / 2, HEIGHT - 4, label, { class: 'axis-title', 'text-anchor': 'middle' }); +} + +/** Crosshair and tooltip for the bin under the mouse. */ +function attachBinHover(svg, plot, { edges, values, scaleX, scaleY, formatX, formatY, xLabel, yLabel }) { + const crosshair = svgElement('line', { y1: plot.y1, y2: plot.y0, class: 'hover-line', visibility: 'hidden' }, svg); + const dot = svgElement('circle', { r: 4, class: 'hover-dot', visibility: 'hidden' }, svg); + const hitArea = svgElement('rect', { x: plot.x0, y: plot.y1, width: plot.x1 - plot.x0, height: plot.y0 - plot.y1, class: 'hit-area' }, svg); + hitArea.addEventListener('mousemove', event => { + const x = svgPoint(svg, event).x; + let bin_i = edges.findIndex((edge, i) => i < values.length && scaleX(edge) <= x && x < scaleX(edges[i + 1])); + if (bin_i < 0) bin_i = x < scaleX(edges[0]) ? 0 : values.length - 1; + const centerX = (scaleX(edges[bin_i]) + scaleX(edges[bin_i + 1])) / 2; + crosshair.setAttribute('x1', centerX); + crosshair.setAttribute('x2', centerX); + crosshair.setAttribute('visibility', 'visible'); + dot.setAttribute('cx', centerX); + dot.setAttribute('cy', scaleY(values[bin_i])); + dot.setAttribute('visibility', 'visible'); + showTip(event, `${xLabel}: ${formatX(edges[bin_i])} – ${formatX(edges[bin_i + 1])}
${yLabel}: ${formatY(values[bin_i])}`); + }); + hitArea.addEventListener('mouseleave', () => { + hideTip(); + crosshair.setAttribute('visibility', 'hidden'); + dot.setAttribute('visibility', 'hidden'); + }); +} + +// ---------------------------------------------------------------- heatmap +/** + * A grid of `values[row_i][col_i]` colored by one sequential ramp (--heat-lo → --heat-hi). + * `isDead(row_i, col_i)` marks cells with no meaning (a port absent from a ROC's map); they are grey and have + * no value. `onCell(row_i, col_i)` runs on click. Returns the maximum value, for the color-scale legend. + */ +export function heatmap(svg, { rows, cols, values, rowTitle, colTitle, valueLabel, isDead = () => false, onCell = null }) { + svg.replaceChildren(); + if (!rows.length || !cols.length) { + emptyChart(svg, 'no data'); + return 0; + } + const labelWidth = 70; + const cellWidth = Math.max(14, Math.min(40, Math.floor((WIDTH - labelWidth - MARGIN.right) / cols.length))); + const cellHeight = Math.max(14, Math.min(26, cellWidth)); + const plot = { x0: labelWidth, y0: 22 }; + const height = plot.y0 + rows.length * cellHeight + 36; + const width = Math.max(WIDTH, plot.x0 + cols.length * cellWidth + MARGIN.right); + svg.setAttribute('viewBox', `0 0 ${width} ${height}`); + svg.style.width = `${width}px`; // one SVG unit per pixel: a small grid must not scale its labels up + svg.style.maxWidth = '100%'; + const maximum = Math.max(0, ...values.flatMap((row, row_i) => row.filter((_, col_i) => !isDead(row_i, col_i)))); + + cols.forEach((label, col_i) => text(svg, plot.x0 + col_i * cellWidth + cellWidth / 2, plot.y0 - 6, String(label), { 'text-anchor': 'middle' })); + rows.forEach((label, row_i) => text(svg, plot.x0 - 6, plot.y0 + row_i * cellHeight + cellHeight / 2 + 4, String(label), { 'text-anchor': 'end' })); + text(svg, plot.x0 + cols.length * cellWidth / 2, height - 6, colTitle, { class: 'axis-title', 'text-anchor': 'middle' }); + text(svg, 12, plot.y0 + rows.length * cellHeight / 2, rowTitle, { class: 'axis-title', 'text-anchor': 'middle', + transform: `rotate(-90 12 ${plot.y0 + rows.length * cellHeight / 2})` }); + + rows.forEach((rowLabel, row_i) => cols.forEach((colLabel, col_i) => { + const dead = isDead(row_i, col_i); + const value = values[row_i][col_i]; + const cell = svgElement('rect', { x: plot.x0 + col_i * cellWidth + 1, y: plot.y0 + row_i * cellHeight + 1, width: cellWidth - 2, + height: cellHeight - 2, rx: 2, class: dead ? 'cell-dead' : 'cell' }, svg); + if (!dead) cell.style.fill = heatColor(value, maximum); + cell.addEventListener('mousemove', event => showTip(event, dead + ? `${rowTitle} ${rowLabel}, ${colTitle} ${colLabel}: not in the map` + : `${rowTitle} ${rowLabel}, ${colTitle} ${colLabel}
${valueLabel}: ${formatNumber(value)}`)); + cell.addEventListener('mouseleave', hideTip); + if (onCell && !dead) { + cell.style.cursor = 'pointer'; + cell.addEventListener('click', () => onCell(row_i, col_i)); + } + })); + return maximum; +} + +/** One hue, light to dark, mixed in the browser so both color schemes use their own ramp ends. */ +function heatColor(value, maximum) { + const fraction = maximum > 0 ? Math.sqrt(value / maximum) : 0; // sqrt: a few hot channels must not flatten the rest + return `color-mix(in oklab, var(--heat-hi) ${Math.round(fraction * 100)}%, var(--heat-lo))`; +} + +export function heatmapScale(container, maximum, valueLabel) { + container.replaceChildren(); + if (maximum <= 0) return; + const low = document.createElement('span'); + low.textContent = '0'; + const ramp = document.createElement('span'); + ramp.className = 'ramp'; + const high = document.createElement('span'); + high.textContent = `${formatNumber(maximum)} ${valueLabel} (square-root scale)`; + container.append(low, ramp, high); +} diff --git a/pyevio/web/favicon.svg b/pyevio/web/favicon.svg new file mode 100644 index 0000000..201bb1d --- /dev/null +++ b/pyevio/web/favicon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/pyevio/web/index.html b/pyevio/web/index.html new file mode 100644 index 0000000..74d2fbf --- /dev/null +++ b/pyevio/web/index.html @@ -0,0 +1,121 @@ + + + + + +pyevio web viewer + + + + +
+
+

pyevio viewer

+ + +
+ +
+
+ + / + +
+
+
+ +
+
No file open
+ +
Pick a file in the folder browser, or start the server with pyevio web path/to/file. The server reads and analyzes the file; this page shows the result.
+ +
+ + + + + + + + + + +
+ + + + diff --git a/pyevio/web/jobs.py b/pyevio/web/jobs.py new file mode 100644 index 0000000..d24cd2c --- /dev/null +++ b/pyevio/web/jobs.py @@ -0,0 +1,174 @@ +"""Opened files and their analysis jobs. + +``FileRegistry.open`` validates a path (it must open as an EVIO file before any +status is sent), derives the ``file_id`` from the cache key and either returns +the cached statistics or starts one :class:`AnalysisJob`. One job runs at a +time: opening another file cancels the running job, since the viewer shows one +file and a 15 GB analysis would otherwise keep every core busy for nothing. + +A job is a daemon thread around ``compute_stream_stats`` (which runs its own +worker pool). Progress comes from the callback the stats module calls after +every decoded task; cancellation is a flag the stats module polls between +tasks and chunks. +""" +import logging +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Optional + +from pyevio.evio_file import EvioFile +from pyevio.stream_stats import StatsCancelled, StreamStats, compute_stream_stats +from pyevio.web import ApiError +from pyevio.web.auth import resolve_under_root +from pyevio.web.cache import StatsCache, cache_key + +log = logging.getLogger("pyevio.web") + +STATE_QUEUED, STATE_RUNNING, STATE_DONE, STATE_ERROR, STATE_CANCELLED = "queued", "running", "done", "error", "cancelled" + + +class AnalysisJob: + """Runs ``compute_stream_stats`` for one file in a thread and tracks its progress.""" + + def __init__(self, file_id: str, path: Path, key: str, cache: StatsCache, workers: Optional[int]): + self.file_id, self.path, self.key, self.cache, self.workers = file_id, path, key, cache, workers + self.state = STATE_QUEUED + self.records_done = 0 + self.records_total = 0 + self.error: Optional[str] = None + self.stats: Optional[StreamStats] = None + self.started_at: Optional[float] = None + self.finished_at: Optional[float] = None + self._cancel = threading.Event() + self._thread = threading.Thread(target=self._run, name=f"pyevio-stats-{file_id[:8]}", daemon=True) + + def start(self) -> None: + self._thread.start() + + def cancel(self) -> None: + self._cancel.set() + + @property + def is_active(self) -> bool: + return self.state in (STATE_QUEUED, STATE_RUNNING) + + def _progress(self, records_done: int, records_total: int) -> None: + self.records_done, self.records_total = records_done, records_total + + def _run(self) -> None: + self.state = STATE_RUNNING + self.started_at = time.monotonic() + try: + # The module-level name is looked up at run time so tests can substitute it. + stats = compute_stream_stats(str(self.path), workers=self.workers, progress=self._progress, + cancel=self._cancel.is_set) + if self._cancel.is_set(): + raise StatsCancelled("cancelled after the last task") + try: + self.cache.save(self.key, stats) + except OSError as error: # an unwritable cache dir must not turn a finished analysis into an error + log.warning("cannot cache the result of %s: %s", self.path, error) + self.stats = stats + self.state = STATE_DONE + log.info("analyzed %s in %.1f s (%d records)", self.path, stats.wall_seconds, stats.n_records) + except StatsCancelled: + self.state = STATE_CANCELLED + log.info("cancelled analysis of %s", self.path) + except Exception as error: # the job must record every failure; the thread has no other outlet + self.state = STATE_ERROR + self.error = f"{type(error).__name__}: {error}" + log.exception("analysis of %s failed", self.path) + finally: + self.finished_at = time.monotonic() + + def snapshot(self) -> dict: + """Progress as the ``/progress`` endpoint reports it.""" + now = self.finished_at or time.monotonic() + elapsed = (now - self.started_at) if self.started_at else 0.0 + fraction = self.records_done / self.records_total if self.records_total else (1.0 if self.state == STATE_DONE else 0.0) + eta = (elapsed / fraction - elapsed) if (self.state == STATE_RUNNING and 0 < fraction < 1) else None + return {"state": self.state, "fraction": fraction, "records_done": self.records_done, + "records_total": self.records_total, "elapsed_s": elapsed, "eta_s": eta, "error": self.error} + + +@dataclass +class OpenFile: + """One file the viewer opened: identity, its job (if any) and its statistics once known.""" + file_id: str + path: Path # resolved, inside the root + relative: str + key: str + job: Optional[AnalysisJob] = None + stats: Optional[StreamStats] = None + from_cache: bool = False + opened_at: float = field(default_factory=time.monotonic) + + def progress(self) -> dict: + """Progress as ``/progress`` reports it; a file without a job came from the cache.""" + if self.job is None: + n_records = self.stats.n_records if self.stats is not None else 0 + return {"state": STATE_DONE, "fraction": 1.0, "records_done": n_records, "records_total": n_records, + "elapsed_s": 0.0, "eta_s": None, "error": None, "cached": True} + snapshot = self.job.snapshot() + snapshot["cached"] = False + return snapshot + + +class FileRegistry: + """Files opened through the API, by ``file_id``; starts and cancels their jobs.""" + + def __init__(self, root: Path, cache: StatsCache, workers: Optional[int] = None): + self.root = root + self.cache = cache + self.workers = workers + self.files: Dict[str, OpenFile] = {} + self.active_job: Optional[AnalysisJob] = None + self._lock = threading.Lock() + + def open(self, relative: str) -> OpenFile: + """Open ``relative`` (under the root), return its entry and start its job unless the result is cached.""" + path = resolve_under_root(self.root, relative) + if not path.is_file(): + raise ApiError(404, f"no such file under the served root: {relative!r}") + # Open before answering: an unreadable or non-EVIO file fails here with its reason. + with EvioFile(str(path)): + pass + key = cache_key(path) + file_id = key[:16] + with self._lock: + entry = self.files.get(file_id) + if entry is None: + entry = OpenFile(file_id=file_id, path=path, relative=relative, key=key) + self.files[file_id] = entry + if entry.stats is None: + entry.stats = self.cache.load(key) + entry.from_cache = entry.stats is not None + if entry.stats is None and (entry.job is None or not entry.job.is_active): + self._start_job(entry) + return entry + + def _start_job(self, entry: OpenFile) -> None: + if self.active_job is not None and self.active_job.is_active and self.active_job.file_id != entry.file_id: + log.info("cancelling analysis of %s: %s opened", self.active_job.path, entry.path) + self.active_job.cancel() + entry.job = AnalysisJob(entry.file_id, entry.path, entry.key, self.cache, self.workers) + self.active_job = entry.job + entry.job.start() + + def get(self, file_id: str) -> OpenFile: + entry = self.files.get(file_id) + if entry is None: + raise ApiError(404, f"unknown file id {file_id!r}; open the file first") + return entry + + def stats_of(self, file_id: str) -> StreamStats: + """The statistics of a finished job; 409 while the analysis is not done.""" + entry = self.get(file_id) + if entry.stats is None and entry.job is not None and entry.job.state == STATE_DONE: + entry.stats = entry.job.stats + if entry.stats is None: + state = entry.job.state if entry.job else STATE_QUEUED + raise ApiError(409, f"analysis of {entry.relative!r} is {state}; poll /progress until done") + return entry.stats diff --git a/pyevio/web/report.py b/pyevio/web/report.py new file mode 100644 index 0000000..e31bf0c --- /dev/null +++ b/pyevio/web/report.py @@ -0,0 +1,206 @@ +"""Summary and histogram JSON from a :class:`StreamStats` and the file's record headers. + +Ratios whose denominator is zero are the string ``"n/a"``: a file with frames +but no hits has no hit rate, and the viewer prints the string as given instead +of ``NaN``. +""" +from dataclasses import asdict +from typing import Optional + +import numpy as np + +from pyevio.decoders import fadc250_stream as fs +from pyevio.evio_file import EvioFile +from pyevio.stream_stats import N_CHANNELS, StreamStats +from pyevio.web import ApiError + +HISTOGRAM_NAMES = ("hits_per_frame", "rate", "occupancy", "charge", "time_in_frame") +NOT_AVAILABLE = "n/a" + + +def ratio(numerator: float, denominator: float): + return numerator / denominator if denominator else NOT_AVAILABLE + + +# ---------------------------------------------------------------- summary +def record_table_summary(evio_file: EvioFile) -> dict: + """One pass over the record headers: counts the viewer's record table shows at the top.""" + sizes, events, events_found, with_errors, last_bit, compressed, without_index, truncated = [], 0, 0, 0, 0, 0, 0, 0 + for record_i in range(evio_file.record_count): + record = evio_file.get_record(record_i) + found = record.scan_events() # decompresses a compressed record; released below + sizes.append(record.size) + events += record.header.event_count + events_found += len(found) + with_errors += bool(record.errors) + last_bit += record.header.is_last_record + compressed += record.compression_type != record.COMPRESSION_NONE + without_index += record.header.index_array_length == 0 + truncated += record.is_truncated + record.release() + return {"count": evio_file.record_count, "has_trailer": evio_file.trailer is not None, "events": events, + "events_found": events_found, "with_errors": with_errors, "last_record_bit": last_bit, "compressed": compressed, + "without_index": without_index, "truncated": truncated, "min_size": min(sizes) if sizes else 0, + "max_size": max(sizes) if sizes else 0, "mean_size": ratio(sum(sizes), len(sizes))} + + +def file_scan_checks(evio_file: EvioFile) -> list: + """``EvioFile.checks`` tuples as check rows: a truncated tail and record headers the scan could not read.""" + rows = [] + for check in evio_file.checks: + if check[0] == "truncated_tail": + _, declared, present, salvaged = check + rows.append({"name": "truncated tail", "status": "error", "value": declared - present, + "detail": f"last record declares {declared} bytes, {present} present; {salvaged} complete events kept"}) + elif check[0] == "bad_record_header": + _, offset, message = check + rows.append({"name": "bad record header", "status": "error", "value": offset, "detail": f"scan stopped at 0x{offset:X}: {message}"}) + else: + rows.append({"name": check[0], "status": "warn", "value": None, "detail": repr(check[1:])}) + return rows + + +def file_checks(evio_file: EvioFile, records: dict, stats: StreamStats) -> list: + """Checks as ``{name, status, value, detail}`` rows; status is ok, info, warn or error.""" + checks = stats.checks + + def row(name, status, value, detail=""): + return {"name": name, "status": status, "value": value, "detail": detail} + + rows = [ + row("header layout", "info", evio_file.header_layout, f"{evio_file.header.file_type}, version {evio_file.header.version}"), + row("trailer", "ok" if records["has_trailer"] else "warn", records["has_trailer"], "" if records["has_trailer"] else "file ends without a trailer record"), + *file_scan_checks(evio_file), + row("record errors", "error" if records["with_errors"] else "ok", records["with_errors"], "records whose event walk stopped early"), + row("events found", "ok" if records["events_found"] == records["events"] else "error", records["events_found"], + f"headers declare {records['events']}"), + row("last-record bit", "info", records["last_record_bit"], "records with the last-record bit set"), + row("compressed records", "info", records["compressed"], ""), + row("records without index", "info", records["without_index"], ""), + row("out-of-bounds events", "error" if stats.n_out_of_bounds_events else "ok", stats.n_out_of_bounds_events, "events whose range runs past the file"), + row("stream frames", "info", stats.n_frames, f"of {stats.n_events} events; {stats.n_other_events} other"), + row("frame stride", "info", checks.stride, "most common frame-number step (4 = one stripe of a 4-way run)"), + row("missing frames", "warn" if checks.missing_frames else "ok", checks.missing_frames, "counted in steps of the stride"), + row("duplicate frames", "warn" if checks.duplicate_frames else "ok", checks.duplicate_frames, ""), + row("out-of-order frames", "warn" if checks.out_of_order else "ok", checks.out_of_order, ""), + row("timestamp-step outliers", "warn" if checks.dts_outliers else "ok", checks.dts_outliers, + f"median step {checks.frame_period_ticks:.0f} ticks"), + row("decode errors", "error" if stats.errors else "ok", stats.errors, stats.error_messages[0] if stats.error_messages else ""), + row("decode warnings", "warn" if stats.warnings else "ok", stats.warnings, + stats.warning_messages[0] if stats.warning_messages else ""), + row("flag words", "warn" if stats.n_flag_words else "ok", stats.n_flag_words, "hit words with bit 31 set"), + row("unmapped payload banks", "warn" if stats.n_unmapped_payload_banks else "ok", stats.n_unmapped_payload_banks, + "ports absent from the 0x41 payload map, decoded as fADC250"), + # The SRO hardware fills every AIS slot with one stream's entry. The decoder never uses the AIS + # to find ROC banks, so the repetition costs nothing; the row makes it visible. + row("AIS entries identical", "info" if stats.frames_with_degenerate_ais else "ok", stats.frames_with_degenerate_ais, + f"of {stats.frames_with_ais} frames with an aggregation info segment: the segment repeats one stream's entry " + "in every slot; it does not list the streams" if stats.frames_with_degenerate_ais else ""), + ] + return rows + + +def per_roc_rows(stats: StreamStats) -> list: + hits = stats.hits_per_roc() + rows = [] + for roc_i, roc in enumerate(stats.rocs.tolist()): + modules = sorted({int(m) for m in stats.module_id[roc_i] if m != fs.MODULE_UNKNOWN}) + ports = [int(p) for p, m in zip(stats.ports, stats.module_id[roc_i]) if m != fs.MODULE_UNKNOWN] + rows.append({"roc": roc, "modules": [fs.MODULE_NAMES.get(m, str(m)) for m in modules], "ports": ports, + "hits": hits[roc], "non_fadc_words": int(stats.non_fadc_words_per_port[roc_i].sum()), + "empty_banks": int(stats.empty_banks[roc_i].sum())}) + return rows + + +def build_summary(evio_file: EvioFile, stats: StreamStats, relative_path: str, from_cache: bool) -> dict: + header = evio_file.header + records = record_table_summary(evio_file) + totals = {name: getattr(stats, name) for name in StreamStats.SCALAR_FIELDS if name not in ("version", "path", "tick_ns", "workers", "wall_seconds")} + totals.update({ + "duration_s": stats.duration_s, "live_time_s": stats.live_time_s, "frame_period_ns": stats.frame_period_ns, + "hit_rate_hz": ratio(stats.n_hits, stats.live_time_s), "hits_per_frame": ratio(stats.n_hits, stats.n_frames), + "stream_fraction": ratio(stats.n_frames, stats.n_events), + "modules": [{"id": int(m), "name": fs.MODULE_NAMES.get(int(m), str(m)), "words": int(w)} + for m, w in zip(stats.module_ids, stats.module_words)], + }) + return { + "file": {"path": relative_path, "size": evio_file.file_size, "header_layout": evio_file.header_layout, + "endian": header.endian, "file_type": header.file_type, "version": header.version, + "record_count": evio_file.record_count, "has_trailer": evio_file.trailer is not None, + "header_record_count": header.record_count, "trailer_position": header.trailer_position, + "first_record_offset": evio_file.first_record_offset, "index_array_length": header.index_array_length, + "user_header_length": header.user_header_length}, + "records": records, + "scan_checks": [list(check) for check in evio_file.checks], + "totals": totals, + "frames": {**asdict(stats.checks), "frame_period_ns": stats.frame_period_ns}, + "rocs": per_roc_rows(stats), + "checks": file_checks(evio_file, records, stats), + "stats": {"version": stats.version, "workers": stats.workers, "wall_seconds": stats.wall_seconds, "tick_ns": stats.tick_ns, + "bytes_processed": stats.bytes_processed, "cached": from_cache}, + } + + +# ---------------------------------------------------------------- histograms +def _axis_index(values: np.ndarray, value: int, what: str) -> int: + position = int(np.searchsorted(values, value)) + if position >= len(values) or int(values[position]) != value: + raise ApiError(404, f"{what} {value} not in the file (have {values.tolist()})") + return position + + +def _select(stats: StreamStats, hist: np.ndarray, roc: Optional[int], port: Optional[int], channel: Optional[int]) -> np.ndarray: + """Reduce a (roc, port, channel, …) array to the selection, summing the axes left open.""" + if roc is None and (port is not None or channel is not None): + raise ApiError(400, "port and ch need a roc") + if port is None and channel is not None: + raise ApiError(400, "ch needs a port") + if channel is not None and not 0 <= channel < N_CHANNELS: + raise ApiError(404, f"channel {channel} outside 0—{N_CHANNELS - 1}") + if roc is not None: + hist = hist[_axis_index(stats.rocs, roc, "ROC")] + if port is not None: + hist = hist[_axis_index(stats.ports, port, "port")] + if channel is not None: + hist = hist[channel] + return hist + return hist.sum(axis=0) + return hist.sum(axis=(0, 1)) + return hist.sum(axis=(0, 1, 2)) + + +def histogram(stats: StreamStats, name: str, roc: Optional[int] = None, port: Optional[int] = None, + channel: Optional[int] = None) -> dict: + """One histogram as JSON lists; counts sum to the file totals (hits, or frames for ``hits_per_frame``).""" + selection = {"roc": roc, "port": port, "ch": channel} + if name == "hits_per_frame": + return {"name": name, "counts": stats.hits_per_frame_hist.tolist(), "edges": stats.hits_per_frame_edges.tolist(), + "x_unit": "hits per frame", "y_unit": "frames", "selection": selection} + if name == "rate": + counts = _select(stats, stats.rate_counts, roc, port, channel) + seconds = stats.frames_per_rate_bin * stats.frame_period_ns * 1e-9 + rate_hz = np.divide(counts, seconds, out=np.zeros(len(counts)), where=seconds > 0) + return {"name": name, "counts": counts.tolist(), "rate_hz": rate_hz.tolist(), "edges_ns": stats.rate_edges_ns.tolist(), + "frames_per_bin": stats.frames_per_rate_bin.tolist(), "x_unit": "ns from the first frame", "y_unit": "Hz", + "selection": selection} + if name == "occupancy": + counts = stats.occupancy + if roc is not None: + counts = counts[_axis_index(stats.rocs, roc, "ROC")] + if port is not None: + counts = counts[_axis_index(stats.ports, port, "port")] + if channel is not None: + if not 0 <= channel < N_CHANNELS: + raise ApiError(404, f"channel {channel} outside 0—{N_CHANNELS - 1}") + counts = counts[channel:channel + 1] + elif port is not None or channel is not None: + raise ApiError(400, "port and ch need a roc") + return {"name": name, "rocs": stats.rocs.tolist(), "ports": stats.ports.tolist(), "channels": N_CHANNELS, + "module_id": stats.module_id.tolist(), "counts": counts.tolist(), "y_unit": "hits", "selection": selection} + if name == "charge": + return {"name": name, "counts": _select(stats, stats.charge_hist, roc, port, channel).tolist(), + "edges": stats.charge_edges.tolist(), "x_unit": "charge (13-bit ADC units)", "y_unit": "hits", "selection": selection} + if name == "time_in_frame": + return {"name": name, "counts": _select(stats, stats.time_hist, roc, port, channel).tolist(), + "edges_ns": stats.time_edges_ns.tolist(), "x_unit": "ns from the frame start", "y_unit": "hits", "selection": selection} + raise ApiError(404, f"unknown histogram {name!r}; names: {', '.join(HISTOGRAM_NAMES)}") diff --git a/pyevio/web/routes.py b/pyevio/web/routes.py new file mode 100644 index 0000000..0105133 --- /dev/null +++ b/pyevio/web/routes.py @@ -0,0 +1,115 @@ +"""The ``/api`` endpoints. Every handler is thin: resolve, open the file, call core or stats, return JSON. + +File endpoints open the ``EvioFile`` first, so a file that vanished or cannot be +read fails with a status instead of a broken response. Opening is cheap (a +header scan, ~50 ms on a 15 GB file) and a fresh handle per request keeps the +record cache from growing across requests. +""" +import os +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Query, Request +from pydantic import BaseModel + +from pyevio.evio_file import EvioFile +from pyevio.web import ApiError, is_evio_name +from pyevio.web.auth import inside_root, resolve_under_root +from pyevio.web.jobs import FileRegistry +from pyevio.web.report import build_summary, histogram +from pyevio.web.structure import DEFAULT_CHILDREN, MAX_CHILDREN, MAX_WORDS, children_of, record_rows, words_at + +router = APIRouter(prefix="/api") + +# Files the folder browser lists by default; the corpus names end in ".evio.00000.v6", so match the stem too. +def registry_of(request: Request) -> FileRegistry: + return request.app.state.registry + + +class OpenRequest(BaseModel): + path: str + + +@router.get("/ls") +def list_directory(request: Request, dir: str = "", all: bool = False) -> dict: + """Folders and EVIO files of one directory under the root; paths are relative to the root.""" + root: Path = request.app.state.root + directory = resolve_under_root(root, dir) + if not directory.is_dir(): + raise ApiError(404, f"no such folder under the served root: {dir!r}") + dirs, files = [], [] + try: + entries = sorted(os.scandir(directory), key=lambda entry: entry.name.lower()) + except PermissionError: + raise ApiError(403, f"no permission to read {dir!r}") + for entry in entries: + if entry.name.startswith(".") or not inside_root(root, Path(entry.path)): + continue # hidden, or a symlink that leaves the root: it could not be opened anyway + try: + if entry.is_dir(): + dirs.append(entry.name) + elif entry.is_file() and (all or is_evio_name(entry.name)): + stat = entry.stat() + files.append({"name": entry.name, "size": stat.st_size, "mtime": stat.st_mtime}) + except OSError: + continue + relative = directory.relative_to(root).as_posix() if directory != root else "" + parent = None if directory == root else (directory.parent.relative_to(root).as_posix() if directory.parent != root else "") + return {"dir": relative, "parent": parent, "dirs": dirs, "files": files} + + +@router.post("/open") +def open_file(request: Request, body: OpenRequest) -> dict: + """Open a file under the root; starts its analysis unless cached. Returns the file id and the job state.""" + entry = registry_of(request).open(body.path) + return {"file_id": entry.file_id, "path": entry.relative, "size": entry.path.stat().st_size, **entry.progress()} + + +@router.get("/files/{file_id}/progress") +def progress(request: Request, file_id: str) -> dict: + return {"file_id": file_id, **registry_of(request).get(file_id).progress()} + + +@router.get("/files/{file_id}/summary") +def summary(request: Request, file_id: str) -> dict: + registry = registry_of(request) + entry = registry.get(file_id) + stats = registry.stats_of(file_id) + with EvioFile(str(entry.path)) as evio_file: + return {"file_id": file_id, **build_summary(evio_file, stats, entry.relative, entry.from_cache)} + + +@router.get("/files/{file_id}/records") +def records(request: Request, file_id: str, start: int = Query(0, alias="from", ge=0), + count: int = Query(DEFAULT_CHILDREN, ge=0, le=MAX_CHILDREN)) -> dict: + entry = registry_of(request).get(file_id) + with EvioFile(str(entry.path)) as evio_file: + return {"file_id": file_id, **record_rows(evio_file, start, count)} + + +@router.get("/files/{file_id}/structure") +def structure(request: Request, file_id: str, node: str = "", start: int = Query(0, alias="from", ge=0), + count: int = Query(DEFAULT_CHILDREN, ge=0, le=MAX_CHILDREN)) -> dict: + entry = registry_of(request).get(file_id) + with EvioFile(str(entry.path)) as evio_file: + return {"file_id": file_id, **children_of(evio_file, node, start, count)} + + +@router.get("/files/{file_id}/words") +def words(request: Request, file_id: str, offset: int = Query(..., ge=0), count: int = Query(64, ge=0), + record: Optional[int] = Query(None, ge=0)) -> dict: + """Raw 32-bit words as hex strings; ``count`` is capped at MAX_WORDS. + + ``offset`` is a file offset, or with ``record=N`` an offset into that record's + buffer (the decompressed bytes of an LZ4/gzip record), as the structure tree reports them. + """ + entry = registry_of(request).get(file_id) + with EvioFile(str(entry.path)) as evio_file: + return {"file_id": file_id, **words_at(evio_file, offset, min(count, MAX_WORDS), record)} + + +@router.get("/files/{file_id}/hist/{name}") +def hist(request: Request, file_id: str, name: str, roc: Optional[int] = None, port: Optional[int] = None, + ch: Optional[int] = None) -> dict: + stats = registry_of(request).stats_of(file_id) + return {"file_id": file_id, **histogram(stats, name, roc, port, ch)} diff --git a/pyevio/web/structure.py b/pyevio/web/structure.py new file mode 100644 index 0000000..71212eb --- /dev/null +++ b/pyevio/web/structure.py @@ -0,0 +1,280 @@ +"""Tree nodes, record pages and raw words, read through pyevio core. + +Node ids are strings the viewer passes back unchanged: + +- ``""`` — the file; children are the data records ``r0, r1, …`` and the trailer ``t``. +- ``r12`` — record 12; children are its events ``r12/e0, …``. +- ``r12/e3`` — event 3 of record 12, the top-level bank; children are its banks. +- ``r12/e3/b1a0/b1b8`` — a bank by byte offset (hex) below its parent; children are + its banks, or its hit words when it is an fADC250 payload bank of a stream frame. + +Every offset in an id is checked against the children pyevio found for the +parent, so an id cannot make the server read an arbitrary byte range. Children +lists are paged (``start``, ``count``) because a record can hold thousands of +events and a file thousands of records. + +Offsets of events and banks index the record's buffer (``Record.buffer``): the +file itself for a plain record, the decompressed bytes for an LZ4 or gzip +record. Record offsets are file offsets. ``words_at`` reads either, by record. + +Format knowledge stays in core and the decoder: this module only names what +``Bank`` and ``fadc250_stream`` return. +""" +import re +from typing import List, Optional + +import numpy as np + +from pyevio.bank import Bank +from pyevio.decoders import fadc250_stream as fs +from pyevio.evio_file import EvioFile +from pyevio.record import Record +from pyevio.web import ApiError + +MAX_WORDS = 4096 +MAX_HITS = 1024 +DEFAULT_CHILDREN = 500 +MAX_CHILDREN = 5000 +MAX_NODE_DEPTH = 32 +NODE_PART = re.compile(r"^(r\d+|e\d+|b[0-9a-f]+|t)$") +COMPRESSION_NAMES = {Record.COMPRESSION_NONE: "none", Record.COMPRESSION_LZ4: "lz4", Record.COMPRESSION_LZ4_BEST: "lz4_best", + Record.COMPRESSION_GZIP: "gzip"} + + +def page(items: list, start: int, count: int) -> dict: + count = max(0, min(count, MAX_CHILDREN)) + start = max(0, start) + return {"total": len(items), "start": start, "children": items[start:start + count]} + + +# ---------------------------------------------------------------- records +def describe_record(record: Record, index: int, evio_file: Optional[EvioFile] = None) -> dict: + """Record header fields plus the events found and the walk errors; ``index`` is the viewer's row number. + + ``header_quirk`` is what core knows about a malformed header at this offset + (``EvioFile.record_header_problem``, when the installed core has it), else None. + """ + header = record.header + record.scan_events() # fills record.errors + header_problem = getattr(evio_file, "record_header_problem", None) + return { + "header_quirk": header_problem(record.offset) if header_problem else None, + "id": "t" if record.is_trailer else f"r{index}", "kind": "trailer" if record.is_trailer else "record", + "index": index, "offset": record.offset, "size": record.size, "record_number": header.record_number, + "header_length": header.header_length, "header_type": header.header_type, "is_trailer": record.is_trailer, + "event_count": header.event_count, "events_found": len(record.scan_events()), + "index_array_length": header.index_array_length, "user_header_length": header.user_header_length, + "compression_type": header.compression_type, "compression": COMPRESSION_NAMES.get(header.compression_type, str(header.compression_type)), + "compressed_length": header.compressed_data_length, + "uncompressed_length": header.uncompressed_data_length, "version": header.version, + "is_truncated": record.is_truncated, "declared_end": record.declared_end, + "bit_info": {"raw": header.bit_info, "has_dictionary": header.has_dictionary, "is_last_record": header.is_last_record, + "has_first_event": header.has_first_event, "event_type": header.event_type, + "pad": [header.pad1, header.pad2, header.pad3]}, + "errors": list(record.errors), "has_children": header.event_count > 0 and not record.is_trailer, + } + + +def record_rows(evio_file: EvioFile, start: int, count: int) -> dict: + """Rows ``start … start+count`` of the record table; the trailer is the last row.""" + total = evio_file.record_count + (1 if evio_file.trailer is not None else 0) + start = max(0, start) + count = max(0, min(count, MAX_CHILDREN)) + rows = [] + for index in range(start, min(start + count, total)): + if index < evio_file.record_count: + record = evio_file.get_record(index) + rows.append(describe_record(record, index, evio_file)) + record.release() # a compressed record's buffer is not needed once its header row is built + else: + rows.append(describe_record(evio_file.trailer, index, evio_file)) + return {"total": total, "start": start, "rows": rows} + + +# ---------------------------------------------------------------- banks +def describe_bank(bank: Bank, node_id: str, role: Optional[str] = None) -> dict: + data = { + "id": node_id, "kind": "bank", "structure": bank.STRUCTURE_NAME, "tag": bank.tag, "data_type": bank.data_type, + "num": bank.num, "pad": bank.pad, "length": bank.length, "offset": bank.offset, "size": bank.size, "type_name": bank.type_name, + "is_container": bank.is_container(), "has_children": bank.is_container() and bank.data_length > 0, + } + if role: + data["role"] = role + return data + + +# Segments inside the stream-info banks of a frame; the AIS (0x42) is displayed as the file has it, never decoded. +INFO_SEGMENT_ROLES = {fs.SIB_TSS_SEGMENT_TAG: "time_slice", fs.SIB_AIS_SEGMENT_TAG: "aggregation_info", + fs.ROC_TSS_SEGMENT_TAG: "time_slice", fs.ROC_PAYLOAD_PORT_SEGMENT_TAG: "payload_map"} + + +def payload_map_rows(evio_file: EvioFile, segment: Bank) -> List[dict]: + """The decoded entries of a 0x41 payload-port segment, zero entries included, from the decoder.""" + words = fs.event_words(segment.mm, segment.data_offset, segment.data_length, evio_file.header.endian).tolist() + return [{"port": port, "module_id": module, "module": fs.MODULE_NAMES.get(module, str(module))} + for port, module in fs.payload_map_entries(words, segment.pad or 0, evio_file.header.endian == ">")] + + +def _event_bank(evio_file: EvioFile, record_index: int, event_index: int) -> Bank: + record = evio_file.get_record(record_index) + offsets = record.get_event_offsets() + if event_index >= len(offsets): + raise ApiError(404, f"record {record_index} has {len(offsets)} events, no event {event_index}") + offset, length = offsets[event_index] + return Bank(record.buffer, offset, evio_file.header.endian, limit=offset + length) + + +def _child_at(parent: Bank, offset: int) -> Bank: + for child in parent.get_children(): + if child.offset == offset: + return child + raise ApiError(404, f"no bank at offset 0x{offset:X} inside the bank at 0x{parent.offset:X}") + + +def _payload_labels(evio_file: EvioFile, event: Bank) -> dict: + """``{(roc, stream, port): (role, module id)}`` for the payload banks of one stream frame, from the decoder.""" + words = fs.event_words(event.mm, event.offset, event.size, evio_file.header.endian) + frame = fs.decode_frame(words) + del words # a view of the buffer; dropped before EvioFile.close() + labels = {} + for row in frame.empty_banks: + labels[(int(row["roc"]), int(row["stream"]), int(row["port"]))] = ("payload_empty", int(row["module_id"])) + for row in frame.non_fadc_banks: + labels[(int(row["roc"]), int(row["stream"]), int(row["port"]))] = ("payload_words", int(row["module_id"])) + if frame.n_hits: + keys = np.unique(np.stack([frame.roc, frame.stream, frame.port], axis=1).astype(np.int64), axis=0) + for roc, stream, port in keys.tolist(): + labels[(roc, stream, port)] = ("payload_hits", fs.MODULE_FADC250) + return labels + + +def _bank_children(evio_file: EvioFile, chain: List[Bank], node_id: str) -> List[dict]: + """Children of the last bank in ``chain`` with their roles inside a stream frame (if the event is one).""" + event, bank = chain[0], chain[-1] + children = [] + in_stream_frame = event.tag == fs.STREAM_EVENT_TAG + labels = _payload_labels(evio_file, event) if (in_stream_frame and len(chain) == 2 and bank.tag != fs.SIB_TAG) else None + info_segments = in_stream_frame and len(chain) in (2, 3) and bank.tag in (fs.SIB_TAG, fs.ROC_STREAM_INFO_TAG) + for child in bank.get_children(): + role = None + if in_stream_frame and len(chain) == 1: + role = "stream_info" if child.tag == fs.SIB_TAG else "roc_bank" + elif labels is not None: + role = "roc_stream_info" if child.tag == fs.ROC_STREAM_INFO_TAG else "payload" + elif info_segments: + role = INFO_SEGMENT_ROLES.get(child.tag) + described = describe_bank(child, f"{node_id}/b{child.offset:x}", role) + if role == "payload_map": + described["entries"] = payload_map_rows(evio_file, child) + if role == "roc_bank": + described.update({"roc": child.tag, "stream": (child.num or 0) & 0xF}) + if role == "payload": + label, module = labels.get((bank.tag, (bank.num or 0) & 0xF, child.tag), ("payload_unmapped", fs.MODULE_UNKNOWN)) + n_words = child.data_length // 4 + described.update({"port": child.tag, "n_words": n_words, "module": fs.MODULE_NAMES.get(module, str(module)), + "role": label, "has_children": label == "payload_hits" and n_words > 0}) + children.append(described) + return children + + +def _hit_children(evio_file: EvioFile, bank: Bank, node_id: str) -> List[dict]: + """Decoded hit words of an fADC250 payload bank, capped at MAX_HITS.""" + words = fs.event_words(bank.mm, bank.data_offset, bank.data_length, evio_file.header.endian).tolist() # a copy, no live view + channel, time_raw, charge = (column.tolist() for column in fs.unpack_hit_words(np.asarray(words, dtype=np.uint32))) + return [{"id": f"{node_id}/h{i}", "kind": "hit", "index": i, "word": f"{word:08x}", "channel": channel[i], + "time_raw": time_raw[i], "t_ns": time_raw[i] * fs.HIT_TIME_TICK_NS, "charge": charge[i], + "flag": bool(word >> fs.HIT_FLAG_BIT), "has_children": False} + for i, word in enumerate(words)] + + +# ---------------------------------------------------------------- nodes +def _parse_node(node: str) -> List[str]: + parts = [part for part in node.strip("/").split("/") if part] if node else [] + if len(parts) > MAX_NODE_DEPTH or any(not NODE_PART.match(part) for part in parts): + raise ApiError(400, f"malformed node id {node!r}") + return parts + + +def children_of(evio_file: EvioFile, node: str, start: int = 0, count: int = DEFAULT_CHILDREN) -> dict: + """The node's own description and a page of its children (see the module docstring for the id scheme).""" + parts = _parse_node(node) + if not parts: + header = evio_file.header + node_info = {"id": "", "kind": "file", "size": evio_file.file_size, "header_layout": evio_file.header_layout, + "record_count": evio_file.record_count, "has_trailer": evio_file.trailer is not None, + "endian": header.endian, "version": header.version, "file_type": header.file_type} + rows = record_rows(evio_file, start, count) + return {"total": rows["total"], "start": rows["start"], "children": rows["rows"], "node": node_info, "errors": []} + + if parts[0] == "t": + if evio_file.trailer is None or len(parts) > 1: + raise ApiError(404, "the file has no trailer" if evio_file.trailer is None else "a trailer has no children") + result = page([], start, count) + result.update({"node": describe_record(evio_file.trailer, evio_file.record_count, evio_file), "errors": []}) + return result + + record_index = int(parts[0][1:]) + if record_index >= evio_file.record_count: + raise ApiError(404, f"record {record_index} does not exist ({evio_file.record_count} records)") + record = evio_file.get_record(record_index) + if len(parts) == 1: + events = [] + buffer = record.buffer + for event_i, (offset, length) in enumerate(record.get_event_offsets()): + bank = Bank(buffer, offset, evio_file.header.endian, limit=offset + length) + described = describe_bank(bank, f"r{record_index}/e{event_i}") + described.update({"kind": "event", "index": event_i, "length_bytes": length, "has_children": bank.is_container(), + "compressed": record.compression_type != record.COMPRESSION_NONE}) + events.append(described) + result = page(events, start, count) + result.update({"node": describe_record(record, record_index, evio_file), "errors": list(record.errors)}) + return result + + if not parts[1].startswith("e"): + raise ApiError(400, f"expected an event id after {parts[0]!r}") + chain = [_event_bank(evio_file, record_index, int(parts[1][1:]))] + for part in parts[2:]: + if not part.startswith("b"): + raise ApiError(400, f"expected a bank id, found {part!r}") + chain.append(_child_at(chain[-1], int(part[1:], 16))) + bank = chain[-1] + node_id = "/".join(parts) + if bank.is_container(): + children = _bank_children(evio_file, chain, node_id) + elif len(chain) == 3 and chain[0].tag == fs.STREAM_EVENT_TAG and chain[1].tag != fs.SIB_TAG and bank.data_type == 0: + labels = _payload_labels(evio_file, chain[0]) + label, _ = labels.get((chain[1].tag, (chain[1].num or 0) & 0xF, bank.tag), ("payload_unmapped", fs.MODULE_UNKNOWN)) + children = _hit_children(evio_file, bank, node_id) if label == "payload_hits" else [] + else: + children = [] + node_info = describe_bank(bank, node_id) + if len(chain) == 1: + node_info.update({"kind": "event", "index": int(parts[1][1:])}) + result = page(children, start, count) + result.update({"node": node_info, "errors": list(bank.errors)}) + return result + + +# ---------------------------------------------------------------- raw words +def words_at(evio_file: EvioFile, offset: int, count: int, record_index: Optional[int] = None) -> dict: + """``count`` 32-bit words from byte ``offset`` as hex strings in the file's byte order, clipped to the buffer. + + Without ``record_index`` the offset is a file offset (record headers, events of + plain records). With it, the offset indexes that record's buffer, which is the + only way to see the events of an LZ4 or gzip record; for a plain record both + views agree. + """ + if offset < 0 or offset % 4: + raise ApiError(400, f"offset must be a non-negative multiple of 4, got {offset}") + if record_index is None: + buffer, source = evio_file.mm, "file" + else: + if record_index >= evio_file.record_count: + raise ApiError(404, f"record {record_index} does not exist ({evio_file.record_count} records)") + buffer, source = evio_file.get_record(record_index).buffer, f"record {record_index}" + if offset >= len(buffer): + raise ApiError(404, f"offset {offset} is past the end of the {source} buffer ({len(buffer)} bytes)") + count = max(0, min(count, MAX_WORDS, (len(buffer) - offset) // 4)) + words = np.frombuffer(buffer, dtype=np.dtype(evio_file.header.endian + "u4"), count=count, offset=offset).tolist() + return {"offset": offset, "count": count, "record": record_index, "endian": evio_file.header.endian, + "max_count": MAX_WORDS, "words": [f"{word:08x}" for word in words]} diff --git a/pyevio/web/style.css b/pyevio/web/style.css new file mode 100644 index 0000000..165146e --- /dev/null +++ b/pyevio/web/style.css @@ -0,0 +1,142 @@ +/* pyevio web viewer — one palette, defined once here; charts read it through var(--…) in SVG attributes. + Layout: app bar → folder browser → status strip → tabs (Overview / Plots / Records / Structure). 1280 px is the target width. */ +:root { + --bg: #f6f6f3; --surface: #fcfcfb; --line: #e3e2dc; --grid: #ecebe6; --hover: #efeee9; --sel: #dbe8fa; + --fg: #0b0b0b; --fg2: #52514e; --fg3: #7a7974; + --s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; + --heat-lo: #e4eefb; --heat-hi: #0d366b; --dead: #d9d8d2; --hdr: #fde8d5; + --c-file: #52514e; --c-rec: #4a3aa7; --c-bank: #1c5cab; --c-seg: #0f7a55; --c-hit: #b4500f; + --ok: #0ca30c; --info: #6b6a66; --warn: #e0a010; --error: #d03b3b; + --sans: system-ui, -apple-system, "Segoe UI", sans-serif; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; +} +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { + --bg: #121211; --surface: #1a1a19; --line: #33332f; --grid: #2a2a27; --hover: #252523; --sel: #1d3556; + --fg: #f4f4f1; --fg2: #c3c2b7; --fg3: #8f8e86; + --s1: #4a93ea; --s2: #e8784c; --s3: #2bbd88; + --heat-lo: #1d2a3d; --heat-hi: #8fc0f5; --dead: #3a3a36; --hdr: #4a2c16; + --c-file: #c3c2b7; --c-rec: #9085e9; --c-bank: #6da7ec; --c-seg: #3cc495; --c-hit: #f08a52; + --ok: #3cc43c; --info: #9a9990; --warn: #f0b429; --error: #ef6464; color-scheme: dark; } } +:root[data-theme="dark"] { + --bg: #121211; --surface: #1a1a19; --line: #33332f; --grid: #2a2a27; --hover: #252523; --sel: #1d3556; + --fg: #f4f4f1; --fg2: #c3c2b7; --fg3: #8f8e86; + --s1: #4a93ea; --s2: #e8784c; --s3: #2bbd88; + --heat-lo: #1d2a3d; --heat-hi: #8fc0f5; --dead: #3a3a36; --hdr: #4a2c16; + --c-file: #c3c2b7; --c-rec: #9085e9; --c-bank: #6da7ec; --c-seg: #3cc495; --c-hit: #f08a52; + --ok: #3cc43c; --info: #9a9990; --warn: #f0b429; --error: #ef6464; color-scheme: dark; } + +body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 var(--sans); } +[hidden] { display: none !important; } /* the hidden attribute must win over display: grid/flex on the same element */ +.wrap { max-width: 1248px; margin: 0 auto; padding: 20px 16px 48px; display: grid; gap: 16px; } +h1 { font-size: 20px; margin: 0; font-weight: 600; } +h2 { font-size: 15px; margin: 0; font-weight: 600; } +h3 { font-size: 14px; margin: 0; font-weight: 600; } +code { font-family: var(--mono); font-size: .9em; } +.small { font-size: 12.5px; color: var(--fg3); } +.bar { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } +.bar .grow { flex: 1; } +.btn { font: 500 13px var(--sans); background: var(--s1); color: #fff; border: 0; border-radius: 6px; padding: 7px 14px; cursor: pointer; } +.btn.ghost { background: transparent; color: var(--fg); border: 1px solid var(--line); } +.btn:hover { filter: brightness(1.08); } .btn.ghost:hover { background: var(--hover); filter: none; } +.btn:disabled { opacity: .45; cursor: default; filter: none; } +.btn:focus-visible, .tab:focus-visible, select:focus-visible, input:focus-visible { outline: 2px solid var(--s1); outline-offset: 2px; } +select, input[type=number], input[type=text] { font: 13px var(--sans); background: var(--surface); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; } + +/* folder browser */ +.browser { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; display: grid; min-width: 0; } +.browser .bh { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; padding: 10px 12px; border-bottom: 1px solid var(--line); } +.browser .bh .path { flex: 1; min-width: 0; font: 13px var(--mono); color: var(--fg2); overflow-wrap: anywhere; } +.browser .bh label { font-size: 12.5px; color: var(--fg2); display: flex; gap: 4px; align-items: center; } +.blist { max-height: 300px; overflow: auto; padding: 4px; font: 13px var(--mono); } +.bitem { display: flex; gap: 10px; align-items: baseline; width: 100%; text-align: left; background: none; border: 0; color: var(--fg); font: inherit; padding: 4px 8px; border-radius: 4px; cursor: pointer; } +.bitem:hover { background: var(--hover); } +.bitem .bk { font-size: 10.5px; padding: 0 5px; border-radius: 3px; border: 1px solid currentColor; flex: none; } +.bitem.dir .bk { color: var(--c-rec); } .bitem.file .bk { color: var(--c-bank); } +.bitem .bn { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bitem .bs { color: var(--fg3); flex: none; } +.bempty { padding: 10px 12px; color: var(--fg3); font-family: var(--sans); font-size: 13px; } + +/* status strip */ +.status { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 10px 14px; display: grid; gap: 6px; } +.status .file { font: 500 14px var(--mono); overflow-wrap: anywhere; } +.status .msg { color: var(--fg2); font-size: 13px; } +.status .err { color: var(--error); font-size: 13px; } +.prog { height: 4px; background: var(--grid); border-radius: 2px; overflow: hidden; } +.prog i { display: block; height: 100%; background: var(--s1); width: 0; transition: width .2s; } + +/* tabs and views */ +.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); } +.tab { font: 500 14px var(--sans); background: none; border: 0; border-bottom: 2px solid transparent; color: var(--fg2); padding: 8px 12px; cursor: pointer; } +.tab[aria-selected="true"] { color: var(--fg); border-bottom-color: var(--s1); } +.tab:disabled { color: var(--fg3); cursor: default; } +.view { display: grid; gap: 16px; } +.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 16px; min-width: 0; display: grid; gap: 10px; align-content: start; } +.panel p { margin: 0; color: var(--fg2); font-size: 13.5px; } +.grid2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 440px), 1fr)); gap: 16px; } +.grid3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: 16px 20px; } +.cell { display: grid; gap: 4px; align-content: start; min-width: 0; } +.cell .sub { font-size: 12.5px; color: var(--fg3); } +.controls { display: flex; gap: 8px 14px; flex-wrap: wrap; align-items: center; font-size: 13px; color: var(--fg2); } +.controls label { display: flex; gap: 6px; align-items: center; } +.empty { color: var(--fg3); font-size: 13px; padding: 8px 0; } + +/* overview */ +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; } +.tile { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; } +.tile .v { font: 500 21px var(--mono); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } +.tile .k { color: var(--fg2); font-size: 13px; } +.checks { display: grid; gap: 8px; } +.ck { display: grid; grid-template-columns: 22px 1fr; gap: 8px; font-size: 14px; } +.ico { font: 600 12px var(--mono); width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; color: #fff; margin-top: 1px; } +.ico.ok { background: var(--ok); } .ico.info { background: var(--info); } .ico.warn { background: var(--warn); color: #0b0b0b; } .ico.error { background: var(--error); } +.ck .d { color: var(--fg2); } +ul.find { margin: 0; padding-left: 18px; color: var(--fg2); display: grid; gap: 4px; } +ul.find b { color: var(--fg); font-weight: 500; } +.fields { border-collapse: collapse; font: 12.5px var(--mono); width: 100%; } +.fields td { padding: 3px 8px 3px 0; border-bottom: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; } +.fields td:first-child { color: var(--fg2); font-family: var(--sans); white-space: nowrap; width: 40%; } + +/* tables (records, ROC rows, map entries) */ +.scroll { overflow-x: auto; } +table.t { border-collapse: collapse; font: 12.5px var(--mono); font-variant-numeric: tabular-nums; width: 100%; } +table.t th, table.t td { padding: 4px 8px; text-align: right; border-bottom: 1px solid var(--grid); white-space: nowrap; } +table.t th { color: var(--fg2); font-weight: 500; position: sticky; top: 0; background: var(--surface); } +table.t th.l, table.t td.l { text-align: left; } +table.t tbody tr.click { cursor: pointer; } table.t tbody tr.click:hover { background: var(--hover); } +table.t td.bad { color: var(--error); } table.t td.warn { color: var(--warn); } +.pager { display: flex; gap: 8px 14px; align-items: center; flex-wrap: wrap; font-size: 13px; color: var(--fg2); } + +/* charts */ +svg.chart { display: block; overflow: visible; width: 100%; height: auto; } +svg text { fill: var(--fg2); font: 11px var(--mono); } +svg text.axis-title { fill: var(--fg3); font-family: var(--sans); } +svg .grid { stroke: var(--grid); } svg .axis { stroke: var(--line); } +svg .series { fill: none; stroke: var(--s1); stroke-width: 2; stroke-linejoin: round; } +svg .hover-line { stroke: var(--fg3); stroke-dasharray: 3 3; } +svg .hover-dot { fill: var(--s1); stroke: var(--surface); stroke-width: 2; } +svg .hit-area { fill: transparent; } +svg .cell-dead { fill: var(--dead); } +svg .cell:hover { stroke: var(--fg); stroke-width: 1.5; } +.scale { display: flex; align-items: center; gap: 8px; font: 11px var(--mono); color: var(--fg2); } +.scale .ramp { width: 160px; height: 10px; border-radius: 2px; background: linear-gradient(90deg, var(--heat-lo), var(--heat-hi)); } +.tip { position: fixed; pointer-events: none; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 6px 9px; font: 12px/1.45 var(--mono); color: var(--fg); box-shadow: 0 4px 14px rgb(0 0 0 / .12); z-index: 10; white-space: nowrap; } + +/* structure tab */ +.panes { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: 16px; align-items: start; } +.pane { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; min-width: 0; } +.pane-h { padding: 10px 14px; border-bottom: 1px solid var(--line); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 13px; color: var(--fg2); } +.tree { padding: 8px 6px 12px; overflow: auto; max-height: 76vh; font: 12.5px/1.4 var(--mono); } +.node > .row { display: flex; align-items: baseline; gap: 6px; padding: 2px 6px; border-radius: 4px; cursor: pointer; white-space: nowrap; } +.node > .row:hover { background: var(--hover); } .node > .row.sel { background: var(--sel); } +.tw { width: 12px; flex: none; color: var(--fg3); text-align: center; } +.kind { font-size: 10.5px; padding: 0 5px; border-radius: 3px; border: 1px solid currentColor; flex: none; } +.k-file { color: var(--c-file); } .k-record { color: var(--c-rec); } .k-trailer { color: var(--c-rec); } .k-event { color: var(--c-bank); } +.k-bank { color: var(--c-bank); } .k-segment { color: var(--c-seg); } .k-hit { color: var(--c-hit); } +.nm { color: var(--fg); font-family: var(--sans); font-size: 13px; } .meta { color: var(--fg3); } .role { color: var(--c-seg); } +.kids { margin-left: 11px; padding-left: 10px; border-left: 1px solid var(--line); } +.more { color: var(--s1); font-family: var(--sans); font-size: 12.5px; padding: 2px 6px 2px 24px; cursor: pointer; background: none; border: 0; } +.detail { padding: 14px; display: grid; gap: 12px; } +.detail .path { font: 11.5px var(--mono); color: var(--fg3); overflow-wrap: anywhere; } +.detail p { margin: 0; color: var(--fg2); font-size: 13.5px; } +.hex { font: 12px/1.6 var(--mono); display: grid; grid-template-columns: auto repeat(4, auto); gap: 0 12px; justify-content: start; overflow-x: auto; } +.hex .a { color: var(--fg3); } .hex .h { background: var(--hdr); border-radius: 3px; padding: 0 2px; } +.errs { color: var(--error); font: 12.5px var(--mono); margin: 0; padding-left: 16px; } diff --git a/pyevio/web/tree.js b/pyevio/web/tree.js new file mode 100644 index 0000000..b01d289 --- /dev/null +++ b/pyevio/web/tree.js @@ -0,0 +1,305 @@ +// tree.js — the records table, the lazy structure tree and the hex view. +// Every label here prints a field the server sent (/records, /structure, /words). The viewer never reads a bank +// header itself: the record index for /words?record= is parsed from the node id, and the only format knowledge is +// how many words of a dump to highlight as "header" (2 for a bank, 1 for a segment, header_length for a record). + +import { formatNumber } from './charts.js'; + +const HEX_WORDS_FIRST = 64; +const HEX_WORDS_STEP = 256; + +export const hex = (value, digits = 4) => '0x' + Number(value).toString(16).padStart(digits, '0'); + +function element(tag, className, textContent) { + const node = document.createElement(tag); + if (className) node.className = className; + if (textContent !== undefined) node.textContent = textContent; + return node; +} + +// ---------------------------------------------------------------- records table +const RECORD_COLUMNS = [ + { title: '#', value: row => row.index, left: true }, + { title: 'offset', value: row => hex(row.offset, 8) }, + { title: 'size (B)', value: row => formatNumber(row.size) }, + { title: 'record #', value: row => row.record_number }, + { title: 'events declared / found', value: row => `${row.event_count} / ${row.events_found}`, + className: row => (row.events_found !== row.event_count ? 'bad' : '') }, + { title: 'header type', value: row => (row.is_trailer ? `${row.header_type} (trailer)` : row.header_type) }, + { title: 'compression', value: row => row.compression }, + { title: 'last-record bit', value: row => (row.bit_info.is_last_record ? 'set' : '—') }, + { title: 'index', value: row => (row.index_array_length ? `${row.index_array_length} B` : 'none') }, + { title: 'truncated', value: row => (row.is_truncated ? 'yes' : '—'), className: row => (row.is_truncated ? 'bad' : '') }, + { title: 'header quirk', value: row => row.header_quirk ?? '—', className: row => (row.header_quirk ? 'warn' : ''), left: true }, + { title: 'errors', value: row => (row.errors.length ? row.errors.length : '—'), className: row => (row.errors.length ? 'bad' : ''), + title_of: row => row.errors.join('\n') }, +]; + +/** Fill `table` with the rows of one /records page; `onRow(row)` runs when a row is clicked. */ +export function renderRecordsTable(table, rows, onRow) { + table.replaceChildren(); + const head = table.createTHead().insertRow(); + RECORD_COLUMNS.forEach(column => head.appendChild(element('th', column.left ? 'l' : '', column.title))); + const body = table.createTBody(); + rows.forEach(row => { + const tableRow = body.insertRow(); + tableRow.className = 'click'; + tableRow.dataset.id = row.id; + RECORD_COLUMNS.forEach(column => { + const cell = tableRow.insertCell(); + cell.textContent = column.value(row); + cell.className = [column.left ? 'l' : '', column.className ? column.className(row) : ''].join(' ').trim(); + if (column.title_of) cell.title = column.title_of(row); + }); + tableRow.addEventListener('click', () => onRow(row)); + }); +} + +// ---------------------------------------------------------------- node labels +const ROLE_LABELS = { + stream_info: 'stream info', roc_bank: 'ROC bank', roc_stream_info: 'ROC stream info', payload_hits: 'payload', + payload_empty: 'payload (empty)', payload_words: 'payload', payload_unmapped: 'payload (unmapped port)', + time_slice: 'time slice', aggregation_info: 'aggregation info (AIS)', payload_map: 'payload-port map', +}; + +/** `{kind, name, meta}` for one tree row, from the node's fields. */ +export function nodeLabel(node) { + switch (node.kind) { + case 'file': + return { kind: 'file', name: 'file', meta: `${node.header_layout}, ${node.record_count} records${node.has_trailer ? ' + trailer' : ''}` }; + case 'trailer': + return { kind: 'trailer', name: 'trailer', meta: `${formatNumber(node.size)} B at ${hex(node.offset, 8)}` }; + case 'record': + return { kind: 'record', name: `record ${node.index}`, meta: recordMeta(node) }; + case 'event': + return { kind: 'event', name: `event ${node.index}`, meta: `tag ${hex(node.tag)} type ${hex(node.data_type, 2)} num ${node.num}, ${node.length + 1} words` }; + case 'hit': + return { kind: 'hit', name: `hit ${node.index}`, meta: `ch ${node.channel} t ${node.t_ns} ns q ${node.charge} ${node.word}${node.flag ? ' FLAG' : ''}` }; + default: + return { kind: node.structure === 'Bank' ? 'bank' : 'segment', name: bankName(node), meta: bankMeta(node) }; + } +} + +function recordMeta(node) { + const parts = [`${node.event_count} events`, `${formatNumber(node.size)} B`]; + if (node.compression !== 'none') parts.push(node.compression); + if (node.is_truncated) parts.push('TRUNCATED'); + if (node.errors && node.errors.length) parts.push(`${node.errors.length} errors`); + return parts.join(', '); +} + +function bankName(node) { + const role = ROLE_LABELS[node.role]; + if (node.role === 'roc_bank') return `${role} ${node.roc} stream ${node.stream}`; + if (node.port !== undefined) return `${role} port ${node.port}`; + return role || `${node.structure} ${hex(node.tag)}`; +} + +function bankMeta(node) { + const parts = [`tag ${hex(node.tag)}`, `type ${hex(node.data_type, 2)}`]; + if (node.num !== null && node.num !== undefined) parts.push(`num ${node.num}`); + parts.push(`${node.length} words`); + if (node.module) parts.push(node.module); + if (node.n_words !== undefined && node.role === 'payload_hits') parts.push(`${node.n_words} hits`); + if (node.entries) parts.push(`${node.entries.length} entries`); + return parts.join(' '); +} + +// ---------------------------------------------------------------- tree +/** + * A lazy tree over /structure. `loadChildren(nodeId, start)` returns the server page `{total, start, children}`; + * `onSelect(node)` runs when a row is clicked. Rows carry data-id and data-role for tests and styling. + */ +export function createTree(container, { loadChildren, onSelect }) { + const entries = new Map(); // node id → {node, element, kidsElement, loaded, total} + let selectedRow = null; + + function reset(rootPage) { + container.replaceChildren(); + entries.clear(); + const rootElement = renderNode(rootPage.node, container); + const entry = entries.get(rootPage.node.id); + entry.expanded = true; + entry.kidsElement.hidden = false; + appendPage(entry, rootPage); + rootElement.querySelector('.tw').textContent = '▾'; + } + + function renderNode(node, parent) { + const label = nodeLabel(node); + const nodeElement = element('div', 'node'); + nodeElement.dataset.id = node.id; + if (node.role) nodeElement.dataset.role = node.role; + const row = element('div', 'row'); + row.tabIndex = 0; + row.append(element('span', 'tw', node.has_children ? '▸' : ''), element('span', `kind k-${label.kind}`, label.kind), + element('span', 'nm', label.name), element('span', 'meta', label.meta)); + const kidsElement = element('div', 'kids'); + kidsElement.hidden = true; + nodeElement.append(row, kidsElement); + parent.appendChild(nodeElement); + const entry = { node, element: nodeElement, row, kidsElement, loaded: 0, total: null, expanded: false }; + entries.set(node.id, entry); + row.addEventListener('click', () => { select(entry); toggle(entry); }); + row.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); row.click(); } }); + return nodeElement; + } + + function select(entry) { + if (selectedRow) selectedRow.classList.remove('sel'); + selectedRow = entry.row; + selectedRow.classList.add('sel'); + onSelect(entry.node); + } + + async function toggle(entry) { + if (!entry.node.has_children) return; + entry.expanded = !entry.expanded; + entry.kidsElement.hidden = !entry.expanded; + entry.row.querySelector('.tw').textContent = entry.expanded ? '▾' : '▸'; + if (entry.expanded && entry.total === null) await loadMore(entry); + } + + async function loadMore(entry) { + const page = await loadChildren(entry.node.id, entry.loaded); + if (!page) return; // a stale response: the file changed under this tree + appendPage(entry, page); + } + + function appendPage(entry, page) { + const moreButton = entry.kidsElement.querySelector('.more'); + if (moreButton) moreButton.remove(); + page.children.forEach(child => renderNode(child, entry.kidsElement)); + entry.loaded += page.children.length; + entry.total = page.total; + if (page.errors && page.errors.length && !entry.kidsElement.querySelector('.errs')) { + const list = element('ul', 'errs'); + page.errors.forEach(message => list.appendChild(element('li', '', message))); + entry.kidsElement.prepend(list); + } + if (entry.loaded < entry.total) { + const more = element('button', 'more', `show more (${formatNumber(entry.loaded)} of ${formatNumber(entry.total)} loaded)`); + more.type = 'button'; + more.addEventListener('click', () => loadMore(entry)); + entry.kidsElement.appendChild(more); + } + } + + /** Expand and select a top-level record (`r12`) or the trailer (`t`), loading root pages until it is present. */ + async function openRecord(recordId) { + const root = entries.get(''); + if (!root) return; + while (!entries.has(recordId) && root.loaded < root.total) await loadMore(root); + const entry = entries.get(recordId); + if (!entry) return; + select(entry); + if (!entry.expanded) await toggle(entry); + entry.row.scrollIntoView({ block: 'center' }); + } + + return { reset, openRecord }; +} + +// ---------------------------------------------------------------- detail pane +const HIDDEN_FIELDS = new Set(['id', 'has_children', 'kind', 'entries', 'bit_info', 'errors']); + +/** + * Show a node's fields, its decoded entries (payload-port map) and its raw words. + * `loadWords({offset, count, record})` fetches /words; hit rows have no offset and show their fields only. + */ +export async function renderDetail(container, node, loadWords) { + container.replaceChildren(); + container.appendChild(element('div', 'path', node.id || 'file')); + container.appendChild(fieldsTable(node)); + if (node.bit_info) { + container.appendChild(element('h3', '', 'bit info')); + container.appendChild(fieldsTable(node.bit_info)); + } + if (node.errors && node.errors.length) { + const list = element('ul', 'errs'); + node.errors.forEach(message => list.appendChild(element('li', '', message))); + container.appendChild(list); + } + if (node.entries) container.appendChild(entriesTable(node.entries)); + const words = wordsRequest(node); + if (words) await appendHex(container, node, words, loadWords); +} + +function fieldsTable(object) { + const table = element('table', 'fields'); + Object.entries(object).forEach(([name, value]) => { + if (HIDDEN_FIELDS.has(name) || value === undefined) return; + const row = table.insertRow(); + row.insertCell().textContent = name; + row.insertCell().textContent = fieldText(name, value); + }); + return table; +} + +function fieldText(name, value) { + if (value === null) return '—'; + if (typeof value === 'boolean') return value ? 'yes' : 'no'; + if (Array.isArray(value)) return value.join(', '); + if (typeof value === 'number' && (name === 'tag' || name === 'offset' || name === 'declared_end')) return `${hex(value)} (${formatNumber(value)})`; + if (typeof value === 'number' && name === 'data_type') return hex(value, 2); + return formatNumber(value); +} + +function entriesTable(entries) { + const table = element('table', 't'); + const head = table.createTHead().insertRow(); + ['entry', 'port', 'module id', 'module'].forEach(title => head.appendChild(element('th', '', title))); + const body = table.createTBody(); + entries.forEach((entry, entry_i) => { + const row = body.insertRow(); + [entry_i, entry.port, entry.module_id, entry.module].forEach(value => { row.insertCell().textContent = value; }); + }); + return table; +} + +/** Which words to fetch for a node: record headers are file offsets, banks index their record's buffer. */ +function wordsRequest(node) { + if (node.kind === 'hit' || node.kind === 'file' || node.offset === undefined) return null; + const totalWords = Math.floor(node.size / 4); + if (node.kind === 'record' || node.kind === 'trailer') { + return { offset: node.offset, record: null, totalWords, headerWords: node.header_length }; + } + const recordMatch = /^r(\d+)\//.exec(node.id); + return { offset: node.offset, record: recordMatch ? Number(recordMatch[1]) : null, totalWords, + headerWords: node.structure === 'Bank' ? 2 : 1 }; +} + +async function appendHex(container, node, request, loadWords) { + const heading = element('h3', '', 'raw words'); + const hexElement = element('div', 'hex'); + const note = element('p', 'small'); + container.append(heading, hexElement, note); + let shown = 0; + const showMore = async () => { + const count = Math.min(shown ? HEX_WORDS_STEP : HEX_WORDS_FIRST, request.totalWords - shown); + const page = await loadWords({ offset: request.offset + shown * 4, count, record: request.record }); + if (!page) return; + renderHexWords(hexElement, page.words, request.offset + shown * 4, request.headerWords - shown); + shown += page.count; + note.replaceChildren(); + note.textContent = `${formatNumber(shown)} of ${formatNumber(request.totalWords)} words, byte order ${page.endian === '<' ? 'little' : 'big'}-endian; offsets ${request.record === null ? 'in the file' : `in record ${request.record}'s buffer`}. `; + if (shown < request.totalWords && page.count > 0) { + const button = element('button', 'more', `show ${Math.min(HEX_WORDS_STEP, request.totalWords - shown)} more`); + button.type = 'button'; + button.addEventListener('click', showMore); + note.appendChild(button); + } + }; + await showMore(); +} + +/** Four words per row, the row's byte offset first; the first `headerWords` words are marked as the header. */ +export function renderHexWords(container, words, baseOffset, headerWords) { + for (let word_i = 0; word_i < words.length; word_i += 4) { + container.appendChild(element('span', 'a', hex(baseOffset + word_i * 4, 8))); + for (let column_i = 0; column_i < 4; column_i++) { + const index = word_i + column_i; + container.appendChild(element('span', index < headerWords ? 'h' : '', index < words.length ? words[index] : '')); + } + } +} diff --git a/pyproject.toml b/pyproject.toml index 5222f32..2c3d4b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,12 +12,34 @@ dependencies = [ "rich", "numpy", "matplotlib", - "textual" ] [project.optional-dependencies] test = ["pytest"] +web = [ + "fastapi>=0.110", + "uvicorn>=0.27", +] +gui = [ + "textual>=4.0", + "textual-plotext>=1.0", +] + +# uv installs the dev group by default, so `uv run pytest` works without extras. +[dependency-groups] +dev = [ + "fastapi>=0.128.8", + "httpx>=0.28.1", + "playwright>=1.60.0", + "pyarrow>=21.0.0", + "pytest", + "pytest-asyncio>=0.24", + "pyyaml>=6.0.3", + "textual>=4.0", + "textual-plotext>=1.0", + "uvicorn>=0.39.0", +] [build-system] @@ -27,5 +49,20 @@ build-backend = "setuptools.build_meta" [tool.setuptools.dynamic] version = {attr = "pyevio.__version__"} +# Only the library is a package; docs/, examples/, tests/ and any other +# top-level folder sit beside it in a flat layout and must not be discovered as packages. +[tool.setuptools.packages.find] +include = ["pyevio*"] + [project.scripts] -pyevio = "pyevio.cli:main" \ No newline at end of file +pyevio = "pyevio.cli:main" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +markers = [ + "slow: scans multi-gigabyte files; tests/golden/test_corpus.py runs only with PYEVIO_CORPUS=1", +] + +[tool.setuptools.package-data] +pyevio = ["web/*.html", "web/*.js", "web/*.css"] diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..dbf06d9 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,1793 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.8", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "evio-stream" +version = "0.1.0" +dependencies = [ + "flate2", + "lz4_flex", +] + +[[package]] +name = "evio-tui" +version = "0.1.0" +dependencies = [ + "crossterm", + "evio-stream", + "ratatui", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "finl_unicode" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.21", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "lz4_flex" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" + +[[package]] +name = "mac_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0581a75c45969c63afd2bb87b1d8b25cfcc556c7a918cf9bb13b0fe8d2381" +dependencies = [ + "nix 0.31.3", + "windows-sys", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b568374ba38b33a6c627141f891faf16902b08d2db26b8ede1bcb0a15b1919fa" +dependencies = [ + "memchr", + "psm", + "stacker", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66e184b924cebaaff20ab2256ca52f12332d528a39aa76553b5d96f92aacf7f" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a87478d267e4de54a626af9754f2f0f58e927aac6ed0575fe89bc05ad6851694" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "pest_meta" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f986f248b4241ac359b831f6139aaa34e03b08a37b6caf7e201a33f95c869e1" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01016da373cd8f7ef12624f796309f5c31ba8d646dd08856c02cd741d823c622" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.2", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.2", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.8", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..ddd5256 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] +resolver = "2" +members = ["evio-stream", "evio-tui"] + +# Cargo reads profiles from the workspace root only (a member's profile is +# ignored with a warning), so evio-stream's release profile lives here. +[profile.release] +# Line tables for `perf`; no effect on speed. +debug = 1 diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..f03953b --- /dev/null +++ b/rust/README.md @@ -0,0 +1,252 @@ +# evio-stream: a streaming EVIO v6 reader in Rust + +`rust/` is a cargo workspace of two crates: `evio-stream`, a read-only, +streaming reader for the files pyevio reads, written against the same +format references (`docs/evio6_file_structure.md`, +`docs/stream_decoding.md`) and checked against the same rulers, a library +with two command-line tools; and `evio-tui`, a minimal terminal viewer on +top of it (see [evio-tui](#evio-tui-a-minimal-terminal-viewer) below). + +What it reads: + +- EVIO v6 files with a file header (`v6`), bare v6 record streams without one + (`v6-records`, the evio writer buffer dumps), and the JLab SRO hardware + stream of 8-word block headers with no file header (`sro-8word`). +- Plain, LZ4, LZ4_BEST and gzip records; both byte orders. +- A final record cut by the end of the file: its complete events are kept and + reported as a `truncated_tail` check, as pyevio does. Garbage after the last + record ends the scan with a `bad_record_header` check. +- The bank / segment / tagsegment tree of any event, every length checked + against its container; a bad length is an error value, never a panic. +- CODA streaming fADC250 frames (tag 0xFF60): the frame walk of + `pyevio.decoders.fadc250_stream`, with the module per port from the 0x41 + payload map, hits for fADC250 ports, word counts for other modules (DCRB), + empty payload banks, and the AIS counted but never used to locate ROC banks. + +What it refuses: evio-library v4 files and v1—3 files, with an error naming +the version (`EVIO version 4 file is not supported ... evio2xml, eviocopy`), +as pyevio does. + +The reader works over any `std::io::Read` (file, stdin, pipe), never seeks, +and holds one record in memory at a time. No `unsafe` (`#![forbid(unsafe_code)]`). + +## Library + +```rust +use evio_stream::{Bank, Checks, FrameDecoder, RecordReader}; + +let mut reader = RecordReader::open("run.evio")?; // or RecordReader::new(any_read) +println!("{}", reader.layout()); // v6 | v6-records | sro-8word +let mut decoder = FrameDecoder::new(); +while let Some(record) = reader.next_record()? { // one record in memory + for event in record.iter_events() { // Words<'_> over the record buffer + let bank = Bank::event(event)?; // tag, data_type, num, length, children() + if let Some(frame) = decoder.decode(event) { // None for a non-0xFF60 event + for hit in frame.hits() { /* frame, timestamp, roc, stream, port, channel, time_raw, charge */ } + } + } + reader.recycle(record); // reuse the buffer +} +reader.truncated_tail(); // Option +reader.bad_record_header(); // Option +// Or in one call: Checks::scan(reader)? -> records, events, trailer, checks, errors, bytes +``` + +Types: `RecordReader` (iterator of `Result`), `Record` +(header, buffer, `events()`, `event(i)`, `errors`, `is_truncated`), +`RecordHeader` / `FileHeader` / `FileLayout` / `Compression`, `Words` +(bounds-checked word view in file byte order), `Bank` / `Children` / +`WalkError`, `FrameDecoder` / `Frame` / `Hit` / `RocBank` / `PayloadBank`, +`Checks`, `Error`. + +Layout decisions: + +- Words stay in file byte order; `Words::get` decodes each word with the + file's `ByteOrder` on read. A little-endian file on a little-endian host + pays nothing, and the SRO walk reads only headers, so swapping a whole + 15 GB record stream in place would touch every byte twice for nothing. +- A `Record` owns one `Vec`: the bytes after the header as read from the + file for a plain record, the decompressed bytes for a compressed one + (sized as index array + padded user header + uncompressed length). Event + spans and bank views are slices into it; nothing is copied per event. The + reader reads the body into reserved capacity without zeroing it, and + `recycle` hands the allocation back for the next record. +- `FrameDecoder` keeps its scratch vectors across frames; a decoded `Frame` + borrows the event and the decoder, and `hits()` decodes the words lazily. + +## Tools + +``` +cargo build --release +target/release/evio-count FILE|- [--fadc250] +target/release/evio-dump FILE|- [--record N] [--event M] [--words K] +``` + +`evio-count` prints layout, byte order, records, events, trailer, the +`truncated_tail` and `bad_record_header` checks, record errors and the +throughput. With `--fadc250` it walks every stream frame and decodes every +hit word: frames, ROC banks, payload banks (empty, unmapped), fADC250 hits +(complete records and the salvaged tail separately), other-module words, flag +words, AIS counts, decode errors, a per-ROC table. + +`evio-dump` prints the file header and one line per record header; with +`--record N` the full header and event table of record N; with `--event M` +the bank tree of event M of record N (tag, type, num, length) with the first +K words of each leaf in hex. + +## evio-tui: a minimal terminal viewer + +``` +cd rust +cargo build --release +target/release/evio-tui FILE +``` + +`rust/evio-tui` is a fast way to look inside a file from a terminal, +nothing more; the rich viewers are the Python ones (`pyevio gui`, +`pyevio web`). It is built on `ratatui` and `crossterm` (the only +dependencies beyond `evio-stream`). Three screens, one level deeper each: + +- **Records.** A background thread streams the file from the start with + `RecordReader` and sends one small summary per record over a channel; the + list fills while the scan runs and the interface never waits on the file. + One row per record: index, offset, bytes, record or trailer, events + declared and found, compression, truncated, error count (rows with errors + or a cut tail in red). The side panel (terminals 100 columns and wider) + shows every header field of the selected record and the file line + (layout, byte order, file type, declared record count, dictionary, first + event, trailer index). The status line shows records, events, bytes, + seconds and GB/s while the scan runs, then the checks: trailer, + `truncated_tail (declared, present, salvaged)`, `bad_record_header`. +- **Events of a record.** Enter opens the selected record: index, length in + words, tag, data type, num, and "stream frame" for tag 0xFF60. One record + is in memory at a time. +- **Bank tree and words of an event.** Enter opens the selected event: one + row per bank, segment or tagsegment at every level (kind, tag, type, num, + length, total words), a `WalkError` in red where a length was bad. The + panel below shows the selected structure's first 64 words in hex, 8 per + row with the word offset at the left; `n`/`p` move the window. For a + stream frame `f` shows the `FrameDecoder` summary: frame number, + timestamp, ROC and payload bank counts, hits, other-module words, AIS, a + per-ROC table (module, payload banks, empty, hits, other words), and the + decoder's errors (red) and warnings (yellow). + +Keys: arrows, PgUp/PgDn, Home/End, `g`/`G` move; Enter (or Right) opens; +Esc (or Left, Backspace) goes back one level (it closes the frame panel +first); `n`/`p` next/previous 64 words; `f` frame summary; `?` one-screen +help; `q` or Ctrl-C quits from anywhere. +Standard input is not supported: the viewer opens records by seeking to +their offset, so `evio-tui -` prints a message and exits. + +Random access: the library reader is forward-only, but every layout starts +each record with a record header, so opening a record seeks a fresh file +handle to the offset noted during the scan and starts a new +`RecordReader::new` there: a 14-word v6 header is taken for the +`v6-records` layout, an 8-word SRO block header for `sro-8word`, and the +first record read is the one wanted. No layout hint and no library change +were needed; `evio-tui/tests/corpus_access.rs` checks it on the converted +corpus file and on the raw SRO stream (record 7000 of each, the trailer and +the cut tail), and `tests/render.rs` draws the screens into ratatui's +`TestBackend` for the golden files. + +Measured on this host, one thread, by the harness in +`tests/corpus_access.rs` (`--test-threads=1`; the logged run is +`/data/pyevio/logs/phase6_rust_tui_tests.log`): + +- `sro_000791.evio.00000.v6` (15.5 GB, `v6`): scan 1.66 s, 9.35 GB/s with + the file in the page cache, 7891 records + trailer (the list has one row + per record, the trailer included, and the status line names the trailer + separately so the count matches `evio-count`), 86,794 events; the first + record message arrives 1 ms after the thread starts; record 7000 (offset + 0x3364DC560, 1.9 MB, 11 stream frames) opens in 0.3 ms. +- Raw `sro_000791.evio.00000` (15.5 GB, `sro-8word`): 7893 records (the cut + tail included), 86,794 events, `truncated_tail (1967400, 531760, 2)`; + record 7000 (offset 0x33610BA10) opens in 0.4 ms; the cut tail opens with + its 2 salvaged events. The scan took 1.59 s (9.80 GB/s) with the file in + the page cache and 4.97 s (3.13 GB/s) in the logged run, where part of it + had been evicted by the scans before it. + +Opening a record is a seek plus one record read, so it costs the same at +record 0 and at record 7000; a 64 MB gzip record would take as long as its +decompression. + +Code layout: `scan` (the thread and its messages), `access` (seek and read +one record), `state` (what is shown and selected, the keys), `format` (the +text of every cell and panel), `tree` (the flattened bank tree), `ui` (the +only module that draws). Everything but `ui` runs without a terminal, and +the state machine is what the tests drive. + +## Rulers and tests + +``` +cd rust +cargo test --release --workspace # both crates: unit, golden, render, corpus harness +cargo fmt --all --check && cargo clippy --all-targets -- -D warnings +cd evio-stream +EVIO_CORPUS=1 cargo test --release --test corpus -- --nocapture # 140 GB, minutes +``` + +- Golden set (`tests/golden.rs`, `tests/data/golden_manifest.tsv` generated + from the evio-optim `manifest.yaml`): all 44 rows pass. 37 readable rows + (C++ and Java writers: plain LE/BE, LZ4, LZ4_BEST, gzip, dictionary, first + event, odd user header, trailer with and without index, append, split, + many records, 12-deep nesting, one 40 MB event, zero events, HIPO file + type, buffer dumps) give the manifest event count with the expected layout + and no check; every event's bank tree walks without error. The 7 reject + rows (v4 LE/BE, v4 with dictionary, v1—3) end with the version error. + The test skips when `/data/evio-lib/golden` (or `EVIO_GOLDEN_ROOT`) is absent. +- Corpus (`tests/corpus.rs`, `tests/data/corpus.tsv` from `corpus.yaml`): the + 8 converted v6 files (plain and LZ4) give their record and event counts. +- Raw SRO files of run 791: file 00000 gives 7893 records (the cut tail + included), 86,794 events, `truncated_tail (1967400, 531760, 2)`, + 763,992,122 fADC250 hits in the complete blocks + 17,652 in the two + salvaged frames = 764,009,774, 3,065,574,754 DCRB words, 357,743 empty + payload banks, 0 decode errors; file 00001 gives 7893 records, 86,805 + events, `(1960136, 1939708, 10)`, 764,052,526 hits in the complete blocks + + 87,735 in the ten salvaged frames = 764,140,261, and 3,065,895,268 DCRB + words. These match pyevio and the independent decoders + (`docs/stream_decoding.md`, the per-ROC `+salvage` columns of the SRO + reference tables). On both files `evio-count` also reports one record + error: the cut event of the tail ("event 2 ... declares 176292 bytes, + record data ends at ..."), the same message pyevio keeps in + `Record.errors`; it is the expected trace of the truncation, not a defect. + +Logs of the last run: `/data/pyevio/logs/phase6_rust_tests.log` and +`/data/pyevio/logs/phase6_rust_corpus.log`; the workspace run with +evio-tui: `/data/pyevio/logs/phase6_rust_tui_tests.log`. + +## Speed (one thread) + +Measured on this host (24 cores, 62 GB) with the raw file +`sro_000791.evio.00000` (15.5 GB) in the page cache, one thread: + +- `evio-count --fadc250`: 3.1 s, 5.0 GB/s, every one of the 764 million hit + words decoded (channel, time, charge, flag) and the 3.8 billion payload + words walked. pyevio decodes hits at about 1 GB/s on one core, and + `pyevio stats` takes 26 s on the same file with 20 workers. +- `evio-count` alone (record scan, event walk by length words): 1.6 s, + 9.5 GB/s. +- Converted v6 files from disk, record scan only: about 3.1 GB/s plain + (1.3 GB/s on a cold first read); LZ4 records 1.3 GB/s of compressed input, + bounded by the safe-Rust LZ4 decoder (`lz4_flex` with `safe-decode`), + which also bounds `--fadc250` on an LZ4 file (11.2 s, 1.07 GB/s of input, + about 1.4 GB/s of decompressed output). + +## Not included + +Writing, random access by record index in the library (the reader is +forward-only; evio-tui seeks to offsets it noted in its own scan), +dictionaries and composite data decoding (the bytes are exposed, not +parsed), and the statistics layer of `pyevio.stream_stats`. + +## Streaming differences from pyevio + +pyevio walks the whole block chain at open to tell an evio-library v4 file +from an SRO stream. A forward-only reader cannot look ahead, so a v4 file +whose first block has no bit-info bits yields its first blocks as `sro-8word` +records and returns the version error on the block that sets the last-block +or dictionary bit. The golden test drains the iterator and checks the +terminal error. Likewise, after a record with the last-record bit, bytes that +are no record header end the scan quietly (pyevio records no check there +either), and a record cut by the end of the file after such a record is +reported as a cut tail rather than ignored. diff --git a/rust/evio-stream/Cargo.toml b/rust/evio-stream/Cargo.toml new file mode 100644 index 0000000..6ea98af --- /dev/null +++ b/rust/evio-stream/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "evio-stream" +version = "0.1.0" +edition = "2021" +description = "Streaming, read-only reader for EVIO v6 files and JLab SRO hardware streams" +license = "MIT" +publish = false + +[dependencies] +# LZ4 block decompression: records with compression type 1 (LZ4) and 2 +# (LZ4_BEST) hold one LZ4 block without a frame header. Pure Rust, no C +# toolchain; "safe-decode" keeps the decoder free of unsafe code. +lz4_flex = { version = "0.11", default-features = false, features = ["std", "safe-decode"] } +# gzip (and zlib) decompression for records with compression type 3. +flate2 = "1.1" + +# The release profile (debug = 1 for `perf`) is set in the workspace root +# Cargo.toml, the only place cargo reads profiles from. diff --git a/rust/evio-stream/src/bank.rs b/rust/evio-stream/src/bank.rs new file mode 100644 index 0000000..fb00c05 --- /dev/null +++ b/rust/evio-stream/src/bank.rs @@ -0,0 +1,387 @@ +//! Bank, segment and tagsegment views with every length checked against the +//! container. + +use std::fmt; + +use crate::words::Words; + +/// The three EVIO structure kinds. A bank has a two-word header, a segment +/// and a tagsegment a one-word header. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StructureKind { + Bank, + Segment, + TagSegment, +} + +impl StructureKind { + /// Header words of the kind. + pub fn header_words(self) -> usize { + match self { + StructureKind::Bank => 2, + _ => 1, + } + } + + /// "Bank", "Segment" or "TagSegment". + pub fn name(self) -> &'static str { + match self { + StructureKind::Bank => "Bank", + StructureKind::Segment => "Segment", + StructureKind::TagSegment => "TagSegment", + } + } +} + +/// EVIO content types. +pub mod data_type { + pub const UNKNOWN32: u8 = 0x0; + pub const UINT32: u8 = 0x1; + pub const FLOAT32: u8 = 0x2; + pub const STRING: u8 = 0x3; + pub const INT16: u8 = 0x4; + pub const UINT16: u8 = 0x5; + pub const INT8: u8 = 0x6; + pub const UINT8: u8 = 0x7; + pub const FLOAT64: u8 = 0x8; + pub const INT64: u8 = 0x9; + pub const UINT64: u8 = 0xa; + pub const INT32: u8 = 0xb; + pub const TAGSEGMENT: u8 = 0xc; + pub const SEGMENT: u8 = 0xd; + pub const BANK: u8 = 0xe; + pub const COMPOSITE: u8 = 0xf; + pub const BANK2: u8 = 0x10; + pub const SEGMENT2: u8 = 0x20; + + /// Name of a content type code. + pub fn name(code: u8) -> &'static str { + match code { + UNKNOWN32 => "unknown32", + UINT32 => "uint32", + FLOAT32 => "float32", + STRING => "string", + INT16 => "int16", + UINT16 => "uint16", + INT8 => "int8", + UINT8 => "uint8", + FLOAT64 => "float64", + INT64 => "int64", + UINT64 => "uint64", + INT32 => "int32", + TAGSEGMENT => "tagsegments", + SEGMENT | SEGMENT2 => "segments", + BANK | BANK2 => "banks", + COMPOSITE => "composite", + _ => "unknown", + } + } + + /// The kind of the children of a container type, `None` for a leaf type. + pub fn child_kind(code: u8) -> Option { + match code { + BANK | BANK2 => Some(super::StructureKind::Bank), + SEGMENT | SEGMENT2 => Some(super::StructureKind::Segment), + TAGSEGMENT => Some(super::StructureKind::TagSegment), + _ => None, + } + } +} + +/// A length that does not fit its container. The walk that met it stops. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WalkError { + /// Word offset of the offending structure inside its container. + pub word: usize, + pub message: String, +} + +impl fmt::Display for WalkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "word {}: {}", self.word, self.message) + } +} + +impl std::error::Error for WalkError {} + +/// A view of one structure: its header words and the words that follow. +/// +/// Every length word counts the words after it, so a structure with length +/// N occupies N + 1 words. Children are typed by the parent's content type: +/// a bank of banks holds banks, a bank of segments holds segments (one-word +/// headers), a bank of tagsegments holds tagsegments. +#[derive(Clone, Copy)] +pub struct Bank<'a> { + words: Words<'a>, + kind: StructureKind, + /// Words that follow the length word (the length field). + pub length: u32, + pub tag: u16, + pub data_type: u8, + /// Bank num byte; 0 for segments and tagsegments. + pub num: u8, + /// Padding bytes for 8- and 16-bit types; 0 for tagsegments. + pub pad: u8, +} + +impl<'a> Bank<'a> { + /// Parse the structure of `kind` that starts at word 0 of `words`; the + /// view bounds it. The error names a header that has no room or a + /// length that runs past the view. + pub fn parse(words: Words<'a>, kind: StructureKind) -> Result, WalkError> { + let header_words = kind.header_words(); + if words.len() < header_words { + return Err(WalkError { + word: 0, + message: format!( + "{} header needs {header_words} words, {} present", + kind.name(), + words.len() + ), + }); + } + let (length, tag, data_type, num, pad) = match kind { + StructureKind::Bank => { + let length = words.get(0).unwrap_or(0); + let info = words.get(1).unwrap_or(0); + ( + length, + (info >> 16) as u16, + ((info >> 8) & 0x3F) as u8, + (info & 0xFF) as u8, + ((info >> 14) & 3) as u8, + ) + } + StructureKind::Segment => { + let h = words.get(0).unwrap_or(0); + ( + h & 0xFFFF, + (h >> 24) as u16, + ((h >> 16) & 0x3F) as u8, + 0, + ((h >> 22) & 3) as u8, + ) + } + StructureKind::TagSegment => { + let h = words.get(0).unwrap_or(0); + ( + h & 0xFFFF, + ((h >> 20) & 0xFFF) as u16, + ((h >> 16) & 0xF) as u8, + 0, + 0, + ) + } + }; + let total = length as usize + 1; + if total < header_words { + return Err(WalkError { + word: 0, + message: format!( + "{} declares length {length}, no room for its {header_words}-word header", + kind.name() + ), + }); + } + if total > words.len() { + return Err(WalkError { + word: 0, + message: format!( + "{} declares {length} words, ends at word {total} past its limit {}", + kind.name(), + words.len() + ), + }); + } + let words = words.slice(0, total).ok_or_else(|| WalkError { + word: 0, + message: "structure range out of view".into(), + })?; + Ok(Bank { + words, + kind, + length, + tag, + data_type, + num, + pad, + }) + } + + /// Parse an event: the top-level bank of `words`. + pub fn event(words: Words<'a>) -> Result, WalkError> { + Bank::parse(words, StructureKind::Bank) + } + + /// Bank, segment or tagsegment. + pub fn kind(&self) -> StructureKind { + self.kind + } + + /// Header words plus the words that follow (length + 1). + pub fn total_words(&self) -> usize { + self.words.len() + } + + /// The whole structure, header included. + pub fn words(&self) -> Words<'a> { + self.words + } + + /// The words after the header: child structures or leaf data. + pub fn data(&self) -> Words<'a> { + self.words + .slice(self.kind.header_words(), self.words.len()) + .unwrap_or(Words::new(&[], self.words.order())) + } + + /// Leaf data bytes without the trailing `pad` bytes. + pub fn data_bytes(&self) -> &'a [u8] { + let bytes = self.data().bytes(); + let end = bytes.len().saturating_sub(self.pad as usize); + &bytes[..end] + } + + /// True for a bank of banks, segments or tagsegments. + pub fn is_container(&self) -> bool { + data_type::child_kind(self.data_type).is_some() + } + + /// The kind of the children, `None` for a leaf. + pub fn child_kind(&self) -> Option { + data_type::child_kind(self.data_type) + } + + /// Name of the content type. + pub fn type_name(&self) -> &'static str { + data_type::name(self.data_type) + } + + /// The children of a container, in order; empty for a leaf. A child + /// whose length runs past this structure ends the iteration with one + /// `Err` item. + pub fn children(&self) -> Children<'a> { + Children { + words: self.data(), + pos: 0, + kind: self.child_kind(), + failed: false, + } + } +} + +impl fmt::Debug for Bank<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}(tag=0x{:04X}, type=0x{:02X}, num={}, length={})", + self.kind.name(), + self.tag, + self.data_type, + self.num, + self.length + ) + } +} + +/// Iterator over the children of a container (see [`Bank::children`]). +pub struct Children<'a> { + words: Words<'a>, + pos: usize, + kind: Option, + failed: bool, +} + +impl<'a> Iterator for Children<'a> { + type Item = Result, WalkError>; + + fn next(&mut self) -> Option { + let kind = self.kind?; + if self.failed || self.pos >= self.words.len() { + return None; + } + let rest = self.words.slice(self.pos, self.words.len())?; + match Bank::parse(rest, kind) { + Ok(child) => { + self.pos += child.total_words(); + Some(Ok(child)) + } + Err(mut e) => { + self.failed = true; + e.word += self.pos; + Some(Err(e)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::words::ByteOrder; + + fn le(words: &[u32]) -> Vec { + words.iter().flat_map(|w| w.to_le_bytes()).collect() + } + + #[test] + fn walks_banks_segments_and_tagsegments() { + // bank of banks: [ bank of segments: [ segment len 2 ], leaf uint32 len 1 ] + let bytes = le(&[ + 9, + 0xFF60_1000, + 4, + 0xFF31_2000, + 0x3201_0002, + 11, + 22, + 2, + 0x0001_0100, + 99, + ]); + let event = Bank::event(Words::new(&bytes, ByteOrder::Little)).unwrap(); + assert_eq!(event.tag, 0xFF60); + assert_eq!(event.data_type, 0x10); + assert!(event.is_container()); + let children: Vec<_> = event.children().map(Result::unwrap).collect(); + assert_eq!(children.len(), 2); + assert_eq!(children[0].tag, 0xFF31); + assert_eq!(children[0].child_kind(), Some(StructureKind::Segment)); + let segs: Vec<_> = children[0].children().map(Result::unwrap).collect(); + assert_eq!(segs.len(), 1); + assert_eq!(segs[0].tag, 0x32); + assert_eq!(segs[0].data().to_vec(), vec![11, 22]); + assert_eq!(children[1].tag, 1); + assert_eq!(children[1].num, 0); + assert_eq!(children[1].data().to_vec(), vec![99]); + assert!(children[1].children().next().is_none()); + } + + #[test] + fn bad_lengths_are_errors_not_panics() { + // child declares 10 words inside a 3-word parent + let bytes = le(&[3, 0x0001_1000, 10, 0x0002_0100]); + let event = Bank::event(Words::new(&bytes, ByteOrder::Little)).unwrap(); + let items: Vec<_> = event.children().collect(); + assert_eq!(items.len(), 1); + assert!(items[0].is_err()); + assert_eq!(items[0].as_ref().unwrap_err().word, 0); + + // top-level bank longer than the event + let bytes = le(&[u32::MAX, 0x0001_1000]); + assert!(Bank::event(Words::new(&bytes, ByteOrder::Little)).is_err()); + // bank length 0: no room for the second header word + let bytes = le(&[2, 0x0001_1000, 0, 0x0002_0100, 5]); + let event = Bank::event(Words::new(&bytes, ByteOrder::Little)).unwrap(); + assert!(event.children().next().unwrap().is_err()); + // too short for a header + assert!(Bank::event(Words::new(&bytes[..4], ByteOrder::Little)).is_err()); + // a tagsegment of length 0 is fine + let bytes = le(&[2, 0x0001_0C00, 0x1230_0000]); + let event = Bank::event(Words::new(&bytes, ByteOrder::Little)).unwrap(); + let ts = event.children().next().unwrap().unwrap(); + assert_eq!(ts.kind(), StructureKind::TagSegment); + assert_eq!(ts.tag, 0x123); + assert_eq!(ts.length, 0); + } +} diff --git a/rust/evio-stream/src/bin/evio-count.rs b/rust/evio-stream/src/bin/evio-count.rs new file mode 100644 index 0000000..444ad2a --- /dev/null +++ b/rust/evio-stream/src/bin/evio-count.rs @@ -0,0 +1,277 @@ +//! `evio-count FILE|- [--fadc250]`: records, events, trailer, layout and the +//! file checks of a stream, with throughput; `--fadc250` adds the streaming +//! fADC250 totals (frames, ROC banks, hits, other-module words, empty +//! payload banks) with every hit word decoded. + +use std::collections::BTreeMap; +use std::process::ExitCode; +use std::time::Instant; + +use evio_stream::fadc250::{module_name, MODULE_UNKNOWN}; +use evio_stream::{Checks, Error, FrameDecoder, RecordReader}; + +const USAGE: &str = "usage: evio-count FILE|- [--fadc250]\n\ + Reads the stream once and prints records, events, trailer, layout, the file checks\n\ + and the throughput. With --fadc250 every 0xFF60 stream frame is walked and every\n\ + fADC250 hit word decoded; the per-ROC table and the totals follow."; + +#[derive(Default)] +struct Fadc250Totals { + frames: u64, + other_events: u64, + roc_banks: u64, + payload_banks: u64, + empty_payload_banks: u64, + unmapped_payload_banks: u64, + payload_words: u64, + hits: u64, + hits_decoded: u64, + non_fadc_words: u64, + flag_words: u64, + charge_overflow: u64, + charge_sum: u64, + max_time_raw: u16, + errors: u64, + warnings: u64, + frames_with_ais: u64, + frames_with_degenerate_ais: u64, + first_frame: Option, + last_frame: u32, + first_timestamp: u64, + last_timestamp: u64, + error_messages: Vec, + /// Per ROC id: module, hits, other words, empty banks, ROC banks. + per_roc: BTreeMap, + /// Hits and words in the salvaged events of a cut tail. + tail_hits: u64, + tail_frames: u64, +} + +fn run(path: &str, fadc250: bool) -> Result<(), Error> { + let start = Instant::now(); + let mut decoder = FrameDecoder::new(); + let mut totals = Fadc250Totals::default(); + + let visit = |record: &evio_stream::Record| { + if !fadc250 { + return; + } + for event in record.iter_events() { + let Some(frame) = decoder.decode(event) else { + totals.other_events += 1; + continue; + }; + totals.frames += 1; + totals.roc_banks += frame.roc_banks.len() as u64; + totals.payload_banks += frame.n_payload_banks as u64; + totals.empty_payload_banks += frame.n_empty_payload_banks as u64; + totals.unmapped_payload_banks += frame.n_unmapped_payload_banks as u64; + totals.payload_words += frame.n_payload_words; + totals.hits += frame.n_hits; + totals.non_fadc_words += frame.non_fadc_words; + totals.errors += frame.n_errors as u64; + totals.warnings += frame.n_warnings as u64; + if frame.n_ais_entries > 0 { + totals.frames_with_ais += 1; + if frame.n_ais_entries > 1 && frame.n_ais_distinct == 1 { + totals.frames_with_degenerate_ais += 1; + } + } + if totals.first_frame.is_none() { + totals.first_frame = Some(frame.frame_number); + totals.first_timestamp = frame.timestamp; + } + totals.last_frame = frame.frame_number; + totals.last_timestamp = frame.timestamp; + if record.is_truncated { + totals.tail_hits += frame.n_hits; + totals.tail_frames += 1; + } + for message in frame.errors { + if totals.error_messages.len() < 20 { + totals.error_messages.push(format!( + "record {} frame {}: {message}", + record.index, frame.frame_number + )); + } + } + for roc in frame.roc_banks { + let entry = totals + .per_roc + .entry(roc.roc) + .or_insert((MODULE_UNKNOWN, 0, 0, 0, 0)); + if entry.0 == MODULE_UNKNOWN { + entry.0 = roc.module; + } + entry.1 += roc.n_hits; + entry.2 += roc.non_fadc_words; + entry.3 += roc.n_empty_payload_banks as u64; + entry.4 += 1; + } + // Decode every hit word: the checks below depend on each one. + let mut decoded = 0u64; + let mut charge_sum = 0u64; + let mut flags = 0u64; + let mut overflow = 0u64; + let mut max_time = totals.max_time_raw; + for hit in frame.hits() { + decoded += 1; + charge_sum += hit.charge as u64; + flags += hit.flag as u64; + overflow += (hit.charge == 0x1FFF) as u64; + max_time = max_time.max(hit.time_raw); + } + totals.hits_decoded += decoded; + totals.charge_sum += charge_sum; + totals.flag_words += flags; + totals.charge_overflow += overflow; + totals.max_time_raw = max_time; + } + }; + + let checks = if path == "-" { + Checks::scan_with(RecordReader::stdin()?, visit)? + } else { + Checks::scan_with(RecordReader::open(path)?, visit)? + }; + let elapsed = start.elapsed().as_secs_f64(); + + println!("file: {path}"); + println!("layout: {}", checks.layout); + println!("byte order: {}", checks.order); + println!("records: {}", checks.records); + println!("events: {}", checks.events); + println!( + "trailer: {}", + if checks.trailer { "yes" } else { "no" } + ); + match checks.truncated_tail { + Some(t) => println!( + "truncated_tail: declared {} bytes, present {}, salvaged {} events ({}, {}, {})", + t.declared_bytes, + t.present_bytes, + t.salvaged_events, + t.declared_bytes, + t.present_bytes, + t.salvaged_events + ), + None => println!("truncated_tail: none"), + } + match &checks.bad_record_header { + Some(b) => println!("bad_record_header: offset 0x{:X}: {}", b.offset, b.message), + None => println!("bad_record_header: none"), + } + println!("record errors: {} records", checks.records_with_errors); + for message in &checks.error_messages { + println!(" {message}"); + } + println!( + "bytes: {} time {:.2} s {:.2} GB/s", + checks.bytes, + elapsed, + checks.bytes as f64 / 1e9 / elapsed.max(1e-9) + ); + + if fadc250 { + println!(); + println!("fADC250 stream decode (every hit word read)"); + println!("frames: {}", totals.frames); + println!("other events: {}", totals.other_events); + match totals.first_frame { + Some(first) => println!( + "frame range: {first}..{} timestamps {}..{}", + totals.last_frame, totals.first_timestamp, totals.last_timestamp + ), + None => println!("frame range: none"), + } + println!("ROC banks: {}", totals.roc_banks); + println!("payload banks: {}", totals.payload_banks); + println!("empty payload banks: {}", totals.empty_payload_banks); + println!("unmapped payload banks: {}", totals.unmapped_payload_banks); + println!("payload words: {}", totals.payload_words); + println!( + "fADC250 hits: {} (complete records {}, salvaged tail {} in {} frames)", + totals.hits, + totals.hits - totals.tail_hits, + totals.tail_hits, + totals.tail_frames + ); + println!("hits decoded: {}", totals.hits_decoded); + println!("other-module words: {}", totals.non_fadc_words); + println!("flag words (bit 31): {}", totals.flag_words); + println!("charge overflow: {}", totals.charge_overflow); + println!("charge sum: {}", totals.charge_sum); + println!( + "max time_raw: {} ({} ns)", + totals.max_time_raw, + totals.max_time_raw as u32 * 4 + ); + println!( + "AIS: {} frames with an AIS, {} with one value repeated", + totals.frames_with_ais, totals.frames_with_degenerate_ais + ); + println!("decode errors: {}", totals.errors); + println!("decode warnings: {}", totals.warnings); + for message in &totals.error_messages { + println!(" {message}"); + } + let consistent = totals.hits + totals.non_fadc_words == totals.payload_words + && totals.hits == totals.hits_decoded; + println!( + "consistency: hits + other words == payload words, hits decoded == hits: {}", + if consistent { "ok" } else { "FAILED" } + ); + println!(); + println!( + "{:>6} {:>8} {:>10} {:>14} {:>14} {:>12}", + "roc", "module", "ROC banks", "hits", "other words", "empty banks" + ); + for (roc, (module, hits, other, empty, banks)) in &totals.per_roc { + let name = module_name(*module); + println!("{roc:>6} {name:>8} {banks:>10} {hits:>14} {other:>14} {empty:>12}"); + } + println!( + "decode: {:.2} s {:.2} GB/s", + elapsed, + checks.bytes as f64 / 1e9 / elapsed.max(1e-9) + ); + } + Ok(()) +} + +fn main() -> ExitCode { + let mut path = None; + let mut fadc250 = false; + for arg in std::env::args().skip(1) { + match arg.as_str() { + "--fadc250" => fadc250 = true, + "-h" | "--help" => { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + "-" => path = Some(arg), + a if a.starts_with('-') => { + eprintln!("unknown option {a}\n{USAGE}"); + return ExitCode::from(2); + } + _ => { + if path.is_some() { + eprintln!("one file only\n{USAGE}"); + return ExitCode::from(2); + } + path = Some(arg); + } + } + } + let Some(path) = path else { + eprintln!("{USAGE}"); + return ExitCode::from(2); + }; + match run(&path, fadc250) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("evio-count: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/rust/evio-stream/src/bin/evio-dump.rs b/rust/evio-stream/src/bin/evio-dump.rs new file mode 100644 index 0000000..db87f34 --- /dev/null +++ b/rust/evio-stream/src/bin/evio-dump.rs @@ -0,0 +1,333 @@ +//! `evio-dump FILE|- [--record N] [--event M] [--words K]`: the file header +//! and every record header; with `--record N` that record's header in full +//! and its events; with `--event M` the bank tree of event M of record N +//! (tag, type, num, length) with the first K words of each leaf in hex. + +use std::process::ExitCode; + +use evio_stream::{Bank, Error, Record, RecordReader, StructureKind, Words}; + +const USAGE: &str = "usage: evio-dump FILE|- [--record N] [--event M] [--words K]\n\ + Without options: the file header and one line per record header.\n\ + --record N: the full header of record N and its event table.\n\ + --event M: the bank tree of event M of record N (default record 0), with the\n\ + first K words (default 8) of each leaf in hex."; + +struct Options { + path: String, + record: Option, + event: Option, + words: usize, +} + +fn parse_args() -> Result { + let mut path = None; + let mut record = None; + let mut event = None; + let mut words = 8usize; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + let mut value = |name: &str| -> Result { + let v = args.next().ok_or_else(|| format!("{name} needs a value"))?; + v.parse().map_err(|_| format!("{name}: not a number: {v}")) + }; + match arg.as_str() { + "--record" => record = Some(value("--record")?), + "--event" => event = Some(value("--event")?), + "--words" => words = value("--words")?, + "-h" | "--help" => return Err(String::new()), + "-" => path = Some(arg), + a if a.starts_with('-') => return Err(format!("unknown option {a}")), + _ => { + if path.is_some() { + return Err("one file only".into()); + } + path = Some(arg); + } + } + } + let path = path.ok_or_else(|| "no file given".to_string())?; + Ok(Options { + path, + record, + event, + words, + }) +} + +fn print_file_header(reader: &RecordReader) { + let h = reader.file_header(); + println!("layout: {}", reader.layout()); + println!("byte order: {}", reader.order()); + println!("file type: {}", h.file_type); + if reader.layout() == evio_stream::FileLayout::V6 { + println!("file number: {}", h.file_number); + println!("header length: {} words", h.header_length); + println!("record count: {} (declared)", h.record_count); + println!("index array length: {} bytes", h.index_array_length); + println!("version: {}", h.version); + println!("user header length: {} bytes", h.user_header_length); + println!( + "dictionary / first event / trailer index: {} / {} / {}", + h.has_dictionary, h.has_first_event, h.has_trailer + ); + println!("user register: 0x{:016x}", h.user_register); + println!("trailer position: 0x{:x}", h.trailer_position); + println!( + "user integers: {} {}", + h.user_integer1, h.user_integer2 + ); + } else { + println!( + "version: {} (container headers say {})", + h.version, h.container_version + ); + } + println!(); +} + +fn record_line(record: &Record) { + let h = &record.header; + let mut flags = Vec::new(); + if h.is_last_record { + flags.push("last"); + } + if h.is_trailer() { + flags.push("trailer"); + } + if record.is_truncated { + flags.push("truncated"); + } + if !record.errors.is_empty() { + flags.push("errors"); + } + println!( + "{:>6} 0x{:<12X} {:>9} {:>3} {:>6} {:>6} {:>8} {:>8} {:<20} {}", + record.index, + record.offset, + h.record_length, + h.header_length, + h.event_count, + record.event_count(), + h.compression.name(), + h.record_number, + h.event_type_name(), + flags.join(",") + ); +} + +fn print_record(record: &Record) { + let h = &record.header; + println!("record {} at offset 0x{:X}", record.index, record.offset); + println!( + " record length: {} words ({} bytes)", + h.record_length, + h.record_bytes() + ); + println!(" record number: {}", h.record_number); + println!(" header length: {} words", h.header_length); + println!( + " event count: {} declared, {} found", + h.event_count, + record.event_count() + ); + println!(" index array length: {} bytes", h.index_array_length); + println!(" version: {}", h.version); + println!( + " header type: {} ({})", + h.header_type, + if h.is_trailer() { "trailer" } else { "record" } + ); + println!( + " bit info: 0x{:05X} last {} dictionary {} first event {} event type {}", + h.bit_info, + h.is_last_record, + h.has_dictionary, + h.has_first_event, + h.event_type_name() + ); + println!(" pad1/pad2/pad3: {} {} {}", h.pad1, h.pad2, h.pad3); + println!(" user header length: {} bytes", h.user_header_length); + println!( + " uncompressed length: {} bytes", + h.uncompressed_data_length + ); + println!( + " compression: {} ({} words)", + h.compression.name(), + h.compressed_data_length + ); + println!( + " user registers: 0x{:016x} 0x{:016x}", + h.user_register1, h.user_register2 + ); + println!( + " truncated: {} (declared {} bytes, present {})", + record.is_truncated, record.declared_bytes, record.present_bytes + ); + for e in &record.errors { + println!(" error: {e}"); + } + println!(" events (offset into the record buffer, bytes):"); + for (i, span) in record.events().iter().enumerate() { + let tag = record + .event(i) + .and_then(|w| w.get(1)) + .map(|w| { + format!( + "tag 0x{:04X} type 0x{:02X} num {}", + w >> 16, + (w >> 8) & 0x3F, + w & 0xFF + ) + }) + .unwrap_or_default(); + println!( + " {i:>6} 0x{:<10X} {:>10} {tag}", + span.offset, span.length + ); + } +} + +fn hex_words(words: Words<'_>, limit: usize) -> String { + let mut out: Vec = words + .iter() + .take(limit) + .map(|w| format!("{w:08x}")) + .collect(); + if words.len() > limit { + out.push(format!("... ({} words)", words.len())); + } + out.join(" ") +} + +fn print_tree(bank: &Bank<'_>, depth: usize, words: usize) { + let indent = " ".repeat(depth); + let num = match bank.kind() { + StructureKind::Bank => format!(" num {}", bank.num), + _ => String::new(), + }; + println!( + "{indent}{} tag 0x{:04X} type 0x{:02X} ({}){num} length {} ({} words)", + bank.kind().name(), + bank.tag, + bank.data_type, + bank.type_name(), + bank.length, + bank.total_words() + ); + if bank.is_container() { + for child in bank.children() { + match child { + Ok(child) => print_tree(&child, depth + 1, words), + Err(e) => println!("{indent} ERROR {e}"), + } + } + } else if words > 0 { + println!("{indent} {}", hex_words(bank.data(), words)); + } +} + +fn run(options: &Options) -> Result<(), Error> { + let mut reader = if options.path == "-" { + RecordReaderKind::Stdin(RecordReader::stdin()?) + } else { + RecordReaderKind::File(RecordReader::open(&options.path)?) + }; + match &reader { + RecordReaderKind::File(r) => print_file_header(r), + RecordReaderKind::Stdin(r) => print_file_header(r), + } + let want_record = options.record.or(if options.event.is_some() { + Some(0) + } else { + None + }); + if want_record.is_none() { + println!( + " index offset length hdr events found compress number type flags" + ); + } + loop { + let next = match &mut reader { + RecordReaderKind::File(r) => r.next_record()?, + RecordReaderKind::Stdin(r) => r.next_record()?, + }; + let Some(record) = next else { break }; + match want_record { + None => record_line(&record), + Some(n) if n == record.index => { + print_record(&record); + if let Some(m) = options.event { + println!(); + match record.event(m) { + Some(event) => { + println!("event {m} of record {n}: {} words", event.len()); + match Bank::event(event) { + Ok(bank) => print_tree(&bank, 0, options.words), + Err(e) => println!("ERROR {e}"), + } + } + None => println!( + "record {n} has no event {m} ({} events)", + record.event_count() + ), + } + } + return Ok(()); + } + Some(_) => {} + } + } + let (tail, bad, records) = match &reader { + RecordReaderKind::File(r) => ( + r.truncated_tail(), + r.bad_record_header().cloned(), + r.data_records(), + ), + RecordReaderKind::Stdin(r) => ( + r.truncated_tail(), + r.bad_record_header().cloned(), + r.data_records(), + ), + }; + if let Some(n) = want_record { + println!("no record {n}: the stream holds {records} data records"); + } + if let Some(t) = tail { + println!( + "truncated_tail: declared {} bytes, present {}, salvaged {} events", + t.declared_bytes, t.present_bytes, t.salvaged_events + ); + } + if let Some(b) = bad { + println!("bad_record_header at 0x{:X}: {}", b.offset, b.message); + } + Ok(()) +} + +enum RecordReaderKind { + File(RecordReader), + Stdin(RecordReader>), +} + +fn main() -> ExitCode { + let options = match parse_args() { + Ok(o) => o, + Err(message) if message.is_empty() => { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + Err(message) => { + eprintln!("{message}\n{USAGE}"); + return ExitCode::from(2); + } + }; + match run(&options) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("evio-dump: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/rust/evio-stream/src/checks.rs b/rust/evio-stream/src/checks.rs new file mode 100644 index 0000000..d06ba84 --- /dev/null +++ b/rust/evio-stream/src/checks.rs @@ -0,0 +1,108 @@ +//! The per-file summary: counts and the checks of pyevio's `EvioFile.checks`. + +use std::io::Read; + +use crate::error::Error; +use crate::header::FileLayout; +use crate::reader::{BadRecordHeader, RecordReader, TruncatedTail}; +use crate::record::Record; +use crate::words::ByteOrder; + +/// What one pass over a stream found. +#[derive(Clone, Debug)] +pub struct Checks { + pub layout: FileLayout, + pub order: ByteOrder, + /// Data records read, a cut tail included, the trailer excluded. + pub records: u64, + /// Events found, the salvaged events of a cut tail included. + pub events: u64, + /// True when the stream ended in a trailer record. + pub trailer: bool, + pub truncated_tail: Option, + pub bad_record_header: Option, + /// Records whose event walk or decompression reported an error. + pub records_with_errors: u64, + /// The first [`Checks::MAX_MESSAGES`] record error messages, prefixed + /// with the record index. + pub error_messages: Vec, + /// Bytes consumed from the stream. + pub bytes: u64, +} + +impl Checks { + /// Error messages kept. + pub const MAX_MESSAGES: usize = 50; + + /// Start a summary for `reader`. + pub fn start(reader: &RecordReader) -> Checks { + Checks { + layout: reader.layout(), + order: reader.order(), + records: 0, + events: 0, + trailer: false, + truncated_tail: None, + bad_record_header: None, + records_with_errors: 0, + error_messages: Vec::new(), + bytes: 0, + } + } + + /// Count one record. + pub fn add(&mut self, record: &Record) { + if record.is_trailer() { + self.trailer = true; + } else { + self.records += 1; + self.events += record.event_count() as u64; + } + if !record.errors.is_empty() { + self.records_with_errors += 1; + for message in &record.errors { + if self.error_messages.len() >= Self::MAX_MESSAGES { + break; + } + self.error_messages + .push(format!("record {}: {message}", record.index)); + } + } + } + + /// Copy the end-of-stream state from the reader. + pub fn finish(&mut self, reader: &RecordReader) { + self.truncated_tail = reader.truncated_tail(); + self.bad_record_header = reader.bad_record_header().cloned(); + self.trailer = reader.trailer_seen(); + self.bytes = reader.bytes_read(); + } + + /// Read every record of `reader`, calling `visit` on each, and return + /// the summary. The reader's record buffers are recycled. + pub fn scan_with( + mut reader: RecordReader, + mut visit: F, + ) -> Result { + let mut checks = Checks::start(&reader); + while let Some(record) = reader.next_record()? { + checks.add(&record); + visit(&record); + reader.recycle(record); + } + checks.finish(&reader); + Ok(checks) + } + + /// Read every record of `reader` and return the summary. + pub fn scan(reader: RecordReader) -> Result { + Self::scan_with(reader, |_| {}) + } + + /// True when no check fired and no record reported an error. + pub fn is_clean(&self) -> bool { + self.truncated_tail.is_none() + && self.bad_record_header.is_none() + && self.records_with_errors == 0 + } +} diff --git a/rust/evio-stream/src/error.rs b/rust/evio-stream/src/error.rs new file mode 100644 index 0000000..7060321 --- /dev/null +++ b/rust/evio-stream/src/error.rs @@ -0,0 +1,52 @@ +//! The error type of the reader. + +use std::fmt; +use std::io; + +/// Why a stream could not be opened or read further. +/// +/// Malformed content inside a record (a bad event or bank length, a failed +/// decompression) is never an `Error`: it is recorded in +/// [`Record::errors`](crate::Record::errors) or returned by the bank walk as a +/// [`WalkError`](crate::WalkError), and reading goes on with what is sound. +#[derive(Debug)] +pub enum Error { + /// The underlying reader failed. + Io(io::Error), + /// The first bytes are no EVIO header at all (no magic word, too short). + NotEvio(String), + /// An EVIO version this crate does not read: an evio-library v4 file or a + /// v1—3 file. `version` is the version word of the header that gave it + /// away; `detail` says where. + UnsupportedVersion { version: u8, detail: String }, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Io(e) => write!(f, "I/O error: {e}"), + Error::NotEvio(m) => write!(f, "Not an EVIO file: {m}"), + Error::UnsupportedVersion { version, detail } => write!( + f, + "EVIO version {version} file is not supported: evio-stream reads EVIO version 6 \ + files and SRO streaming files. Convert the file with the evio tools (evio2xml, \ + eviocopy) or read it with the evio library. ({detail})" + ), + } + } +} + +impl std::error::Error for Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Error::Io(e) => Some(e), + _ => None, + } + } +} + +impl From for Error { + fn from(e: io::Error) -> Self { + Error::Io(e) + } +} diff --git a/rust/evio-stream/src/fadc250.rs b/rust/evio-stream/src/fadc250.rs new file mode 100644 index 0000000..56aca3c --- /dev/null +++ b/rust/evio-stream/src/fadc250.rs @@ -0,0 +1,767 @@ +//! The CODA streaming fADC250 frame walk. +//! +//! A streaming time frame is one EVIO event (tag 0xFF60, a bank of banks): +//! +//! ```text +//! event bank tag 0xFF60, type 0x10, num 0 +//! SIB tag 0xFF31, type 0x20 (bank of segments) +//! TSS segment tag 0x32, length 3: {frame number, timestamp low, timestamp high} +//! AIS segment tag 0x42: aggregated-stream entries (counted, never walked) +//! ROC bank tag = ROC id, type 0x10, num = stream status byte +//! stream-info tag 0xFF30, type 0x20: TSS 0x31 + payload-port map 0x41 +//! payload bank tag = payload port, type 0x00, one word per hit +//! payload bank tag = payload port, length 1, num 1: an empty port +//! ROC bank ... +//! ``` +//! +//! The walk mirrors `pyevio.decoders.fadc250_stream`: every bank end is +//! checked against its container and a bad length ends the walk of that +//! event with a counted error. The module behind each payload port comes +//! from the 0x41 map of the ROC bank's stream-info bank; only fADC250 ports +//! (and unmapped ones) yield hits, the words of other modules (DCRB) are +//! counted. The AIS segment is counted (entries, distinct values) and never +//! used to locate ROC banks. +//! +//! Hit word: bit 31 flag (never set in the SRO data), bits 30—17 time in +//! 4 ns ticks, bits 16—13 channel, bits 12—0 charge (8191 = overflow). + +use crate::words::Words; + +pub const STREAM_EVENT_TAG: u16 = 0xFF60; +pub const SIB_TAG: u16 = 0xFF31; +pub const SIB_TSS_SEGMENT_TAG: u8 = 0x32; +pub const SIB_AIS_SEGMENT_TAG: u8 = 0x42; +pub const ROC_STREAM_INFO_TAG: u16 = 0xFF30; +pub const ROC_TSS_SEGMENT_TAG: u8 = 0x31; +pub const ROC_PAYLOAD_PORT_SEGMENT_TAG: u8 = 0x41; + +/// Module id of an fADC250 in the payload-port map. +pub const MODULE_FADC250: u8 = 0; +/// Module id of a DCRB in the payload-port map. +pub const MODULE_DCRB: u8 = 1; +/// Stand-in module id of a port absent from the map (decoded as fADC250). +pub const MODULE_UNKNOWN: u8 = 0xFF; + +/// Length of one hit-time tick in nanoseconds (the fADC250 sample period). +pub const HIT_TIME_TICK_NS: u16 = 4; + +/// Error and warning messages kept per frame; the counts are always exact. +pub const MAX_MESSAGES: usize = 50; + +/// Name of a module id: "fADC250", "DCRB", "unmapped" or "module N". +pub fn module_name(module: u8) -> String { + match module { + MODULE_FADC250 => "fADC250".into(), + MODULE_DCRB => "DCRB".into(), + MODULE_UNKNOWN => "unmapped".into(), + other => format!("module {other}"), + } +} + +/// One fADC250 hit. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Hit { + pub frame: u32, + pub timestamp: u64, + pub roc: u16, + /// Stream mask from the ROC bank num byte (1 or 2). + pub stream: u8, + pub port: u16, + pub channel: u8, + /// Time from the frame start in 4 ns ticks (14 bits). + pub time_raw: u16, + /// 13-bit charge; 8191 means overflow. + pub charge: u16, + /// Bit 31 of the word; never set in the SRO data. + pub flag: bool, +} + +impl Hit { + /// Split a hit word into `(channel, time_raw, charge, flag)`. + #[inline(always)] + pub fn unpack(word: u32) -> (u8, u16, u16, bool) { + ( + ((word >> 13) & 0xF) as u8, + ((word >> 17) & 0x3FFF) as u16, + (word & 0x1FFF) as u16, + word >> 31 != 0, + ) + } + + /// Time from the frame start in nanoseconds. + pub fn t_ns(&self) -> u16 { + self.time_raw * HIT_TIME_TICK_NS + } +} + +/// What one ROC bank of a frame held. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RocBank { + pub roc: u16, + /// Stream mask (bits 3—0 of the num byte). + pub stream: u8, + /// The whole num byte. + pub stream_status: u8, + /// Module of the first payload bank; [`MODULE_UNKNOWN`] without any. + pub module: u8, + pub n_payload_banks: u32, + pub n_empty_payload_banks: u32, + pub n_unmapped_payload_banks: u32, + pub n_hits: u64, + pub non_fadc_words: u64, +} + +/// A payload bank located by the walk: word offsets into the event. +#[derive(Clone, Copy, Debug)] +struct PayloadRef { + /// First data word (after the 2-word bank header). + start: u32, + n_words: u32, + roc: u16, + port: u16, + stream: u8, + module: u8, +} + +/// One payload bank of a frame. +#[derive(Clone, Copy, Debug)] +pub struct PayloadBank<'a> { + pub roc: u16, + pub stream: u8, + pub port: u16, + pub module: u8, + /// The data words (hits for an fADC250 port). + pub words: Words<'a>, +} + +impl PayloadBank<'_> { + /// True for a length-1 bank (no hit on the port in this frame). + pub fn is_empty(&self) -> bool { + self.words.is_empty() + } + + /// True when the words are fADC250 hits (module fADC250 or unmapped). + pub fn is_fadc250(&self) -> bool { + self.module == MODULE_FADC250 || self.module == MODULE_UNKNOWN + } +} + +/// Walks stream frames. Holds reusable scratch space so decoding a frame +/// allocates nothing in the common case. +#[derive(Default)] +pub struct FrameDecoder { + roc_banks: Vec, + payload: Vec, + ais: Vec, + errors: Vec, + warnings: Vec, +} + +/// One decoded frame, borrowing the event and the decoder. +pub struct Frame<'a> { + event: Words<'a>, + pub frame_number: u32, + pub timestamp: u64, + pub roc_banks: &'a [RocBank], + payload: &'a [PayloadRef], + pub n_payload_banks: u32, + pub n_empty_payload_banks: u32, + pub n_unmapped_payload_banks: u32, + /// Words of every payload bank (hits plus other-module words). + pub n_payload_words: u64, + /// fADC250 hit words (= sum of payload lengths of fADC250 ports). + pub n_hits: u64, + /// Payload words of other modules (DCRB). + pub non_fadc_words: u64, + /// Entries of the AIS segment, 0 without one. + pub n_ais_entries: u32, + /// Distinct values among the AIS entries. + pub n_ais_distinct: u32, + /// Bad lengths met; the walk of the structure stopped there. + pub n_errors: u32, + /// Malformed SIB segments after the TSS; no hit depends on them. + pub n_warnings: u32, + /// The first [`MAX_MESSAGES`] error messages. + pub errors: &'a [String], + /// The first [`MAX_MESSAGES`] warning messages. + pub warnings: &'a [String], +} + +impl<'a> Frame<'a> { + /// Every payload bank in walk order. + pub fn payload_banks(&self) -> impl Iterator> + '_ { + let event = self.event; + self.payload.iter().map(move |p| PayloadBank { + roc: p.roc, + stream: p.stream, + port: p.port, + module: p.module, + words: event + .slice(p.start as usize, p.start as usize + p.n_words as usize) + .unwrap_or(Words::new(&[], event.order())), + }) + } + + /// Every fADC250 hit of the frame, in walk order. + pub fn hits(&self) -> Hits<'a> { + Hits { + event: self.event, + frame: self.frame_number, + timestamp: self.timestamp, + payload: self.payload, + next_bank: 0, + current: None, + pos: 0, + } + } +} + +/// Iterator over the hits of a frame (see [`Frame::hits`]). +pub struct Hits<'a> { + event: Words<'a>, + frame: u32, + timestamp: u64, + payload: &'a [PayloadRef], + next_bank: usize, + current: Option<(PayloadRef, Words<'a>)>, + pos: usize, +} + +impl Iterator for Hits<'_> { + type Item = Hit; + + #[inline] + fn next(&mut self) -> Option { + loop { + if let Some((p, words)) = &self.current { + if let Some(word) = words.get(self.pos) { + self.pos += 1; + let (channel, time_raw, charge, flag) = Hit::unpack(word); + return Some(Hit { + frame: self.frame, + timestamp: self.timestamp, + roc: p.roc, + stream: p.stream, + port: p.port, + channel, + time_raw, + charge, + flag, + }); + } + self.current = None; + } + let p = *self.payload.get(self.next_bank)?; + self.next_bank += 1; + if p.module != MODULE_FADC250 && p.module != MODULE_UNKNOWN { + continue; + } + let start = p.start as usize; + let words = self + .event + .slice(start, start + p.n_words as usize) + .unwrap_or(Words::new(&[], self.event.order())); + self.current = Some((p, words)); + self.pos = 0; + } + } +} + +impl FrameDecoder { + /// A decoder with empty scratch space. + pub fn new() -> Self { + Self::default() + } + + fn error(&mut self, n_errors: &mut u32, word: usize, reason: String) { + *n_errors += 1; + if self.errors.len() < MAX_MESSAGES { + self.errors.push(format!("word {word}: {reason}")); + } + } + + fn warning(&mut self, n_warnings: &mut u32, word: usize, reason: String) { + *n_warnings += 1; + if self.warnings.len() < MAX_MESSAGES { + self.warnings.push(format!("word {word}: {reason}")); + } + } + + /// Decode one event. `None` when the event is no stream frame (its tag + /// is not 0xFF60); malformed lengths yield what was walked before them + /// plus counted errors, never a panic. + pub fn decode<'a>(&'a mut self, event: Words<'a>) -> Option> { + let n = event.len(); + let w = |i: usize| event.get(i).unwrap_or(0); + if n < 2 || (w(1) >> 16) as u16 != STREAM_EVENT_TAG { + return None; + } + self.roc_banks.clear(); + self.payload.clear(); + self.errors.clear(); + self.warnings.clear(); + let mut n_errors = 0u32; + let mut n_warnings = 0u32; + + let mut event_end = w(0) as usize + 1; + if event_end > n { + self.error( + &mut n_errors, + 0, + format!("event length {event_end} words runs past the {n} words given"), + ); + event_end = n; + } + + let sib = self.read_sib(event, event_end, &mut n_errors, &mut n_warnings); + let mut totals = Totals::default(); + + let mut roc_i = sib.end; + while roc_i < event_end { + if roc_i + 1 >= event_end { + self.error( + &mut n_errors, + roc_i, + "ROC bank header runs past the event end".into(), + ); + break; + } + let roc_len = w(roc_i) as usize; + let roc_end = roc_i + roc_len + 1; + if roc_end > event_end { + self.error( + &mut n_errors, + roc_i, + format!("ROC bank length {roc_len} runs past the event end {event_end}"), + ); + break; + } + let roc_header = w(roc_i + 1); + let dt = ((roc_header >> 8) & 0x3F) as u8; + if dt != 0x10 && dt != 0x0E { + self.error( + &mut n_errors, + roc_i, + format!("ROC bank 0x{roc_header:08X} is not a bank of banks"), + ); + roc_i = roc_end; + continue; + } + self.walk_roc_bank( + event, + roc_i + 2, + roc_end, + roc_header, + &mut totals, + &mut n_errors, + ); + roc_i = roc_end; + } + + Some(Frame { + event, + frame_number: sib.frame_number, + timestamp: sib.timestamp, + roc_banks: &self.roc_banks, + payload: &self.payload, + n_payload_banks: totals.payload_banks, + n_empty_payload_banks: totals.empty_banks, + n_unmapped_payload_banks: totals.unmapped_banks, + n_payload_words: totals.payload_words, + n_hits: totals.hits, + non_fadc_words: totals.non_fadc_words, + n_ais_entries: sib.n_ais_entries, + n_ais_distinct: sib.n_ais_distinct, + n_errors, + n_warnings, + errors: &self.errors, + warnings: &self.warnings, + }) + } + + /// The SIB at word 2: frame number, timestamp, where the ROC banks + /// start (from the SIB length word alone), and the AIS counts. + fn read_sib( + &mut self, + event: Words<'_>, + event_end: usize, + n_errors: &mut u32, + n_warnings: &mut u32, + ) -> Sib { + let w = |i: usize| event.get(i).unwrap_or(0); + let mut sib = Sib { + end: event_end, + ..Sib::default() + }; + if 3 >= event_end { + self.error(n_errors, 2, "no room for the stream info bank".into()); + return sib; + } + let sib_len = w(2) as usize; + let sib_end = 2 + sib_len + 1; + if sib_end > event_end { + self.error( + n_errors, + 2, + format!("SIB length {sib_len} runs past the event end {event_end}"), + ); + return sib; + } + sib.end = sib_end; + let tag = (w(3) >> 16) as u16; + if tag != SIB_TAG { + self.error( + n_errors, + 3, + format!("expected SIB tag 0x{SIB_TAG:04X}, found 0x{tag:04X}"), + ); + return sib; + } + // The TSS is the first segment: tag<<24 | pad<<22 | type<<16 | length. + let tss = w(4); + if 7 >= sib_end || (tss >> 24) as u8 != SIB_TSS_SEGMENT_TAG || (tss & 0xFFFF) < 3 { + self.error( + n_errors, + 4, + "SIB does not start with a time slice segment (tag 0x32, length 3)".into(), + ); + return sib; + } + sib.frame_number = w(5); + sib.timestamp = w(6) as u64 | ((w(7) as u64) << 32); + let next = 4 + (tss & 0xFFFF) as usize + 1; + self.count_ais(event, next, sib_end, &mut sib, n_warnings); + sib + } + + /// Entries and distinct values of the 0x42 segment among the SIB + /// segments from `segment_i`. A missing segment, an empty one or one + /// whose length runs past the SIB end leaves the counts at 0; the last + /// two are warnings, not errors. + fn count_ais( + &mut self, + event: Words<'_>, + mut segment_i: usize, + sib_end: usize, + sib: &mut Sib, + n_warnings: &mut u32, + ) { + let w = |i: usize| event.get(i).unwrap_or(0); + while segment_i < sib_end { + let header = w(segment_i); + let segment_len = (header & 0xFFFF) as usize; + let segment_end = segment_i + segment_len + 1; + if segment_end > sib_end { + self.warning( + n_warnings, + segment_i, + format!("SIB segment length {segment_len} runs past the SIB end {sib_end}"), + ); + return; + } + if (header >> 24) as u8 == SIB_AIS_SEGMENT_TAG { + if segment_len == 0 { + self.warning(n_warnings, segment_i, "AIS segment holds no entries".into()); + return; + } + self.ais.clear(); + self.ais.extend((segment_i + 1..segment_end).map(w)); + self.ais.sort_unstable(); + self.ais.dedup(); + sib.n_ais_entries = segment_len as u32; + sib.n_ais_distinct = self.ais.len() as u32; + return; + } + segment_i = segment_end; + } + } + + /// `port -> module` from the 0x41 segment of the stream-info bank at + /// `info_i`. Entries are uint16, two per word in memory order; pad 2 + /// means an odd entry count. Ports absent from the map stay + /// [`MODULE_UNKNOWN`]. + fn read_payload_port_map( + &mut self, + event: Words<'_>, + info_i: usize, + info_end: usize, + n_errors: &mut u32, + ) -> [u8; 32] { + let w = |i: usize| event.get(i).unwrap_or(0); + let mut map = [MODULE_UNKNOWN; 32]; + let mut segment_i = info_i + 2; + while segment_i < info_end { + let header = w(segment_i); + let segment_len = (header & 0xFFFF) as usize; + let segment_end = segment_i + segment_len + 1; + if segment_end > info_end { + self.error( + n_errors, + segment_i, + format!("stream-info segment length {segment_len} runs past the bank end {info_end}"), + ); + break; + } + if (header >> 24) as u8 == ROC_PAYLOAD_PORT_SEGMENT_TAG { + let pad = (header >> 22) & 3; + let n_entries = 2 * segment_len - usize::from(pad == 2); + let first_half = (segment_i + 1) * 2; + for entry_i in 0..n_entries { + let entry = event.get_u16(first_half + entry_i).unwrap_or(0); + let port = (entry & 0x1F) as usize; + if port != 0 { + map[port] = ((entry >> 8) & 0xF) as u8; + } + } + break; + } + segment_i = segment_end; + } + map + } + + fn walk_roc_bank( + &mut self, + event: Words<'_>, + mut child_i: usize, + roc_end: usize, + roc_header: u32, + totals: &mut Totals, + n_errors: &mut u32, + ) { + let w = |i: usize| event.get(i).unwrap_or(0); + let roc = (roc_header >> 16) as u16; + let stream_status = (roc_header & 0xFF) as u8; + let stream = stream_status & 0xF; + let mut bank = RocBank { + roc, + stream, + stream_status, + module: MODULE_UNKNOWN, + ..RocBank::default() + }; + let mut map = [MODULE_UNKNOWN; 32]; + // The stream-info bank comes first; its 0x41 segment names the module behind each port. + if child_i + 1 < roc_end && (w(child_i + 1) >> 16) as u16 == ROC_STREAM_INFO_TAG { + let info_end = (child_i + w(child_i) as usize + 1).min(roc_end); + map = self.read_payload_port_map(event, child_i, info_end, n_errors); + child_i = info_end; + } + while child_i < roc_end { + if child_i + 1 >= roc_end { + self.error( + n_errors, + child_i, + "payload bank header runs past the ROC bank end".into(), + ); + break; + } + let payload_len = w(child_i) as usize; + if payload_len == 0 || child_i + payload_len + 1 > roc_end { + self.error( + n_errors, + child_i, + format!( + "payload bank length {payload_len} runs past the ROC bank end {roc_end}" + ), + ); + break; + } + let port = (w(child_i + 1) >> 16) as u16; + let module = map.get(port as usize).copied().unwrap_or(MODULE_UNKNOWN); + if module == MODULE_UNKNOWN { + bank.n_unmapped_payload_banks += 1; + } + if bank.module == MODULE_UNKNOWN { + bank.module = module; + } + let n_words = (payload_len - 1) as u64; + bank.n_payload_banks += 1; + totals.payload_words += n_words; + if n_words == 0 { + bank.n_empty_payload_banks += 1; + } else if module == MODULE_FADC250 || module == MODULE_UNKNOWN { + bank.n_hits += n_words; + } else { + bank.non_fadc_words += n_words; + } + self.payload.push(PayloadRef { + start: (child_i + 2) as u32, + n_words: n_words as u32, + roc, + port, + stream, + module, + }); + child_i += payload_len + 1; + } + totals.payload_banks += bank.n_payload_banks; + totals.empty_banks += bank.n_empty_payload_banks; + totals.unmapped_banks += bank.n_unmapped_payload_banks; + totals.hits += bank.n_hits; + totals.non_fadc_words += bank.non_fadc_words; + self.roc_banks.push(bank); + } +} + +#[derive(Default)] +struct Sib { + frame_number: u32, + timestamp: u64, + end: usize, + n_ais_entries: u32, + n_ais_distinct: u32, +} + +#[derive(Default)] +struct Totals { + payload_banks: u32, + empty_banks: u32, + unmapped_banks: u32, + payload_words: u64, + hits: u64, + non_fadc_words: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::words::ByteOrder; + + /// A frame with one ROC bank (ROC 7, stream 1) mapping port 1 to fADC250 + /// and port 3 to DCRB; port 1 holds two hits, port 3 two words, port 5 + /// is empty and unmapped. + fn frame_words() -> Vec { + let mut v = vec![ + 0, // event length (patched) + 0xFF60_1000, + 0, // SIB length (patched) + 0xFF31_2000, + 0x3201_0003, // TSS + 42, // frame number + 0x1000, // timestamp low + 0x2, // timestamp high + 0x4201_0003, // AIS, 3 entries + 7, + 7, + 7, + ]; + v[2] = (v.len() - 3) as u32; + let roc_start = v.len(); + v.extend([ + 0, // ROC bank length (patched) + 0x0007_1021, // ROC 7, bank of banks, num 0x21 + 0, // stream-info length (patched) + 0xFF30_2000, + 0x3101_0003, // TSS + 42, + 0x1000, + 0x2, + 0x4105_0001, // payload map: tag 0x41, pad 0, 1 word = two entries + 0x0103_0001, // LE: low half 0x0001 (port 1, module 0), high half 0x0103 (port 3, module 1) + ]); + let info_i = roc_start + 2; + v[info_i] = (v.len() - info_i - 1) as u32; + v.extend([ + 3, + 0x0001_0000, // port 1, two hits + (5 << 17) | (3 << 13) | 100, + (7 << 17) | (15 << 13) | 8191, + 3, + 0x0003_0000, // port 3, two DCRB words + 0xDEAD, + 0xBEEF, + 1, + 0x0005_0001, // port 5, empty, unmapped + ]); + v[roc_start] = (v.len() - roc_start - 1) as u32; + v[0] = (v.len() - 1) as u32; + v + } + + fn le(words: &[u32]) -> Vec { + words.iter().flat_map(|w| w.to_le_bytes()).collect() + } + + #[test] + fn decodes_a_frame() { + let bytes = le(&frame_words()); + let mut decoder = FrameDecoder::new(); + let frame = decoder + .decode(Words::new(&bytes, ByteOrder::Little)) + .expect("stream frame"); + assert_eq!(frame.frame_number, 42); + assert_eq!(frame.timestamp, 0x2_0000_1000); + assert_eq!(frame.n_ais_entries, 3); + assert_eq!(frame.n_ais_distinct, 1); + assert_eq!(frame.n_errors, 0, "{:?}", frame.errors); + assert_eq!(frame.roc_banks.len(), 1); + let roc = frame.roc_banks[0]; + assert_eq!(roc.roc, 7); + assert_eq!(roc.stream, 1); + assert_eq!(roc.module, MODULE_FADC250); + assert_eq!(roc.n_payload_banks, 3); + assert_eq!(roc.n_empty_payload_banks, 1); + assert_eq!(roc.n_unmapped_payload_banks, 1); + assert_eq!(roc.n_hits, 2); + assert_eq!(roc.non_fadc_words, 2); + assert_eq!(frame.n_payload_words, 4); + let hits: Vec = frame.hits().collect(); + assert_eq!(hits.len(), 2); + assert_eq!(hits[0].channel, 3); + assert_eq!(hits[0].time_raw, 5); + assert_eq!(hits[0].charge, 100); + assert_eq!(hits[0].port, 1); + assert_eq!(hits[1].channel, 15); + assert_eq!(hits[1].charge, 8191); + assert_eq!(hits[1].t_ns(), 28); + assert!(!hits[1].flag); + let banks: Vec<_> = frame.payload_banks().collect(); + assert_eq!(banks.len(), 3); + assert!(banks[2].is_empty()); + assert!(!banks[1].is_fadc250()); + } + + #[test] + fn non_frame_and_bad_lengths() { + let mut decoder = FrameDecoder::new(); + let other = le(&[1, 0x0001_0100, 5]); + assert!(decoder + .decode(Words::new(&other, ByteOrder::Little)) + .is_none()); + assert!(decoder + .decode(Words::new(&other[..4], ByteOrder::Little)) + .is_none()); + + // ROC bank length past the event end + let mut words = frame_words(); + let roc_start = 12; + words[roc_start] = 1000; + let bytes = le(&words); + let frame = decoder + .decode(Words::new(&bytes, ByteOrder::Little)) + .unwrap(); + assert_eq!(frame.n_errors, 1); + assert_eq!(frame.roc_banks.len(), 0); + assert_eq!(frame.hits().count(), 0); + + // payload bank length 0xFFFFFFFF + let mut words = frame_words(); + let last_bank = words.len() - 2; + words[last_bank] = u32::MAX; + let bytes = le(&words); + let frame = decoder + .decode(Words::new(&bytes, ByteOrder::Little)) + .unwrap(); + assert_eq!(frame.n_errors, 1); + assert_eq!(frame.n_hits, 2); + + // event cut mid-bank: the length word says more than present + let words = frame_words(); + let bytes = le(&words); + let frame = decoder + .decode(Words::new(&bytes[..bytes.len() - 8], ByteOrder::Little)) + .unwrap(); + assert!(frame.n_errors >= 1); + } +} diff --git a/rust/evio-stream/src/header.rs b/rust/evio-stream/src/header.rs new file mode 100644 index 0000000..49eeeef --- /dev/null +++ b/rust/evio-stream/src/header.rs @@ -0,0 +1,467 @@ +//! File header, record header and layout detection. + +use std::fmt; + +use crate::words::{ByteOrder, Words}; + +/// The magic word at word 7 of every EVIO header, which fixes the byte order. +pub const MAGIC: u32 = 0xc0da_0100; +/// File type id "EVIO" in word 0 of a v6 file header. +pub const FILE_TYPE_EVIO: u32 = 0x4556_494F; +/// File type id "HIPO" in word 0 of a v6 file header. +pub const FILE_TYPE_HIPO: u32 = 0x4849_504F; + +/// Byte length of the short (8-word) header every layout starts with. +pub const SHORT_HEADER_BYTES: usize = 32; +/// Byte length of the full 14-word v6 header. +pub const FULL_HEADER_BYTES: usize = 56; + +/// Name of a file type id read in either byte order ("EVIO", "HIPO"), else +/// `None`. HIPO-flavored writers store the ASCII bytes in order whatever +/// the file's byte order, so both orders are accepted. +pub fn file_type_name(word: u32) -> Option<&'static str> { + match word { + FILE_TYPE_EVIO => Some("EVIO"), + FILE_TYPE_HIPO => Some("HIPO"), + _ => match word.swap_bytes() { + FILE_TYPE_EVIO => Some("EVIO"), + FILE_TYPE_HIPO => Some("HIPO"), + _ => None, + }, + } +} + +/// Byte order of the header whose first 32 bytes are `head`, from the magic +/// word at word 7. `None` when neither order matches or `head` is short. +pub fn detect_byte_order(head: &[u8]) -> Option { + let m = head.get(28..32)?; + let bytes = [m[0], m[1], m[2], m[3]]; + if u32::from_le_bytes(bytes) == MAGIC { + Some(ByteOrder::Little) + } else if u32::from_be_bytes(bytes) == MAGIC { + Some(ByteOrder::Big) + } else { + None + } +} + +/// A byte length rounded up to whole words, as user headers are stored. +pub fn padded_to_words(bytes: u64) -> u64 { + (bytes + 3) & !3 +} + +/// How a file starts; mirrors `EvioFile.header_layout` of pyevio. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FileLayout { + /// A 14-word file header followed by 14-word record headers. + V6, + /// A bare stream of 14-word v6 records with no file header (a writer + /// buffer dump). + V6Records, + /// The JLab SRO hardware stream: no file header, 8-word block headers + /// `{length, block number, 8, event count, 0, 4, 0, magic}` whose + /// version word is exactly 4, no index arrays, no trailer. + Sro8Word, +} + +impl FileLayout { + /// The pyevio name of the layout. + pub fn name(self) -> &'static str { + match self { + FileLayout::V6 => "v6", + FileLayout::V6Records => "v6-records", + FileLayout::Sro8Word => "sro-8word", + } + } +} + +impl fmt::Display for FileLayout { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// The v6 file header (14 words), or a stand-in for the layouts without one. +#[derive(Clone, Debug)] +pub struct FileHeader { + pub layout: FileLayout, + pub order: ByteOrder, + /// "EVIO", "HIPO", "EVIO records" or "SRO". + pub file_type: &'static str, + pub file_type_id: u32, + pub file_number: u32, + /// Words in the header; 0 for a stand-in. + pub header_length: u32, + /// Record count the header declares; 0 for a stand-in (count the records). + pub record_count: u32, + pub index_array_length: u32, + /// Bits 8—31 of word 5. + pub bit_info: u32, + /// Content version: 6 for every layout this crate reads. + pub version: u8, + /// Version word of the container headers: 6, or 4 for the SRO blocks. + pub container_version: u8, + pub user_header_length: u32, + pub user_register: u64, + pub trailer_position: u64, + pub user_integer1: u32, + pub user_integer2: u32, + pub has_dictionary: bool, + pub has_first_event: bool, + pub has_trailer: bool, +} + +impl FileHeader { + /// Parse a 14-word file header; `head` holds at least `header_length * 4` + /// bytes (and at least 32). Words 8—13 read as 0 when absent. + pub(crate) fn parse(head: &[u8], order: ByteOrder) -> Result { + let w = Words::new(head, order); + let word = |i: usize| w.get(i).unwrap_or(0); + let file_type_id = word(0); + let file_type = file_type_name(file_type_id) + .ok_or_else(|| format!("file type id 0x{file_type_id:08x}, expected EVIO or HIPO"))?; + let bit_info_version = word(5); + let bit_info = bit_info_version >> 8; + let version = (bit_info_version & 0xFF) as u8; + let u64_at = |i: usize| (word(i) as u64) | ((word(i + 1) as u64) << 32); + Ok(FileHeader { + layout: FileLayout::V6, + order, + file_type, + file_type_id, + file_number: word(1), + header_length: word(2), + record_count: word(3), + index_array_length: word(4), + bit_info, + version, + container_version: version, + user_header_length: word(6), + user_register: u64_at(8), + trailer_position: u64_at(10), + user_integer1: word(12), + user_integer2: word(13), + has_dictionary: bit_info & 1 != 0, + has_first_event: bit_info & 2 != 0, + has_trailer: bit_info & 4 != 0, + }) + } + + /// The stand-in header of a layout without a file header. + pub(crate) fn stand_in(layout: FileLayout, order: ByteOrder) -> FileHeader { + let (file_type, container_version) = match layout { + FileLayout::Sro8Word => ("SRO", 4), + _ => ("EVIO records", 6), + }; + FileHeader { + layout, + order, + file_type, + file_type_id: 0, + file_number: 0, + header_length: 0, + record_count: 0, + index_array_length: 0, + bit_info: 0, + version: 6, + container_version, + user_header_length: 0, + user_register: 0, + trailer_position: 0, + user_integer1: 0, + user_integer2: 0, + has_dictionary: false, + has_first_event: false, + has_trailer: false, + } + } +} + +/// Compression type of a record, bits 28—31 of header word 9. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Compression { + None, + Lz4, + Lz4Best, + Gzip, + Unknown(u8), +} + +impl Compression { + fn from_code(code: u8) -> Compression { + match code { + 0 => Compression::None, + 1 => Compression::Lz4, + 2 => Compression::Lz4Best, + 3 => Compression::Gzip, + other => Compression::Unknown(other), + } + } + + /// Short name: "none", "lz4", "lz4_best", "gzip" or "unknown(N)". + pub fn name(self) -> String { + match self { + Compression::None => "none".into(), + Compression::Lz4 => "lz4".into(), + Compression::Lz4Best => "lz4_best".into(), + Compression::Gzip => "gzip".into(), + Compression::Unknown(c) => format!("unknown({c})"), + } + } +} + +/// Header species, bits 28—31 of record header word 5. +pub const HEADER_TYPE_RECORD: u8 = 0; +/// Header species of a v6 file trailer. +pub const HEADER_TYPE_TRAILER: u8 = 3; + +/// Word 5 of an SRO hardware block header: version 4 and no bit-info bits. +pub const SRO_BLOCK_WORD: u32 = 0x0000_0004; + +/// A record header: 14 words, or the 8-word SRO block header whose words +/// 8—13 read as 0. +/// +/// ```text +/// 0 record length (words) 7 magic 0xc0da0100 +/// 1 record number 8 uncompressed data length (bytes, events only) +/// 2 header length (words) 9 compression type (bits 28—31) | compressed length (words) +/// 3 event count 10—11 user register 1 +/// 4 index array length (bytes) 12—13 user register 2 +/// 5 species (28—31) | bit-info (8—27) | version (0—7) +/// 6 user header length (bytes) +/// ``` +#[derive(Clone, Debug)] +pub struct RecordHeader { + pub record_length: u32, + pub record_number: u32, + pub header_length: u32, + pub event_count: u32, + pub index_array_length: u32, + pub version: u8, + /// Bits 8—27 of word 5. + pub bit_info: u32, + /// Bits 28—31 of word 5: 0 record, 3 trailer. + pub header_type: u8, + pub pad1: u8, + pub pad2: u8, + pub pad3: u8, + pub user_header_length: u32, + pub uncompressed_data_length: u32, + pub compression: Compression, + /// Padded length of the compressed data in words. + pub compressed_data_length: u32, + pub user_register1: u64, + pub user_register2: u64, + pub has_dictionary: bool, + pub is_last_record: bool, + pub has_first_event: bool, + /// CODA event type code, bits 10—13 of word 5. + pub event_type: u8, +} + +impl RecordHeader { + /// Parse the header from `head`, which holds `header_length * 4` bytes + /// (32 for an 8-word header, 56 for a 14-word one). The caller has + /// already run [`record_header_problem`] on the first 32 bytes. + pub(crate) fn parse(head: &[u8], order: ByteOrder) -> RecordHeader { + let w = Words::new(head, order); + let word = |i: usize| w.get(i).unwrap_or(0); + let header_length = word(2); + let bit_info_version = word(5); + let bit_info = (bit_info_version >> 8) & 0xF_FFFF; + let full = header_length >= 14 && head.len() >= FULL_HEADER_BYTES; + let compression_word = if full { word(9) } else { 0 }; + let u64_at = |i: usize| (word(i) as u64) | ((word(i + 1) as u64) << 32); + RecordHeader { + record_length: word(0), + record_number: word(1), + header_length, + event_count: word(3), + index_array_length: word(4), + version: (bit_info_version & 0xFF) as u8, + bit_info, + header_type: ((bit_info_version >> 28) & 0xF) as u8, + pad1: ((bit_info_version >> 20) & 3) as u8, + pad2: ((bit_info_version >> 22) & 3) as u8, + pad3: ((bit_info_version >> 24) & 3) as u8, + user_header_length: word(6), + uncompressed_data_length: if full { word(8) } else { 0 }, + compression: Compression::from_code(((compression_word >> 28) & 0xF) as u8), + compressed_data_length: compression_word & 0x0FFF_FFFF, + user_register1: if full { u64_at(10) } else { 0 }, + user_register2: if full { u64_at(12) } else { 0 }, + has_dictionary: bit_info & 1 != 0, + is_last_record: bit_info & 2 != 0, + has_first_event: bit_info & (1 << 6) != 0, + event_type: ((bit_info >> 2) & 0xF) as u8, + } + } + + /// True for the trailer record that closes a file (species 3). Its index + /// array lists records, never events. + pub fn is_trailer(&self) -> bool { + self.header_type == HEADER_TYPE_TRAILER + } + + /// True for the SRO hardware block header shape: 8 words, version 4, no + /// bit-info bits. + pub fn is_sro_block(&self) -> bool { + self.header_length == 8 && self.version == 4 && self.bit_info == 0 && self.header_type == 0 + } + + /// Byte length of the header itself. + pub fn header_bytes(&self) -> u64 { + self.header_length as u64 * 4 + } + + /// Byte length the header declares for the whole record, header included. + pub fn record_bytes(&self) -> u64 { + self.record_length as u64 * 4 + } + + /// The CODA event type name of bits 10—13. + pub fn event_type_name(&self) -> &'static str { + match self.event_type { + 0 => "ROC Raw", + 1 => "Physics", + 2 => "Partial Physics", + 3 => "Disentangled Physics", + 4 => "User", + 5 => "Control", + 6 => "Mixed", + 8 => "ROC Raw Streaming", + 9 => "Physics Streaming", + 15 => "Other", + _ => "Unknown", + } + } +} + +/// Why no record header can start with the 32 bytes `head`, or `None` when +/// one can. Mirrors `EvioFile.record_header_problem` of pyevio: a record +/// header has the magic at word 7, a header length of 8 or 14, a record +/// length of at least the header, and no file type id in word 0 (that is a +/// second file header, as in two files concatenated). +pub fn record_header_problem(head: &[u8], order: ByteOrder) -> Option { + if head.len() < SHORT_HEADER_BYTES { + return Some(format!( + "{} bytes left, a record header needs {SHORT_HEADER_BYTES}", + head.len() + )); + } + let w = Words::new(head, order); + let word = |i: usize| w.get(i).unwrap_or(0); + if let Some(name) = file_type_name(word(0)) { + return Some(format!( + "file header ({name}) where a record header should start" + )); + } + let magic = word(7); + if magic != MAGIC { + let hint = if magic.swap_bytes() == MAGIC { + " (byte-swapped record header)" + } else { + "" + }; + return Some(format!("no magic number at word 7 (0x{magic:08X}){hint}")); + } + let header_length = word(2); + if header_length != 8 && header_length != 14 { + return Some(format!("header length {header_length}, expected 8 or 14")); + } + let length_words = word(0); + if length_words < header_length { + return Some(format!( + "record length {length_words} words is shorter than its {header_length}-word header" + )); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn le_words(words: &[u32]) -> Vec { + words.iter().flat_map(|w| w.to_le_bytes()).collect() + } + + #[test] + fn file_type_both_orders() { + assert_eq!(file_type_name(FILE_TYPE_EVIO), Some("EVIO")); + assert_eq!(file_type_name(FILE_TYPE_HIPO.swap_bytes()), Some("HIPO")); + assert_eq!(file_type_name(0x1234), None); + } + + #[test] + fn detects_order_and_problems() { + let sro = le_words(&[100, 1, 8, 11, 0, 4, 0, MAGIC]); + assert_eq!(detect_byte_order(&sro), Some(ByteOrder::Little)); + assert_eq!(record_header_problem(&sro, ByteOrder::Little), None); + let h = RecordHeader::parse(&sro, ByteOrder::Little); + assert!(h.is_sro_block()); + assert_eq!(h.record_bytes(), 400); + assert_eq!(h.compression, Compression::None); + + let be: Vec = [100u32, 1, 8, 11, 0, 4, 0, MAGIC] + .iter() + .flat_map(|w| w.to_be_bytes()) + .collect(); + assert_eq!(detect_byte_order(&be), Some(ByteOrder::Big)); + assert!(record_header_problem(&be, ByteOrder::Little) + .unwrap() + .contains("byte-swapped")); + + let short = le_words(&[5, 1, 8, 11, 0, 4, 0, MAGIC]); + assert!(record_header_problem(&short, ByteOrder::Little) + .unwrap() + .contains("shorter than")); + let bad_len = le_words(&[100, 1, 9, 11, 0, 4, 0, MAGIC]); + assert!(record_header_problem(&bad_len, ByteOrder::Little) + .unwrap() + .contains("expected 8 or 14")); + let file = le_words(&[FILE_TYPE_EVIO, 1, 14, 11, 0, 6, 0, MAGIC]); + assert!(record_header_problem(&file, ByteOrder::Little) + .unwrap() + .contains("file header")); + assert!(record_header_problem(&sro[..20], ByteOrder::Little) + .unwrap() + .contains("bytes left")); + assert_eq!(detect_byte_order(&sro[..20]), None); + } + + #[test] + fn parses_full_header_and_trailer() { + let trailer = le_words(&[14, 3, 14, 0, 0, 0x3000_0206, 0, MAGIC, 0, 0, 0, 0, 0, 0]); + let h = RecordHeader::parse(&trailer, ByteOrder::Little); + assert!(h.is_trailer()); + assert!(h.is_last_record); + assert_eq!(h.version, 6); + let lz4 = le_words(&[ + 40, + 1, + 14, + 2, + 8, + 0x0100_0006, + 0, + MAGIC, + 100, + (1 << 28) | 20, + 7, + 0, + 9, + 0, + ]); + let h = RecordHeader::parse(&lz4, ByteOrder::Little); + assert_eq!(h.compression, Compression::Lz4); + assert_eq!(h.compressed_data_length, 20); + assert_eq!(h.pad3, 1); + assert_eq!(h.uncompressed_data_length, 100); + assert_eq!(h.user_register1, 7); + assert_eq!(h.user_register2, 9); + assert!(!h.is_sro_block()); + assert_eq!(padded_to_words(5), 8); + assert_eq!(padded_to_words(8), 8); + } +} diff --git a/rust/evio-stream/src/lib.rs b/rust/evio-stream/src/lib.rs new file mode 100644 index 0000000..6780560 --- /dev/null +++ b/rust/evio-stream/src/lib.rs @@ -0,0 +1,57 @@ +//! Streaming, read-only reader for EVIO v6 files and JLab SRO hardware streams. +//! +//! The reader works over any [`std::io::Read`] (a file, standard input, a +//! pipe), never seeks and holds one record in memory at a time. It reads +//! the three layouts pyevio reads ([`FileLayout`]): v6 files with a file +//! header, bare v6 record streams, and the SRO hardware stream of 8-word +//! block headers. LZ4, LZ4_BEST and gzip records are decompressed. An +//! evio-library v4 file or a v1—3 file is refused with +//! [`Error::UnsupportedVersion`], which names the version. +//! +//! Words stay in file byte order inside the record buffer; [`Words`] decodes +//! them on read. Every length (record, event, bank, segment) is checked +//! against its container before it is trusted: a bad length ends the walk +//! with an error value, never a panic. +//! +//! ```no_run +//! use evio_stream::{Bank, FrameDecoder, RecordReader}; +//! +//! let mut reader = RecordReader::open("run.evio")?; +//! let mut decoder = FrameDecoder::new(); +//! while let Some(record) = reader.next_record()? { +//! for event in record.iter_events() { +//! let bank = Bank::event(event)?; +//! if let Some(frame) = decoder.decode(event) { +//! let hits = frame.hits().count(); +//! println!("frame {} tag 0x{:04X}: {hits} hits", frame.frame_number, bank.tag); +//! } +//! } +//! reader.recycle(record); +//! } +//! # Ok::<(), Box>(()) +//! ``` +//! +//! Modules: [`header`] (file and record headers, layout detection), +//! [`record`] (one record and its events), [`reader`] (the streaming +//! iterator), [`bank`] (bank, segment and tagsegment views), [`fadc250`] +//! (the streaming fADC250 frame walk), [`checks`] (the per-file summary). + +#![forbid(unsafe_code)] + +pub mod bank; +pub mod checks; +pub mod error; +pub mod fadc250; +pub mod header; +pub mod reader; +pub mod record; +pub mod words; + +pub use bank::{Bank, Children, StructureKind, WalkError}; +pub use checks::Checks; +pub use error::Error; +pub use fadc250::{Frame, FrameDecoder, Hit, Hits, PayloadBank, RocBank}; +pub use header::{Compression, FileHeader, FileLayout, RecordHeader}; +pub use reader::{BadRecordHeader, RecordReader, TruncatedTail}; +pub use record::{EventSpan, Record}; +pub use words::{ByteOrder, Words}; diff --git a/rust/evio-stream/src/reader.rs b/rust/evio-stream/src/reader.rs new file mode 100644 index 0000000..9aa6b73 --- /dev/null +++ b/rust/evio-stream/src/reader.rs @@ -0,0 +1,390 @@ +//! The streaming record reader. + +use std::fs::File; +use std::io::{self, Read}; +use std::path::Path; + +use crate::error::Error; +use crate::header::{ + detect_byte_order, file_type_name, padded_to_words, record_header_problem, FileHeader, + FileLayout, RecordHeader, FULL_HEADER_BYTES, SHORT_HEADER_BYTES, SRO_BLOCK_WORD, +}; +use crate::record::Record; +use crate::words::{ByteOrder, Words}; + +/// Largest buffer reserved before a record is read (64 MB); see `read_record`. +const RESERVE_CAP: u64 = 1 << 26; + +/// The `("bad_record_header", offset, message)` check of pyevio: bytes that +/// are no record header ended the scan. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BadRecordHeader { + pub offset: u64, + pub message: String, +} + +/// The `("truncated_tail", declared, present, salvaged)` check of pyevio: the +/// last record's declared length ran past the end of the stream; its +/// complete events were kept. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TruncatedTail { + /// Bytes the record header declares, header included. + pub declared_bytes: u64, + /// Bytes of the record present, header included. + pub present_bytes: u64, + /// Complete events read from the cut record. + pub salvaged_events: usize, +} + +/// Reads records one after another from any [`Read`], never seeking. +/// +/// The reader holds one record in memory at a time. It is an iterator over +/// `Result`; iteration ends at the end of the stream, after +/// the trailer, at a cut tail (the cut record is yielded with its complete +/// events) or at bytes that are no record header (recorded in +/// [`bad_record_header`](Self::bad_record_header), never an `Err`). The +/// only `Err` items are I/O errors and the version error of an evio-library +/// v4 file whose block chain only reveals itself after the first block. +pub struct RecordReader { + inner: R, + layout: FileLayout, + order: ByteOrder, + file_header: FileHeader, + file_user_header: Vec, + /// Bytes consumed from the stream so far. + offset: u64, + next_index: usize, + pending_head: Option<[u8; SHORT_HEADER_BYTES]>, + spare: Vec, + scratch: Vec, + done: bool, + after_last_bit: bool, + bad_record_header: Option, + truncated_tail: Option, + data_records: u64, + trailer_seen: bool, +} + +impl RecordReader { + /// Open a file. `"-"` reads standard input (see [`RecordReader::stdin`]). + pub fn open>(path: P) -> Result, Error> { + RecordReader::new(File::open(path)?) + } +} + +impl RecordReader> { + /// Read standard input. + pub fn stdin() -> Result { + RecordReader::new(io::stdin().lock()) + } +} + +impl RecordReader { + /// Detect the layout from the first bytes and position the reader at the + /// first record. Fails with [`Error::UnsupportedVersion`] for an EVIO v4 + /// or v1—3 file and [`Error::NotEvio`] for anything without the magic. + pub fn new(mut inner: R) -> Result { + let mut head = [0u8; SHORT_HEADER_BYTES]; + let got = read_full(&mut inner, &mut head)?; + if got < SHORT_HEADER_BYTES { + return Err(Error::NotEvio(format!( + "{got} bytes, a header needs {SHORT_HEADER_BYTES}" + ))); + } + let order = detect_byte_order(&head).ok_or_else(|| { + Error::NotEvio("no magic number 0xc0da0100 at word 7 of the first header".into()) + })?; + let w = Words::new(&head, order); + let word = |i: usize| w.get(i).unwrap_or(0); + + let mut offset = 0u64; + let mut file_user_header = Vec::new(); + let (layout, file_header, pending_head) = if file_type_name(word(0)).is_some() { + let header_length = word(2) as usize; + let header_bytes = header_length * 4; + let mut full = head.to_vec(); + if header_bytes > SHORT_HEADER_BYTES { + full.resize(header_bytes, 0); + let more = read_full(&mut inner, &mut full[SHORT_HEADER_BYTES..])?; + if more < header_bytes - SHORT_HEADER_BYTES { + return Err(Error::NotEvio(format!( + "file header declares {header_length} words, the file ends inside it" + ))); + } + } else if header_length < 8 { + return Err(Error::NotEvio(format!( + "file header length {header_length} words, expected 14" + ))); + } + let header = FileHeader::parse(&full, order).map_err(Error::NotEvio)?; + if header.version != 6 { + return Err(Error::UnsupportedVersion { + version: header.version, + detail: "file header".into(), + }); + } + offset += header_bytes as u64; + // The file-level index array (record lengths) is not used; the + // user header (dictionary, first event) is kept, padded to words. + let skip = header.index_array_length as u64; + offset += skip_bytes(&mut inner, skip)?; + let user_padded = padded_to_words(header.user_header_length as u64); + file_user_header.resize(user_padded as usize, 0); + let got = read_full(&mut inner, &mut file_user_header)?; + file_user_header.truncate(got.min(header.user_header_length as usize)); + offset += got as u64; + (FileLayout::V6, header, None) + } else { + let header_length = word(2); + let bit_info_version = word(5); + let version = (bit_info_version & 0xFF) as u8; + if header_length == 14 && version == 6 { + let header = FileHeader::stand_in(FileLayout::V6Records, order); + (FileLayout::V6Records, header, Some(head)) + } else if header_length == 8 && bit_info_version == SRO_BLOCK_WORD { + let header = FileHeader::stand_in(FileLayout::Sro8Word, order); + (FileLayout::Sro8Word, header, Some(head)) + } else { + let detail = if header_length == 8 && version == 4 { + format!( + "block header at offset 0 has bit-info 0x{:X}, an evio-library v4 file rather than an SRO stream", + bit_info_version >> 8 + ) + } else { + "block header at offset 0, no file header".to_string() + }; + return Err(Error::UnsupportedVersion { version, detail }); + } + }; + + Ok(RecordReader { + inner, + layout, + order, + file_header, + file_user_header, + offset, + next_index: 0, + pending_head, + spare: Vec::new(), + scratch: Vec::new(), + done: false, + after_last_bit: false, + bad_record_header: None, + truncated_tail: None, + data_records: 0, + trailer_seen: false, + }) + } + + /// The layout detected from the first bytes. + pub fn layout(&self) -> FileLayout { + self.layout + } + + /// The byte order of the file. + pub fn order(&self) -> ByteOrder { + self.order + } + + /// The file header, or a stand-in for the layouts without one. + pub fn file_header(&self) -> &FileHeader { + &self.file_header + } + + /// The file header's user header (dictionary and first event as a + /// record), empty for the layouts without one. + pub fn file_user_header(&self) -> &[u8] { + &self.file_user_header + } + + /// Bytes consumed from the stream so far. + pub fn bytes_read(&self) -> u64 { + self.offset + } + + /// Data records yielded so far (the trailer excluded). + pub fn data_records(&self) -> u64 { + self.data_records + } + + /// True once the trailer has been read. + pub fn trailer_seen(&self) -> bool { + self.trailer_seen + } + + /// Set when the scan ended on bytes that are no record header. + pub fn bad_record_header(&self) -> Option<&BadRecordHeader> { + self.bad_record_header.as_ref() + } + + /// Set when the last record was cut by the end of the stream. + pub fn truncated_tail(&self) -> Option { + self.truncated_tail + } + + /// Give a record's buffer back so the next record reuses the allocation. + pub fn recycle(&mut self, record: Record) { + let buffer = record.into_buffer(); + if buffer.capacity() > self.spare.capacity() { + self.spare = buffer; + } + } + + /// Read the next record, `Ok(None)` at the end. See the type docs for + /// what ends the iteration. + pub fn next_record(&mut self) -> Result, Error> { + if self.done { + return Ok(None); + } + match self.read_record() { + Ok(Some(record)) => Ok(Some(record)), + Ok(None) => { + self.done = true; + Ok(None) + } + Err(e) => { + self.done = true; + Err(e) + } + } + } + + fn stop_with(&mut self, message: String) -> Result, Error> { + // After a record with the last-record bit, bytes that are no record + // header end the scan quietly, as pyevio's scan does; otherwise they + // are a bad_record_header check. + if !self.after_last_bit { + self.bad_record_header = Some(BadRecordHeader { + offset: self.offset, + message, + }); + } + Ok(None) + } + + fn read_record(&mut self) -> Result, Error> { + let mut head = [0u8; FULL_HEADER_BYTES]; + let got = match self.pending_head.take() { + Some(pending) => { + head[..SHORT_HEADER_BYTES].copy_from_slice(&pending); + SHORT_HEADER_BYTES + } + None => read_full(&mut self.inner, &mut head[..SHORT_HEADER_BYTES])?, + }; + if got == 0 { + return Ok(None); + } + if let Some(problem) = record_header_problem(&head[..got], self.order) { + return self.stop_with(problem); + } + let header_length = Words::new(&head, self.order).get(2).unwrap_or(0) as usize; + let header_bytes = header_length * 4; + if header_bytes > SHORT_HEADER_BYTES { + let more = read_full(&mut self.inner, &mut head[SHORT_HEADER_BYTES..header_bytes])?; + if more < header_bytes - SHORT_HEADER_BYTES { + let left = SHORT_HEADER_BYTES + more; + return self.stop_with(format!( + "{left} bytes left, the {header_length}-word header needs {header_bytes}" + )); + } + } + let header = RecordHeader::parse(&head[..header_bytes], self.order); + + // Version 6 is the record header; the SRO block header (8 words, + // version 4, no bit-info bits) is the one other shape read. In an SRO + // stream any other 8-word header means an evio-library v4 file, which + // the whole-chain check of pyevio rejects at open; without seeks it is + // rejected where it shows. + if header.version != 6 && !header.is_sro_block() { + if self.layout == FileLayout::Sro8Word && header.header_length == 8 { + let detail = if header.version == 4 { + format!( + "block header at offset 0x{:X} has bit-info 0x{:X}, an evio-library v4 file rather than an SRO stream", + self.offset, + (header.bit_info | ((header.header_type as u32) << 20)) + ) + } else { + format!("block header at offset 0x{:X}, no file header", self.offset) + }; + return Err(Error::UnsupportedVersion { + version: header.version, + detail, + }); + } + return self.stop_with(format!( + "Unsupported EVIO version in record at offset 0x{:X}: version {}, expected 6", + self.offset, header.version + )); + } + + let body_len = header.record_bytes() - header.header_bytes(); + let mut body = std::mem::take(&mut self.spare); + body.clear(); + // Reserve the declared size (no zeroing: read_to_end fills spare + // capacity), capped so a corrupt length word cannot demand 16 GB up + // front; a larger record grows the buffer as it is read. + body.reserve(body_len.min(RESERVE_CAP) as usize); + (&mut self.inner).take(body_len).read_to_end(&mut body)?; + let is_truncated = (body.len() as u64) < body_len; + let present = header.header_bytes() + body.len() as u64; + let is_trailer = header.is_trailer(); + let is_last = header.is_last_record; + let record_offset = self.offset; + let record = Record::build( + self.next_index, + record_offset, + header, + self.order, + body, + is_truncated, + &mut self.scratch, + ); + self.next_index += 1; + self.offset += present; + self.after_last_bit = is_last; + if is_trailer { + self.trailer_seen = true; + self.done = true; + } else { + self.data_records += 1; + } + if is_truncated { + self.truncated_tail = Some(TruncatedTail { + declared_bytes: record.declared_bytes, + present_bytes: present, + salvaged_events: record.event_count(), + }); + self.done = true; + } + Ok(Some(record)) + } +} + +impl Iterator for RecordReader { + type Item = Result; + + fn next(&mut self) -> Option { + self.next_record().transpose() + } +} + +/// Fill `buf` as far as the stream allows; returns the bytes read (less than +/// `buf.len()` only at the end of the stream). +fn read_full(reader: &mut R, buf: &mut [u8]) -> io::Result { + let mut filled = 0; + while filled < buf.len() { + match reader.read(&mut buf[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + } + } + Ok(filled) +} + +/// Discard `n` bytes; returns how many were there. +fn skip_bytes(reader: &mut R, n: u64) -> io::Result { + io::copy(&mut reader.take(n), &mut io::sink()) +} diff --git a/rust/evio-stream/src/record.rs b/rust/evio-stream/src/record.rs new file mode 100644 index 0000000..47ba98b --- /dev/null +++ b/rust/evio-stream/src/record.rs @@ -0,0 +1,270 @@ +//! One record: its header, its bytes and the events inside. + +use std::io::Read; + +use crate::header::{padded_to_words, Compression, RecordHeader}; +use crate::words::{ByteOrder, Words}; + +/// Where one event lives inside [`Record::buffer`], in bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EventSpan { + pub offset: usize, + pub length: usize, +} + +/// A record read from the stream, with its bytes. +/// +/// The record owns one buffer that holds the index array, the user header +/// and the events: the bytes as they came from the file for a plain record, +/// the decompressed bytes for an LZ4, LZ4_BEST or gzip record. Event spans +/// index that buffer. Words stay in file byte order; [`Words`] decodes them. +#[derive(Debug)] +pub struct Record { + /// Position of the record in the stream: data records and the trailer, + /// in the order read, from 0. + pub index: usize, + /// Byte offset of the record header in the stream. + pub offset: u64, + pub header: RecordHeader, + order: ByteOrder, + buffer: Vec, + /// True when the record's declared length runs past the end of the + /// stream; `events` then holds the complete events present. + pub is_truncated: bool, + /// Byte length the header declares, header included. + pub declared_bytes: u64, + /// Bytes of the record present in the stream, header included. + pub present_bytes: u64, + data_start: usize, + data_end: usize, + events: Vec, + /// Why an event walk or a decompression stopped early; empty when every + /// length fit. + pub errors: Vec, +} + +impl Record { + /// Build a record from its header and the body bytes that follow the + /// header (`body` has `record_bytes - header_bytes` bytes, fewer for a + /// cut tail). `scratch` receives the compressed bytes of a compressed + /// record so the caller can recycle the allocation. + pub(crate) fn build( + index: usize, + offset: u64, + header: RecordHeader, + order: ByteOrder, + body: Vec, + is_truncated: bool, + scratch: &mut Vec, + ) -> Record { + let declared_bytes = header.record_bytes(); + let present_bytes = header.header_bytes() + body.len() as u64; + let mut errors = Vec::new(); + let index_len = header.index_array_length as usize; + let user_padded = padded_to_words(header.user_header_length as u64) as usize; + let data_start = index_len + user_padded; + + let (buffer, data_end) = if header.compression == Compression::None { + let end = body.len(); + (body, end) + } else { + let declared_end = data_start + header.uncompressed_data_length as usize; + let out = decompress(&header, &body, declared_end, &mut errors); + *scratch = body; + let end = declared_end.min(out.len()); + (out, end) + }; + + let mut record = Record { + index, + offset, + header, + order, + buffer, + is_truncated, + declared_bytes, + present_bytes, + data_start, + data_end, + events: Vec::new(), + errors, + }; + record.scan_events(); + record + } + + /// Walk the events: by the index array when the record has one, else by + /// the length word of each event bank. A trailer yields no events. + fn scan_events(&mut self) { + if self.header.is_trailer() { + return; + } + let w = Words::new(&self.buffer, self.order); + let data_end = self.data_end.min(self.buffer.len()); + let mut offset = self.data_start; + let declared = self.header.event_count as usize; + let index_len = self.header.index_array_length as usize; + + if index_len > 0 { + // The header's event count, the index size and the bytes present + // are three claims about the same number; trust none beyond the + // others (a cut tail may cut the index array itself). + let index_present = index_len.min(self.buffer.len()) / 4; + let count = declared.min(index_len / 4).min(index_present); + for i in 0..count { + let length = w.get(i).unwrap_or(0) as usize; + if length < 8 || offset.saturating_add(length) > data_end { + self.errors.push(format!( + "event {i} at 0x{offset:X} declares {length} bytes, record data ends at 0x{data_end:X}" + )); + break; + } + self.events.push(EventSpan { offset, length }); + offset += length; + } + } else { + while self.events.len() < declared { + let i = self.events.len(); + if offset.saturating_add(8) > data_end { + self.errors.push(format!( + "event {i} at 0x{offset:X} has no room for a bank header, record data ends at 0x{data_end:X}" + )); + break; + } + let bank_length = w.get(offset / 4).unwrap_or(0) as usize; + let length = (bank_length + 1) * 4; + if bank_length == 0 || offset.saturating_add(length) > data_end { + self.errors.push(format!( + "event {i} at 0x{offset:X} declares {length} bytes, record data ends at 0x{data_end:X}" + )); + break; + } + self.events.push(EventSpan { offset, length }); + offset += length; + } + } + } + + /// Byte order of the file. + pub fn order(&self) -> ByteOrder { + self.order + } + + /// True for the trailer that closes a file; it holds no events. + pub fn is_trailer(&self) -> bool { + self.header.is_trailer() + } + + /// The events found; for a cut tail, the complete ones. + pub fn events(&self) -> &[EventSpan] { + &self.events + } + + /// Number of events found (see [`Record::events`]). + pub fn event_count(&self) -> usize { + self.events.len() + } + + /// The words of event `i`. + pub fn event(&self, i: usize) -> Option> { + let span = self.events.get(i)?; + let bytes = self.buffer.get(span.offset..span.offset + span.length)?; + Some(Words::new(bytes, self.order)) + } + + /// The words of every event in order. + pub fn iter_events(&self) -> impl Iterator> + '_ { + self.events.iter().filter_map(move |span| { + let bytes = self.buffer.get(span.offset..span.offset + span.length)?; + Some(Words::new(bytes, self.order)) + }) + } + + /// Index array, user header and events (file bytes, or decompressed). + pub fn buffer(&self) -> &[u8] { + &self.buffer + } + + /// The index array bytes (one word per event length), empty without one. + pub fn index_array(&self) -> &[u8] { + let end = (self.header.index_array_length as usize).min(self.buffer.len()); + &self.buffer[..end] + } + + /// The user header bytes, unpadded. + pub fn user_header(&self) -> &[u8] { + let start = (self.header.index_array_length as usize).min(self.buffer.len()); + let end = (start + self.header.user_header_length as usize).min(self.buffer.len()); + &self.buffer[start..end] + } + + /// The event bytes present (from the first event to the data end). + pub fn data(&self) -> &[u8] { + let start = self.data_start.min(self.buffer.len()); + let end = self.data_end.min(self.buffer.len()).max(start); + &self.buffer[start..end] + } + + /// Take the buffer back for reuse (see [`RecordReader::recycle`](crate::RecordReader::recycle)). + pub fn into_buffer(self) -> Vec { + self.buffer + } +} + +/// Decompress a record body. The compressed bytes start right after the +/// header and run `compressed_data_length * 4 - pad3` bytes; LZ4 needs the +/// output size up front, which is index array + padded user header + the +/// uncompressed event bytes (`declared_end`). +fn decompress( + header: &RecordHeader, + body: &[u8], + declared_end: usize, + errors: &mut Vec, +) -> Vec { + let compressed_bytes = header.compressed_data_length as usize * 4; + let mut end = compressed_bytes.saturating_sub(header.pad3 as usize); + if end > body.len() { + errors.push(format!( + "compressed data declares {} words, {} bytes present", + header.compressed_data_length, + body.len() + )); + end = body.len(); + } + let compressed = &body[..end]; + let result: Result, String> = match header.compression { + Compression::Lz4 | Compression::Lz4Best => { + lz4_flex::block::decompress(compressed, declared_end).map_err(|e| e.to_string()) + } + Compression::Gzip => { + let mut out = Vec::with_capacity(declared_end); + let gzip_magic = + compressed.len() >= 2 && compressed[0] == 0x1f && compressed[1] == 0x8b; + let r = if gzip_magic { + flate2::read::MultiGzDecoder::new(compressed).read_to_end(&mut out) + } else { + flate2::read::ZlibDecoder::new(compressed).read_to_end(&mut out) + }; + r.map(|_| out).map_err(|e| e.to_string()) + } + Compression::None => unreachable!("plain records are not decompressed"), + Compression::Unknown(code) => Err(format!("unknown compression type {code}")), + }; + let out = match result { + Ok(out) => out, + Err(message) => { + errors.push(format!( + "decompression failed (type {}): {message}", + header.compression.name() + )); + Vec::new() + } + }; + if out.len() < declared_end { + errors.push(format!( + "decompressed {} bytes, header declares {declared_end}", + out.len() + )); + } + out +} diff --git a/rust/evio-stream/src/words.rs b/rust/evio-stream/src/words.rs new file mode 100644 index 0000000..0cc7fa9 --- /dev/null +++ b/rust/evio-stream/src/words.rs @@ -0,0 +1,169 @@ +//! A bounds-checked view of 32-bit words over bytes kept in file byte order. + +use std::fmt; + +/// Byte order of a file, decided by the magic word at word 7 of its first +/// header. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ByteOrder { + Little, + Big, +} + +impl ByteOrder { + /// Decode one word. + #[inline(always)] + pub fn u32(self, bytes: [u8; 4]) -> u32 { + match self { + ByteOrder::Little => u32::from_le_bytes(bytes), + ByteOrder::Big => u32::from_be_bytes(bytes), + } + } + + /// Decode one half-word. + #[inline(always)] + pub fn u16(self, bytes: [u8; 2]) -> u16 { + match self { + ByteOrder::Little => u16::from_le_bytes(bytes), + ByteOrder::Big => u16::from_be_bytes(bytes), + } + } + + /// Decode a 64-bit value stored as two words, low word first. + #[inline] + pub fn u64(self, bytes: [u8; 8]) -> u64 { + match self { + ByteOrder::Little => u64::from_le_bytes(bytes), + ByteOrder::Big => u64::from_be_bytes(bytes), + } + } +} + +impl fmt::Display for ByteOrder { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + ByteOrder::Little => "little", + ByteOrder::Big => "big", + }) + } +} + +/// Words of a byte slice in the file's byte order. +/// +/// The bytes are never swapped in place: every read decodes one word with +/// the slice's [`ByteOrder`], which is a single instruction on the common +/// path (a little-endian file on a little-endian host). All accessors are +/// bounds checked and return `Option`; nothing here panics on short input. +#[derive(Clone, Copy)] +pub struct Words<'a> { + bytes: &'a [u8], + order: ByteOrder, +} + +impl<'a> Words<'a> { + /// View `bytes` as words. A trailing partial word is ignored. + #[inline] + pub fn new(bytes: &'a [u8], order: ByteOrder) -> Self { + Words { bytes, order } + } + + /// The bytes behind the view. + #[inline] + pub fn bytes(&self) -> &'a [u8] { + self.bytes + } + + /// The byte order used to decode words. + #[inline] + pub fn order(&self) -> ByteOrder { + self.order + } + + /// Number of whole words. + #[inline] + pub fn len(&self) -> usize { + self.bytes.len() / 4 + } + + /// True when the view holds no whole word. + #[inline] + pub fn is_empty(&self) -> bool { + self.bytes.len() < 4 + } + + /// Word `i`, or `None` past the end. + #[inline(always)] + pub fn get(&self, i: usize) -> Option { + let start = i.checked_mul(4)?; + let chunk = self.bytes.get(start..start.checked_add(4)?)?; + Some(self.order.u32([chunk[0], chunk[1], chunk[2], chunk[3]])) + } + + /// Half-word `i` in memory order (half-word 0 is the first two bytes), + /// or `None` past the end. + #[inline] + pub fn get_u16(&self, i: usize) -> Option { + let start = i.checked_mul(2)?; + let chunk = self.bytes.get(start..start.checked_add(2)?)?; + Some(self.order.u16([chunk[0], chunk[1]])) + } + + /// The words `start..end`, or `None` when the range is not inside the view. + #[inline] + pub fn slice(&self, start: usize, end: usize) -> Option> { + if start > end { + return None; + } + let bytes = self.bytes.get(start.checked_mul(4)?..end.checked_mul(4)?)?; + Some(Words { + bytes, + order: self.order, + }) + } + + /// Every whole word in order. + #[inline] + pub fn iter(&self) -> impl Iterator + 'a { + let order = self.order; + self.bytes + .chunks_exact(4) + .map(move |c| order.u32([c[0], c[1], c[2], c[3]])) + } + + /// The words copied into a `Vec` in host order. + pub fn to_vec(&self) -> Vec { + self.iter().collect() + } +} + +impl fmt::Debug for Words<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Words({} words, {} endian)", self.len(), self.order) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_both_orders_and_bounds() { + let bytes = [1u8, 0, 0, 0, 0, 0, 0, 2, 9]; + let le = Words::new(&bytes, ByteOrder::Little); + assert_eq!(le.len(), 2); + assert_eq!(le.get(0), Some(1)); + assert_eq!(le.get(1), Some(0x0200_0000)); + assert_eq!(le.get(2), None); + assert_eq!(le.get(usize::MAX), None); + let be = Words::new(&bytes, ByteOrder::Big); + assert_eq!(be.get(0), Some(0x0100_0000)); + assert_eq!(be.get(1), Some(2)); + assert_eq!(le.get_u16(1), Some(0)); + assert_eq!(be.get_u16(3), Some(2)); + assert_eq!(le.get_u16(4), None); + assert!(le.slice(1, 3).is_none()); + assert_eq!(le.slice(1, 2).unwrap().get(0), Some(0x0200_0000)); + assert!(le.slice(2, 1).is_none()); + assert_eq!(le.iter().count(), 2); + } +} diff --git a/rust/evio-stream/tests/corpus.rs b/rust/evio-stream/tests/corpus.rs new file mode 100644 index 0000000..fe3e1dd --- /dev/null +++ b/rust/evio-stream/tests/corpus.rs @@ -0,0 +1,155 @@ +//! The corpus and SRO rulers (15 GB files; run with `EVIO_CORPUS=1`). +//! +//! `tests/data/corpus.tsv` is generated from +//! `evio-optim/space-evio-lib-23/golden_manifest/corpus.yaml`: the eight +//! converted v6 files (plain and LZ4) with their record and event counts. +//! The raw SRO hardware files of run 791 are checked against the reference +//! measured with pyevio and two independent decoders (`docs/stream_decoding.md`). + +use std::path::Path; + +use evio_stream::{Checks, FileLayout, FrameDecoder, RecordReader}; + +fn enabled() -> bool { + std::env::var("EVIO_CORPUS") + .map(|v| v == "1") + .unwrap_or(false) +} + +struct Stream { + checks: Checks, + frames: u64, + hits: u64, + tail_hits: u64, + non_fadc_words: u64, + empty_payload_banks: u64, + decode_errors: u64, + seconds: f64, +} + +fn scan(path: &Path, fadc250: bool) -> Stream { + let start = std::time::Instant::now(); + let reader = RecordReader::open(path).unwrap(); + let mut decoder = FrameDecoder::new(); + let (mut frames, mut hits, mut tail_hits, mut other, mut empty, mut errors) = + (0, 0, 0, 0, 0, 0); + let checks = Checks::scan_with(reader, |record| { + if !fadc250 { + return; + } + for event in record.iter_events() { + if let Some(frame) = decoder.decode(event) { + frames += 1; + hits += frame.n_hits; + if record.is_truncated { + tail_hits += frame.n_hits; + } + other += frame.non_fadc_words; + empty += frame.n_empty_payload_banks as u64; + errors += frame.n_errors as u64; + } + } + }) + .unwrap(); + Stream { + checks, + frames, + hits, + tail_hits, + non_fadc_words: other, + empty_payload_banks: empty, + decode_errors: errors, + seconds: start.elapsed().as_secs_f64(), + } +} + +#[test] +fn converted_corpus() { + if !enabled() { + eprintln!("EVIO_CORPUS not set, skipping"); + return; + } + for line in include_str!("data/corpus.tsv").lines() { + if line.starts_with('#') || line.trim().is_empty() { + continue; + } + let f: Vec<&str> = line.split('\t').collect(); + let (path, records, events) = ( + f[0], + f[2].parse::().unwrap(), + f[3].parse::().unwrap(), + ); + let s = scan(Path::new(path), false); + eprintln!( + "{path}: {} records, {} events, trailer {}, {:.1} s, {:.2} GB/s", + s.checks.records, + s.checks.events, + s.checks.trailer, + s.seconds, + s.checks.bytes as f64 / 1e9 / s.seconds + ); + assert_eq!(s.checks.layout, FileLayout::V6, "{path}"); + assert_eq!(s.checks.records, records, "{path}: records"); + assert_eq!(s.checks.events, events, "{path}: events"); + assert!(s.checks.is_clean(), "{path}: {:?}", s.checks); + } +} + +#[test] +fn raw_sro_files() { + if !enabled() { + eprintln!("EVIO_CORPUS not set, skipping"); + return; + } + // (path, records incl. the cut tail, events incl. salvaged, (declared, present, salvaged), + // fADC250 hits in the complete blocks) + let expected = [ + ( + "/data/sro_boyarinov_data_2026/sro_000791.evio.00000", + 7893u64, + 86_794u64, + (1_967_400u64, 531_760u64, 2usize), + 763_992_122u64, + Some(764_009_774u64), + ), + ( + "/data/sro_boyarinov_data_2026/sro_000791.evio.00001", + 7893, + 86_805, + (1_960_136, 1_939_708, 10), + 764_052_526, + // Sum of the '+salvage' payload words of the fADC250 ROC ids in + // /data/pyevio/sro_reference/tables.md (independent reference). + Some(764_140_261), + ), + ]; + for (path, records, events, tail, hits_complete, hits_with_salvage) in expected { + let s = scan(Path::new(path), true); + eprintln!( + "{path}: {} records, {} events, tail {:?}, {} frames, {} hits ({} in the tail), {} other words, {} empty banks, {} decode errors, {:.1} s, {:.2} GB/s", + s.checks.records, s.checks.events, s.checks.truncated_tail, s.frames, s.hits, s.tail_hits, + s.non_fadc_words, s.empty_payload_banks, s.decode_errors, s.seconds, + s.checks.bytes as f64 / 1e9 / s.seconds + ); + assert_eq!(s.checks.layout, FileLayout::Sro8Word, "{path}"); + assert_eq!(s.checks.records, records, "{path}: records"); + assert_eq!(s.checks.events, events, "{path}: events"); + let t = s.checks.truncated_tail.expect("cut tail"); + assert_eq!( + (t.declared_bytes, t.present_bytes, t.salvaged_events), + tail, + "{path}: tail" + ); + assert!(s.checks.bad_record_header.is_none(), "{path}"); + assert_eq!(s.frames, events, "{path}: every event is a frame"); + assert_eq!( + s.hits - s.tail_hits, + hits_complete, + "{path}: hits in the complete blocks" + ); + if let Some(total) = hits_with_salvage { + assert_eq!(s.hits, total, "{path}: hits with the salvaged frames"); + } + assert_eq!(s.decode_errors, 0, "{path}: decode errors"); + } +} diff --git a/rust/evio-stream/tests/data/corpus.tsv b/rust/evio-stream/tests/data/corpus.tsv new file mode 100644 index 0000000..572b67e --- /dev/null +++ b/rust/evio-stream/tests/data/corpus.tsv @@ -0,0 +1,10 @@ +# generated from evio-optim golden_manifest/corpus.yaml +# columns: path, compression, records, event_count, salvaged_events +/data/evio-lib/corpus/sro_000791.evio.00000.v6 none 7891 86794 2 +/data/evio-lib/corpus/sro_000791.evio.00000.v6.lz4 lz4 7891 86794 2 +/data/evio-lib/corpus/sro_000791.evio.00001.v6 none 7892 86805 10 +/data/evio-lib/corpus/sro_000791.evio.00001.v6.lz4 lz4 7892 86805 10 +/data/evio-lib/corpus/sro_000791.evio.00002.v6 none 7890 86790 8 +/data/evio-lib/corpus/sro_000791.evio.00002.v6.lz4 lz4 7890 86790 8 +/data/evio-lib/corpus/sro_000791.evio.00003.v6 none 7891 86801 0 +/data/evio-lib/corpus/sro_000791.evio.00003.v6.lz4 lz4 7891 86801 0 diff --git a/rust/evio-stream/tests/data/golden_manifest.tsv b/rust/evio-stream/tests/data/golden_manifest.tsv new file mode 100644 index 0000000..885da6a --- /dev/null +++ b/rust/evio-stream/tests/data/golden_manifest.tsv @@ -0,0 +1,46 @@ +# generated from evio-optim golden_manifest/manifest.yaml (lib_git_sha cbb56c79827268129870a4b89e0682b4bc8cc2df) +# columns: path (below golden_root), expected (v6 | v6-records | reject:), event_count, sha256 +cases/cpp/append_le.evio v6 19 173a00b9fc678fdbfe232b168e4869bd050d22bc7312169b9ab6f04d801d12f2 +cases/cpp/basic_be.evio v6 18 19ada7d0d78dd2f189809d10306111d3705b84b062c8e925859624df84a2e1fd +cases/cpp/basic_le.evio v6 18 e1756f3410a47e7d897c45bee1cb4e7e6346f735eabb4a6a3ad186cab9df3daa +cases/cpp/buffer_evw_dict_le.dump v6-records 18 2ba2b2f3175aebf5afd34be303899b1a3c3c396138b13741c5c7e8f4177b81d9 +cases/cpp/buffer_evw_le.dump v6-records 18 1cd5e47d883b955738905509b36531421b98f4da16ff4a7b14cd1fc3f4c72d97 +cases/cpp/deepnest_le.evio v6 1 47cb4def33b1a2d99296cb1f1e4ab0eb31e356cabc9107c8a87ed61c78ac0308 +cases/cpp/dict_le.evio v6 18 afc0d3acbe59d6488c1159dd30f00a3a77c11a43bfd4ff8ce991e385956a0aa1 +cases/cpp/dictfirst_le.evio v6 18 b0887886dcb57e611cc0f611f3e3f68ed7e1809878185cf8f51373a49d9ae22b +cases/cpp/first_le.evio v6 18 611bbcd7fffcaa5a110a941334252bdf2c1011f1bebb462851bf3fd2b5f9415b +cases/cpp/hipo_le.evio v6 18 ee21bd6492ed394782751c784f68e144b8ce866f1ff7a7c46bafe015c7b61f2d +cases/cpp/hugeev_le.evio v6 1 c7086456947d5b8ea5481bbe3ce7fdb684e410d5822b436e151ef6306fb63fe5 +cases/cpp/lz4_le.evio v6 18 7067998006c8e31342c3a904eca0c8341f446da9ba5dc4d939d5cfba9f699072 +cases/cpp/lz4best_le.evio v6 18 f46e51e2a2c027d3b0a9c088a9104c0dbda85ad7b7c2514f0c9aa391bb2d99dd +cases/cpp/manyrec_le.evio v6 18 f65128d9ebba77e6dd61f6c97293f05985a436101d54c22fce00e9471bebb504 +cases/cpp/v4_be.evio reject:4 18 250c5ba0688bf6fe827daeccd6fea6eaa4441f4328f755d16aac1b64cbd20361 +cases/cpp/v4_le.evio reject:4 18 071ef5512bd25539eea31734bf0ec57dbfcde3ba339937f938d6414ded65c493 +cases/cpp/v4dict_le.evio reject:4 18 83d4eba7a14b2f6876358edbfafb9f7d56ed1336026b66349df55adbaa6ca7d3 +cases/cpp/writer_noidx_le.evio v6 18 f6e22dfd5a8239c3f3e078f41699a61e810536371bf936d7f54d8e02360dc6f0 +cases/cpp/writer_traileridx_le.evio v6 18 46936aa483b2c2fd31bd8939fa0b8179af65e863c6f7269d1a1213ee9cfc576f +cases/cpp/zeroev_le.evio v6 0 8f5677de276c253d3df001a181ba1be43218af85e46d082b219c418fef493fb5 +cases/java/append_le.evio v6 19 173a00b9fc678fdbfe232b168e4869bd050d22bc7312169b9ab6f04d801d12f2 +cases/java/basic_be.evio v6 18 19ada7d0d78dd2f189809d10306111d3705b84b062c8e925859624df84a2e1fd +cases/java/basic_le.evio v6 18 e1756f3410a47e7d897c45bee1cb4e7e6346f735eabb4a6a3ad186cab9df3daa +cases/java/buffer_evw_dict_le.dump v6-records 18 2ba2b2f3175aebf5afd34be303899b1a3c3c396138b13741c5c7e8f4177b81d9 +cases/java/buffer_evw_le.dump v6-records 18 1cd5e47d883b955738905509b36531421b98f4da16ff4a7b14cd1fc3f4c72d97 +cases/java/deepnest_le.evio v6 1 47cb4def33b1a2d99296cb1f1e4ab0eb31e356cabc9107c8a87ed61c78ac0308 +cases/java/dict_le.evio v6 18 afc0d3acbe59d6488c1159dd30f00a3a77c11a43bfd4ff8ce991e385956a0aa1 +cases/java/dictfirst_le.evio v6 18 b0887886dcb57e611cc0f611f3e3f68ed7e1809878185cf8f51373a49d9ae22b +cases/java/first_le.evio v6 18 611bbcd7fffcaa5a110a941334252bdf2c1011f1bebb462851bf3fd2b5f9415b +cases/java/gzip_le.evio v6 18 f1b1408ef757e81660ee2343252c3f6e28b8f0453f4a1607b62fb8ebc5f76d8c +cases/java/hipo_le.evio v6 18 ee21bd6492ed394782751c784f68e144b8ce866f1ff7a7c46bafe015c7b61f2d +cases/java/hugeev_le.evio v6 1 c7086456947d5b8ea5481bbe3ce7fdb684e410d5822b436e151ef6306fb63fe5 +cases/java/lz4_le.evio v6 18 7067998006c8e31342c3a904eca0c8341f446da9ba5dc4d939d5cfba9f699072 +cases/java/lz4best_le.evio v6 18 00143030286895a5fe5d25bba3f4a115f1dc9dda7de0d283906e18bd334b83ce +cases/java/manyrec_le.evio v6 18 f65128d9ebba77e6dd61f6c97293f05985a436101d54c22fce00e9471bebb504 +cases/java/split_le_1.evio.0 v6 102 9d355dbad3a3223323614ac2a6998b68203ebbd272a7ec4b5f412b72e17e4aa6 +cases/java/split_le_1.evio.1 v6 98 5e23e77fcfaee211d669b2b18cd94fcf04d009199c68d9ff4b554f6ba2354ba3 +cases/java/v4_be.evio reject:4 18 250c5ba0688bf6fe827daeccd6fea6eaa4441f4328f755d16aac1b64cbd20361 +cases/java/v4_le.evio reject:4 18 071ef5512bd25539eea31734bf0ec57dbfcde3ba339937f938d6414ded65c493 +cases/java/v4dict_le.evio reject:4 18 83d4eba7a14b2f6876358edbfafb9f7d56ed1336026b66349df55adbaa6ca7d3 +cases/java/writer_noidx_le.evio v6 18 f6e22dfd5a8239c3f3e078f41699a61e810536371bf936d7f54d8e02360dc6f0 +cases/java/writer_traileridx_le.evio v6 18 46936aa483b2c2fd31bd8939fa0b8179af65e863c6f7269d1a1213ee9cfc576f +cases/java/zeroev_le.evio v6 0 8f5677de276c253d3df001a181ba1be43218af85e46d082b219c418fef493fb5 +cases/v13_legacy.evio reject:3 3 a9294a1d0435f623f79d7e2345c1c81e86943858fb4f9b2566908f13bc24485e diff --git a/rust/evio-stream/tests/golden.rs b/rust/evio-stream/tests/golden.rs new file mode 100644 index 0000000..1bbf1fc --- /dev/null +++ b/rust/evio-stream/tests/golden.rs @@ -0,0 +1,256 @@ +//! The golden-set ruler: every row of the evio-optim golden manifest. +//! +//! `tests/data/golden_manifest.tsv` is generated from +//! `evio-optim/space-evio-lib-23/golden_manifest/manifest.yaml` (path, +//! expected layout or `reject:`, event count, sha256). The files +//! live under `EVIO_GOLDEN_ROOT` (default `/data/evio-lib/golden`); the test +//! skips with a note when that directory is absent. +//! +//! For a readable row the layout and the event count must match and no +//! check may fire. For a reject row (evio-library v4, v1—3) the reader must +//! end with `Error::UnsupportedVersion` naming the version, either at open +//! or, for a v4 file whose first block looks like an SRO block, on the block +//! that gives it away. + +use std::path::{Path, PathBuf}; + +use evio_stream::{Bank, Error, FileLayout, RecordReader}; + +struct Row { + path: String, + expected: String, + event_count: u64, +} + +fn rows() -> Vec { + let tsv = include_str!("data/golden_manifest.tsv"); + tsv.lines() + .filter(|l| !l.starts_with('#') && !l.trim().is_empty()) + .map(|l| { + let f: Vec<&str> = l.split('\t').collect(); + Row { + path: f[0].to_string(), + expected: f[1].to_string(), + event_count: f[2].parse().expect("event count"), + } + }) + .collect() +} + +fn golden_root() -> Option { + let root = std::env::var("EVIO_GOLDEN_ROOT").unwrap_or_else(|_| "/data/evio-lib/golden".into()); + let root = PathBuf::from(root); + root.is_dir().then_some(root) +} + +#[derive(Debug)] +struct Outcome { + layout: FileLayout, + events: u64, + declared: u64, + problems: Vec, +} + +/// Drain the reader; return the terminal error if any, and the counts. +fn read_all(path: &Path) -> Result { + let mut reader = RecordReader::open(path)?; + let layout = reader.layout(); + let mut records = 0u64; + let mut events = 0u64; + let mut declared = 0u64; + let mut problems = Vec::new(); + while let Some(record) = reader.next_record()? { + if record.is_trailer() { + assert_eq!(record.event_count(), 0, "a trailer yields no events"); + continue; + } + records += 1; + events += record.event_count() as u64; + declared += record.header.event_count as u64; + problems.extend(record.errors.iter().cloned()); + for (i, event) in record.iter_events().enumerate() { + // Every event is a bank whose length fits its span exactly. + let bank = Bank::event(event) + .unwrap_or_else(|e| panic!("record {} event {i}: {e}", record.index)); + assert_eq!(bank.total_words(), event.len(), "event span == bank length"); + // Walk the whole tree; a bad length is a failure for golden data. + fn walk(bank: &Bank<'_>, errors: &mut Vec) { + for child in bank.children() { + match child { + Ok(c) => walk(&c, errors), + Err(e) => errors.push(e.to_string()), + } + } + } + walk(&bank, &mut problems); + } + reader.recycle(record); + } + if let Some(t) = reader.truncated_tail() { + problems.push(format!("truncated tail {t:?}")); + } + if let Some(b) = reader.bad_record_header() { + problems.push(format!("bad record header {b:?}")); + } + let _ = records; + Ok(Outcome { + layout, + events, + declared, + problems, + }) +} + +#[test] +fn golden_manifest() { + let Some(root) = golden_root() else { + eprintln!("golden root absent, skipping (set EVIO_GOLDEN_ROOT)"); + return; + }; + let rows = rows(); + assert!(rows.len() >= 40, "manifest rows"); + let mut passed = 0; + let mut rejected = 0; + for row in &rows { + let path = root.join(&row.path); + assert!(path.is_file(), "golden file missing: {}", path.display()); + let result = read_all(&path); + if let Some(version) = row.expected.strip_prefix("reject:") { + let version: u8 = version.parse().unwrap(); + match result { + Err(Error::UnsupportedVersion { version: v, detail }) => { + assert_eq!(v, version, "{}: version in the error ({detail})", row.path); + let message = Error::UnsupportedVersion { version: v, detail }.to_string(); + assert!(message.contains(&format!("version {version}")), "{message}"); + assert!(message.contains("evio2xml"), "{message}"); + rejected += 1; + } + other => panic!( + "{}: expected the version {version} error, got {other:?}", + row.path + ), + } + continue; + } + let Outcome { + layout, + events, + declared, + problems, + } = result.unwrap_or_else(|e| panic!("{}: {e}", row.path)); + let expected_layout = match row.expected.as_str() { + "v6" => FileLayout::V6, + "v6-records" => FileLayout::V6Records, + other => panic!("unknown expectation {other}"), + }; + assert_eq!(layout, expected_layout, "{}: layout", row.path); + assert_eq!(events, row.event_count, "{}: events found", row.path); + assert_eq!( + declared, row.event_count, + "{}: events declared by the headers", + row.path + ); + assert!(problems.is_empty(), "{}: {problems:?}", row.path); + passed += 1; + } + eprintln!("golden: {passed} rows read, {rejected} rows rejected as expected"); + assert_eq!(passed + rejected, rows.len()); + assert_eq!(rejected, 7, "v4 (cpp and java, 3 each) and v1-3 rows"); +} + +#[test] +fn golden_from_a_pipe() { + // The reader never seeks: the same counts must come from a non-seekable source. + let Some(root) = golden_root() else { + return; + }; + let path = root.join("cases/cpp/lz4_le.evio"); + let bytes = std::fs::read(&path).unwrap(); + let mut reader = RecordReader::new(std::io::Cursor::new(bytes)).unwrap(); + let mut events = 0; + while let Some(record) = reader.next_record().unwrap() { + events += record.event_count(); + } + assert_eq!(events, 18); + assert!(reader.trailer_seen()); +} + +#[test] +fn truncated_and_garbage_tails() { + let Some(root) = golden_root() else { + return; + }; + let path = root.join("cases/cpp/manyrec_le.evio"); + let bytes = std::fs::read(&path).unwrap(); + // Cut the file inside the body of record 7 (58 words, 2 events): the + // complete events before the cut are kept. (A cut inside a record + // header is a bad_record_header instead, as in pyevio.) + let mut probe = RecordReader::new(std::io::Cursor::new(bytes.clone())).unwrap(); + let cut = loop { + let record = probe.next_record().unwrap().expect("record 7"); + if record.index == 7 { + break (record.offset + record.header.header_bytes() + 100) as usize; + } + }; + let mut reader = RecordReader::new(std::io::Cursor::new(bytes[..cut].to_vec())).unwrap(); + let mut events = 0; + let mut last_truncated = false; + while let Some(record) = reader.next_record().unwrap() { + events += record.event_count(); + last_truncated = record.is_truncated; + } + let tail = reader.truncated_tail().expect("truncated tail"); + assert!(last_truncated); + assert!(tail.present_bytes < tail.declared_bytes); + assert!(events < 18 && events > 0, "{events}"); + // Garbage after the last record: bad_record_header, no panic. + let mut garbage = bytes.clone(); + garbage.extend_from_slice(&[0xAB; 40]); + let mut reader = RecordReader::new(std::io::Cursor::new(garbage)).unwrap(); + let mut events = 0; + while let Some(record) = reader.next_record().unwrap() { + events += record.event_count(); + } + // manyrec ends in a trailer, so the garbage after it is never read. + assert_eq!(events, 18); + // A record with the last-record bit followed by garbage ends quietly, as + // in pyevio (the .dump's one record carries the bit). + let path = root.join("cases/cpp/buffer_evw_le.dump"); + let dump = std::fs::read(&path).unwrap(); + let mut garbage = dump.clone(); + garbage.extend_from_slice(&[0xAB; 40]); + let mut reader = RecordReader::new(std::io::Cursor::new(garbage)).unwrap(); + let mut events = 0; + let mut last_bit = false; + while let Some(record) = reader.next_record().unwrap() { + events += record.event_count(); + last_bit = record.header.is_last_record; + } + assert_eq!(events, 18); + assert!(last_bit); + assert!(reader.bad_record_header().is_none()); + // The same events in an SRO-style 8-word block (no last bit, no index + // array) followed by garbage: bad_record_header names the offset. + let mut probe = RecordReader::new(std::io::Cursor::new(dump)).unwrap(); + let record = probe.next_record().unwrap().unwrap(); + let data = record.data().to_vec(); + let n_events = record.header.event_count; + let mut sro = Vec::new(); + let length_words = 8 + data.len() as u32 / 4; + for word in [length_words, 1, 8, n_events, 0, 4, 0, 0xc0da_0100] { + sro.extend_from_slice(&word.to_le_bytes()); + } + sro.extend_from_slice(&data); + let block_bytes = sro.len() as u64; + sro.extend_from_slice(&[0xAB; 40]); + let mut reader = RecordReader::new(std::io::Cursor::new(sro)).unwrap(); + assert_eq!(reader.layout(), FileLayout::Sro8Word); + let mut events = 0; + while let Some(record) = reader.next_record().unwrap() { + events += record.event_count(); + } + assert_eq!(events, 18); + let bad = reader.bad_record_header().expect("garbage after the block"); + assert_eq!(bad.offset, block_bytes); + assert!(bad.message.contains("no magic number"), "{}", bad.message); +} diff --git a/rust/evio-tui/Cargo.toml b/rust/evio-tui/Cargo.toml new file mode 100644 index 0000000..cb776e4 --- /dev/null +++ b/rust/evio-tui/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "evio-tui" +version = "0.1.0" +edition = "2021" +description = "Minimal terminal viewer for EVIO v6 files and JLab SRO streams" +license = "MIT" +publish = false + +[dependencies] +evio-stream = { path = "../evio-stream" } +# Terminal drawing (widgets, buffers, the test backend). +ratatui = "0.30" +# Raw mode, the alternate screen and key events. +crossterm = "0.29" diff --git a/rust/evio-tui/src/access.rs b/rust/evio-tui/src/access.rs new file mode 100644 index 0000000..2041ee6 --- /dev/null +++ b/rust/evio-tui/src/access.rs @@ -0,0 +1,33 @@ +//! Random access to one record of a file. +//! +//! The library reader is forward-only, but every layout it reads starts +//! each record with a record header: a 14-word v6 header (which a fresh +//! reader takes for the `v6-records` layout) or an 8-word SRO block header +//! (`sro-8word`). So a file seeked to a record offset noted during the scan +//! and handed to [`RecordReader::new`] yields that record first, with no +//! layout hint and no second pass over the file. + +use std::fs::File; +use std::io::{Seek, SeekFrom}; +use std::path::Path; + +use evio_stream::{Record, RecordReader}; + +/// Read the record whose header starts at `offset`. The returned record's +/// own `index` and `offset` are relative to the fresh reader (0); callers +/// keep the scan's numbers. +pub fn load_record(path: &Path, offset: u64) -> Result { + let mut file = File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?; + file.seek(SeekFrom::Start(offset)) + .map_err(|e| format!("seek to 0x{offset:X}: {e}"))?; + let mut reader = + RecordReader::new(file).map_err(|e| format!("no record header at 0x{offset:X}: {e}"))?; + match reader.next_record() { + Ok(Some(record)) => Ok(record), + Ok(None) => Err(match reader.bad_record_header() { + Some(bad) => format!("no record at 0x{offset:X}: {}", bad.message), + None => format!("no record at 0x{offset:X}"), + }), + Err(e) => Err(format!("record at 0x{offset:X}: {e}")), + } +} diff --git a/rust/evio-tui/src/format.rs b/rust/evio-tui/src/format.rs new file mode 100644 index 0000000..3c2e85f --- /dev/null +++ b/rust/evio-tui/src/format.rs @@ -0,0 +1,381 @@ +//! Text for every cell, line and panel the screens show. Nothing here +//! touches the terminal, so unit tests check the strings directly. + +use evio_stream::bank::data_type; +use evio_stream::fadc250::{module_name, STREAM_EVENT_TAG}; +use evio_stream::{Frame, StructureKind}; + +use crate::scan::{RecordSummary, ScanStart}; +use crate::state::{EventRow, ScanProgress}; +use crate::tree::{TreeItem, TreeRow}; + +/// Words shown per hex row. +pub const HEX_WORDS_PER_ROW: usize = 8; + +/// Column titles of the records table; see [`record_cells`]. +pub const RECORD_COLUMNS: [&str; 9] = [ + "index", "offset", "bytes", "type", "events", "found", "compress", "trunc", "errors", +]; + +/// Column titles of the events table; see [`event_cells`]. +pub const EVENT_COLUMNS: [&str; 6] = ["event", "words", "tag", "type", "num", "note"]; + +/// Column titles of the bank tree table; see [`tree_cells`]. +pub const TREE_COLUMNS: [&str; 6] = ["structure", "tag", "type", "num", "length", "words"]; + +/// `1.23 GB`, `456.7 MB`, `12.3 kB`, `99 B`. +pub fn human_bytes(bytes: u64) -> String { + let b = bytes as f64; + if bytes >= 1_000_000_000 { + format!("{:.2} GB", b / 1e9) + } else if bytes >= 1_000_000 { + format!("{:.1} MB", b / 1e6) + } else if bytes >= 1_000 { + format!("{:.1} kB", b / 1e3) + } else { + format!("{bytes} B") + } +} + +/// The cells of one records-table row. +pub fn record_cells(s: &RecordSummary) -> [String; 9] { + let h = &s.header; + [ + s.index.to_string(), + format!("0x{:X}", s.offset), + s.declared_bytes().to_string(), + s.kind_name().to_string(), + h.event_count.to_string(), + s.events_found.to_string(), + h.compression.name(), + if s.is_truncated { "yes" } else { "" }.to_string(), + if s.error_count > 0 { + s.error_count.to_string() + } else { + String::new() + }, + ] +} + +/// The side panel of a record: every header field. +pub fn record_header_lines(s: &RecordSummary) -> Vec { + let h = &s.header; + let mut lines = vec![ + format!("record {} at 0x{:X}", s.index, s.offset), + format!( + "record length {} words ({} bytes)", + h.record_length, + s.declared_bytes() + ), + format!("present {} bytes", s.present_bytes), + format!("record number {}", h.record_number), + format!("header length {} words", h.header_length), + format!("header type {} ({})", h.header_type, s.kind_name()), + format!("version {}", h.version), + format!( + "events {} declared, {} found", + h.event_count, s.events_found + ), + format!("index array {} bytes", h.index_array_length), + format!("user header {} bytes", h.user_header_length), + format!("uncompressed {} bytes", h.uncompressed_data_length), + format!( + "compression {} ({} words)", + h.compression.name(), + h.compressed_data_length + ), + format!("bit info 0x{:05X}", h.bit_info), + format!("event type {}", h.event_type_name()), + format!( + "last / dict / first {} / {} / {}", + h.is_last_record, h.has_dictionary, h.has_first_event + ), + format!("pad1/2/3 {} {} {}", h.pad1, h.pad2, h.pad3), + format!("user register 1 0x{:016x}", h.user_register1), + format!("user register 2 0x{:016x}", h.user_register2), + format!("truncated {}", s.is_truncated), + ]; + if s.error_count > 0 { + lines.push(format!("errors {}", s.error_count)); + for e in &s.errors { + lines.push(format!(" {e}")); + } + } + lines +} + +/// The file line under the title: layout, byte order, file type. +pub fn file_line(start: Option<&ScanStart>) -> String { + match start { + Some(s) => { + let h = &s.file_header; + let mut line = format!( + "layout {} byte order {} file type {}", + s.layout, s.order, h.file_type + ); + if h.header_length > 0 { + line.push_str(&format!( + " records declared {} dict {} first event {} trailer index {}", + h.record_count, h.has_dictionary, h.has_first_event, h.has_trailer + )); + } + line + } + None => "opening...".to_string(), + } +} + +/// The status line: progress while the scan runs, the checks once it ends. +pub fn status_line(scan: &ScanProgress) -> String { + if let Some(failed) = &scan.failed { + return format!("scan failed: {failed}"); + } + let elapsed = scan.elapsed_s(); + let rate = scan.bytes as f64 / 1e9 / elapsed.max(1e-9); + let base = format!( + "{} {} events {} {elapsed:.2} s {rate:.2} GB/s", + scan.records_label(), + scan.events, + human_bytes(scan.bytes) + ); + match &scan.end { + None => format!("scanning: {base}"), + Some(end) => { + let mut parts = vec![format!("done: {base}")]; + parts.push(format!( + "trailer {}", + if end.trailer { "yes" } else { "no" } + )); + match end.truncated_tail { + Some(t) => parts.push(format!( + "truncated_tail ({}, {}, {})", + t.declared_bytes, t.present_bytes, t.salvaged_events + )), + None => parts.push("truncated_tail none".into()), + } + match &end.bad_record_header { + Some(b) => parts.push(format!( + "bad_record_header at 0x{:X}: {}", + b.offset, b.message + )), + None => parts.push("bad_record_header none".into()), + } + if let Some(e) = &end.error { + parts.push(format!("reader error: {e}")); + } + parts.join(" ") + } + } +} + +/// The cells of one events-table row. +pub fn event_cells(e: &EventRow) -> [String; 6] { + let note = if !e.header_ok { + "no bank header" + } else if e.tag == STREAM_EVENT_TAG { + "stream frame" + } else { + "" + }; + [ + e.index.to_string(), + e.words.to_string(), + format!("0x{:04X}", e.tag), + format!("0x{:02X} {}", e.data_type, data_type::name(e.data_type)), + e.num.to_string(), + note.to_string(), + ] +} + +/// The cells of one bank-tree row, and whether the row is an error. +pub fn tree_cells(row: &TreeRow) -> ([String; 6], bool) { + let indent = " ".repeat(row.depth); + match &row.item { + TreeItem::Node { + kind, + tag, + data_type: dt, + num, + length, + total_words, + .. + } => ( + [ + format!("{indent}{}", kind.name()), + format!("0x{tag:04X}"), + format!("0x{dt:02X} {}", data_type::name(*dt)), + match kind { + StructureKind::Bank => num.to_string(), + _ => String::new(), + }, + length.to_string(), + total_words.to_string(), + ], + false, + ), + TreeItem::Error { start, message } => ( + [ + format!("{indent}ERROR word {start}: {message}"), + String::new(), + String::new(), + String::new(), + String::new(), + String::new(), + ], + true, + ), + } +} + +/// Hex rows of `words`, [`HEX_WORDS_PER_ROW`] per row, the word offset of +/// each row at the left (`first_word` is the offset of `words[0]`). +pub fn hex_lines(first_word: usize, words: &[u32]) -> Vec { + words + .chunks(HEX_WORDS_PER_ROW) + .enumerate() + .map(|(i, chunk)| { + let offset = first_word + i * HEX_WORDS_PER_ROW; + let hex: Vec = chunk.iter().map(|w| format!("{w:08x}")).collect(); + format!("{offset:>6} {}", hex.join(" ")) + }) + .collect() +} + +/// The fADC250 frame summary of one stream event, as text. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FrameSummary { + pub lines: Vec, + pub errors: Vec, + pub warnings: Vec, +} + +/// Summarize a decoded frame: frame number, timestamp, totals, the per-ROC +/// table, then the decoder's error and warning messages. +pub fn frame_summary(frame: &Frame<'_>) -> FrameSummary { + let mut lines = vec![ + format!( + "frame {} timestamp {} (0x{:X})", + frame.frame_number, frame.timestamp, frame.timestamp + ), + format!( + "ROC banks {} payload banks {} empty {} unmapped {}", + frame.roc_banks.len(), + frame.n_payload_banks, + frame.n_empty_payload_banks, + frame.n_unmapped_payload_banks + ), + format!( + "fADC250 hits {} other-module words {} payload words {}", + frame.n_hits, frame.non_fadc_words, frame.n_payload_words + ), + format!( + "AIS entries {} ({} distinct) errors {} warnings {}", + frame.n_ais_entries, frame.n_ais_distinct, frame.n_errors, frame.n_warnings + ), + format!( + "{:>5} {:>6} {:>8} {:>8} {:>6} {:>10} {:>12}", + "roc", "stream", "module", "payload", "empty", "hits", "other words" + ), + ]; + for roc in frame.roc_banks { + lines.push(format!( + "{:>5} {:>6} {:>8} {:>8} {:>6} {:>10} {:>12}", + roc.roc, + roc.stream, + module_name(roc.module), + roc.n_payload_banks, + roc.n_empty_payload_banks, + roc.n_hits, + roc.non_fadc_words + )); + } + FrameSummary { + lines, + errors: frame.errors.to_vec(), + warnings: frame.warnings.to_vec(), + } +} + +/// The one-screen key help. +pub const HELP_LINES: [&str; 15] = [ + "evio-tui keys", + "", + " Up/Down, PgUp/PgDn move the selection", + " Home/End, g/G first / last row", + " Enter (or Right) open the record / the event", + " Esc (or Left, Backspace) back one level (closes the frame view first)", + " Ctrl-C quit, like q", + " n / p next / previous 64 words of the selected structure", + " f fADC250 frame summary of a stream event (tag 0xFF60)", + " ? this help", + " q quit", + "", + "Screens: records of the file (the scan fills the list while it runs),", + "events of one record (read from the file at the record's offset),", + "the bank tree and words of one event.", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bytes_and_hex() { + assert_eq!(human_bytes(12), "12 B"); + assert_eq!(human_bytes(1_500), "1.5 kB"); + assert_eq!(human_bytes(2_500_000), "2.5 MB"); + assert_eq!(human_bytes(15_550_062_464), "15.55 GB"); + let lines = hex_lines(16, &[1, 2, 3, 4, 5, 6, 7, 8, 9]); + assert_eq!(lines.len(), 2); + assert_eq!( + lines[0], + " 16 00000001 00000002 00000003 00000004 00000005 00000006 00000007 00000008" + ); + assert_eq!(lines[1], " 24 00000009"); + assert!(hex_lines(0, &[]).is_empty()); + } + + #[test] + fn event_and_tree_cells() { + let e = EventRow { + index: 3, + words: 7, + tag: 0xFF60, + data_type: 0x10, + num: 0, + header_ok: true, + }; + let cells = event_cells(&e); + assert_eq!(cells[2], "0xFF60"); + assert_eq!(cells[3], "0x10 banks"); + assert_eq!(cells[5], "stream frame"); + let row = TreeRow { + depth: 2, + item: TreeItem::Error { + start: 9, + message: "too long".into(), + }, + }; + let (cells, is_error) = tree_cells(&row); + assert!(is_error); + assert_eq!(cells[0], " ERROR word 9: too long"); + let row = TreeRow { + depth: 1, + item: TreeItem::Node { + kind: StructureKind::Segment, + tag: 0x32, + data_type: 1, + num: 0, + length: 3, + start: 4, + total_words: 4, + is_container: false, + }, + }; + let (cells, is_error) = tree_cells(&row); + assert!(!is_error); + assert_eq!(cells[0], " Segment"); + assert_eq!(cells[3], ""); + assert_eq!(cells[5], "4"); + } +} diff --git a/rust/evio-tui/src/lib.rs b/rust/evio-tui/src/lib.rs new file mode 100644 index 0000000..f63e5fe --- /dev/null +++ b/rust/evio-tui/src/lib.rs @@ -0,0 +1,23 @@ +//! A minimal terminal viewer for EVIO v6 files and JLab SRO streams, built +//! on the `evio-stream` reader: a fast way to look inside a file from a +//! terminal, nothing more. +//! +//! The binary `evio-tui FILE` scans the file on a background thread +//! ([`scan`]) and shows the records as they are found, opens one record by +//! seeking to its offset ([`access`]), lists its events, and shows the bank +//! tree and the words of one event, with the fADC250 frame summary of a +//! stream frame. +//! +//! Modules: [`state`] (what is shown and selected; keys), [`format`] (the +//! text of every cell and panel), [`tree`] (the flattened bank tree), and +//! [`ui`] (drawing with ratatui). Everything but `ui` runs without a +//! terminal. + +#![forbid(unsafe_code)] + +pub mod access; +pub mod format; +pub mod scan; +pub mod state; +pub mod tree; +pub mod ui; diff --git a/rust/evio-tui/src/main.rs b/rust/evio-tui/src/main.rs new file mode 100644 index 0000000..3cae40b --- /dev/null +++ b/rust/evio-tui/src/main.rs @@ -0,0 +1,122 @@ +//! `evio-tui FILE`: a minimal terminal viewer for EVIO v6 files and SRO +//! streams. The file is scanned on a background thread while the records +//! list fills; Enter opens a record, then an event; `?` lists the keys. + +use std::io; +use std::path::PathBuf; +use std::process::ExitCode; +use std::sync::mpsc::Receiver; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::DefaultTerminal; + +use evio_tui::scan::{self, ScanMessage}; +use evio_tui::state::{App, Key}; +use evio_tui::ui; + +const USAGE: &str = "usage: evio-tui FILE\n\ + Shows the records of an EVIO v6 file or SRO stream, the events of a record and the\n\ + bank tree and words of an event. Keys: ? for help, q to quit.\n\ + Standard input is not supported: the viewer opens records by seeking to their offset."; + +/// How long one loop iteration waits for a key before redrawing. +const TICK: Duration = Duration::from_millis(50); + +fn main() -> ExitCode { + let mut path = None; + for arg in std::env::args_os().skip(1) { + let text = arg.to_string_lossy().into_owned(); + match text.as_str() { + "-h" | "--help" => { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + "-" => { + eprintln!("evio-tui: standard input is not supported; random access to records needs a file.\n{USAGE}"); + return ExitCode::from(2); + } + a if a.starts_with('-') => { + eprintln!("unknown option {a}\n{USAGE}"); + return ExitCode::from(2); + } + _ => { + if path.is_some() { + eprintln!("one file only\n{USAGE}"); + return ExitCode::from(2); + } + path = Some(PathBuf::from(arg)); + } + } + } + let Some(path) = path else { + eprintln!("{USAGE}"); + return ExitCode::from(2); + }; + // Open once before taking the terminal so a missing or unsupported + // file is reported on stderr, not as an empty screen. + if let Err(e) = evio_stream::RecordReader::open(&path) { + eprintln!("evio-tui: {}: {e}", path.display()); + return ExitCode::FAILURE; + } + + let rx = scan::spawn(path.clone()); + let mut app = App::new(path); + let mut terminal = ratatui::init(); + let result = run(&mut terminal, &mut app, &rx); + ratatui::restore(); + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("evio-tui: {e}"); + ExitCode::FAILURE + } + } +} + +/// The loop: drain the scan channel, draw, wait up to one tick for a key. +fn run( + terminal: &mut DefaultTerminal, + app: &mut App, + rx: &Receiver, +) -> io::Result<()> { + loop { + // Empty and Disconnected both end the drain; a finished scan + // thread has already sent its last message. + while let Ok(message) = rx.try_recv() { + app.apply(message); + } + terminal.draw(|frame| ui::draw(frame, app))?; + if event::poll(TICK)? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + if let Some(key) = map_key(key) { + app.key(key); + } + } + } + } + if app.quit { + return Ok(()); + } + } +} + +/// Translate a crossterm key press; `None` for keys the viewer ignores. +fn map_key(key: KeyEvent) -> Option { + Some(match key.code { + KeyCode::Up => Key::Up, + KeyCode::Down => Key::Down, + KeyCode::PageUp => Key::PageUp, + KeyCode::PageDown => Key::PageDown, + KeyCode::Home => Key::Home, + KeyCode::End => Key::End, + KeyCode::Enter => Key::Enter, + KeyCode::Esc => Key::Esc, + KeyCode::Backspace | KeyCode::Left => Key::Esc, + KeyCode::Right => Key::Enter, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => Key::Char('q'), + KeyCode::Char(c) => Key::Char(c), + _ => return None, + }) +} diff --git a/rust/evio-tui/src/scan.rs b/rust/evio-tui/src/scan.rs new file mode 100644 index 0000000..5fc521f --- /dev/null +++ b/rust/evio-tui/src/scan.rs @@ -0,0 +1,170 @@ +//! The background scan: the whole file streamed once from the start with +//! [`RecordReader`], one small [`RecordSummary`] per record sent over a +//! channel so the user interface never waits on the file. +//! +//! [`scan`] is synchronous and takes the sender; [`spawn`] runs it on a +//! thread. Tests call `scan` inline and drain the receiver. + +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::thread; +use std::time::Instant; + +use evio_stream::{ + BadRecordHeader, ByteOrder, FileHeader, FileLayout, Record, RecordHeader, RecordReader, + TruncatedTail, +}; + +/// Record error messages kept per summary (the count is always exact). +pub const MAX_ERRORS_KEPT: usize = 4; + +/// What one record looked like when the scan passed it. The full header is +/// kept (it is small) so the side panel needs no second look at the file. +#[derive(Clone, Debug)] +pub struct RecordSummary { + /// Position in the stream, from 0 (the trailer included). + pub index: usize, + /// Byte offset of the record header in the file. + pub offset: u64, + pub header: RecordHeader, + /// Bytes of the record present in the file, header included. + pub present_bytes: u64, + /// Events found (the complete ones for a cut tail). + pub events_found: usize, + pub is_truncated: bool, + /// Record errors (event walk, decompression); see [`error_count`](Self::error_count). + pub errors: Vec, + pub error_count: usize, + /// Bytes the scan had consumed after this record. + pub bytes_read: u64, +} + +impl RecordSummary { + /// Summarize a record just read by a reader that has consumed + /// `bytes_read` bytes so far. + pub fn new(record: &Record, bytes_read: u64) -> RecordSummary { + RecordSummary { + index: record.index, + offset: record.offset, + header: record.header.clone(), + present_bytes: record.present_bytes, + events_found: record.event_count(), + is_truncated: record.is_truncated, + errors: record + .errors + .iter() + .take(MAX_ERRORS_KEPT) + .cloned() + .collect(), + error_count: record.errors.len(), + bytes_read, + } + } + + /// Byte length the header declares for the record. + pub fn declared_bytes(&self) -> u64 { + self.header.record_bytes() + } + + /// "trailer" or "record". + pub fn kind_name(&self) -> &'static str { + if self.header.is_trailer() { + "trailer" + } else { + "record" + } + } +} + +/// What the first bytes of the file said. +#[derive(Clone, Debug)] +pub struct ScanStart { + pub layout: FileLayout, + pub order: ByteOrder, + pub file_header: FileHeader, +} + +/// How the scan ended. +#[derive(Clone, Debug)] +pub struct ScanEnd { + pub truncated_tail: Option, + pub bad_record_header: Option, + pub trailer: bool, + /// Data records read (the trailer excluded). + pub data_records: u64, + pub bytes: u64, + pub elapsed_s: f64, + /// Set when the reader returned an error (an evio-library v4 file that + /// revealed itself late, an I/O error); the records before it stand. + pub error: Option, +} + +/// One message from the scan. +#[derive(Clone, Debug)] +pub enum ScanMessage { + Started(ScanStart), + Record(RecordSummary), + Finished(ScanEnd), + /// The file could not be opened as EVIO; nothing else follows. + Failed(String), +} + +/// Stream `path` from the start and send one message per record. Returns +/// when the file ends, when the reader fails or when the receiver is gone. +pub fn scan(path: &Path, tx: &Sender) { + let start = Instant::now(); + let mut reader = match RecordReader::open(path) { + Ok(reader) => reader, + Err(e) => { + let _ = tx.send(ScanMessage::Failed(e.to_string())); + return; + } + }; + let started = ScanStart { + layout: reader.layout(), + order: reader.order(), + file_header: reader.file_header().clone(), + }; + if tx.send(ScanMessage::Started(started)).is_err() { + return; + } + let mut error = None; + loop { + match reader.next_record() { + Ok(Some(record)) => { + let summary = RecordSummary::new(&record, reader.bytes_read()); + let delivered = tx.send(ScanMessage::Record(summary)).is_ok(); + reader.recycle(record); + if !delivered { + return; + } + } + Ok(None) => break, + Err(e) => { + error = Some(e.to_string()); + break; + } + } + } + let end = ScanEnd { + truncated_tail: reader.truncated_tail(), + bad_record_header: reader.bad_record_header().cloned(), + trailer: reader.trailer_seen(), + data_records: reader.data_records(), + bytes: reader.bytes_read(), + elapsed_s: start.elapsed().as_secs_f64(), + error, + }; + let _ = tx.send(ScanMessage::Finished(end)); +} + +/// Run [`scan`] on a background thread. Dropping the receiver ends the +/// thread at its next record. +pub fn spawn(path: PathBuf) -> Receiver { + let (tx, rx) = mpsc::channel(); + thread::Builder::new() + .name("evio-scan".into()) + .spawn(move || scan(&path, &tx)) + .expect("spawn the scan thread"); + rx +} diff --git a/rust/evio-tui/src/state.rs b/rust/evio-tui/src/state.rs new file mode 100644 index 0000000..ff66a91 --- /dev/null +++ b/rust/evio-tui/src/state.rs @@ -0,0 +1,612 @@ +//! What is shown and what is selected: the state machine behind the +//! screens, with no terminal in sight. +//! +//! [`App`] takes scan messages ([`App::apply`]) and keys ([`App::key`]). +//! Opening a record reads it from the file ([`crate::access`]); everything +//! else is bookkeeping, so tests drive the whole flow with keys. + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +use evio_stream::fadc250::STREAM_EVENT_TAG; +use evio_stream::{FrameDecoder, Record}; + +use crate::access; +use crate::format::{frame_summary, FrameSummary}; +use crate::scan::{RecordSummary, ScanEnd, ScanMessage, ScanStart}; +use crate::tree::{flatten, TreeRow}; + +/// Words shown at once of the selected structure. +pub const WORDS_PER_PAGE: usize = 64; + +/// A key press, independent of the terminal library. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Key { + Up, + Down, + PageUp, + PageDown, + Home, + End, + Enter, + Esc, + Char(char), +} + +/// Which screen is shown. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Screen { + Records, + Events, + Event, + Help, +} + +/// Selection and scroll of one list. `height` is the number of rows the +/// last draw had room for; the drawing code sets it before it reads +/// `first`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ListCursor { + pub selected: usize, + /// First visible row. + pub first: usize, + pub len: usize, + pub height: usize, +} + +impl ListCursor { + /// A list of `len` rows, the first selected. + pub fn new(len: usize) -> ListCursor { + ListCursor { + selected: 0, + first: 0, + len, + height: 0, + } + } + + /// Set the row count; the selection is clamped. + pub fn set_len(&mut self, len: usize) { + self.len = len; + self.clamp(); + } + + /// Set the rows the view has room for; the window follows the selection. + pub fn set_height(&mut self, height: usize) { + self.height = height.max(1); + self.clamp(); + } + + /// Move the selection by `delta` rows, clamped to the list. + pub fn move_by(&mut self, delta: isize) { + let target = self.selected as isize + delta; + self.selected = target.clamp(0, self.len.saturating_sub(1) as isize) as usize; + self.clamp(); + } + + /// Select `index` (clamped). + pub fn select(&mut self, index: usize) { + self.selected = index; + self.clamp(); + } + + /// Select the last row. + pub fn select_last(&mut self) { + self.select(self.len.saturating_sub(1)); + } + + /// Apply a navigation key; `true` when it was one. + pub fn navigate(&mut self, key: Key) -> bool { + let page = self.height.max(1) as isize; + match key { + Key::Up => self.move_by(-1), + Key::Down => self.move_by(1), + Key::PageUp => self.move_by(-page), + Key::PageDown => self.move_by(page), + Key::Home | Key::Char('g') => self.select(0), + Key::End | Key::Char('G') => self.select_last(), + _ => return false, + } + true + } + + /// Rows visible: `first..first + height`, clipped to the list. + pub fn visible(&self) -> std::ops::Range { + let end = (self.first + self.height.max(1)).min(self.len); + self.first.min(end)..end + } + + /// Selected row relative to `first`, for the drawing code. + pub fn selected_in_view(&self) -> Option { + (self.len > 0 && self.selected >= self.first).then(|| self.selected - self.first) + } + + fn clamp(&mut self) { + if self.len == 0 { + self.selected = 0; + self.first = 0; + return; + } + self.selected = self.selected.min(self.len - 1); + let height = self.height.max(1); + if self.selected < self.first { + self.first = self.selected; + } else if self.selected >= self.first + height { + self.first = self.selected + 1 - height; + } + self.first = self.first.min(self.len.saturating_sub(height)); + } +} + +/// Progress of the scan as the messages arrive. +#[derive(Debug)] +pub struct ScanProgress { + pub start: Option, + /// Every record seen, the trailer included (it is a row of the list). + pub records: Vec, + /// Data records seen so far (the trailer excluded, as `evio-count` counts). + pub data_records: u64, + /// True once the trailer record has arrived. + pub trailer_seen: bool, + /// Events found so far. + pub events: u64, + /// Bytes consumed so far. + pub bytes: u64, + pub end: Option, + /// Set when the file could not be opened as EVIO. + pub failed: Option, + started_at: Instant, +} + +impl ScanProgress { + fn new() -> ScanProgress { + ScanProgress { + start: None, + records: Vec::new(), + data_records: 0, + trailer_seen: false, + events: 0, + bytes: 0, + end: None, + failed: None, + started_at: Instant::now(), + } + } + + /// `"7891 records + trailer"`, `"7893 records"`: the data record count + /// with the trailer named separately. + pub fn records_label(&self) -> String { + format!( + "{} records{}", + self.data_records, + if self.trailer_seen { " + trailer" } else { "" } + ) + } + + /// Seconds the scan has run, frozen at its end. + pub fn elapsed_s(&self) -> f64 { + match &self.end { + Some(end) => end.elapsed_s, + None => self.started_at.elapsed().as_secs_f64(), + } + } + + /// True once the scan has finished or failed. + pub fn is_done(&self) -> bool { + self.end.is_some() || self.failed.is_some() + } +} + +/// One row of the events table. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EventRow { + pub index: usize, + /// Length of the event in words. + pub words: usize, + pub tag: u16, + pub data_type: u8, + pub num: u8, + /// False when the event is too short for a bank header. + pub header_ok: bool, +} + +impl EventRow { + fn new(index: usize, record: &Record) -> EventRow { + let words = record.event(index); + let header = words.and_then(|w| w.get(1)); + match (words, header) { + (Some(w), Some(h)) => EventRow { + index, + words: w.len(), + tag: (h >> 16) as u16, + data_type: ((h >> 8) & 0x3F) as u8, + num: (h & 0xFF) as u8, + header_ok: true, + }, + _ => EventRow { + index, + words: words.map(|w| w.len()).unwrap_or(0), + tag: 0, + data_type: 0, + num: 0, + header_ok: false, + }, + } + } + + /// True for a CODA stream frame (tag 0xFF60). + pub fn is_stream_frame(&self) -> bool { + self.header_ok && self.tag == STREAM_EVENT_TAG + } +} + +/// The record held in memory: the one whose events are shown. +#[derive(Debug)] +pub struct LoadedRecord { + /// Index and offset from the scan (the record's own are relative to + /// the reader that read it). + pub index: usize, + pub offset: u64, + pub record: Record, + pub events: Vec, +} + +/// The event whose tree is shown. +#[derive(Debug)] +pub struct EventView { + pub event_index: usize, + pub rows: Vec, + /// First word of the hex window, relative to the selected structure. + pub word_window: usize, + /// The frame summary, decoded on `f`; `None` for a non-frame event. + pub frame: Option, + pub show_frame: bool, +} + +/// The whole application state. +#[derive(Debug)] +pub struct App { + path: PathBuf, + pub screen: Screen, + help_from: Screen, + pub scan: ScanProgress, + pub records: ListCursor, + pub loaded: Option, + pub events: ListCursor, + pub tree: ListCursor, + pub view: Option, + /// A one-line message for the status area (a failed load, say). + pub message: Option, + pub quit: bool, +} + +impl App { + /// State for `path` before any scan message. + pub fn new(path: impl Into) -> App { + App { + path: path.into(), + screen: Screen::Records, + help_from: Screen::Records, + scan: ScanProgress::new(), + records: ListCursor::new(0), + loaded: None, + events: ListCursor::new(0), + tree: ListCursor::new(0), + view: None, + message: None, + quit: false, + } + } + + /// The file shown. + pub fn path(&self) -> &Path { + &self.path + } + + /// Take one scan message in. + pub fn apply(&mut self, message: ScanMessage) { + match message { + ScanMessage::Started(start) => self.scan.start = Some(start), + ScanMessage::Record(summary) => { + if summary.header.is_trailer() { + self.scan.trailer_seen = true; + } else { + self.scan.data_records += 1; + self.scan.events += summary.events_found as u64; + } + self.scan.bytes = summary.bytes_read; + self.scan.records.push(summary); + self.records.set_len(self.scan.records.len()); + } + ScanMessage::Finished(end) => { + self.scan.bytes = end.bytes; + self.scan.end = Some(end); + } + ScanMessage::Failed(e) => self.scan.failed = Some(e), + } + } + + /// Take one key in. + pub fn key(&mut self, key: Key) { + self.message = None; + if key == Key::Char('q') { + self.quit = true; + return; + } + if key == Key::Char('?') { + if self.screen == Screen::Help { + self.screen = self.help_from; + } else { + self.help_from = self.screen; + self.screen = Screen::Help; + } + return; + } + match self.screen { + Screen::Help => { + if key == Key::Esc { + self.screen = self.help_from; + } + } + Screen::Records => { + if !self.records.navigate(key) && key == Key::Enter { + self.open_selected_record(); + } + } + Screen::Events => { + if self.events.navigate(key) { + return; + } + match key { + Key::Enter => self.open_selected_event(), + Key::Esc => self.screen = Screen::Records, + _ => {} + } + } + Screen::Event => { + if self.tree.navigate(key) { + if let Some(view) = &mut self.view { + view.word_window = 0; + } + return; + } + match key { + Key::Esc => { + let frame_shown = self.view.as_ref().is_some_and(|v| v.show_frame); + if frame_shown { + if let Some(view) = &mut self.view { + view.show_frame = false; + } + } else { + self.screen = Screen::Events; + } + } + Key::Char('n') => self.scroll_words(1), + Key::Char('p') => self.scroll_words(-1), + Key::Char('f') => self.toggle_frame(), + _ => {} + } + } + } + } + + /// The record summary under the cursor. + pub fn selected_record(&self) -> Option<&RecordSummary> { + self.scan.records.get(self.records.selected) + } + + /// The event row under the cursor of the loaded record. + pub fn selected_event(&self) -> Option<&EventRow> { + self.loaded.as_ref()?.events.get(self.events.selected) + } + + /// The tree row under the cursor. + pub fn selected_tree_row(&self) -> Option<&TreeRow> { + self.view.as_ref()?.rows.get(self.tree.selected) + } + + /// Read the selected record from the file and show its events. + pub fn open_selected_record(&mut self) { + let Some(summary) = self.selected_record() else { + return; + }; + let (index, offset) = (summary.index, summary.offset); + match access::load_record(&self.path, offset) { + Ok(record) => { + let events = (0..record.event_count()) + .map(|i| EventRow::new(i, &record)) + .collect::>(); + self.events = ListCursor::new(events.len()); + self.loaded = Some(LoadedRecord { + index, + offset, + record, + events, + }); + self.view = None; + self.screen = Screen::Events; + } + Err(e) => self.message = Some(format!("record {index}: {e}")), + } + } + + /// Build the bank tree of the selected event and show it. + pub fn open_selected_event(&mut self) { + let Some(loaded) = &self.loaded else { + return; + }; + let Some(row) = loaded.events.get(self.events.selected) else { + return; + }; + let event_index = row.index; + let Some(words) = loaded.record.event(event_index) else { + return; + }; + let rows = flatten(words); + self.tree = ListCursor::new(rows.len()); + self.view = Some(EventView { + event_index, + rows, + word_window: 0, + frame: None, + show_frame: false, + }); + self.screen = Screen::Event; + } + + /// The hex window of the selected structure: the absolute word offset + /// of its first word and up to [`WORDS_PER_PAGE`] words. For an error + /// row, the words from the offending word to the end of the event. + pub fn selected_words(&self) -> Option<(usize, Vec)> { + let view = self.view.as_ref()?; + let row = view.rows.get(self.tree.selected)?; + let event = self.loaded.as_ref()?.record.event(view.event_index)?; + let start = row.start(); + let end = match row.total_words() { + Some(total) => (start + total).min(event.len()), + None => event.len(), + }; + let first = (start + view.word_window).min(end); + let last = (first + WORDS_PER_PAGE).min(end); + let words = event.slice(first, last)?.to_vec(); + Some((first, words)) + } + + /// Words of the selected structure beyond the current window. + pub fn words_after_window(&self) -> usize { + let Some(view) = &self.view else { + return 0; + }; + let Some(row) = view.rows.get(self.tree.selected) else { + return 0; + }; + let Some(event) = self + .loaded + .as_ref() + .and_then(|l| l.record.event(view.event_index)) + else { + return 0; + }; + let start = row.start(); + let end = match row.total_words() { + Some(total) => (start + total).min(event.len()), + None => event.len(), + }; + (end - start.min(end)).saturating_sub(view.word_window + WORDS_PER_PAGE) + } + + fn scroll_words(&mut self, direction: isize) { + let more = self.words_after_window(); + let Some(view) = &mut self.view else { + return; + }; + if direction > 0 { + if more > 0 { + view.word_window += WORDS_PER_PAGE; + } + } else { + view.word_window = view.word_window.saturating_sub(WORDS_PER_PAGE); + } + } + + /// Show or hide the fADC250 frame summary of the shown event. A + /// non-frame event gets a message instead. + pub fn toggle_frame(&mut self) { + let Some(loaded) = &self.loaded else { + return; + }; + let Some(view) = &mut self.view else { + return; + }; + if view.show_frame { + view.show_frame = false; + return; + } + if view.frame.is_none() { + let Some(words) = loaded.record.event(view.event_index) else { + return; + }; + let mut decoder = FrameDecoder::new(); + match decoder.decode(words) { + Some(frame) => view.frame = Some(frame_summary(&frame)), + None => { + self.message = Some(format!( + "event {} is no stream frame (tag 0x{:04X} expected)", + view.event_index, STREAM_EVENT_TAG + )); + return; + } + } + } + view.show_frame = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cursor_scrolls_with_the_selection() { + let mut c = ListCursor::new(100); + c.set_height(10); + assert_eq!(c.visible(), 0..10); + c.navigate(Key::Down); + assert_eq!(c.selected, 1); + c.navigate(Key::PageDown); + assert_eq!(c.selected, 11); + assert_eq!(c.visible(), 2..12); + assert_eq!(c.selected_in_view(), Some(9)); + c.navigate(Key::End); + assert_eq!(c.selected, 99); + assert_eq!(c.visible(), 90..100); + c.navigate(Key::Char('g')); + assert_eq!(c.visible(), 0..10); + c.navigate(Key::Up); + assert_eq!(c.selected, 0); + c.navigate(Key::Char('G')); + c.navigate(Key::PageUp); + assert_eq!(c.selected, 89); + assert!(!c.navigate(Key::Enter)); + // shrinking the list clamps the selection and the window + c.set_len(5); + assert_eq!(c.selected, 4); + assert_eq!(c.visible(), 0..5); + c.set_len(0); + assert_eq!(c.selected_in_view(), None); + // a list that grows while the cursor sits at the top stays put + c.set_len(50); + assert_eq!(c.visible(), 0..10); + } + + #[test] + fn help_and_quit_from_anywhere() { + let mut app = App::new("/nonexistent"); + assert_eq!(app.screen, Screen::Records); + app.key(Key::Char('?')); + assert_eq!(app.screen, Screen::Help); + app.key(Key::Esc); + assert_eq!(app.screen, Screen::Records); + app.key(Key::Char('?')); + app.key(Key::Char('?')); + assert_eq!(app.screen, Screen::Records); + // Enter with nothing scanned does nothing; Esc stays + app.key(Key::Enter); + app.key(Key::Esc); + assert_eq!(app.screen, Screen::Records); + assert!(!app.quit); + app.key(Key::Char('q')); + assert!(app.quit); + } + + #[test] + fn scan_messages_fill_the_list() { + let mut app = App::new("/nonexistent"); + app.apply(ScanMessage::Failed("not evio".into())); + assert!(app.scan.is_done()); + assert!(crate::format::status_line(&app.scan).contains("not evio")); + // a failed open then Enter: a message, no screen change + app.key(Key::Enter); + assert_eq!(app.screen, Screen::Records); + } +} diff --git a/rust/evio-tui/src/tree.rs b/rust/evio-tui/src/tree.rs new file mode 100644 index 0000000..2129517 --- /dev/null +++ b/rust/evio-tui/src/tree.rs @@ -0,0 +1,178 @@ +//! The bank tree of one event flattened into owned rows. +//! +//! An event is small, so the whole tree is walked at once with +//! [`Bank::event`] and [`Bank::children`]; every level is a row with its +//! depth. A length that does not fit its container ends that container's +//! walk with an [`TreeItem::Error`] row at the offending word. + +use evio_stream::{Bank, StructureKind, Words}; + +/// One row of the flattened tree. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TreeRow { + /// Nesting depth, 0 for the event bank. + pub depth: usize, + pub item: TreeItem, +} + +/// A structure, or the walk error that stopped a container. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TreeItem { + Node { + kind: StructureKind, + tag: u16, + data_type: u8, + num: u8, + /// The length field: words after the length word. + length: u32, + /// Word offset of the structure inside the event. + start: usize, + /// Header words plus the words that follow (length + 1). + total_words: usize, + is_container: bool, + }, + Error { + /// Word offset inside the event where the bad structure starts. + start: usize, + message: String, + }, +} + +impl TreeRow { + /// Word offset of the row's structure inside the event. + pub fn start(&self) -> usize { + match &self.item { + TreeItem::Node { start, .. } | TreeItem::Error { start, .. } => *start, + } + } + + /// Words the row covers: the whole structure, or nothing known for an + /// error (`None`). + pub fn total_words(&self) -> Option { + match &self.item { + TreeItem::Node { total_words, .. } => Some(*total_words), + TreeItem::Error { .. } => None, + } + } + + /// True for an error row. + pub fn is_error(&self) -> bool { + matches!(self.item, TreeItem::Error { .. }) + } +} + +/// Flatten the bank tree of `event` (the words of one event). +pub fn flatten(event: Words<'_>) -> Vec { + let mut rows = Vec::new(); + match Bank::event(event) { + Ok(bank) => walk(&bank, 0, 0, &mut rows), + Err(e) => rows.push(TreeRow { + depth: 0, + item: TreeItem::Error { + start: e.word, + message: e.message, + }, + }), + } + rows +} + +fn walk(bank: &Bank<'_>, depth: usize, start: usize, rows: &mut Vec) { + rows.push(TreeRow { + depth, + item: TreeItem::Node { + kind: bank.kind(), + tag: bank.tag, + data_type: bank.data_type, + num: bank.num, + length: bank.length, + start, + total_words: bank.total_words(), + is_container: bank.is_container(), + }, + }); + if !bank.is_container() { + return; + } + let data_start = start + bank.kind().header_words(); + let mut pos = 0; + for child in bank.children() { + match child { + Ok(child) => { + walk(&child, depth + 1, data_start + pos, rows); + pos += child.total_words(); + } + // The iterator reports the word relative to the parent's data. + Err(e) => rows.push(TreeRow { + depth: depth + 1, + item: TreeItem::Error { + start: data_start + e.word, + message: e.message, + }, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use evio_stream::ByteOrder; + + fn le(words: &[u32]) -> Vec { + words.iter().flat_map(|w| w.to_le_bytes()).collect() + } + + #[test] + fn flattens_every_level_with_offsets() { + // bank of banks: [ bank of segments: [ segment len 2 ], leaf uint32 len 1 ] + let bytes = le(&[ + 9, + 0xFF60_1000, + 4, + 0xFF31_2000, + 0x3201_0002, + 11, + 22, + 2, + 0x0001_0100, + 99, + ]); + let rows = flatten(Words::new(&bytes, ByteOrder::Little)); + assert_eq!(rows.len(), 4); + assert_eq!(rows[0].depth, 0); + assert_eq!(rows[0].start(), 0); + assert_eq!(rows[0].total_words(), Some(10)); + assert_eq!(rows[1].depth, 1); + assert_eq!(rows[1].start(), 2); + assert_eq!(rows[2].depth, 2); + assert_eq!(rows[2].start(), 4); + assert_eq!(rows[2].total_words(), Some(3)); + match &rows[2].item { + TreeItem::Node { kind, tag, .. } => { + assert_eq!(*kind, StructureKind::Segment); + assert_eq!(*tag, 0x32); + } + other => panic!("segment expected, got {other:?}"), + } + assert_eq!(rows[3].depth, 1); + assert_eq!(rows[3].start(), 7); + assert!(rows.iter().all(|r| !r.is_error())); + } + + #[test] + fn bad_length_becomes_an_error_row() { + // first child fine (2 words), second declares 10 words inside a 5-word parent + let bytes = le(&[5, 0x0001_1000, 1, 0x0002_0100, 10, 0x0003_0100]); + let rows = flatten(Words::new(&bytes, ByteOrder::Little)); + assert_eq!(rows.len(), 3); + assert!(rows[2].is_error()); + assert_eq!(rows[2].depth, 1); + // the second child starts at word 2 (header) + 2 (first child) = 4 + assert_eq!(rows[2].start(), 4); + // too short for a bank header + let rows = flatten(Words::new(&bytes[..4], ByteOrder::Little)); + assert_eq!(rows.len(), 1); + assert!(rows[0].is_error()); + } +} diff --git a/rust/evio-tui/src/ui.rs b/rust/evio-tui/src/ui.rs new file mode 100644 index 0000000..e5e1e50 --- /dev/null +++ b/rust/evio-tui/src/ui.rs @@ -0,0 +1,345 @@ +//! Drawing the screens with ratatui. This is the only module that knows +//! about widgets; it reads the [`App`] state, tells each list how many rows +//! fit, and renders only the visible rows. + +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Cell, Paragraph, Row, Table, TableState, Wrap}; +use ratatui::Frame; + +use crate::format::{ + event_cells, file_line, hex_lines, record_cells, record_header_lines, status_line, tree_cells, + EVENT_COLUMNS, HELP_LINES, RECORD_COLUMNS, TREE_COLUMNS, +}; +use crate::state::{App, ListCursor, Screen}; + +/// Terminals narrower than this draw the records table without the header +/// side panel. +const SIDE_PANEL_MIN_WIDTH: u16 = 100; + +/// Draw the current screen. +pub fn draw(frame: &mut Frame, app: &mut App) { + let [title, body, status, hint] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(frame.area()); + + let screen_name = match app.screen { + Screen::Records => "records", + Screen::Events => "events", + Screen::Event => "event", + Screen::Help => "help", + }; + let title_line = Line::from(format!( + "evio-tui {} [{screen_name}]", + app.path().display() + )) + .style(Style::new().add_modifier(Modifier::BOLD)); + frame.render_widget(Paragraph::new(title_line), title); + + match app.screen { + Screen::Records => draw_records(frame, app, body), + Screen::Events => draw_events(frame, app, body), + Screen::Event => draw_event(frame, app, body), + Screen::Help => draw_help(frame, body), + } + + let status_text = match &app.message { + Some(message) => Line::from(message.clone()).style(Style::new().fg(Color::Red)), + None => Line::from(status_line(&app.scan)), + }; + frame.render_widget(Paragraph::new(status_text), status); + + let keys = match app.screen { + Screen::Records => "Enter open record arrows/PgUp/PgDn/Home/End/g/G move ? help q quit", + Screen::Events => "Enter open event Esc back arrows move ? help q quit", + Screen::Event => "n/p words f frame summary Esc back arrows move ? help q quit", + Screen::Help => "Esc or ? back q quit", + }; + frame.render_widget( + Paragraph::new(Line::from(keys).style(Style::new().fg(Color::DarkGray))), + hint, + ); +} + +/// Rows of a bordered table with a header row. +fn table_rows_available(area: Rect) -> usize { + area.height.saturating_sub(3) as usize +} + +/// Render the visible window of a list as a table. +fn render_table( + frame: &mut Frame, + area: Rect, + title: String, + columns: &[&str], + widths: &[Constraint], + cursor: &ListCursor, + rows: Vec>, +) { + let header = Row::new(columns.iter().map(|c| Cell::from(*c))) + .style(Style::new().add_modifier(Modifier::UNDERLINED)); + let table = Table::new(rows, widths) + .header(header) + .block(Block::bordered().title(title)) + .row_highlight_style(Style::new().add_modifier(Modifier::REVERSED)); + let mut state = TableState::new().with_selected(cursor.selected_in_view()); + frame.render_stateful_widget(table, area, &mut state); +} + +fn cells(values: &[String]) -> Vec> { + values.iter().cloned().map(Cell::from).collect() +} + +fn draw_records(frame: &mut Frame, app: &mut App, body: Rect) { + let (table_area, panel) = if body.width >= SIDE_PANEL_MIN_WIDTH { + let [left, right] = + Layout::horizontal([Constraint::Min(60), Constraint::Length(50)]).areas(body); + (left, Some(right)) + } else { + (body, None) + }; + app.records.set_height(table_rows_available(table_area)); + let visible = app.records.visible(); + let rows: Vec> = app.scan.records[visible] + .iter() + .map(|s| { + let row = Row::new(cells(&record_cells(s))); + if s.error_count > 0 || s.is_truncated { + row.style(Style::new().fg(Color::Red)) + } else { + row + } + }) + .collect(); + let widths = [ + Constraint::Length(7), + Constraint::Length(13), + Constraint::Length(10), + Constraint::Length(7), + Constraint::Length(7), + Constraint::Length(7), + Constraint::Length(8), + Constraint::Length(5), + Constraint::Length(6), + ]; + let title = format!(" {} ", app.scan.records_label()); + render_table( + frame, + table_area, + title, + &RECORD_COLUMNS, + &widths, + &app.records, + rows, + ); + + if let Some(panel) = panel { + let mut lines = vec![file_line(app.scan.start.as_ref()), String::new()]; + match app.selected_record() { + Some(s) => lines.extend(record_header_lines(s)), + None => lines.push("no record yet".into()), + } + let text: Vec = lines.into_iter().map(Line::from).collect(); + frame.render_widget( + Paragraph::new(text) + .block(Block::bordered().title(" header ")) + .wrap(Wrap { trim: false }), + panel, + ); + } +} + +fn draw_events(frame: &mut Frame, app: &mut App, body: Rect) { + let Some(loaded) = &app.loaded else { + frame.render_widget( + Paragraph::new("no record loaded").block(Block::bordered()), + body, + ); + return; + }; + app.events.set_height(table_rows_available(body)); + let visible = app.events.visible(); + let rows: Vec> = loaded.events[visible] + .iter() + .map(|e| { + let row = Row::new(cells(&event_cells(e))); + if !e.header_ok { + row.style(Style::new().fg(Color::Red)) + } else { + row + } + }) + .collect(); + let widths = [ + Constraint::Length(7), + Constraint::Length(10), + Constraint::Length(8), + Constraint::Length(18), + Constraint::Length(5), + Constraint::Min(14), + ]; + let title = format!( + " record {} at 0x{:X}: {} events, {} bytes{} ", + loaded.index, + loaded.offset, + loaded.events.len(), + loaded.record.present_bytes, + if loaded.record.errors.is_empty() { + String::new() + } else { + format!(", {} errors", loaded.record.errors.len()) + } + ); + let cursor = app.events; + render_table(frame, body, title, &EVENT_COLUMNS, &widths, &cursor, rows); +} + +fn draw_event(frame: &mut Frame, app: &mut App, body: Rect) { + let (Some(loaded), Some(view)) = (&app.loaded, &app.view) else { + frame.render_widget( + Paragraph::new("no event opened").block(Block::bordered()), + body, + ); + return; + }; + let [tree_area, panel] = + Layout::vertical([Constraint::Percentage(55), Constraint::Min(6)]).areas(body); + app.tree.set_height(table_rows_available(tree_area)); + let visible = app.tree.visible(); + let rows: Vec> = view.rows[visible] + .iter() + .map(|r| { + let (values, is_error) = tree_cells(r); + let row = Row::new(cells(&values)); + if is_error { + row.style(Style::new().fg(Color::Red)) + } else { + row + } + }) + .collect(); + let widths = [ + Constraint::Min(24), + Constraint::Length(8), + Constraint::Length(18), + Constraint::Length(5), + Constraint::Length(10), + Constraint::Length(8), + ]; + let event_words = loaded + .record + .event(view.event_index) + .map(|w| w.len()) + .unwrap_or(0); + let title = format!( + " record {} event {}: {} words, {} structures ", + loaded.index, + view.event_index, + event_words, + view.rows.len() + ); + let cursor = app.tree; + render_table( + frame, + tree_area, + title, + &TREE_COLUMNS, + &widths, + &cursor, + rows, + ); + + if view.show_frame { + if let Some(summary) = &view.frame { + let mut lines: Vec = summary.lines.iter().cloned().map(Line::from).collect(); + for e in &summary.errors { + lines.push(Line::from(format!("error: {e}")).style(Style::new().fg(Color::Red))); + } + for w in &summary.warnings { + lines.push( + Line::from(format!("warning: {w}")).style(Style::new().fg(Color::Yellow)), + ); + } + frame.render_widget( + Paragraph::new(lines) + .block(Block::bordered().title(" fADC250 frame (f/Esc closes) ")), + panel, + ); + return; + } + } + let (title, lines) = match app.selected_words() { + Some((first, words)) => { + let more = app.words_after_window(); + let title = format!( + " words {}..{} of the selected structure{} ", + first, + first + words.len(), + if more > 0 { + format!(", {more} more (n/p)") + } else { + String::new() + } + ); + (title, hex_lines(first, &words)) + } + None => ( + " words ".to_string(), + vec!["no structure selected".to_string()], + ), + }; + let text: Vec = lines.into_iter().map(Line::from).collect(); + frame.render_widget( + Paragraph::new(text).block(Block::bordered().title(title)), + panel, + ); +} + +fn draw_help(frame: &mut Frame, body: Rect) { + let text: Vec = HELP_LINES.iter().map(|l| Line::from(*l)).collect(); + frame.render_widget( + Paragraph::new(text) + .block(Block::bordered().title(" help ")) + .wrap(Wrap { trim: false }), + body, + ); +} + +/// The text of a rendered buffer, one string per row (trailing spaces +/// kept), for tests with ratatui's `TestBackend`. +pub fn buffer_lines(buffer: &ratatui::buffer::Buffer) -> Vec { + let area = *buffer.area(); + (area.top()..area.bottom()) + .map(|y| { + (area.left()..area.right()) + .filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string())) + .collect() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + #[test] + fn draws_an_empty_app_and_help() { + let mut app = App::new("/nowhere/run.evio"); + let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap(); + terminal.draw(|f| draw(f, &mut app)).unwrap(); + let text = buffer_lines(terminal.backend().buffer()).join("\n"); + assert!(text.contains(" 0 records ")); + assert!(text.contains("scanning:")); + app.key(crate::state::Key::Char('?')); + terminal.draw(|f| draw(f, &mut app)).unwrap(); + let text = buffer_lines(terminal.backend().buffer()).join("\n"); + assert!(text.contains("evio-tui keys")); + } +} diff --git a/rust/evio-tui/tests/corpus_access.rs b/rust/evio-tui/tests/corpus_access.rs new file mode 100644 index 0000000..0a45c54 --- /dev/null +++ b/rust/evio-tui/tests/corpus_access.rs @@ -0,0 +1,209 @@ +//! The headless harness on the big files: the converted v6 corpus file +//! (15.5 GB, `v6` layout) and the raw SRO stream (`sro-8word`). Each test +//! scans the file inline, times the scan, opens record 7000 by its offset +//! through the state machine, times that, and checks the events, the bank +//! tree and the frame summary. Skipped when the file is absent. Run with +//! `--nocapture` to see the timings. + +use std::path::Path; +use std::sync::mpsc; +use std::time::Instant; + +use evio_tui::scan::{self, ScanMessage}; +use evio_tui::state::{App, Key, Screen}; +use evio_tui::tree::TreeItem; + +const CORPUS_V6: &str = "/data/evio-lib/corpus/sro_000791.evio.00000.v6"; +const RAW_SRO: &str = "/data/sro_boyarinov_data_2026/sro_000791.evio.00000"; + +struct Expect { + layout: &'static str, + records: usize, + events: u64, + trailer: bool, + salvaged: Option, + record_7000_offset: u64, +} + +fn drive(path: &str, expect: &Expect) { + let path = Path::new(path); + if !path.is_file() { + eprintln!("skipped: {} is absent", path.display()); + return; + } + let (tx, rx) = mpsc::channel(); + let t0 = Instant::now(); + scan::scan(path, &tx); + drop(tx); + let scan_s = t0.elapsed().as_secs_f64(); + let mut app = App::new(path); + let mut messages = 0usize; + for message in rx { + messages += 1; + app.apply(message); + } + let end = app.scan.end.clone().expect("the scan finished"); + println!( + "{}: scan {scan_s:.2} s, {} records, {} events, {:.2} GB/s, {messages} messages", + path.display(), + app.scan.records.len(), + app.scan.events, + end.bytes as f64 / 1e9 / end.elapsed_s + ); + assert_eq!( + app.scan.start.as_ref().unwrap().layout.name(), + expect.layout + ); + assert_eq!(app.scan.records.len(), expect.records); + assert_eq!(app.scan.events, expect.events); + assert_eq!(end.trailer, expect.trailer); + assert_eq!( + end.truncated_tail.map(|t| t.salvaged_events), + expect.salvaged + ); + assert!(end.bad_record_header.is_none()); + assert!(end.error.is_none(), "{:?}", end.error); + + // record 7000: select it and open it through the state machine + app.records.select(7000); + let summary = app.selected_record().unwrap().clone(); + assert_eq!(summary.index, 7000); + assert_eq!(summary.offset, expect.record_7000_offset); + assert_eq!(summary.header.record_number, 7001); + let t1 = Instant::now(); + app.key(Key::Enter); + let open_s = t1.elapsed().as_secs_f64(); + assert_eq!(app.screen, Screen::Events, "{:?}", app.message); + let loaded = app.loaded.as_ref().unwrap(); + assert_eq!(loaded.index, 7000); + assert_eq!(loaded.offset, summary.offset); + assert_eq!(loaded.record.header.record_number, 7001); + assert_eq!(loaded.events.len(), summary.events_found); + assert_eq!(loaded.events.len(), 11); + assert!(loaded.events.iter().all(|e| e.is_stream_frame())); + println!( + "record 7000 at 0x{:X}: opened in {:.4} s ({} bytes, {} events)", + loaded.offset, + open_s, + loaded.record.present_bytes, + loaded.events.len() + ); + assert!(open_s < 1.0, "opening record 7000 took {open_s} s"); + + // the tree of event 0 walks without an error; the frame summary has hits + app.key(Key::Enter); + assert_eq!(app.screen, Screen::Event); + let view = app.view.as_ref().unwrap(); + let errors = view.rows.iter().filter(|r| r.is_error()).count(); + assert_eq!(errors, 0); + // depth 1 holds the SIB (tag 0xFF31) and the ROC banks + let depth1: Vec = view + .rows + .iter() + .filter(|r| r.depth == 1) + .filter_map(|r| match r.item { + TreeItem::Node { tag, .. } => Some(tag), + TreeItem::Error { .. } => None, + }) + .collect(); + assert_eq!(depth1.first(), Some(&0xFF31)); + assert!(depth1.len() >= 2, "{depth1:?}"); + let (first, words) = app.selected_words().unwrap(); + assert_eq!(first, 0); + assert_eq!(words.len(), 64); + assert_eq!(words[1] >> 16, 0xFF60); + app.key(Key::Char('n')); + let (first, _) = app.selected_words().unwrap(); + assert_eq!(first, 64); + app.key(Key::Char('p')); + assert_eq!(app.selected_words().unwrap().0, 0); + app.key(Key::Char('f')); + let view = app.view.as_ref().unwrap(); + assert!(view.show_frame); + let frame = view.frame.as_ref().unwrap(); + println!("frame summary: {}", frame.lines.join(" | ")); + assert!(frame.lines[0].starts_with("frame ")); + assert!(frame.lines[2].contains("fADC250 hits")); + assert!(frame.lines.len() > 5, "per-ROC rows expected"); + assert!(frame.errors.is_empty(), "{:?}", frame.errors); + app.key(Key::Esc); + assert!(!app.view.as_ref().unwrap().show_frame); + assert_eq!(app.screen, Screen::Event); + + // the last record opens too (the cut tail of the raw file, the trailer + // of the converted one) + app.key(Key::Esc); + app.key(Key::Esc); + assert_eq!(app.screen, Screen::Records); + app.key(Key::End); + let last = app.selected_record().unwrap().clone(); + app.key(Key::Enter); + assert_eq!(app.screen, Screen::Events, "{:?}", app.message); + let loaded = app.loaded.as_ref().unwrap(); + assert_eq!(loaded.index, last.index); + assert_eq!(loaded.events.len(), last.events_found); + assert_eq!(loaded.record.is_truncated, last.is_truncated); + println!( + "last record {} at 0x{:X}: {} events, truncated {}", + last.index, + last.offset, + loaded.events.len(), + loaded.record.is_truncated + ); +} + +#[test] +fn converted_v6_corpus_file() { + drive( + CORPUS_V6, + &Expect { + layout: "v6", + records: 7892, + events: 86794, + trailer: true, + salvaged: None, + record_7000_offset: 0x3364DC560, + }, + ); +} + +#[test] +fn raw_sro_stream() { + drive( + RAW_SRO, + &Expect { + layout: "sro-8word", + records: 7893, + events: 86794, + trailer: false, + salvaged: Some(2), + record_7000_offset: 0x33610BA10, + }, + ); +} + +/// The scan runs on its own thread and the receiver drains as messages +/// come: the first records arrive long before the end. +#[test] +fn background_scan_streams_records() { + let path = Path::new(CORPUS_V6); + if !path.is_file() { + eprintln!("skipped: {} is absent", path.display()); + return; + } + let t0 = Instant::now(); + let rx = scan::spawn(path.to_path_buf()); + let mut app = App::new(path); + let mut first_record_at = None; + for message in rx { + if first_record_at.is_none() && matches!(message, ScanMessage::Record(_)) { + first_record_at = Some(t0.elapsed().as_secs_f64()); + } + app.apply(message); + } + let total = t0.elapsed().as_secs_f64(); + let first = first_record_at.unwrap(); + println!("first record after {first:.4} s, scan done after {total:.2} s"); + assert!(app.scan.is_done()); + assert!(first < total / 2.0); +} diff --git a/rust/evio-tui/tests/render.rs b/rust/evio-tui/tests/render.rs new file mode 100644 index 0000000..81712aa --- /dev/null +++ b/rust/evio-tui/tests/render.rs @@ -0,0 +1,178 @@ +//! The screens drawn into ratatui's `TestBackend` for the small golden files +//! (`/data/evio-lib/golden`, read-only; skipped when absent): the records +//! screen text after a full scan, then the events and the event screens +//! driven by keys. + +use std::path::{Path, PathBuf}; +use std::sync::mpsc; + +use ratatui::backend::TestBackend; +use ratatui::Terminal; + +use evio_tui::scan::{self, ScanMessage}; +use evio_tui::state::{App, Key, Screen}; +use evio_tui::ui; + +fn golden(name: &str) -> Option { + let root = std::env::var_os("EVIO_GOLDEN_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("/data/evio-lib/golden")); + let path = root.join("cases/cpp").join(name); + if path.is_file() { + Some(path) + } else { + eprintln!("skipped: {} is absent", path.display()); + None + } +} + +/// Scan `path` inline and feed every message to a new `App`. +fn scanned_app(path: &Path) -> App { + let (tx, rx) = mpsc::channel(); + scan::scan(path, &tx); + drop(tx); + let mut app = App::new(path); + let mut finished = false; + for message in rx { + finished |= matches!(message, ScanMessage::Finished(_)); + app.apply(message); + } + assert!(finished, "the scan must end with Finished"); + app +} + +fn screen_text(terminal: &mut Terminal, app: &mut App) -> String { + terminal.draw(|f| ui::draw(f, app)).unwrap(); + ui::buffer_lines(terminal.backend().buffer()).join("\n") +} + +#[test] +fn records_screen_of_basic_le() { + let Some(path) = golden("basic_le.evio") else { + return; + }; + let mut app = scanned_app(&path); + assert_eq!(app.scan.records.len(), 2); + assert_eq!(app.scan.events, 18); + let end = app.scan.end.as_ref().unwrap(); + assert!(end.trailer); + assert!(end.truncated_tail.is_none()); + assert!(end.bad_record_header.is_none()); + + let mut terminal = Terminal::new(TestBackend::new(140, 30)).unwrap(); + let text = screen_text(&mut terminal, &mut app); + // the title, the table header, the two record rows and the status line + assert!(text.contains("basic_le.evio"), "{text}"); + assert!(text.contains("[records]"), "{text}"); + assert!(text.contains(" 1 records + trailer "), "{text}"); + assert!(text.contains(" index "), "{text}"); + assert!(text.contains("0x38"), "{text}"); + assert!(text.contains(" 752 "), "{text}"); + assert!(text.contains("record "), "{text}"); + assert!(text.contains("18"), "{text}"); + assert!(text.contains("0x328"), "{text}"); + assert!(text.contains("trailer"), "{text}"); + assert!(text.contains("none"), "{text}"); + // the side panel shows the selected record's header fields + assert!(text.contains("layout v6"), "{text}"); + assert!(text.contains("record number 1"), "{text}"); + assert!(text.contains("index array 72 bytes"), "{text}"); + // the status line shows the finished scan and the checks + assert!( + text.contains("done: 1 records + trailer 18 events"), + "{text}" + ); + assert!(text.contains("trailer yes"), "{text}"); + assert!(text.contains("truncated_tail none"), "{text}"); + assert!(text.contains("bad_record_header none"), "{text}"); + + // a narrow terminal drops the side panel but keeps the table + let mut narrow = Terminal::new(TestBackend::new(80, 12)).unwrap(); + let text = screen_text(&mut narrow, &mut app); + assert!(text.contains("0x38"), "{text}"); + assert!(!text.contains("record number"), "{text}"); +} + +#[test] +fn events_and_event_screens_of_lz4_le() { + let Some(path) = golden("lz4_le.evio") else { + return; + }; + let mut app = scanned_app(&path); + assert_eq!(app.scan.records.len(), 2); + assert_eq!(app.scan.records[0].header.compression.name(), "lz4"); + let mut terminal = Terminal::new(TestBackend::new(120, 30)).unwrap(); + let text = screen_text(&mut terminal, &mut app); + assert!(text.contains("lz4"), "{text}"); + + // Enter opens record 0: 18 events read from the file at offset 0x38 + app.key(Key::Enter); + assert_eq!(app.screen, Screen::Events, "{:?}", app.message); + let loaded = app.loaded.as_ref().unwrap(); + assert_eq!(loaded.index, 0); + assert_eq!(loaded.offset, 0x38); + assert_eq!(loaded.events.len(), 18); + assert_eq!(loaded.record.header.record_number, 1); + let text = screen_text(&mut terminal, &mut app); + assert!(text.contains("[events]"), "{text}"); + assert!(text.contains("record 0 at 0x38: 18 events"), "{text}"); + assert!(text.contains("0x0101"), "{text}"); + assert!(text.contains("0x01 uint32"), "{text}"); + + // event 16 (tag 0x010E, a bank of banks): the tree has more than one row + app.key(Key::End); + app.key(Key::Up); + assert_eq!(app.selected_event().unwrap().tag, 0x010E); + app.key(Key::Enter); + assert_eq!(app.screen, Screen::Event); + let view = app.view.as_ref().unwrap(); + assert_eq!(view.event_index, 16); + assert!(view.rows.len() > 1, "{:?}", view.rows); + assert!(view.rows.iter().all(|r| !r.is_error()), "{:?}", view.rows); + let text = screen_text(&mut terminal, &mut app); + assert!(text.contains("[event]"), "{text}"); + assert!(text.contains("Bank"), "{text}"); + assert!(text.contains("0x010E"), "{text}"); + assert!(text.contains("words 0.."), "{text}"); + // the first hex row starts at word 0 with the length word of the event + assert!(text.contains(" 0 00000007"), "{text}"); + + // f on a non-frame event leaves a message, not a frame panel + app.key(Key::Char('f')); + assert!(app + .message + .as_deref() + .unwrap_or("") + .contains("no stream frame")); + assert!(!app.view.as_ref().unwrap().show_frame); + + // Esc walks back: event -> events -> records + app.key(Key::Esc); + assert_eq!(app.screen, Screen::Events); + app.key(Key::Esc); + assert_eq!(app.screen, Screen::Records); + + // the trailer opens too: a record with no events + app.key(Key::End); + app.key(Key::Enter); + assert_eq!(app.screen, Screen::Events, "{:?}", app.message); + assert_eq!(app.loaded.as_ref().unwrap().events.len(), 0); + assert!(app.loaded.as_ref().unwrap().record.is_trailer()); + app.key(Key::Enter); + assert_eq!(app.screen, Screen::Events); +} + +#[test] +fn a_missing_file_fails_the_scan() { + let (tx, rx) = mpsc::channel(); + scan::scan(Path::new("/nonexistent/run.evio"), &tx); + drop(tx); + let messages: Vec = rx.iter().collect(); + assert_eq!(messages.len(), 1); + assert!(matches!(messages[0], ScanMessage::Failed(_))); + let mut app = App::new("/nonexistent/run.evio"); + app.apply(messages[0].clone()); + let mut terminal = Terminal::new(TestBackend::new(100, 10)).unwrap(); + let text = screen_text(&mut terminal, &mut app); + assert!(text.contains("scan failed"), "{text}"); +} diff --git a/tests/golden/test_corpus.py b/tests/golden/test_corpus.py new file mode 100644 index 0000000..827904d --- /dev/null +++ b/tests/golden/test_corpus.py @@ -0,0 +1,100 @@ +"""Converted SRO corpus: record and event counts per file. + +How to run +---------- +The corpus holds eight files of 12—16 GB each; walking the record headers of +one takes seconds. The module is marked ``slow`` and runs only when +``PYEVIO_CORPUS=1`` is set:: + + PYEVIO_CORPUS=1 uv run pytest tests/golden/test_corpus.py -v + +Paths: the manifest ``CORPUS_MANIFEST`` (default +``/home/romanov/dev/evio-optim/space-evio-lib-23/golden_manifest/corpus.yaml``) +names each file by absolute path under ``/data/evio-lib/corpus/``. The +module skips itself when the manifest is absent. + +Where the data comes from +------------------------- +evio-optim converted the raw SRO streaming files +(``/data/sro_boyarinov_data_2026/sro_000791.evio.0000N``) to clean EVIO v6 +with ``evio_convert_format``, uncompressed and LZ4. Each manifest row +records ``records`` (data records written) and ``event_count`` (complete +events plus salvaged tail events) as counted by the converter. + +What one test checks +-------------------- +``EvioFile.record_count`` equals ``records``, the record header event-count +sum equals ``event_count``, and no event from ``Record.scan_events`` +extends past the end of the file. Rows that fail today carry +``xfail(strict=True)`` with the reason. +""" + +import os + +import pytest + +yaml = pytest.importorskip("yaml") + +from pyevio.evio_file import EvioFile # noqa: E402 + +pytestmark = pytest.mark.slow + +MANIFEST_PATH = os.environ.get( + "CORPUS_MANIFEST", + "/home/romanov/dev/evio-optim/space-evio-lib-23/golden_manifest/corpus.yaml", +) + +if os.environ.get("PYEVIO_CORPUS") != "1": + pytest.skip("set PYEVIO_CORPUS=1 to scan the 15 GB corpus files", allow_module_level=True) + +if not os.path.isfile(MANIFEST_PATH): + pytest.skip(f"corpus manifest absent: {MANIFEST_PATH}", allow_module_level=True) + +with open(MANIFEST_PATH) as manifest_file: + MANIFEST = yaml.safe_load(manifest_file) + +# Failures by compression kind. Empty: every row passes. A new entry maps the +# compression kind to the reason the row fails. +XFAIL_BY_COMPRESSION = {} + + +def corpus_params(): + params = [] + for row in MANIFEST["files"]: + reason = XFAIL_BY_COMPRESSION.get(row["compression"]) + marks = [pytest.mark.xfail(strict=True, reason=reason)] if reason else [] + params.append(pytest.param(row, id=os.path.basename(row["path"]), marks=marks)) + return params + + +@pytest.mark.parametrize("row", corpus_params()) +def test_corpus_file(row): + path = row["path"] + if not os.path.isfile(path): + pytest.skip(f"corpus file absent: {path}") + + file_size = os.path.getsize(path) + with EvioFile(path) as evio_file: + record_count = evio_file.record_count + header_events = evio_file.get_total_event_count() + scanned_events = 0 + past_eof = 0 + for record_i in range(record_count): + record = evio_file.get_record(record_i) + # Event offsets are into record.buffer: the file for a plain + # record, the decompressed bytes for a compressed one. + buffer_end = file_size if record.buffer is evio_file.mm else len(record.buffer) + for offset, length in record.scan_events(): + scanned_events += 1 + if offset + length > buffer_end: + past_eof += 1 + + # The counts are the report numbers: print them so `-s` or the log shows them. + print(f"{os.path.basename(path)}: records={record_count} (manifest {row['records']}) " + f"header_events={header_events} scanned={scanned_events} (manifest {row['event_count']}) " + f"past_eof={past_eof}") + + assert record_count == row["records"], "record count" + assert header_events == row["event_count"], "record header event counts" + assert scanned_events == row["event_count"], "events found by scan_events" + assert past_eof == 0, f"{past_eof} events past EOF" diff --git a/tests/golden/test_golden.py b/tests/golden/test_golden.py new file mode 100644 index 0000000..a58a4fe --- /dev/null +++ b/tests/golden/test_golden.py @@ -0,0 +1,157 @@ +"""Golden-set harness: pyevio against the evio-optim golden manifest. + +How to run +---------- +The golden set lives outside the repository. The module skips itself when +either path is absent: + +- manifest: ``GOLDEN_MANIFEST`` (default + ``/home/romanov/dev/evio-optim/space-evio-lib-23/golden_manifest/manifest.yaml``) +- files: ``golden_root`` from the manifest (``/data/evio-lib/golden``) + +Run only this module:: + + uv run pytest tests/golden/test_golden.py -v + +The sibling module ``test_corpus.py`` covers the converted SRO corpus +(15 GB files); it runs only with ``PYEVIO_CORPUS=1``. + +Where the data comes from +------------------------- +The golden set was written by the evio C++ and Java libraries at the +``lib_git_sha`` recorded in the manifest (generators +``evio/src/utils/cpp/golden_gen.cpp``, ``GoldenGen.java``, ``gen_v13.py``). +Each manifest row carries ``path``, ``case``, ``description``, ``sha256``, +``event_count`` and content digests. One test runs per row; the test id is +the path below ``cases/`` because the same ``case`` name exists for both +writers. + +What one test checks +-------------------- +1. The file is present and its sha256 matches the manifest (a mismatch is a + broken reference: stop and ask, never regenerate over it). +2. ``.dump`` rows are record streams without a file header: read as layout + ``"v6-records"`` and checked like the v6 rows (the dictionary in the + record user header of ``buffer_evw_dict_le.dump`` is skipped, not read). +3. v4 and v1—3 rows: ``EvioFile`` raises ``ValueError`` whose message names + the detected version and points at the evio tools (see + ``assert_unsupported_version_message``). +4. v6 rows: the header event-count sum equals the manifest count, the + events found by ``Record.scan_events`` number the same, and no event + extends past the end of the file. + +Rows that fail today carry ``xfail(strict=True)`` with the reason, so a fix +flips the test loudly. +""" + +import hashlib +import os +import re + +import pytest + +yaml = pytest.importorskip("yaml") + +from pyevio.evio_file import EvioFile # noqa: E402 + +MANIFEST_PATH = os.environ.get( + "GOLDEN_MANIFEST", + "/home/romanov/dev/evio-optim/space-evio-lib-23/golden_manifest/manifest.yaml", +) + +if not os.path.isfile(MANIFEST_PATH): + pytest.skip(f"golden manifest absent: {MANIFEST_PATH}", allow_module_level=True) + +with open(MANIFEST_PATH) as manifest_file: + MANIFEST = yaml.safe_load(manifest_file) + +GOLDEN_ROOT = MANIFEST["golden_root"] + +if not os.path.isdir(GOLDEN_ROOT): + pytest.skip(f"golden root absent: {GOLDEN_ROOT}", allow_module_level=True) + + +# EVIO version each legacy row carries in word 5 of its first block header. +# These rows must fail with the unsupported-version message instead of being read. +LEGACY_VERSION = { + "v4_le.evio": 4, + "v4_be.evio": 4, + "v4dict_le.evio": 4, + "v13_legacy.evio": 3, +} + +# Failures by case name, decided by running every row with pyevio. Empty: +# every row passes. A new entry maps the case name to the reason the row fails. +XFAIL_BY_CASE = {} + + +def manifest_params(): + params = [] + for row in MANIFEST["files"]: + test_id = row["path"].split("cases/", 1)[-1] + marks = [] + if row["case"] in XFAIL_BY_CASE: + marks.append(pytest.mark.xfail(strict=True, reason=XFAIL_BY_CASE[row["case"]])) + params.append(pytest.param(row, id=test_id, marks=marks)) + return params + + +def sha256_of(path): + digest = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def assert_unsupported_version_message(message, version): + """The message contract for files pyevio does not read. + + The message names the detected EVIO version ("version 4") and tells the + reader where to go: an evio tool (evio2xml, eviocopy) or the evio + library. + """ + assert re.search(rf"(?i)\bversion\s*{version}\b", message), \ + f"message does not name EVIO version {version}: {message!r}" + assert re.search(r"(?i)evio2xml|eviocopy|evio (tools?|librar|utilit)", message), \ + f"message does not point at the evio tools: {message!r}" + + +@pytest.mark.parametrize("row", manifest_params()) +def test_golden_file(row): + path = os.path.join(GOLDEN_ROOT, row["path"]) + if not os.path.isfile(path): + pytest.fail(f"golden file missing: {path} (regenerate per the decision table)") + assert sha256_of(path) == row["sha256"], \ + f"sha256 mismatch for {path}: the golden reference is broken, stop and ask" + + if row["case"] in LEGACY_VERSION: + with pytest.raises(ValueError) as raised: + EvioFile(path) + assert_unsupported_version_message(str(raised.value), LEGACY_VERSION[row["case"]]) + return + + file_size = os.path.getsize(path) + with EvioFile(path) as evio_file: + expected_layout = "v6-records" if path.endswith(".dump") else "v6" + assert evio_file.header_layout == expected_layout + header_events = evio_file.get_total_event_count() + scanned_events = 0 + past_end = [] + errors = [] + for record_i in range(evio_file.record_count): + record = evio_file.get_record(record_i) + # Event offsets are into record.buffer: the file for a plain + # record, the decompressed bytes for a compressed one. + buffer_end = file_size if record.buffer is evio_file.mm else len(record.buffer) + for offset, length in record.scan_events(): + scanned_events += 1 + if offset + length > buffer_end: + past_end.append((record_i, offset, length)) + errors += record.errors + + assert header_events == row["event_count"], "record header event counts" + assert scanned_events == row["event_count"], "events found by scan_events" + assert past_end == [], f"events past their buffer end: {past_end[:5]}" + assert errors == [], f"record errors: {errors[:5]}" + assert evio_file.checks == [], f"file checks: {evio_file.checks}" diff --git a/tests/gui/conftest.py b/tests/gui/conftest.py new file mode 100644 index 0000000..510a8e6 --- /dev/null +++ b/tests/gui/conftest.py @@ -0,0 +1,63 @@ +"""Fixtures for the console viewer tests: a synthetic stream file, a session with a temporary cache. + +``tests/`` is added to ``sys.path`` so the synthetic-file builder of +``test_stream_stats.py`` can be reused; pytest puts only this folder on the path. +The app tests need Textual (``pyevio[gui]``); the session tests do not. +""" +import sys +import time +from pathlib import Path + +import pytest + +TESTS_DIR = Path(__file__).resolve().parents[1] +if str(TESTS_DIR) not in sys.path: + sys.path.insert(0, str(TESTS_DIR)) + +from test_stream_stats import write_synthetic_file # noqa: E402 + +GOLDEN_FILE = Path("/data/evio-lib/golden/cases/cpp/basic_le.evio") +GOLDEN_LZ4_FILE = GOLDEN_FILE.with_name("lz4_le.evio") +WAIT_S = 60 + + +@pytest.fixture +def synthetic(tmp_path): + """``runs/synthetic.evio`` (12 stream frames, one other event, a trailer) and a non-EVIO file next to it.""" + folder = tmp_path / "runs" + folder.mkdir() + path, expected_hits, frame_numbers = write_synthetic_file(folder / "synthetic.evio") + (folder / "notes.txt").write_text("not an evio file") + (folder / "garbage.evio").write_bytes(b"\0" * 64) + return {"path": Path(path), "folder": folder, "expected_hits": expected_hits, "frame_numbers": frame_numbers, + "garbage": folder / "garbage.evio", "cache_dir": tmp_path / "cache", + "golden": GOLDEN_FILE if GOLDEN_FILE.is_file() else None, + "golden_lz4": GOLDEN_LZ4_FILE if GOLDEN_LZ4_FILE.is_file() else None} + + +@pytest.fixture +def session(synthetic): + from pyevio.gui.session import Session + + session = Session(cache_dir=synthetic["cache_dir"], workers=1) + yield session + try: + session.close() + except BufferError: + pass + + +def wait_until(predicate, timeout_s=WAIT_S, what="condition"): + """Block (outside the app) until ``predicate()`` is true.""" + deadline = time.monotonic() + timeout_s + while not predicate(): + assert time.monotonic() < deadline, f"{what} did not happen in {timeout_s} s" + time.sleep(0.02) + + +async def pilot_wait(pilot, predicate, timeout_s=WAIT_S, what="condition"): + """Pause the pilot until ``predicate()`` is true, letting the app's timers run.""" + deadline = time.monotonic() + timeout_s + while not predicate(): + assert time.monotonic() < deadline, f"{what} did not happen in {timeout_s} s" + await pilot.pause(0.05) diff --git a/tests/gui/make_screenshots.py b/tests/gui/make_screenshots.py new file mode 100644 index 0000000..848063b --- /dev/null +++ b/tests/gui/make_screenshots.py @@ -0,0 +1,213 @@ +"""Regenerate the console viewer screenshots of docs/console_gui.md as SVG files. + +Not a test (pytest ignores the name). Run from the repository root: + + uv run python tests/gui/make_screenshots.py [FILE] [--out docs/images] [--size 140x45] [--plots-size 260x50] + +FILE defaults to the SRO corpus file; when it is unreadable the synthetic +stream file of the tests is written to a temporary folder and used instead. +The two Plots shots are taken at ``--plots-size``: the occupancy heatmap prints +its counts only when every count fits in its cell with a column to spare, which +with 16 cells across and counts in the millions takes a terminal of about 250 +columns, and it draws one line per row (16 ports) only in a plot 19 lines high, +i.e. a terminal of about 50 lines. + +The analysis runs before the app starts, through the session alone, and its +result goes to the default cache (``~/.cache/pyevio/stats``), so the app loads +it at once and a second run or ``pyevio web`` reuses it. (Under Textual the +worker pool of the analysis does not start; see "Known problems" in +docs/console_gui.md.) + +The app is driven headless through Textual's pilot, as the tests do, and each +screen is exported with ``App.export_screenshot(simplify=True)``: the SVG +holds the terminal cells as text, so it can be grepped for what it shows. +""" +import argparse +import asyncio +import sys +import tempfile +import time +from pathlib import Path + +from textual.widgets import Tree + +HERE = Path(__file__).resolve().parent +TESTS_DIR = HERE.parent +if str(TESTS_DIR) not in sys.path: + sys.path.insert(0, str(TESTS_DIR)) + +from pyevio.gui.app import OpenScreen, PyEvioGui # noqa: E402 +from pyevio.gui.session import Session # noqa: E402 + +CORPUS_FILE = Path("/data/evio-lib/corpus/sro_000791.evio.00000.v6") +DEFAULT_OUT = TESTS_DIR.parent / "docs" / "images" +ANALYSIS_TIMEOUT_S = 300 +PREFIX = "console_gui_" + + +def ensure_cached(path: Path) -> None: + """Run the analysis outside Textual (where its worker pool starts) so the app finds it in the cache.""" + session = Session() + session.open(path) + try: + deadline = time.monotonic() + ANALYSIS_TIMEOUT_S + while session.state not in ("done", "error", "cancelled"): + if time.monotonic() > deadline: + raise TimeoutError(f"the analysis of {path} did not finish in {ANALYSIS_TIMEOUT_S} s") + progress = session.progress() + print(f"\ranalyzing {progress['records_done']:,} of {progress['records_total']:,} records", end="", flush=True) + time.sleep(0.5) + progress = session.progress() + if progress["state"] != "done": + raise RuntimeError(f"the analysis of {path} ended in state {progress['state']}: {progress['error']}") + source = "from cache" if progress["cached"] else "done in {:.0f} s".format(progress["elapsed_s"]) + print(f"\ranalysis {source}") + finally: + try: + session.close() + except BufferError: + pass + + +async def wait_for(pilot, predicate, timeout_s, what): + deadline = time.monotonic() + timeout_s + while not predicate(): + if time.monotonic() > deadline: + raise TimeoutError(f"{what} did not happen in {timeout_s} s") + await pilot.pause(0.1) + + +async def settle(pilot, n=3): + for _ in range(n): + await pilot.pause(0.1) + + +def save(app, out_dir: Path, name: str) -> Path: + path = out_dir / f"{PREFIX}{name}.svg" + path.write_text(app.export_screenshot(simplify=True), encoding="utf-8") + print(f"{path} {path.stat().st_size / 1024:.0f} KB") + return path + + +def first_child(node, **match): + """The first child whose data has every ``match`` item.""" + for child in node.children: + data = child.data or {} + if all(data.get(key) == value for key, value in match.items()): + return child + return None + + +async def open_app(path: Path, size: tuple): + session = Session() # the default cache dir, shared with pyevio web + app = PyEvioGui(session, start_path=path) + pilot_cm = app.run_test(size=size) + pilot = await pilot_cm.__aenter__() + await wait_for(pilot, lambda: session.state == "done", 60, "the cached analysis") + await wait_for(pilot, lambda: app.overview.summary is not None, 30, "stats_ready") + await settle(pilot) + return app, pilot, pilot_cm, session + + +async def close_app(pilot_cm, session) -> None: + await pilot_cm.__aexit__(None, None, None) + try: + session.close() + except BufferError: + pass + + +async def shoot_main(path: Path, out_dir: Path, size: tuple) -> None: + """Overview, Records, Structure and the open screen at ``size``.""" + app, pilot, pilot_cm, session = await open_app(path, size) + try: + save(app, out_dir, "overview") + + await pilot.press("3") + await settle(pilot) + save(app, out_dir, "records") + + # Structure: the first stream frame of record 0 expanded, its first ROC bank and ROC stream info expanded, + # the cursor on the payload-port map so the detail panel shows the fields, the decoded map and the raw words. + await pilot.press("4") + await settle(pilot) + tree = app.query_one("#structure-tree", Tree) + app.structure.show_node("r0/e0") + await wait_for(pilot, lambda: tree.cursor_node.data.get("_total") is not None, 30, "the frame's children") + frame = tree.cursor_node + roc_bank = first_child(frame, role="roc_bank") + if roc_bank is not None: + app.structure.show_node(roc_bank.data["id"]) + await wait_for(pilot, lambda: roc_bank.data.get("_total") is not None, 30, "the ROC bank's children") + info = first_child(roc_bank, role="roc_stream_info") + if info is not None: + app.structure.show_node(info.data["id"]) + await wait_for(pilot, lambda: info.data.get("_total") is not None, 30, "the ROC stream info's children") + payload_map = first_child(info, role="payload_map") + if payload_map is not None: + app.structure.show_node(payload_map.data["id"]) + await settle(pilot) + save(app, out_dir, "structure") + + # The open screen, rooted at the file's folder. + await pilot.press("o") + await wait_for(pilot, lambda: isinstance(app.screen, OpenScreen), 10, "the open screen") + await settle(pilot) + save(app, out_dir, "open") + await pilot.press("escape") + await settle(pilot) + finally: + await close_app(pilot_cm, session) + + +async def shoot_plots(path: Path, out_dir: Path, size: tuple) -> None: + """The Plots tab at file level and at ROC level, at ``size``.""" + app, pilot, pilot_cm, session = await open_app(path, size) + try: + await pilot.press("2") + await settle(pilot) + save(app, out_dir, "plots_file") + await pilot.press("r") + await settle(pilot) + save(app, out_dir, "plots_roc") + finally: + await close_app(pilot_cm, session) + + +def parse_size(text: str) -> tuple: + columns, rows = (int(part) for part in text.lower().split("x")) + return columns, rows + + +def run(path: Path, out_dir: Path, size: tuple, plots_size: tuple) -> None: + print(f"file: {path}") + ensure_cached(path) + asyncio.run(shoot_main(path, out_dir, size)) + asyncio.run(shoot_plots(path, out_dir, plots_size)) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("file", nargs="?", default=str(CORPUS_FILE)) + parser.add_argument("--out", default=str(DEFAULT_OUT)) + parser.add_argument("--size", default="140x45", help="terminal columns x rows (default 140x45)") + parser.add_argument("--plots-size", default="260x50", help="terminal size of the two Plots shots (default 260x50)") + args = parser.parse_args() + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + size, plots_size = parse_size(args.size), parse_size(args.plots_size) + + path = Path(args.file) + if path.is_file(): + run(path, out_dir, size, plots_size) + return + print(f"{path} is not readable; using the synthetic stream file of the tests", file=sys.stderr) + from test_stream_stats import write_synthetic_file + + with tempfile.TemporaryDirectory() as folder: + synthetic, _, _ = write_synthetic_file(Path(folder) / "synthetic.evio") + run(Path(synthetic), out_dir, size, plots_size) + + +if __name__ == "__main__": + main() diff --git a/tests/gui/test_app.py b/tests/gui/test_app.py new file mode 100644 index 0000000..8d5f248 --- /dev/null +++ b/tests/gui/test_app.py @@ -0,0 +1,80 @@ +"""The app skeleton: open on start, progress to done, tabs, the open screen, errors.""" +import pytest + +textual = pytest.importorskip("textual") + +from textual.widgets import Input, Static, TabbedContent # noqa: E402 + +from conftest import pilot_wait # noqa: E402 +from pyevio.gui.app import OpenScreen, PyEvioGui, StatusStrip # noqa: E402 +from pyevio.gui.overview import OverviewPane # noqa: E402 +from pyevio.gui.records import RecordsPane # noqa: E402 + + +def status_text(app): + return str(app.query_one("#status-text", Static).render()) + + +def error_text(app): + return str(app.query_one("#status-error", Static).render()) + + +async def test_start_with_a_file_runs_the_analysis_and_fills_the_panes(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(120, 40)) as pilot: + await pilot_wait(pilot, lambda: app.query_one(OverviewPane).summary is not None, what="stats_ready") + assert "analysis done" in status_text(app) and "3 records" in status_text(app) + assert app.query_one(OverviewPane).summary["totals"]["n_hits"] == synthetic["expected_hits"] + assert "4 records" in str(app.query_one("#records-status", Static).render()) + assert error_text(app) == "" + assert app.sub_title == str(synthetic["path"].resolve()) + + +async def test_number_keys_switch_tabs(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(120, 40)) as pilot: + await pilot_wait(pilot, lambda: session.state == "done", what="analysis") + tabs = app.query_one("#tabs", TabbedContent) + for key, tab in (("3", "tab-records"), ("4", "tab-structure"), ("2", "tab-plots"), ("1", "tab-overview")): + await pilot.press(key) + await pilot.pause() + assert tabs.active == tab + app.show_structure_node("r1") + await pilot.pause() + assert tabs.active == "tab-structure" and "'r1'" in str(app.query_one("#structure-path", Static).render()) + + +async def test_open_screen_opens_a_typed_path_and_an_unreadable_file_keeps_the_current_one(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(120, 40)) as pilot: + await pilot_wait(pilot, lambda: session.state == "done", what="analysis") + await pilot.press("o") + await pilot.pause() + assert isinstance(app.screen, OpenScreen) + field = app.screen.query_one("#open-path", Input) + field.value = str(synthetic["garbage"]) + await pilot.press("enter") + await pilot_wait(pilot, lambda: not isinstance(app.screen, OpenScreen), what="open screen to close") + assert "garbage.evio" in error_text(app) + assert session.path == synthetic["path"].resolve() and session.state == "done" + assert app.query_one(RecordsPane) is not None and "4 records" in str(app.query_one("#records-status", Static).render()) + + # Escape cancels without touching the session. + await pilot.press("o") + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + assert not isinstance(app.screen, OpenScreen) and session.path == synthetic["path"].resolve() + + +async def test_start_without_a_file_shows_the_open_screen(session, synthetic): + app = PyEvioGui(session, start_dir=synthetic["folder"]) + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + assert isinstance(app.screen, OpenScreen) + assert "No file open" in status_text(app) + app.screen.query_one("#open-path", Input).value = str(synthetic["path"]) + await pilot.press("enter") + await pilot_wait(pilot, lambda: session.is_open, what="file to open") + await pilot_wait(pilot, lambda: app.query_one(OverviewPane).summary is not None, what="stats_ready") + assert "analysis done" in status_text(app) diff --git a/tests/gui/test_export.py b/tests/gui/test_export.py new file mode 100644 index 0000000..68d1adf --- /dev/null +++ b/tests/gui/test_export.py @@ -0,0 +1,92 @@ +"""PNG export of the histogram dicts, without Textual.""" +import pytest + +pytest.importorskip("matplotlib") + +from pyevio.gui.export import default_png_name, nice_ticks, occupancy_grid, rebin, save_histogram_png, series # noqa: E402 +from pyevio.stream_stats import compute_stream_stats # noqa: E402 +from pyevio.web.report import HISTOGRAM_NAMES, histogram # noqa: E402 + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +@pytest.fixture(scope="module") +def stats(tmp_path_factory): + from test_stream_stats import write_synthetic_file + + path, _, _ = write_synthetic_file(tmp_path_factory.mktemp("export") / "synthetic.evio") + return compute_stream_stats(str(path), workers=1) + + +@pytest.mark.parametrize("name", HISTOGRAM_NAMES) +def test_save_histogram_png_writes_a_png_for_every_histogram(stats, name, tmp_path): + hist = histogram(stats, name) + path = save_histogram_png(hist, tmp_path / f"{name}.png", f"{name} of the synthetic file") + assert path == tmp_path / f"{name}.png" + assert path.read_bytes()[:8] == PNG_SIGNATURE and path.stat().st_size > 1000 + + +def test_save_histogram_png_at_roc_and_channel_level(stats, tmp_path): + for selection in ({"roc": 7}, {"roc": 7, "port": 1, "channel": 3}, {"roc": 44}): + for name in ("occupancy", "charge", "rate", "time_in_frame"): + hist = histogram(stats, name, **selection) + path = save_histogram_png(hist, tmp_path / default_png_name("synthetic.evio", name, hist["selection"])) + assert path.read_bytes()[:8] == PNG_SIGNATURE + assert (tmp_path / "synthetic_charge_roc7_port1_ch3.png").is_file() + assert (tmp_path / "synthetic_occupancy_roc44.png").is_file() + + +def test_default_png_name(): + assert default_png_name("/data/runs/synthetic.evio", "charge", {"roc": 7, "port": 1, "ch": 3}) == "synthetic_charge_roc7_port1_ch3.png" + assert default_png_name("synthetic.evio", "hits_per_frame", {"roc": None, "port": None, "ch": None}) == "synthetic_hits_per_frame.png" + assert default_png_name("run_001.evio.0", "rate", {"roc": 44}) == "run_001.evio_rate_roc44.png" + assert default_png_name(None, "occupancy", None) == "histogram_occupancy.png" + + +def test_occupancy_grid_marks_unmapped_ports(stats): + grid = occupancy_grid(histogram(stats, "occupancy")) + assert grid["rows"] == ["ROC 7", "ROC 44"] and grid["cols"] == ["1", "3", "4", "5", "6"] + assert grid["dead"][0] == [False, False, True, False, True] and grid["dead"][1] == [True, True, False, True, False] + assert sum(grid["values"][0]) == stats.n_hits and grid["empty"] is None + roc = occupancy_grid(histogram(stats, "occupancy", roc=7)) + assert roc["rows"] == ["port 1", "port 3", "port 4", "port 5", "port 6"] and len(roc["cols"]) == 16 + assert roc["dead"][2] == [True] * 16 and not any(roc["dead"][0]) + channel = occupancy_grid(histogram(stats, "occupancy", roc=7, port=1, channel=3)) + assert channel["rows"] == ["port 1"] and channel["cols"] == ["3"] and channel["dead"] == [[False]] + port = occupancy_grid(histogram(stats, "occupancy", roc=7, port=4)) + assert port["rows"] == ["port 4"] and len(port["cols"]) == 16 and port["dead"] == [[True] * 16] + + +def test_occupancy_without_rocs_is_empty_and_still_exports(tmp_path): + hist = {"name": "occupancy", "rocs": [], "ports": [1, 3], "channels": 16, "module_id": [], "counts": [], + "y_unit": "hits", "selection": {"roc": None, "port": None, "ch": None}} + grid = occupancy_grid(hist) + assert grid["rows"] == [] and grid["empty"] == "no stream frames" + path = save_histogram_png(hist, tmp_path / "empty.png") + assert path.read_bytes()[:8] == PNG_SIGNATURE + + +def test_series_and_rebin_keep_the_totals(stats): + charge = histogram(stats, "charge") + data = series(charge) + assert len(data["edges"]) == len(data["values"]) + 1 and data["log_y"] and data["empty"] is None + merged = rebin(charge, 20) + assert len(merged["counts"]) <= 20 and sum(merged["counts"]) == sum(charge["counts"]) + assert merged["edges"][0] == charge["edges"][0] and merged["edges"][-1] == charge["edges"][-1] + rate = histogram(stats, "rate") + merged = rebin(rate, 16) + assert len(merged["rate_hz"]) <= 16 and sum(merged["counts"]) == sum(rate["counts"]) + assert sum(merged["frames_per_bin"]) == sum(rate["frames_per_bin"]) + # The merged rate is the merged counts over the merged live time. + live = [frames for frames in merged["frames_per_bin"]] + for rate_hz, counts, frames in zip(merged["rate_hz"], merged["counts"], live): + if frames: + assert rate_hz == pytest.approx(counts / (frames * stats.frame_period_ns * 1e-9)) + assert rebin(charge, 1000) is charge + assert series(histogram(stats, "charge", roc=44))["empty"] == "no hits" + + +def test_nice_ticks(): + assert nice_ticks(0, 64, 5) == [0, 20, 40, 60] + assert nice_ticks(0, 1, 4) == pytest.approx([0, 0.5, 1.0]) + assert nice_ticks(0, 0, 4) == [0] diff --git a/tests/gui/test_overview.py b/tests/gui/test_overview.py new file mode 100644 index 0000000..a74d02f --- /dev/null +++ b/tests/gui/test_overview.py @@ -0,0 +1,108 @@ +"""The Overview pane: totals, checks, what stands out, file facts and the per-ROC table from the summary dict.""" +import io + +import pytest + +textual = pytest.importorskip("textual") + +from rich.console import Console # noqa: E402 +from textual.content import Content # noqa: E402 +from textual.widgets import Static # noqa: E402 + +from conftest import pilot_wait # noqa: E402 +from pyevio.gui.app import PyEvioGui # noqa: E402 +from pyevio.gui.overview import OverviewPane, fmt # noqa: E402 + + +def plain(app, section: str) -> str: + """The text of one Overview section: markup strings through Content, Rich tables through a recording console.""" + content = app.query_one(f"#{section}", Static).content + if isinstance(content, str): + return Content.from_markup(content).plain + console = Console(record=True, width=200, file=io.StringIO(), force_terminal=False) + console.print(content) + return console.export_text() + + +def check_line(text: str, name: str) -> str: + lines = [line for line in text.splitlines() if name in line] + assert lines, f"no line with {name!r} in:\n{text}" + return lines[0] + + +async def test_overview_shows_totals_checks_and_rocs_of_the_synthetic_file(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(160, 50)) as pilot: + await pilot_wait(pilot, lambda: app.query_one(OverviewPane).summary is not None, what="stats_ready") + await pilot.pause() + + state = plain(app, "overview-state") + assert "synthetic.evio" in state and "analysis" in state + + totals = plain(app, "overview-totals") + assert check_line(totals, "fADC250 hits").endswith(f"{synthetic['expected_hits']:,}") + assert "3 + 1 trailer" in check_line(totals, "records") + assert "12 stream frames, 1 other, 0 out of bounds" in check_line(totals, "events") + assert "Hz" in check_line(totals, "hit rate") + assert "fADC250" in check_line(totals, "modules") and "DCRB" in check_line(totals, "modules") + assert check_line(totals, "decode errors").endswith("0") + + checks = plain(app, "overview-checks") + assert "warn" in check_line(checks, "missing frames") + assert "info" in check_line(checks, "AIS entries identical") + assert "ok" in check_line(checks, "trailer") + + standout = plain(app, "overview-standout") + assert "missing frames" in standout and "duplicate frames" in standout + assert "AIS entries identical" not in standout # info checks stay in the checks table, as in the web viewer + assert "all checks pass" not in standout + assert "all checks pass" in OverviewPane.standout_markup([{"name": "x", "status": "ok", "value": 0, "detail": ""}]) + + facts = plain(app, "overview-file") + assert "little-endian" in check_line(facts, "byte order") + assert "3 scanned" in check_line(facts, "records") + assert "present" in check_line(facts, "trailer") + assert "every record header read cleanly" in check_line(facts, "record scan") + + rocs = plain(app, "overview-rocs") + assert "fADC250" in check_line(rocs, " 7 ") and f"{synthetic['expected_hits']:,}" in check_line(rocs, " 7 ") + assert "DCRB" in check_line(rocs, " 44 ") + + +async def test_overview_says_analyzing_before_stats_and_no_file_after_closing(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(160, 50)) as pilot: + await pilot_wait(pilot, lambda: app.overview.summary is not None, what="stats_ready") + summary = app.overview.summary + + # The hooks are the contract; drive them directly so the job thread's timing does not matter. + app.overview.file_closed() + await pilot.pause() + assert "No file open" in plain(app, "overview-state") and plain(app, "overview-totals") == "" + app.overview.file_opened() + await pilot.pause() + assert "Analyzing synthetic.evio" in plain(app, "overview-state") and plain(app, "overview-checks") == "" + app.overview.stats_ready(summary) + await pilot.pause() + assert "synthetic.evio" in plain(app, "overview-state") and "fADC250 hits" in plain(app, "overview-totals") + + +async def test_opening_an_unreadable_file_keeps_the_current_overview(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(160, 50)) as pilot: + await pilot_wait(pilot, lambda: app.overview.summary is not None, what="stats_ready") + app.open_path(synthetic["garbage"]) + await pilot.pause() + await pilot_wait(pilot, lambda: app.overview.summary is not None, what="stats_ready after the failed open") + assert session.path == synthetic["path"].resolve() + assert "synthetic.evio" in plain(app, "overview-state") + assert f"{synthetic['expected_hits']:,}" in plain(app, "overview-totals") + + +def test_scan_checks_in_words_and_value_formatting(): + assert OverviewPane.scan_check_words(["truncated_tail", 4096, 1024, 7]) == \ + "truncated tail: the last record declares 4,096 B but 1,024 B are present; 7 complete events kept" + assert OverviewPane.scan_check_words(["bad_record_header", 0x1000, "bad magic"]) == \ + "bad record header at 0x1000: the scan stopped there (bad magic)" + assert fmt("n/a") == "n/a" and fmt(None) == "—" and fmt(True) == "yes" and fmt([]) == "none" + assert fmt(12345) == "12,345" and fmt(2.5) == "2.5" and fmt([1, 3]) == "1, 3" diff --git a/tests/gui/test_plots.py b/tests/gui/test_plots.py new file mode 100644 index 0000000..ef37bdb --- /dev/null +++ b/tests/gui/test_plots.py @@ -0,0 +1,277 @@ +"""The Plots tab: the three levels, the keys, a ROC without hits, the PNG save; the heatmap and the log charge axis.""" +import re + +import pytest + +textual = pytest.importorskip("textual") +pytest.importorskip("textual_plotext") + +from conftest import pilot_wait # noqa: E402 +from pyevio.gui.app import PyEvioGui # noqa: E402 +from pyevio.gui.export import format_compact, occupancy_grid # noqa: E402 +from pyevio.gui.plots import HistogramPlot, PlotsPane, render_heatmap # noqa: E402 + +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +ANSI = re.compile(r"\x1b\[[0-9;]*m") +SCREENSHOT_PLOT = (127, 19) # the size of one plot in the 2 × 2 grid of the 260 × 50 screenshots + + +async def open_plots(app, pilot): + """Wait for the analysis, switch to the Plots tab (the pane takes the focus) and return the pane.""" + await pilot_wait(pilot, lambda: app.plots.summary is not None, what="stats_ready") + await pilot.press("2") + await pilot.pause() + return app.query_one(PlotsPane) + + +def rendered(plot: HistogramPlot) -> str: + return plot.render().plain + + +async def test_file_level_shows_hits_per_frame_rate_and_occupancy(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(120, 40)) as pilot: + pane = await open_plots(app, pilot) + titles = pane.titles() + assert len(titles) == 3 + assert "Hits per frame" in titles[0] and "Hit rate" in titles[1] and "Occupancy" in titles[2] + assert all("file" in title for title in titles) + hits, rate, occupancy = pane.plots[:3] + assert rate.hist["name"] == "rate" and rate.hist["selection"] == {"roc": None, "port": None, "ch": None} + assert "hits per frame" in rendered(hits) and "frames" in rendered(hits) + assert "hit rate (Hz)" in rendered(rate) and "(s)" in rendered(rate) + text = rendered(occupancy) + assert "ROC 7" in text and "ROC 44" in text and "payload port" in text + assert "–" in text # ROC 44 has ports its map does not list + assert not pane.plots[3].display + assert isinstance(app.focused, HistogramPlot) + + +async def test_roc_level_has_four_plots_named_after_the_roc(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(120, 40)) as pilot: + pane = await open_plots(app, pilot) + await pilot.press("r") + await pilot.pause() + titles = pane.titles() + assert len(titles) == 4 and pane.level == "roc" and pane.roc == 7 + assert "Occupancy" in titles[0] and "Hit rate" in titles[1] and "Charge" in titles[2] and "Time in frame" in titles[3] + assert all("ROC 7" in title for title in titles) + charge = pane.plots[2] + assert charge.hist["selection"]["roc"] == 7 and sum(charge.hist["counts"]) > 0 + assert "charge (13-bit ADC units)" in rendered(charge) and "hits (log)" in rendered(charge) + assert "channel" in rendered(pane.plots[0]) and "port 1" in rendered(pane.plots[0]) + assert str(app.query_one("#status-error").render()) == "" + + +async def test_channel_level_steps_to_port_1_channel_3(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(120, 40)) as pilot: + pane = await open_plots(app, pilot) + await pilot.press("c") + await pilot.pause() + for _ in range(3): + await pilot.press("plus") + await pilot.pause() + assert pane.level == "channel" and (pane.roc, pane.port, pane.channel) == (7, 1, 3) + titles = pane.titles() + assert len(titles) == 3 and all("ROC 7 port 1 ch 3" in title for title in titles) + assert pane.plots[1].hist["selection"] == {"roc": 7, "port": 1, "ch": 3} + # Stepping past the last channel of port 1 rolls over into port 3; the port keys move one port. + pane.channel = 15 + await pilot.press("plus") + await pilot.pause() + assert (pane.port, pane.channel) == (3, 0) + await pilot.press("minus") + await pilot.pause() + assert (pane.port, pane.channel) == (1, 15) + await pilot.press("right_curly_bracket") + await pilot.pause() + assert pane.port == 3 and "port 3 ch 15" in pane.titles()[0] + # The Select widgets follow the keys. + from textual.widgets import Select + + assert app.query_one("#plots-port", Select).value == 3 + assert app.query_one("#plots-channel", Select).value == 15 + assert app.query_one("#plots-level", Select).value == "channel" + # And the pane follows the Select widgets (the mouse user's path). + app.query_one("#plots-channel", Select).value = 9 + await pilot.pause() + assert pane.channel == 9 and "port 3 ch 9" in pane.titles()[0] + app.query_one("#plots-roc", Select).value = 44 + await pilot.pause() + assert (pane.roc, pane.port, pane.channel) == (44, 4, 0) and "ROC 44 port 4 ch 0" in pane.titles()[0] + + +async def test_keys_do_not_crash_on_a_roc_without_hits(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(120, 40)) as pilot: + pane = await open_plots(app, pilot) + await pilot.press("r") + await pilot.press("right_square_bracket") # ROC 44, the DCRB + await pilot.pause() + assert pane.roc == 44 and all("ROC 44" in title for title in pane.titles()) + assert "no hits" in rendered(pane.plots[2]) + assert "–" in rendered(pane.plots[0]) # ports the DCRB's map does not list + for key in ("c", "plus", "plus", "minus", "right_curly_bracket", "left_curly_bracket", "right_square_bracket", + "left_square_bracket", "f", "r", "c"): + await pilot.press(key) + await pilot.pause() + for plot in pane.plots: + if plot.display: + rendered(plot) + assert pane.roc == 7 and pane.level == "channel" + assert str(app.query_one("#status-error").render()) == "" + + +async def test_s_saves_a_png_and_notifies(session, synthetic, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + app = PyEvioGui(session, start_path=synthetic["path"]) + notices = [] + monkeypatch.setattr(app, "notify", lambda message, **kwargs: notices.append(message)) + async with app.run_test(size=(120, 40)) as pilot: + pane = await open_plots(app, pilot) + await pilot.press("s") + await pilot.pause() + path = tmp_path / "synthetic_hits_per_frame.png" + assert path.is_file() and path.read_bytes()[:8] == PNG_SIGNATURE + assert notices and str(path) in notices[-1] + # The focused plot is the one saved: Tab moves to the rate plot. + await pilot.press("tab") + await pilot.press("s") + await pilot.pause() + assert (tmp_path / "synthetic_rate.png").is_file() + # S saves every plot of the level; at channel level the names carry the selection. + await pilot.press("c") + await pilot.press("S") + await pilot.pause() + for name in ("rate", "charge", "time_in_frame"): + assert (tmp_path / f"synthetic_{name}_roc7_port1_ch0.png").read_bytes()[:8] == PNG_SIGNATURE + assert "3 PNGs" in notices[-1] + assert pane.level == "channel" + + +# ---------------------------------------------------------------- the heatmap and the log charge axis, without an app +def occupancy_dict(counts: list, ports: list, dead_ports: tuple = (), roc: int = 7) -> dict: + """A ROC-level occupancy dict (as ``pyevio.web.report.histogram`` returns it) of ``counts[port][channel]``.""" + module_id = [[255 if port in dead_ports else 2 for port in ports]] + return {"name": "occupancy", "rocs": [roc], "ports": ports, "channels": len(counts[0]), "module_id": module_id, + "counts": counts, "y_unit": "hits", "selection": {"roc": roc, "port": None, "ch": None}} + + +def row_lines(text: str, labels: list) -> dict: + """The line index of every row label, as ``{label: line}``; a label must be on exactly one line.""" + lines = text.split("\n") + found = {} + for label in labels: + hits = [index for index, line in enumerate(lines) if re.match(r" *" + re.escape(label) + "┤", line)] + assert len(hits) == 1, f"{label!r} on lines {hits}" + found[label] = hits[0] + return found + + +def test_heatmap_prints_every_count_once_and_intact(): + """16 ports in 16 lines, counts whose compact forms overstruck each other under plotext (93.6k under 55k read 955kk).""" + ports = list(range(1, 17)) + counts = [[(port * 16 + channel) * 613 + (0 if (port + channel) % 7 else -(port * 16 + channel) * 613) + for channel in range(16)] for port in ports] + counts[7][0], counts[8][0], counts[7][1], counts[8][1] = 93600, 55000, 144000, 39000 + hist = occupancy_dict(counts, ports) + text = render_heatmap(occupancy_grid(hist), *SCREENSHOT_PLOT).plain + lines = text.split("\n") + assert len(lines) == SCREENSHOT_PLOT[1] and max(len(line) for line in lines) <= SCREENSHOT_PLOT[0] + rows = row_lines(text, [f"port {port}" for port in ports]) + for port, row in zip(ports, counts): + cells = lines[rows[f"port {port}"]].split("┤", 1)[1].rstrip("│").split() + assert cells == [format_compact(value) for value in row], f"port {port}" + assert "955kk" not in text and "kk" not in text + assert lines[-1].startswith("payload port") and "channel" in lines[-1] + for channel in range(16): + assert f" {channel} " in f" {lines[-2]} " + + +def test_heatmap_rows_and_columns_are_all_the_same_size(): + """13 rows at the screenshot size and at a taller one; the dash of an unmapped port is always printed.""" + ports = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] + counts = [[(port * 16 + channel) * 1000 for channel in range(16)] for port in ports] + hist = occupancy_dict(counts, ports, dead_ports=(4, 11)) + labels = [f"port {port}" for port in ports] + for width, height in (SCREENSHOT_PLOT, (126, 19), (126, 45), (80, 30)): + text = render_heatmap(occupancy_grid(hist), width, height).plain + lines = text.split("\n") + assert len(lines) == height and max(len(line) for line in lines) <= width, (width, height) + rows = row_lines(text, labels) + steps = {rows[labels[i + 1]] - rows[labels[i]] for i in range(len(labels) - 1)} + assert len(steps) == 1, (width, height, steps) + axis = next(line for line in lines if "└" in line) + ticks = [index for index, char in enumerate(axis) if char == "┬"] + assert len({ticks[i + 1] - ticks[i] for i in range(len(ticks) - 1)}) == 1, (width, height, ticks) + dashes = [line for line in lines if line.startswith(" port 4┤") or line.startswith("port 11┤")] + assert len(dashes) == 2 and all(line.count("–") == 16 for line in dashes), (width, height) + tall = render_heatmap(occupancy_grid(hist), 126, 45).plain + assert row_lines(tall, labels)[labels[1]] - row_lines(tall, labels)[labels[0]] == 3 # 42 lines // 13 rows + + +def test_heatmap_too_low_for_its_rows_shares_lines_or_says_so(): + ports = list(range(1, 17)) + hist = occupancy_dict([[port * 10 + channel for channel in range(16)] for port in ports], ports) + text = render_heatmap(occupancy_grid(hist), 56, 14).plain # one plot of the 120 × 40 test terminal: 11 lines for 16 rows + lines = text.split("\n") + assert len(lines) == 14 and "▀" in text and " port 1┤" in text and "port 15┤" in text and "port 16" not in text + assert all("▀" * 2 in line for line in lines if "┤" in line) + small = render_heatmap(occupancy_grid(hist), 40, 8).plain + assert "need a plot of" in small and "▀" not in small + # Narrow cells: the tick labels that fit are whole channel numbers (never "12" cut to "1"), evenly thinned. + for width in (36, 44, 50, 70): + lines = render_heatmap(occupancy_grid(hist), width, 14).plain.split("\n") + labels = lines[-2].split() + assert len(labels) >= 4 and all(label in [str(channel) for channel in range(16)] for label in labels), (width, labels) + steps = {int(labels[i + 1]) - int(labels[i]) for i in range(len(labels) - 1)} + assert len(steps) == 1, (width, labels) + + +def charge_render(hist: dict, width: int, height: int) -> str: + """The charge plot as plotext draws it, the way ``HistogramPlot.render`` does but without an app.""" + plot = HistogramPlot() + plot.hist = hist + plot.plt.clear_figure() + empty = plot._build(width, height) + assert empty is None + plot.plt.plotsize(width, height) + plot.plt._set_size(width, height) + return ANSI.sub("", plot.plt.build()) + + +def test_charge_spectrum_is_logarithmic_with_power_of_ten_ticks(): + """The terminal charge plot reads like the browser's: log y, ticks labelled with the real counts.""" + edges = list(range(0, 8193, 64)) + counts = [0] * 128 + counts[0], counts[1], counts[2], counts[10], counts[20], counts[40] = 20_000_000, 2_000_000, 50_000, 300, 7, 1 + hist = {"name": "charge", "counts": counts, "edges": edges, "x_unit": "charge (13-bit ADC units)", "y_unit": "hits", + "selection": {"roc": 7, "port": None, "ch": None}} + text = charge_render(hist, *SCREENSHOT_PLOT) + tick_labels = [line.split("┤")[0].strip() for line in text.split("\n") if "┤" in line] + powers = {format_compact(10 ** power) for power in range(12)} + assert len(tick_labels) >= 4 and all(label in powers for label in tick_labels), tick_labels + assert "1" in tick_labels and "10M" in tick_labels and "hits (log)" in text + # The bars: the full-scale first bin fills the canvas (column 0), the empty upper half of the spectrum draws nothing. + lines = [line for line in text.split("\n") if "│" in line or "┤" in line] + column = lambda line, x: line.split("┤" if "┤" in line else "│", 1)[1][x] # noqa: E731 + heights = {x: sum(column(line, x) != " " for line in lines) for x in (0, 100)} + assert heights[0] >= 12 and heights[100] == 0, heights + # Zero bins (plotext's own log scale raises on them) and a one-count spectrum draw without an error. + for sparse in ([0, 5, 0, 0, 1000, 0], [0, 0, 1, 0]): + hist = dict(hist, counts=sparse, edges=list(range(len(sparse) + 1))) + assert "cannot draw" not in charge_render(hist, 60, 12) + assert "hits (log)" in charge_render(hist, 60, 12) + + +async def test_file_closed_clears_the_plots(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(120, 40)) as pilot: + pane = await open_plots(app, pilot) + pane.file_closed() + await pilot.pause() + assert pane.summary is None and all(plot.hist is None for plot in pane.plots) + assert not pane.query_one("#plots-grid").display + assert "Open a file" in str(pane.query_one("#plots-message").render()) diff --git a/tests/gui/test_records.py b/tests/gui/test_records.py new file mode 100644 index 0000000..79a6719 --- /dev/null +++ b/tests/gui/test_records.py @@ -0,0 +1,123 @@ +"""The Records pane: one page of record rows, the trailer row, Enter to the Structure tab, paging.""" +import pytest + +textual = pytest.importorskip("textual") + +from textual.widgets import Static, TabbedContent # noqa: E402 + +from conftest import pilot_wait # noqa: E402 +from pyevio.gui.app import PyEvioGui # noqa: E402 +from pyevio.gui.records import PAGE_SIZE, RecordsTable # noqa: E402 +from pyevio.gui.session import Session # noqa: E402 +from test_stream_stats import write_synthetic_file # noqa: E402 + + +def status(app) -> str: + return str(app.query_one("#records-status", Static).render()) + + +def cell_texts(table: RecordsTable, row_key: str) -> list: + return [str(cell) for cell in table.get_row(row_key)] + + +async def focus_table(app, pilot) -> RecordsTable: + await pilot.press("3") + await pilot.pause() + table = app.query_one("#records-table", RecordsTable) + app.set_focus(table) + await pilot.pause() + return table + + +async def test_records_table_lists_the_records_and_the_trailer(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(160, 50)) as pilot: + table = app.query_one("#records-table", RecordsTable) + await pilot_wait(pilot, lambda: table.row_count == 4, what="first page") + assert table.cursor_type == "row" + assert "rows 0—3 of 4 records" in status(app) + assert [column.label.plain for column in table.columns.values()][:5] == ["#", "offset", "size (B)", "events decl / found", "type"] + + row0 = cell_texts(table, "r0") + assert row0[0] == "0" and row0[1] == "0x00000038" and row0[3] == "4 / 4" and row0[4] == "0" and row0[5] == "none" # header type 0 in the synthetic records + assert row0[6] == "—" and row0[8] == "—" and row0[9] == "—" and row0[10] == "—" + assert cell_texts(table, "r1")[3] == "5 / 5" # record 1 has the extra non-stream event + trailer = cell_texts(table, "t") + assert trailer[0] == "3" and trailer[4] == "trailer" and trailer[6] == "set" and trailer[3] == "0 / 0" + + # The whole page is in the table before the analysis is necessarily done; the keys are node ids. + assert [key.value for key in table.rows] == ["r0", "r1", "r2", "t"] + + app.records.file_closed() + await pilot.pause() + assert table.row_count == 0 and "No file open" in status(app) + + +async def test_enter_opens_the_record_in_the_structure_tab(session, synthetic): + app = PyEvioGui(session, start_path=synthetic["path"]) + async with app.run_test(size=(160, 50)) as pilot: + table = app.query_one("#records-table", RecordsTable) + await pilot_wait(pilot, lambda: table.row_count == 4, what="first page") + table = await focus_table(app, pilot) + assert app.focused is table + table.move_cursor(row=2) + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + tabs = app.query_one("#tabs", TabbedContent) + assert tabs.active == "tab-structure" + assert "'r2'" in str(app.query_one("#structure-path", Static).render()) + + +async def test_paging_through_a_file_of_more_than_one_page(tmp_path): + path, _, _ = write_synthetic_file(tmp_path / "long.evio", n_records=600, frames_per_record=1) + session = Session(cache_dir=tmp_path / "cache", workers=1) + try: + app = PyEvioGui(session, start_path=path) + async with app.run_test(size=(160, 50)) as pilot: + table = app.query_one("#records-table", RecordsTable) + await pilot_wait(pilot, lambda: table.row_count == PAGE_SIZE, what="first page") + assert app.records.total == 601 and app.records.start == 0 + assert "rows 0—499 of 601 records" in status(app) and "] next page" in status(app) + assert "trailer" not in status(app) # the trailer is on the second page + + table = await focus_table(app, pilot) + # Down at the last row of the page loads the next page with the cursor on its first row. + table.move_cursor(row=PAGE_SIZE - 1) + await pilot.pause() + await pilot.press("down") + await pilot_wait(pilot, lambda: app.records.start == PAGE_SIZE, what="next page by cursor") + assert table.row_count == 101 and table.cursor_row == 0 + assert "rows 500—600 of 601 records (the trailer is the last row)" in status(app) + assert cell_texts(table, "r500")[0] == "500" and cell_texts(table, "t")[4] == "trailer" + assert [key.value for key in table.rows][:2] == ["r500", "r501"] + + # Down at the very last row stays put; up at the first row goes back with the cursor on the last row. + table.move_cursor(row=100) + await pilot.pause() + await pilot.press("down") + await pilot.pause() + assert app.records.start == PAGE_SIZE and table.cursor_row == 100 + table.move_cursor(row=0) + await pilot.pause() + await pilot.press("up") + await pilot_wait(pilot, lambda: app.records.start == 0, what="previous page by cursor") + assert table.row_count == PAGE_SIZE and table.cursor_row == PAGE_SIZE - 1 + + # The bracket keys page explicitly. + await pilot.press("right_square_bracket") + await pilot_wait(pilot, lambda: app.records.start == PAGE_SIZE, what="next page by key") + await pilot.press("right_square_bracket") # no page after the last one + await pilot.pause() + assert app.records.start == PAGE_SIZE + await pilot.press("left_square_bracket") + await pilot_wait(pilot, lambda: app.records.start == 0, what="previous page by key") + assert table.cursor_row == 0 and "rows 0—499 of 601 records" in status(app) + await pilot.press("left_square_bracket") # no page before the first one + await pilot.pause() + assert app.records.start == 0 + finally: + try: + session.close() + except BufferError: + pass diff --git a/tests/gui/test_session.py b/tests/gui/test_session.py new file mode 100644 index 0000000..8cd1c47 --- /dev/null +++ b/tests/gui/test_session.py @@ -0,0 +1,75 @@ +"""Session: open, analyze, cache, switch and refuse files, without Textual.""" +import pytest + +from conftest import wait_until +from pyevio import stream_stats +from pyevio.gui.session import STATE_EMPTY, Session +from pyevio.web import ApiError + + +def test_open_analyze_and_read_the_model(session, synthetic): + assert session.state == STATE_EMPTY and not session.is_open + session.open(synthetic["path"]) + assert session.is_open and session.state in ("queued", "running", "done") + # The file is readable before the analysis is done; the statistics are not. + assert session.records(0, 10)["total"] == 4 and session.children("")["node"]["kind"] == "file" + assert session.words(0, 4)["count"] == 4 + wait_until(lambda: session.state == "done", what="analysis") + progress = session.progress() + assert progress["state"] == "done" and progress["fraction"] == 1.0 and progress["cached"] is False + summary = session.summary() + assert summary["totals"]["n_hits"] == synthetic["expected_hits"] and summary["stats"]["cached"] is False + assert session.histogram("hits_per_frame")["counts"] and sum(session.histogram("hits_per_frame")["counts"]) == 12 + assert session.histogram("occupancy", roc=7)["counts"] + with pytest.raises(ApiError): + session.histogram("nonsense") + + +def test_second_open_comes_from_the_shared_cache(session, synthetic): + session.open(synthetic["path"]) + wait_until(lambda: session.state == "done", what="analysis") + assert list(synthetic["cache_dir"].glob("*.npz")) + + other = Session(cache_dir=synthetic["cache_dir"], workers=1) + other.open(synthetic["path"]) + assert other.state == "done" and other.from_cache and other.progress()["cached"] is True + assert other.summary()["totals"]["n_hits"] == session.summary()["totals"]["n_hits"] + assert other.summary()["stats"]["cached"] is True + other.close() + assert other.state == STATE_EMPTY and not other.is_open + + +def test_unreadable_file_keeps_the_current_one(session, synthetic): + session.open(synthetic["path"]) + with pytest.raises(ValueError): + session.open(synthetic["garbage"]) + with pytest.raises((ValueError, OSError)): + session.open(synthetic["folder"] / "missing.evio") + assert session.is_open and session.path == synthetic["path"].resolve() + + +def test_switching_files_cancels_the_running_analysis(session, synthetic, monkeypatch): + import threading + from pyevio.web import jobs + + release = threading.Event() + real_compute = stream_stats.compute_stream_stats + + def slow_compute(path, workers=None, progress=None, cancel=None, **_): + release.wait(5) + if cancel(): + raise stream_stats.StatsCancelled("cancelled") + return real_compute(path, workers=1) + + monkeypatch.setattr(jobs, "compute_stream_stats", slow_compute) + session.open(synthetic["path"]) + first_job = session.job + assert session.state in ("queued", "running") + with pytest.raises(ApiError, match="is (queued|running)"): + session.summary() + session.open(synthetic["path"]) # the same path: a new job, the old one cancelled + release.set() + wait_until(lambda: not first_job.is_active, what="first job to end") + assert first_job.state == "cancelled" + wait_until(lambda: session.state == "done", what="second analysis") + assert session.summary()["totals"]["n_hits"] == synthetic["expected_hits"] diff --git a/tests/gui/test_structure.py b/tests/gui/test_structure.py new file mode 100644 index 0000000..6ccf47d --- /dev/null +++ b/tests/gui/test_structure.py @@ -0,0 +1,288 @@ +"""The Structure tab: the lazy tree down to hit words, the detail panel with fields and raw words, paging and navigation.""" +from pathlib import Path + +import pytest + +textual = pytest.importorskip("textual") + +from textual.widgets import Static, TabbedContent, Tree # noqa: E402 + +from conftest import pilot_wait # noqa: E402 +from pyevio.decoders import fadc250_stream as fs # noqa: E402 +from pyevio.gui.app import PyEvioGui # noqa: E402 +from pyevio.gui.structure import StructurePane # noqa: E402 +from test_stream_stats import write_synthetic_file # noqa: E402 + + +def labels(node): + return [child.label.plain for child in node.children] + + +def ids(node): + return [child.data["id"] for child in node.children] + + +def child_by_id(node, child_id): + return next(child for child in node.children if child.data.get("id") == child_id) + + +def panel_text(app, widget_id): + content = app.query_one(f"#structure-{widget_id}", Static).content + return content if isinstance(content, str) else content.plain + + +async def open_app(session, path): + app = PyEvioGui(session, start_path=path) + pilot_cm = app.run_test(size=(120, 40)) + pilot = await pilot_cm.__aenter__() + pane = app.query_one(StructurePane) + tree = app.query_one("#structure-tree", Tree) + await pilot_wait(pilot, lambda: session.is_open and tree.root.data.get("_total") is not None, what="root page") + return app, pilot, pilot_cm, pane, tree + + +async def expand_and_wait(pilot, node): + """Expand a node and wait for its first page.""" + node.expand() + await pilot_wait(pilot, lambda: node.data.get("_total") is not None, what=f"children of {node.data['id']!r}") + await pilot.pause() + + +async def test_root_shows_the_records_and_the_trailer(session, synthetic): + app, pilot, pilot_cm, pane, tree = await open_app(session, synthetic["path"]) + try: + tabs = app.query_one("#tabs", TabbedContent) + await pilot.press("4") + await pilot.pause() + assert app.focused is tree # the tab activation focuses the tree: arrows, Enter, n and p work at once + await pilot.press("2") # and the number keys still leave the tab while the tree has the focus + await pilot.pause() + assert tabs.active == "tab-plots" and app.focused is not tree + await pilot.press("4") + await pilot.pause() + assert tabs.active == "tab-structure" and app.focused is tree + assert tree.root.label.plain.startswith("file — ") and "3 records + trailer" in tree.root.label.plain + assert tree.root.is_expanded and ids(tree.root) == ["r0", "r1", "r2", "t"] + assert labels(tree.root)[0].startswith("record 0 — 4 events, ") and labels(tree.root)[3].startswith("trailer — ") + assert not tree.root.children[3].allow_expand and tree.root.children[0].allow_expand + # The root is shown in the panel: its fields and no words (the file node has none). + assert pane.detail["kind"] == "file" and "record_count 3\n" in panel_text(app, "fields") and pane.words_page is None + finally: + await pilot_cm.__aexit__(None, None, None) + + +async def test_expanding_walks_record_frame_roc_payload_and_hits(session, synthetic): + app, pilot, pilot_cm, pane, tree = await open_app(session, synthetic["path"]) + try: + record = child_by_id(tree.root, "r1") + await expand_and_wait(pilot, record) + assert ids(record) == ["r1/e0", "r1/e1", "r1/e2", "r1/e3", "r1/e4"] + event_labels = labels(record) + assert event_labels[0].startswith("event 0 — tag 0xFF60 stream frame, ") and event_labels[0].endswith(" words") + assert event_labels[4].startswith("event 4 — tag 0xFF50 type 0x10 num 0, ") + assert all(child.data["_record"] == 1 and child.data["_compression"] == "none" for child in record.children) + + frame = record.children[0] + await expand_and_wait(pilot, frame) + frame_labels = labels(frame) + assert frame_labels[0].startswith("stream info — tag 0xFF31") + assert frame_labels[1].startswith("ROC bank 7 stream 1 — tag 0x0007") + assert frame_labels[2].startswith("ROC bank 44 stream 2 — tag 0x002C") + assert [child.data["role"] for child in frame.children] == ["stream_info", "roc_bank", "roc_bank"] + + roc_7 = frame.children[1] + await expand_and_wait(pilot, roc_7) + roc_labels = labels(roc_7) + assert roc_labels[0].startswith("ROC stream info — tag 0xFF30") + assert roc_labels[1].startswith("payload port 1 — ") and "fADC250" in roc_labels[1] and "16 hits" in roc_labels[1] + assert roc_labels[3].startswith("payload (empty) port 5 — ") + by_port = {child.data.get("port"): child for child in roc_7.children[1:]} + assert by_port[1].allow_expand and not by_port[5].allow_expand + + payload = by_port[1] + await expand_and_wait(pilot, payload) + assert len(payload.children) == 16 and all(not child.allow_expand for child in payload.children) + n = 1 * 4 # record 1, event 0 is frame index 4 of the synthetic file + assert labels(payload)[3] == f"hit 3 — ch 3 t {(300 + n) * fs.HIT_TIME_TICK_NS} ns q {10 + n} {payload.children[3].data['word']}" + assert [child.data["channel"] for child in payload.children] == list(range(16)) + + # The ROC stream info carries the decoded payload-port map; the DCRB payload has words but no hit children. + info = roc_7.children[0] + await expand_and_wait(pilot, info) + assert [child.data["role"] for child in info.children] == ["time_slice", "payload_map"] + assert "3 entries" in labels(info)[1] + dcrb = frame.children[2] + await expand_and_wait(pilot, dcrb) + dcrb_labels = labels(dcrb) + assert any(label.startswith("payload port 4 — ") and "DCRB" in label for label in dcrb_labels) + assert not next(child for child in dcrb.children if child.data.get("port") == 4).allow_expand + finally: + await pilot_cm.__aexit__(None, None, None) + + +async def test_detail_panel_shows_fields_errors_and_words(session, synthetic): + app, pilot, pilot_cm, pane, tree = await open_app(session, synthetic["path"]) + try: + record = child_by_id(tree.root, "r1") + await expand_and_wait(pilot, record) + event = record.children[0] + tree.move_cursor(event) + await pilot.pause() + assert pane.detail["id"] == "r1/e0" + fields = panel_text(app, "fields") + assert "tag 0xFF60 (65,376)" in fields and "kind event" in fields and "structure Bank" in fields + assert panel_text(app, "path") == "node 'r1/e0'" + assert panel_text(app, "errors") == "" + words = pane.words_page + assert words["record"] == 1 and int(words["words"][0], 16) == event.data["length"] and words["count"] == 64 + hex_text = panel_text(app, "hex") + assert hex_text.splitlines()[0].startswith(f"0x{event.data['offset']:08X} {words['words'][0]} {words['words'][1]}") + assert len(hex_text.splitlines()[0].split()) == 1 + 8 + note = panel_text(app, "hex-note") + assert "words 0–63 of" in note and "record 1" in note and "decompressed" not in note + + # n / p move the window of 64 words while the tree has the focus. + tree.focus() + await pilot.pause() + await pilot.press("n") + await pilot.pause() + assert pane.words_page["offset"] == event.data["offset"] + 64 * 4 and "words 64–" in panel_text(app, "hex-note") + await pilot.press("p") + await pilot.pause() + assert pane.words_page["offset"] == event.data["offset"] + await pilot.press("p") + await pilot.pause() + assert pane.words_page["offset"] == event.data["offset"] # clipped at the first word + + # A record node reads its header words at its file offset. + tree.move_cursor(record) + await pilot.pause() + assert pane.detail["kind"] == "record" and pane.words_page["record"] is None and pane.words_page["offset"] == record.data["offset"] + assert "bit_info.is_last_record" in panel_text(app, "fields") and "offsets in the file" in panel_text(app, "hex-note") + + # Hits have fields but no words. + frame = record.children[0] + await expand_and_wait(pilot, frame) + roc_7 = frame.children[1] + await expand_and_wait(pilot, roc_7) + payload = roc_7.children[1] + await expand_and_wait(pilot, payload) + tree.move_cursor(payload.children[0]) + await pilot.pause() + assert pane.detail["kind"] == "hit" and pane.words_page is None and "channel" in panel_text(app, "fields") and panel_text(app, "hex") == "" + finally: + await pilot_cm.__aexit__(None, None, None) + + +async def test_show_node_expands_the_path_and_selects_the_node(session, synthetic): + app, pilot, pilot_cm, pane, tree = await open_app(session, synthetic["path"]) + try: + app.show_structure_node("r1/e2") + await pilot.pause() + record = child_by_id(tree.root, "r1") + assert record.is_expanded and tree.cursor_node is child_by_id(record, "r1/e2") + assert pane.detail["id"] == "r1/e2" and pane.detail["index"] == 2 + assert panel_text(app, "path") == "node 'r1/e2'" + await pilot_wait(pilot, lambda: child_by_id(record, "r1/e2").data.get("_total") is not None, what="event children") + assert ids(child_by_id(record, "r1/e2"))[0] == "r1/e2/b" + format(child_by_id(record, "r1/e2").children[0].data["offset"], "x") + + pane.show_node("r1/e2/" + ids(child_by_id(record, "r1/e2"))[1].split("/")[-1]) + await pilot.pause() + assert pane.detail["role"] == "roc_bank" and pane.detail["roc"] == 7 and tree.cursor_node.data["id"] == pane.detail["id"] + + pane.show_node("t") + await pilot.pause() + assert pane.detail["kind"] == "trailer" and tree.cursor_node is tree.root.children[3] + + pane.show_node("") + await pilot.pause() + assert pane.detail["kind"] == "file" and tree.cursor_node is tree.root + + # An unknown node reports an error and keeps the selection. + pane.show_node("r7") + await pilot.pause() + assert "r7" in str(app.query_one("#status-error", Static).render()) and tree.cursor_node is tree.root + finally: + await pilot_cm.__aexit__(None, None, None) + + +@pytest.fixture +def many_records(tmp_path): + folder = tmp_path / "many" + folder.mkdir() + path, _, _ = write_synthetic_file(folder / "many.evio", n_records=600, frames_per_record=1) + return Path(path) + + +async def test_more_child_pages_the_root_and_show_node_pages_until_present(session, many_records): + app, pilot, pilot_cm, pane, tree = await open_app(session, many_records) + try: + assert len(tree.root.children) == 501 and tree.root.data["_total"] == 601 + more = tree.root.children[-1] + assert more.label.plain == "… 101 more (press Enter to load)" and more.data["kind"] == "more" and not more.allow_expand + tree.focus() + await pilot.pause() + tree.move_cursor(more) + await pilot.pause() + assert "101 more" in panel_text(app, "path") + await pilot.press("enter") + await pilot.pause() + assert len(tree.root.children) == 601 and ids(tree.root)[-1] == "t" and tree.root.children[-1].data["kind"] == "trailer" + assert tree.cursor_node.data["id"] == "r500" # the cursor lands on the first row of the new page + finally: + await pilot_cm.__aexit__(None, None, None) + + +async def test_show_node_loads_root_pages_until_the_record_is_present(session, many_records): + app, pilot, pilot_cm, pane, tree = await open_app(session, many_records) + try: + assert len(tree.root.children) == 501 + app.show_structure_node("r550/e0") + await pilot.pause() + assert len(tree.root.children) == 601 + record = child_by_id(tree.root, "r550") + assert record.is_expanded and tree.cursor_node.data["id"] == "r550/e0" and pane.detail["id"] == "r550/e0" + finally: + await pilot_cm.__aexit__(None, None, None) + + +async def test_lz4_record_words_come_from_the_decompressed_buffer(session, synthetic): + if synthetic["golden_lz4"] is None: + pytest.skip("golden set absent") + app, pilot, pilot_cm, pane, tree = await open_app(session, synthetic["golden_lz4"]) + try: + record = next(child for child in tree.root.children if child.data["kind"] == "record" and child.data["compression_type"] != 0) + assert record.data["compression"] in record.label.plain + pane.show_node(f"{record.data['id']}/e0") + await pilot.pause() + event = pane.detail + assert event["kind"] == "event" and event["compressed"] and pane.words_page["record"] == record.data["index"] + assert int(pane.words_page["words"][0], 16) == event["length"] + assert "offsets index the decompressed record buffer" in panel_text(app, "hex-note") + await pilot_wait(pilot, lambda: tree.cursor_node.data.get("_total") is not None, what="event children") + assert all(child.data["structure"] in ("Bank", "Segment", "TagSegment") for child in tree.cursor_node.children) + finally: + await pilot_cm.__aexit__(None, None, None) + + +async def test_opening_another_file_rebuilds_the_tree(session, synthetic, many_records): + app, pilot, pilot_cm, pane, tree = await open_app(session, synthetic["path"]) + try: + record = child_by_id(tree.root, "r1") + await expand_and_wait(pilot, record) + tree.move_cursor(record.children[0]) + await pilot.pause() + assert pane.detail["id"] == "r1/e0" + app.open_path(many_records) + await pilot_wait(pilot, lambda: session.path == many_records.resolve() and tree.root.data.get("record_count") == 600, what="new file") + await pilot.pause() + assert "600 records" in tree.root.label.plain and len(tree.root.children) == 501 + assert pane.detail["kind"] == "file" and pane.detail["record_count"] == 600 + assert labels(tree.root)[0].startswith("record 0 — 1 events, ") + # Garbage keeps the current file and its tree. + app.open_path(synthetic["garbage"]) + await pilot.pause() + assert "garbage.evio" in str(app.query_one("#status-error", Static).render()) + assert "600 records" in tree.root.label.plain and len(tree.root.children) == 501 + finally: + await pilot_cm.__aexit__(None, None, None) diff --git a/tests/test_bank_scanning.py b/tests/test_bank_scanning.py index 7e8b5b7..2b29c56 100644 --- a/tests/test_bank_scanning.py +++ b/tests/test_bank_scanning.py @@ -1,49 +1,9 @@ -import pytest -import struct -import io import mmap -from typing import List, Tuple -import os -import tempfile - -from pyevio.bank import Bank - -# Debug function to analyze memory dumps -def debug_memory_dump(data, offset, length, title=None): - """Print a detailed memory dump with analysis of bank structures.""" - if title: - print(f"\n{title}") - - for i in range(0, length*4, 16): - if offset + i >= len(data): - break - - # Get chunk of data - chunk = data[offset+i:offset+i+16] - hex_values = ' '.join([f"{b:02X}" for b in chunk]) - - # Add interpretation for words - words = [] - for j in range(0, len(chunk), 4): - if j+4 <= len(chunk): - word_bytes = chunk[j:j+4] - word_val = struct.unpack("> 16) & 0xFFFF - data_type = (word_val >> 8) & 0xFF - num = word_val & 0xFF - interpretation = f"Tag=0x{tag:04X}, Type=0x{data_type:02X}, Num=0x{num:02X}" - else: - interpretation = f"0x{word_val:08X}" +import struct - words.append(interpretation) +import pytest - word_info = ' | '.join(words) - print(f"{offset+i:04X}: {hex_values:<48} {word_info}") +from pyevio.bank import Bank, Segment, TagSegment class MockMemoryMap: @@ -69,72 +29,30 @@ def __len__(self): return len(self.data) -def create_mock_bank(length, tag, data_type, num, data=None, endian='<'): - """ - Create a mock bank with the specified parameters. - - Args: - length: Bank length in words (including header) - tag: Bank tag - data_type: Bank data type - num: Bank number - data: Optional data (bytes) to include in the bank - endian: Endianness ('<' for little endian, '>' for big endian) - - Returns: - Bytes object representing the bank - """ - # Create bank header (2 words) - header_word1 = struct.pack(f"{endian}I", length) - header_word2 = struct.pack(f"{endian}I", - ((tag & 0xFFFF) << 16) | - ((data_type & 0xFF) << 8) | - (num & 0xFF)) - - # If data is None, create empty data of appropriate length - if data is None: - # Length includes header, so subtract 2 words for data - data_size = max(0, (length - 2) * 4) - data = bytes(data_size) - - # Combine header and data - return header_word1 + header_word2 + data +def evio_bank(tag, data_type, num, payload, endian='<'): + """Build a bank the EVIO way: the length word counts the words that follow it. - -def create_mock_event(banks, endian='<'): + A bank with N payload words gets length N + 1 (the second header word + plus the payload) and occupies N + 2 words. This is what the evio + writers produce and what `Bank` reads. """ - Create a mock event containing the specified banks. - - Args: - banks: List of bank data (bytes) to include in the event - endian: Endianness ('<' for little endian, '>' for big endian) - - Returns: - Tuple of (event_bytes, bank_offsets) - """ - # Calculate total length of all banks in words - total_length = sum(len(bank) for bank in banks) // 4 - - # Add event header (2 words) to total length - event_length = total_length + 2 + assert len(payload) % 4 == 0, "payload is whole 32-bit words" + length = len(payload) // 4 + 1 + header = struct.pack(f"{endian}II", length, + ((tag & 0xFFFF) << 16) | ((data_type & 0xFF) << 8) | (num & 0xFF)) + return header + payload - # Create event header - event_header = struct.pack(f"{endian}I", event_length) - event_tag = struct.pack(f"{endian}I", 0xFF60 << 16 | 0x10 << 8 | 0x01) # Standard ROC event - # Combine header and banks - event_data = event_header + event_tag +def evio_segment(tag, data_type, payload, endian='<'): + """Build a segment: one header word tag (8) | pad (2) | type (6) | length (16), then the payload.""" + assert len(payload) % 4 == 0 + return struct.pack(f"{endian}I", ((tag & 0xFF) << 24) | ((data_type & 0x3F) << 16) | (len(payload) // 4)) + payload - # Track bank offsets - bank_offsets = [] - offset = 8 # Start after event header - for bank in banks: - bank_offsets.append(offset) - event_data += bank - offset += len(bank) - - return event_data, bank_offsets +def evio_tagsegment(tag, data_type, payload, endian='<'): + """Build a tagsegment: one header word tag (12) | type (4) | length (16), then the payload.""" + assert len(payload) % 4 == 0 + return struct.pack(f"{endian}I", ((tag & 0xFFF) << 20) | ((data_type & 0xF) << 16) | (len(payload) // 4)) + payload def print_hex_dump(data, title=None): @@ -154,359 +72,309 @@ class TestBankScanning: def test_basic_bank_structure(self): """Test parsing of a simple container bank with child banks.""" - # Create a container bank with two child banks - child1 = create_mock_bank(4, 0x0101, 0x01, 0x01, struct.pack(" words 0..20 (21 words) + offset 0x08 0xFF31 segment, 4 payload words, length 5 -> 6 words + offset 0x20 0x0002 bank of banks, length 9 -> 10 words + offset 0x28 0xFF30 segment, 3 payload words, length 4 -> 5 words + offset 0x3C 3 zero words of padding inside the ROC bank + offset 0x48 0x000F leaf bank, 1 payload word, length 2 -> 3 words + """ + segment_bank = evio_bank(0xFF31, 0x20, 0x01, struct.pack("= 1 - assert children[0].tag == 0x0101 - assert children[0].data_type == 0x01 + assert [(c.offset, c.tag) for c in children] == [(8, 0x0101)] + assert len(bank.errors) == 1 + assert "declares 1000 words" in bank.errors[0] + # Repeated calls return the cached children and keep one error entry. + assert bank.get_children() == children + assert len(bank.errors) == 1 + def test_child_walk_stops_at_short_header(self): + """One trailing word cannot hold a bank header: the walk stops and reports it.""" + valid_bank = evio_bank(0x0101, 0x01, 0x01, struct.pack(" 0x50), so the event has two top-level banks. + REAL_WORLD_EVENT_WORDS = ( + 21, 0xFF601001, + 7, 0xFF312001, 0x32010003, 0, 0, 0, 0x42010001, + 0x00020011, + 11, 0x00021011, + 7, 0xFF302011, 0x31010003, 0, 0, 0, 0x41850001, + 0, + 2, 0x000F0001, + ) + + def test_real_world_event_structure(self, tmp_path): + """Top-level banks of the dumped event follow the length words, with the 0x000F header nested.""" + path = tmp_path / "real_world_event.bin" + path.write_bytes(struct.pack("<22I", *self.REAL_WORLD_EVENT_WORDS)) + + with open(path, 'rb') as f, mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm: + event_bank = Bank(mm, 0, '<') + assert (event_bank.length, event_bank.tag, event_bank.data_type) == (21, 0xFF60, 0x10) + assert event_bank.size == 88 and event_bank.end_offset == 88 + + children = event_bank.get_children() + assert [(c.offset, c.tag, c.data_type, c.num, c.length) for c in children] == [ + (0x08, 0xFF31, 0x20, 0x01, 7), + (0x28, 0x0002, 0x10, 0x11, 11), + ] + assert event_bank.errors == [] + + # Inside the ROC bank: the 0xFF30 segment, then a zero word, which is no bank. + roc_children = children[1].get_children() + assert [(c.offset, c.tag, c.data_type, c.length) for c in roc_children] == [ + (0x30, 0xFF30, 0x20, 7), + ] + assert len(children[1].errors) == 1 and "0x50" in children[1].errors[0] + assert all(c.tag != 0x000F for c in children + roc_children) + + def test_sro_event_segments_are_one_word_structures(self): + """The SIB of an SRO stream frame holds segments (1-word headers), typed by the parent. + + Layout as the hardware writes it: event 0xFF60 (bank of banks) -> + SIB 0xFF31 (bank of segments: TSS 0x32 with frame, ts_lo, ts_hi; AIS + 0x42 with 24 identical words) + ROC bank (bank of banks: stream-info + bank 0xFF30 of segments 0x31 and 0x41 (ushort16 port map), then + payload banks of type 0x00, an empty one with num 1). """ - Test with data matching the exact structure from our discussion. - This simulates the 21-word event with complex nested structure. - """ - # Create a temporary file with binary data - with tempfile.NamedTemporaryFile(delete=False) as tmp: - try: - # Event header - length 21 words - tmp.write(struct.pack("> 13) & 0x000F decoded_charge = data_word & 0x1FFF decoded_time = ((data_word >> 17) & 0x3FFF) * 4 - # Verify decoded values match what we encoded assert decoded_channel == channel assert decoded_charge == charge - assert decoded_time == time \ No newline at end of file + assert decoded_time == time diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..0c43548 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,135 @@ +"""The pyevio command group: error reporting, --version, no-argument help, offset labels.""" +import os + +import pytest +from click.testing import CliRunner + +import pyevio +from pyevio.cli import cli +from test_record_scanning import make_event, make_record, write_file + +GOLDEN_V4_FILE = "/data/evio-lib/golden/cases/cpp/v4_le.evio" + +# Every command that opens its file with EvioFile, with the arguments it needs after the +# file; the group converts the EvioFile error for all of them. Absent on purpose: +# ``hex`` dumps raw bytes of any file, ``gui`` and ``web`` are interactive viewers that start a loop. +FILE_COMMANDS = { + "info": [], + "dump": [], + "record": ["0"], + "event": ["0"], + "debug": ["--record", "0"], + "ana": ["--limit", "1"], + "stats": ["--workers", "1"], +} + + +def run(args): + # catch_exceptions=False: a ValueError that escapes the group fails the test with its traceback; + # a click error still ends as SystemExit, which the runner records as the exit code. + return CliRunner().invoke(cli, args, catch_exceptions=False) + + +@pytest.mark.parametrize("command", sorted(FILE_COMMANDS)) +@pytest.mark.skipif(not os.path.exists(GOLDEN_V4_FILE), reason="golden set not present") +def test_unsupported_version_is_a_message(command): + """An EVIO v4 file ends every command with the version message and exit code 1.""" + result = run([command, GOLDEN_V4_FILE] + FILE_COMMANDS[command]) + assert result.exit_code == 1 + assert "Error: EVIO version 4 file is not supported" in result.stderr + assert "Traceback" not in result.output + + +@pytest.mark.parametrize("command", sorted(FILE_COMMANDS)) +def test_non_evio_file_is_a_message(tmp_path, command): + """A file without the EVIO magic number ends every command with the message and exit code 1.""" + path = tmp_path / "noise.bin" + path.write_bytes(bytes(range(256)) * 8) + result = run([command, str(path)] + FILE_COMMANDS[command]) + assert result.exit_code == 1 + assert "Error: Not an EVIO file" in result.stderr + assert "Traceback" not in result.output + + +def test_version_option(): + """``pyevio --version`` prints the package version.""" + result = run(["--version"]) + assert result.exit_code == 0 + assert pyevio.__version__ in result.output + + +def test_no_arguments_prints_help(): + """``pyevio`` with no arguments prints the help; the file shortcut is gone.""" + result = run([]) + assert "Usage: " in result.output + assert "Commands:" in result.output + assert "info" in result.output + + +def test_file_as_first_argument_is_an_unknown_command(tmp_path): + """``pyevio FILE`` is rejected as an unknown command.""" + path = write_file(tmp_path / "plain.evio", [make_record(1, [make_event(2)], last=True, with_index=True)]) + result = run([path]) + assert result.exit_code == 2 + assert "No such command" in result.stderr + + +def compressed_and_plain_files(tmp_path): + events = [make_event(2), make_event(3)] + lz4 = write_file(tmp_path / "lz4.evio", [make_record(1, events, last=True, with_index=True, compression=1)]) + plain = write_file(tmp_path / "plain.evio", [make_record(1, events, last=True, with_index=True)]) + return lz4, plain + + +NOTE = "LZ4 compressed record: offsets below are in the decompressed record buffer" + + +def test_event_command_labels_offsets_of_a_compressed_record(tmp_path): + """``pyevio event`` on an LZ4 record says its offsets are buffer offsets; a plain record gets no note.""" + lz4, plain = compressed_and_plain_files(tmp_path) + + result = run(["event", lz4, "1", "--hexdump"]) + assert result.exit_code == 0, result.output + assert NOTE in result.output + # Buffer offset: 2-word index array + event 0 (4 words) → word 6. + assert "Offset: 0x18[6]" in result.output + + result = run(["event", plain, "1"]) + assert result.exit_code == 0, result.output + assert NOTE not in result.output + # File offset: 14-word file header + 14-word record header + 2-word index + event 0 (4 words) → word 34. + assert "Offset: 0x88[34]" in result.output + + +def test_debug_command_labels_offsets_of_a_compressed_record(tmp_path): + """``pyevio debug`` prints the note once per record.""" + lz4, plain = compressed_and_plain_files(tmp_path) + + result = run(["debug", lz4, "--record", "0"]) + assert result.exit_code == 0, result.output + assert result.output.count(NOTE) == 1 + + result = run(["debug", plain, "--record", "0"]) + assert result.exit_code == 0, result.output + assert NOTE not in result.output + + +def test_event_command_names_the_record_of_a_global_index(tmp_path): + """``pyevio event N`` by global index prints the record the event lives in.""" + _, plain = compressed_and_plain_files(tmp_path) + result = run(["event", plain, "1", "--hexdump"]) + assert result.exit_code == 0, result.output + assert "Record #0 Event #1" in result.output + assert "Record #None" not in result.output + assert "Record #0 Event rel#1, Length: 20 bytes\n" in result.output + + +def test_dump_record_out_of_range_is_a_parameter_error(tmp_path): + """``pyevio dump --record R`` past the last record exits 2 with the range message.""" + _, plain = compressed_and_plain_files(tmp_path) + result = run(["dump", plain, "--record", "7"]) + assert result.exit_code == 2 + assert "Record 7 out of range (0-0)" in result.stderr + + result = run(["dump", plain, "--record", "0", "2"]) + assert result.exit_code == 0, result.output diff --git a/tests/test_fadc250_stream.py b/tests/test_fadc250_stream.py new file mode 100644 index 0000000..345876b --- /dev/null +++ b/tests/test_fadc250_stream.py @@ -0,0 +1,455 @@ +"""Tests for pyevio.decoders.fadc250_stream. + +Synthetic frames are built word by word by the helpers below, in both byte +orders. The real-data test runs only with PYEVIO_CORPUS=1 and checks the +decoder against an independent struct-based walk of record 0 of the SRO +corpus file. +""" +import os +import struct + +import numpy as np +import pytest + +from pyevio.decoders import fadc250_stream as fs + +CORPUS_FILE = "/data/evio-lib/corpus/sro_000791.evio.00000.v6" + + +# ---------------------------------------------------------------- frame builders +def bank(tag, data_type, num, payload): + """EVIO bank: length word (words after it), header word, payload words.""" + return [len(payload) + 1, (tag << 16) | (data_type << 8) | num] + list(payload) + + +def segment(tag, data_type, payload, pad=0): + """EVIO segment: one header word tag<<24 | pad<<22 | type<<16 | length, then payload words.""" + return [(tag << 24) | (pad << 22) | (data_type << 16) | len(payload)] + list(payload) + + +def hit_word(channel, time_raw, charge, flag=False): + word = (time_raw << fs.HIT_TIME_SHIFT) | (channel << fs.HIT_CHANNEL_SHIFT) | charge + return word | (1 << fs.HIT_FLAG_BIT) if flag else word + + +def stream_info_bank(frame_number, timestamp, ports, module_id, big_endian=False): + """ROC-level 0xFF30 bank: TSS 0x31 and a payload-port map 0x41 (uint16 entries ``module << 8 | port``). + + Two entries per word; the first entry sits in the low half for a little-endian file and in + the high half for a big-endian one. An odd entry count sets pad bits 2 in the segment header. + """ + entries = [(module_id << fs.MAP_MODULE_SHIFT) | port for port in ports] + words = [] + for i in range(0, len(entries), 2): + first, second = entries[i], entries[i + 1] if i + 1 < len(entries) else 0 + words.append((first << 16) | second if big_endian else (second << 16) | first) + return bank(fs.ROC_STREAM_INFO_TAG, 0x20, 0x21, + segment(fs.ROC_TSS_SEGMENT_TAG, 1, [frame_number, timestamp & 0xFFFFFFFF, timestamp >> 32]) + + segment(fs.ROC_PAYLOAD_PORT_SEGMENT_TAG, 5, words, pad=2 if len(entries) % 2 else 0)) + + +def roc_bank(roc, stream_mask, frame_number, timestamp, payload_banks, with_stream_info=True, + module_id=fs.MODULE_FADC250, big_endian=False): + """ROC bank with num = stream status (2 streams, the given mask) and payload banks ``[(port, [words])]``.""" + children = [] + if with_stream_info: + children = stream_info_bank(frame_number, timestamp, [port for port, _ in payload_banks], module_id, big_endian) + for port, words in payload_banks: + children += bank(port, 0x00, 0 if words else 1, words) # an empty payload bank has num 1 + return bank(roc, 0x10, (2 << 4) | stream_mask, children) + + +DEGENERATE_AIS = [0x00070021] * 24 # what the SRO hardware writes: one stream's (rocid << 16 | status) in every slot +DISTINCT_AIS = [(roc << 16) | status for roc in (7, 9, 25, 27, 29, 44, 45, 46, 53, 54, 55, 84) for status in (0x21, 0x22)] + + +def stream_frame(frame_number, timestamp, roc_banks, ais_entries=DEGENERATE_AIS): + """Stream event: SIB (TSS + AIS, the AIS left out when ``ais_entries`` is None) followed by the ROC banks.""" + sib_segments = segment(fs.SIB_TSS_SEGMENT_TAG, 1, [frame_number, timestamp & 0xFFFFFFFF, timestamp >> 32]) + if ais_entries is not None: + sib_segments += segment(fs.SIB_AIS_SEGMENT_TAG, 1, ais_entries) + sib = bank(fs.SIB_TAG, 0x20, 0, sib_segments) + return bank(fs.STREAM_EVENT_TAG, 0x10, 0, sib + [w for rb in roc_banks for w in rb]) + + +def to_words(words, endian): + """Pack to bytes in the given byte order and read back the way EvioFile callers do.""" + raw = struct.pack(f"{endian}{len(words)}I", *words) + return np.frombuffer(raw, dtype=np.dtype(endian + "u4")) + + +FRAME_NUMBER = 1234 +TIMESTAMP = (5 << 32) | 0x8000_0000 +HITS_A = [hit_word(0, 10, 100), hit_word(3, 20, 200), hit_word(15, 16383, 8191)] +HITS_B = [hit_word(7, 4000, 1)] +HITS_C = [hit_word(1, 1, 1), hit_word(2, 2, 2)] +DCRB_WORDS = [0x2000_0001, 0x0000_0002, 0x4000_0003, 0x0000_0004, 0x0000_0005] + + +def one_crate_frame(endian="<"): + """One crate (ROC 7) as two streams; stream 1 has an empty port 5, stream 2 ends with an empty port 16.""" + big = endian == ">" + return stream_frame(FRAME_NUMBER, TIMESTAMP, [ + roc_bank(7, 1, FRAME_NUMBER, TIMESTAMP, [(1, HITS_A), (3, HITS_B), (5, [])], big_endian=big), + roc_bank(7, 2, FRAME_NUMBER, TIMESTAMP, [(2, HITS_C), (16, [])], big_endian=big), + ]) + + +def two_module_frame(endian="<"): + """An fADC250 crate (ROC 7) and a DCRB crate (ROC 44) with an odd number of mapped ports.""" + big = endian == ">" + return stream_frame(FRAME_NUMBER, TIMESTAMP, [ + roc_bank(7, 1, FRAME_NUMBER, TIMESTAMP, [(1, HITS_A)], big_endian=big), + roc_bank(44, 1, FRAME_NUMBER, TIMESTAMP, [(3, DCRB_WORDS), (5, DCRB_WORDS[:2]), (7, [])], + module_id=fs.MODULE_DCRB, big_endian=big), + ]) + + +# ---------------------------------------------------------------- synthetic frames +@pytest.mark.parametrize("endian", ["<", ">"]) +def test_decode_frame_columns(endian): + frame = fs.decode_frame(to_words(one_crate_frame(endian), endian)) + + assert frame.is_stream_frame + assert frame.frame_number == FRAME_NUMBER + assert frame.timestamp == TIMESTAMP + assert frame.errors == 0 and frame.error_messages == [] + assert frame.n_hits == 6 + assert frame.n_roc_banks == 2 + assert frame.n_payload_banks == 5 + assert frame.n_empty_payload_banks == 2 + assert frame.n_unmapped_payload_banks == 0 + assert frame.n_payload_words == 6 + assert frame.non_fadc_words == 0 + assert frame.n_flag_words == 0 + + assert frame.roc.tolist() == [7] * 6 + assert frame.stream.tolist() == [1, 1, 1, 1, 2, 2] + assert frame.port.tolist() == [1, 1, 1, 3, 2, 2] + assert frame.channel.tolist() == [0, 3, 15, 7, 1, 2] + assert frame.time_raw.tolist() == [10, 20, 16383, 4000, 1, 2] + assert frame.t_ns.tolist() == [40, 80, 65532, 16000, 4, 8] + assert frame.charge.tolist() == [100, 200, 8191, 1, 1, 2] + assert frame.t_ns.dtype == np.uint16 and frame.time_raw.dtype == np.uint16 + + rocs = frame.roc_banks + assert rocs["roc"].tolist() == [7, 7] + assert rocs["stream"].tolist() == [1, 2] + assert rocs["stream_status"].tolist() == [0x21, 0x22] + assert rocs["module_id"].tolist() == [fs.MODULE_FADC250, fs.MODULE_FADC250] + assert rocs["n_payload_banks"].tolist() == [3, 2] + assert rocs["n_empty_payload_banks"].tolist() == [1, 1] + assert rocs["n_hits"].tolist() == [4, 2] + assert rocs["first_hit_index"].tolist() == [0, 4] + assert fs.empty_banks_per_port(frame.empty_banks) == {(7, 1, 5): 1, (7, 2, 16): 1} + assert frame.empty_banks["module_id"].tolist() == [fs.MODULE_FADC250] * 2 + assert (frame.n_ais_entries, frame.n_ais_distinct) == (24, 1) + + +# ---------------------------------------------------------------- AIS segment +@pytest.mark.parametrize("endian", ["<", ">"]) +def test_ais_entries_are_counted_identical_and_distinct(endian): + """The decoder counts the AIS entries and their distinct values per frame; it never walks the AIS for ROC banks.""" + big = endian == ">" + rocs = [roc_bank(7, 1, FRAME_NUMBER, TIMESTAMP, [(1, HITS_A)], big_endian=big)] + degenerate = fs.decode_frame(to_words(stream_frame(FRAME_NUMBER, TIMESTAMP, rocs), endian)) + distinct = fs.decode_frame(to_words(stream_frame(FRAME_NUMBER, TIMESTAMP, rocs, ais_entries=DISTINCT_AIS), endian)) + without_ais = fs.decode_frame(to_words(stream_frame(FRAME_NUMBER, TIMESTAMP, rocs, ais_entries=None), endian)) + + assert (degenerate.n_ais_entries, degenerate.n_ais_distinct) == (24, 1) + assert (distinct.n_ais_entries, distinct.n_ais_distinct) == (24, 24) + assert (without_ais.n_ais_entries, without_ais.n_ais_distinct) == (0, 0) + # The AIS changes nothing else: hits, ROC banks and errors are the same in all three frames. + for frame in (degenerate, distinct, without_ais): + assert frame.errors == 0 and frame.n_hits == 3 and frame.n_roc_banks == 1 and frame.frame_number == FRAME_NUMBER + + hits = fs.decode_frames([to_words(stream_frame(FRAME_NUMBER, TIMESTAMP, rocs, ais_entries=entries), endian) + for entries in (DEGENERATE_AIS, DISTINCT_AIS, None, [0x00070021, 0x00070021, 0x002C0021])]) + assert hits.frames["n_ais_entries"].tolist() == [24, 24, 0, 3] + assert hits.frames["n_ais_distinct"].tolist() == [1, 24, 0, 2] + assert fs.ais_degenerate_mask(hits.frames).tolist() == [True, False, False, False] + + +def test_ais_segment_past_sib_end_is_a_warning_and_keeps_the_hits(): + """A malformed segment after the TSS touches no hit: a warning, not an error, and validate() passes.""" + words = one_crate_frame() + ais_header_i = 4 + 3 + 1 # SIB length and header, then the 4-word TSS segment + assert (words[ais_header_i] >> 24) == fs.SIB_AIS_SEGMENT_TAG + words[ais_header_i] = (words[ais_header_i] & 0xFFFF0000) | 500 + + frame = fs.decode_frame(to_words(words, "<")) + assert frame.errors == 0 and frame.warnings == 1 and "SIB segment length 500" in frame.warning_messages[0] + assert (frame.n_ais_entries, frame.n_ais_distinct) == (0, 0) + assert frame.n_hits == 6 and frame.n_roc_banks == 2 # the ROC walk starts at the SIB end, untouched by the AIS + hits = fs.decode_frames([to_words(words, "<")]) + hits.validate() + assert hits.warnings == 1 and hits.frames["warnings"].tolist() == [1] and hits.frames["errors"].tolist() == [0] + + +def test_ais_segment_without_entries_is_a_warning(): + """An AIS header with length 0 is present but lists nothing: (0, 0) like a missing one, plus a warning.""" + rocs = [roc_bank(7, 1, FRAME_NUMBER, TIMESTAMP, [(1, HITS_A)])] + empty = fs.decode_frame(to_words(stream_frame(FRAME_NUMBER, TIMESTAMP, rocs, ais_entries=[]), "<")) + missing = fs.decode_frame(to_words(stream_frame(FRAME_NUMBER, TIMESTAMP, rocs, ais_entries=None), "<")) + assert (empty.n_ais_entries, empty.n_ais_distinct) == (missing.n_ais_entries, missing.n_ais_distinct) == (0, 0) + assert empty.warnings == 1 and "AIS segment holds no entries" in empty.warning_messages[0] + assert missing.warnings == 0 and empty.errors == missing.errors == 0 + assert empty.n_hits == missing.n_hits == len(HITS_A) + + +@pytest.mark.parametrize("endian", ["<", ">"]) +def test_non_fadc_module_words_are_counted_not_decoded(endian): + """DCRB payload words (module id 1 in the 0x41 map) never reach the hit columns.""" + frame = fs.decode_frame(to_words(two_module_frame(endian), endian)) + + assert frame.errors == 0 and frame.n_unmapped_payload_banks == 0 + assert frame.n_hits == 3 and frame.roc.tolist() == [7, 7, 7] + assert frame.n_payload_words == 3 + 5 + 2 + assert frame.non_fadc_words == 7 + assert frame.roc_banks["module_id"].tolist() == [fs.MODULE_FADC250, fs.MODULE_DCRB] + assert frame.roc_banks["non_fadc_words"].tolist() == [0, 7] + assert frame.roc_banks["n_hits"].tolist() == [3, 0] + assert frame.roc_banks["n_payload_banks"].tolist() == [1, 3] + assert frame.roc_banks["n_empty_payload_banks"].tolist() == [0, 1] + assert fs.non_fadc_words_per_port(frame.non_fadc_banks) == {(44, 3): 5, (44, 5): 2} + assert frame.non_fadc_banks["module_id"].tolist() == [fs.MODULE_DCRB] * 2 + assert frame.empty_banks["module_id"].tolist() == [fs.MODULE_DCRB] + assert fs.words_per_roc(frame.roc_banks) == {7: 0, 44: 7} + assert fs.words_per_roc(frame.roc_banks, "n_hits") == {7: 3, 44: 0} + + +def test_non_stream_event_is_not_an_error(): + frame = fs.decode_frame(to_words(bank(0xFF50, 0x10, 0, bank(1, 1, 0, [1, 2, 3])), "<")) + assert not frame.is_stream_frame + assert frame.n_hits == 0 and frame.errors == 0 + + +def test_last_roc_bank_without_children(): + """The Parquet example crashed on a frame whose last ROC bank has no children (length 1).""" + words = stream_frame(FRAME_NUMBER, TIMESTAMP, [ + roc_bank(7, 1, FRAME_NUMBER, TIMESTAMP, [(1, HITS_A)]), + bank(7, 0x10, 0x22, []), + ]) + frame = fs.decode_frame(to_words(words, "<")) + assert frame.errors == 0 + assert frame.n_hits == 3 + assert frame.roc_banks["n_payload_banks"].tolist() == [1, 0] + assert frame.roc_banks["n_hits"].tolist() == [3, 0] + assert frame.roc_banks["module_id"].tolist() == [fs.MODULE_FADC250, fs.MODULE_UNKNOWN] + + +def test_last_roc_bank_with_only_an_empty_payload_bank(): + words = stream_frame(FRAME_NUMBER, TIMESTAMP, [ + roc_bank(7, 1, FRAME_NUMBER, TIMESTAMP, [(1, HITS_A)]), + roc_bank(7, 2, FRAME_NUMBER, TIMESTAMP, [(2, [])]), + ]) + frame = fs.decode_frame(to_words(words, "<")) + assert frame.errors == 0 + assert frame.n_hits == 3 and frame.n_empty_payload_banks == 1 + assert frame.roc_banks["n_payload_banks"].tolist() == [1, 1] + + +def test_unmapped_ports_decode_as_fadc_and_are_counted(): + """Without a stream-info bank (or a map entry) the port's module is unknown: decoded, but counted.""" + no_info = roc_bank(9, 2, FRAME_NUMBER, TIMESTAMP, [(4, HITS_C)], with_stream_info=False) + # ROC 7 maps port 1 only; its payload bank for port 2 is unmapped. + port_1_mapped = bank(7, 0x10, 0x21, stream_info_bank(FRAME_NUMBER, TIMESTAMP, [1], fs.MODULE_FADC250) + + bank(1, 0x00, 0, HITS_B) + bank(2, 0x00, 0, HITS_B)) + + frame = fs.decode_frame(to_words(stream_frame(FRAME_NUMBER, TIMESTAMP, [no_info, port_1_mapped]), "<")) + assert frame.errors == 0 + assert frame.n_hits == 2 + 1 + 1 + assert frame.port.tolist() == [4, 4, 1, 2] + assert frame.n_unmapped_payload_banks == 2 + assert frame.roc_banks["module_id"].tolist() == [fs.MODULE_UNKNOWN, fs.MODULE_FADC250] + + +# ---------------------------------------------------------------- bounds +def test_payload_bank_length_past_event_end_stops_walk(): + words = one_crate_frame() + # Corrupt the length word of the last payload bank (port 16, empty): make it run past the event. + last_payload_i = len(words) - 2 + assert words[last_payload_i] == 1 and (words[last_payload_i + 1] >> 16) == 16 + words[last_payload_i] = 1000 + + frame = fs.decode_frame(to_words(words, "<")) + assert frame.errors == 1 + assert "payload bank length 1000" in frame.error_messages[0] + assert frame.n_hits == 6 # hits before the bad bank are kept + assert frame.roc_banks["n_payload_banks"].tolist() == [3, 1] + + +def test_roc_bank_length_past_event_end_stops_walk(): + words = one_crate_frame() + sib_end = 2 + words[2] + 1 + words[sib_end] = 100_000 # first ROC bank claims more words than the event holds + + frame = fs.decode_frame(to_words(words, "<")) + assert frame.errors == 1 and frame.n_hits == 0 and frame.n_roc_banks == 0 + assert "ROC bank length 100000" in frame.error_messages[0] + + +def test_payload_map_segment_length_past_bank_end_is_an_error(): + words = one_crate_frame() + sib_end = 2 + words[2] + 1 + map_header_i = sib_end + 2 + 2 + 4 # ROC bank header, stream-info header, TSS segment (4 words) + assert (words[map_header_i] >> 24) == fs.ROC_PAYLOAD_PORT_SEGMENT_TAG + words[map_header_i] = (words[map_header_i] & 0xFFFF0000) | 500 + + frame = fs.decode_frame(to_words(words, "<")) + assert frame.errors == 1 and "stream-info segment length 500" in frame.error_messages[0] + assert frame.n_hits == 6 # the walk continues after the stream-info bank; its ports count as unmapped + assert frame.n_unmapped_payload_banks == 3 + + +def test_event_length_past_given_words_is_an_error(): + words = one_crate_frame() + truncated = words[:-4] # the event length word now exceeds the array + frame = fs.decode_frame(to_words(truncated, "<")) + assert frame.errors >= 1 + assert "runs past" in frame.error_messages[0] + assert frame.is_stream_frame and frame.frame_number == FRAME_NUMBER + + +def test_tiny_arrays_never_raise(): + for words in ([], [0], [5, fs.STREAM_EVENT_TAG << 16], [1, fs.STREAM_EVENT_TAG << 16]): + frame = fs.decode_frame(np.array(words, dtype=np.uint32)) + assert frame.n_hits == 0 + + +# ---------------------------------------------------------------- hit word layout and validation +def test_hit_word_layout_round_trip(): + words = np.array([hit_word(5, 100, 1024)], dtype=np.uint32) + channel, time_raw, charge = fs.unpack_hit_words(words) + assert (channel[0], time_raw[0], charge[0]) == (5, 100, 1024) + assert words[0] == (5 << 13) | (1024 & 0x1FFF) | ((400 // 4) << 17) # layout as tests/test_bank_scanning.py encodes it + assert fs.HIT_TIME_TICK_NS == 4 + + +def test_validate_hit_words_rejects_flag_bit(): + good = np.array([hit_word(1, 16383, 1)], dtype=np.uint32) + fs.validate_hit_words(good) + bad = np.array([hit_word(1, 1, 1, flag=True), hit_word(2, 2, 2)], dtype=np.uint32) + with pytest.raises(ValueError, match="1 hit words"): + fs.validate_hit_words(bad) + assert fs.count_flag_words(bad) == 1 + + +def test_stream_hits_validate_reports_flag_words_and_errors(): + words = stream_frame(FRAME_NUMBER, TIMESTAMP, [roc_bank(7, 1, FRAME_NUMBER, TIMESTAMP, [(1, [hit_word(1, 1, 1, flag=True)])])]) + hits = fs.decode_frames([to_words(words, "<")]) + assert hits.n_flag_words == 1 + with pytest.raises(ValueError, match="bit 31"): + hits.validate() + + +# ---------------------------------------------------------------- batch form +def test_decode_frames_concatenates_and_indexes(): + frame_a = to_words(one_crate_frame(), "<") + frame_b = to_words(stream_frame(FRAME_NUMBER + 4, TIMESTAMP + 262144, [roc_bank(44, 1, 0, 0, [(3, HITS_B)])]), "<") + other = to_words(bank(0xFF50, 0x10, 0, []), "<") + + hits = fs.decode_frames([frame_a, other, frame_b], first_event_index=10) + hits.validate() + assert hits.n_events == 3 and hits.n_stream_frames == 2 and hits.n_other_events == 1 + assert hits.n_hits == 7 + assert hits.frames["frame"].tolist() == [FRAME_NUMBER, FRAME_NUMBER + 4] + assert hits.frames["event_index"].tolist() == [10, 12] + assert hits.frames["n_hits"].tolist() == [6, 1] + assert hits.frames["first_hit_index"].tolist() == [0, 6] + assert hits.frames["n_empty_payload_banks"].tolist() == [2, 0] + assert hits.frame.tolist() == [FRAME_NUMBER] * 6 + [FRAME_NUMBER + 4] + assert hits.event_index.tolist() == [10] * 6 + [12] + assert hits.roc.tolist() == [7] * 6 + [44] + assert hits.roc_banks["first_hit_index"].tolist() == [0, 4, 6] + + dcrb = fs.decode_frames([to_words(two_module_frame(), "<")], first_event_index=20) + joined = fs.concatenate_stream_hits([hits, dcrb]) + assert joined.n_hits == 10 and joined.non_fadc_words == 7 + assert joined.frames["first_hit_index"].tolist() == [0, 6, 7] + assert joined.roc_banks["first_hit_index"].tolist() == [0, 4, 6, 7, 10] + assert joined.event_index.tolist()[-1] == 20 + assert len(joined.non_fadc_banks) == 2 + joined.validate() + + +def test_decode_frames_empty_input(): + hits = fs.decode_frames([]) + assert hits.n_hits == 0 and len(hits.frames) == 0 and len(hits.roc) == 0 and len(hits.non_fadc_banks) == 0 + hits.validate() + + +# ---------------------------------------------------------------- real data +def independent_payload_word_sums(mm, offset, length, endian): + """Plain struct walk of one stream frame: ``{roc id: {port: payload bank length - 1}}``.""" + def word(i): + return struct.unpack_from(endian + "I", mm, offset + 4 * i)[0] + + n = length // 4 + assert word(0) + 1 == n and (word(1) >> 16) == 0xFF60 + sums = {} + roc_i = 2 + word(2) + 1 + while roc_i < n: + roc_end = roc_i + word(roc_i) + 1 + roc = word(roc_i + 1) >> 16 + child_i = roc_i + 2 + if (word(child_i + 1) >> 16) == 0xFF30: + child_i += word(child_i) + 1 + while child_i < roc_end: + port = word(child_i + 1) >> 16 + per_port = sums.setdefault(roc, {}) + per_port[port] = per_port.get(port, 0) + word(child_i) - 1 + child_i += word(child_i) + 1 + roc_i = roc_end + return sums + + +# Module per crate in run 791, determined independently of the decoder's map parsing. +REFERENCE_FADC_ROCIDS = {7, 9, 25, 27, 29, 84} +REFERENCE_DCRB_ROCIDS = {44, 45, 46, 53, 54, 55} + + +@pytest.mark.skipif(os.environ.get("PYEVIO_CORPUS") != "1", reason="set PYEVIO_CORPUS=1 to run on the SRO corpus") +def test_corpus_record0_hits_match_payload_word_sums(): + from pyevio import EvioFile + + with EvioFile(CORPUS_FILE) as evio_file: + record = evio_file.get_record(0) # never the last record: it is a trailer with an index + n_events = record.header.event_count + assert n_events == 11 + offsets = record.get_event_offsets()[:n_events] + endian = evio_file.header.endian + + words_per_roc_port = {} + for offset, length in offsets: + for roc, per_port in independent_payload_word_sums(evio_file.mm, offset, length, endian).items(): + for port, words in per_port.items(): + words_per_roc_port[(roc, port)] = words_per_roc_port.get((roc, port), 0) + words + + hits = fs.decode_frames(fs.event_words(evio_file.mm, offset, length, endian) for offset, length in offsets) + hits.validate() + + assert hits.n_stream_frames == 11 and hits.errors == 0 and hits.n_unmapped_payload_banks == 0 + assert len(hits.roc_banks) == 24 * 11 + assert set(np.unique(hits.stream).tolist()) == {1, 2} + + # The 0x41 map must name DCRB for rocid 44 and fADC250 for rocid 29 (and every crate of the reference sets). + module_per_roc = {int(r["roc"]): int(r["module_id"]) for r in hits.roc_banks} + assert {roc for roc, module in module_per_roc.items() if module == fs.MODULE_FADC250} == REFERENCE_FADC_ROCIDS + assert {roc for roc, module in module_per_roc.items() if module == fs.MODULE_DCRB} == REFERENCE_DCRB_ROCIDS + + expected_fadc = {roc: 0 for roc in REFERENCE_FADC_ROCIDS} + expected_dcrb_per_port = {} + for (roc, port), words in words_per_roc_port.items(): + if roc in REFERENCE_FADC_ROCIDS: + expected_fadc[roc] += words + else: + expected_dcrb_per_port[(roc, port)] = words + rocs, counts = np.unique(hits.roc, return_counts=True) + assert dict(zip(rocs.tolist(), counts.tolist())) == expected_fadc + assert hits.n_hits == sum(expected_fadc.values()) == 99_114 + assert fs.non_fadc_words_per_port(hits.non_fadc_banks) == {k: v for k, v in expected_dcrb_per_port.items() if v} + assert hits.non_fadc_words == sum(expected_dcrb_per_port.values()) == 400_546 + assert hits.n_hits + hits.non_fadc_words == hits.n_payload_words == 499_660 + # Every AIS lists 24 slots holding one value (one stream's entry repeated). + assert hits.frames["n_ais_entries"].tolist() == [24] * 11 and hits.frames["n_ais_distinct"].tolist() == [1] * 11 diff --git a/tests/test_fadc250_stream_example.py b/tests/test_fadc250_stream_example.py new file mode 100644 index 0000000..f2d54e3 --- /dev/null +++ b/tests/test_fadc250_stream_example.py @@ -0,0 +1,104 @@ +"""The Parquet example and `pyevio debug` on a synthetic streaming file. + +Both callers use pyevio.decoders.fadc250_stream; these tests pin that the +example survives a frame whose last ROC bank has no children, +sorts hits by (frame, event, t_ns) so duplicate frames keep valid hit +ranges, and that the debug command decodes a stream frame. +""" +import importlib.util +import struct +import sys +from pathlib import Path + +import numpy as np +import pytest +from click.testing import CliRunner + +from test_fadc250_stream import HITS_A, HITS_B, bank, hit_word, roc_bank, stream_frame + +MAGIC = 0xC0DA0100 +EXAMPLE_PATH = Path(__file__).resolve().parents[1] / "examples" / "fadc250_stream_to_parquet.py" + + +def load_example(): + spec = importlib.util.spec_from_file_location("fadc250_stream_to_parquet", EXAMPLE_PATH) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def pack_words(words): + return struct.pack("<%dI" % len(words), *words) + + +def make_record(number, events): + """EVIO v6 record with an event index, little-endian.""" + index = struct.pack("<%dI" % len(events), *(len(e) for e in events)) + data = b"".join(events) + length_words = 14 + (len(index) + len(data)) // 4 + header = struct.pack("<14I", length_words, number, 14, len(events), len(index), 6, 0, MAGIC, len(data), 0, 0, 0, 0, 0) + return header + index + data + + +def write_stream_file(path): + """Two records: frame 100 (two hits, last ROC bank childless), a non-stream event, then frame 100 again and frame 104.""" + frame_100_a = stream_frame(100, 1000, [ + roc_bank(7, 1, 100, 1000, [(1, [hit_word(2, 50, 10), hit_word(2, 30, 20)])]), + bank(7, 0x10, 0x22, []), # Last ROC bank has no children + ]) + other = bank(0xFF50, 0x10, 0, bank(1, 1, 0, [1, 2])) + frame_100_b = stream_frame(100, 1000, [roc_bank(9, 2, 100, 1000, [(2, [hit_word(4, 40, 30)]), (4, [])])]) + frame_104 = stream_frame(104, 1000 + 262144, [roc_bank(7, 1, 104, 1000 + 262144, [(3, HITS_A + HITS_B)])]) + records = [make_record(1, [pack_words(frame_100_a), pack_words(other)]), + make_record(2, [pack_words(frame_100_b), pack_words(frame_104)])] + file_header = struct.pack("<14I", 0x4556494F, 1, 14, len(records), 0, 6, 0, MAGIC, 0, 0, 0, 0, 0, 0) + path.write_bytes(file_header + b"".join(records)) + return str(path) + + +def test_example_decode_handles_childless_roc_bank_and_duplicate_frames(tmp_path): + example = load_example() + hits, frames, stats, stream_hits = example.decode(write_stream_file(tmp_path / "stream.evio")) + checks = example.validate(stream_hits, frames) + + assert stats["hits"] == 7 and stats["frames"] == 3 and stats["skipped_events"] == 1 + assert stats["payload_words"] == 7 and stats["empty_payload_banks"] == 1 + assert stats["decode_errors"] == 0 and stats["decode_warnings"] == 0 + assert stats["out_of_order"] == 0 + assert checks == {"missing_frames": 3, "duplicate_frames": 1} + + # frames sorted by (frame, event index); hit ranges follow the same order + assert frames["frame"].tolist() == [100, 100, 104] + assert frames["n_hits"].tolist() == [2, 1, 4] + assert frames["hit_offset"].tolist() == [0, 2, 3] + assert hits["frame"].tolist() == [100, 100, 100, 104, 104, 104, 104] + assert hits["roc"].tolist() == [7, 7, 9, 7, 7, 7, 7] + assert hits["t_ns"].tolist()[:3] == [120, 200, 160] # within an event, sorted by time + assert hits["stream"].tolist() == [1, 1, 2, 1, 1, 1, 1] + assert hits["port"].dtype == np.uint16 + + +def test_example_convert_writes_parquet(tmp_path): + pytest.importorskip("pyarrow") + import pyarrow.parquet as pq + + example = load_example() + path = write_stream_file(tmp_path / "stream.evio") + example.convert(path, tmp_path / "out") + hits = pq.read_table(tmp_path / "out" / "stream.hits.parquet") + assert hits.num_rows == 7 + assert hits.schema.metadata[b"decoder_version"] == example.DECODER_VERSION.encode() + + +def test_debug_command_decodes_stream_frame(tmp_path): + from pyevio.cli import cli + + path = write_stream_file(tmp_path / "stream.evio") + result = CliRunner().invoke(cli, ["debug", path, "--record", "0", "--event", "0", "--verbose"]) + assert result.exit_code == 0, result.output + assert "Streaming time frame 100" in result.output + assert "2 hits in 2 ROC banks" in result.output + assert "Data Bank (Data Words: 2)" in result.output # both hit words, including the last one of the bank + assert "Channel: 2, Charge: 10, Time: 200" in result.output + assert "Channel: 2, Charge: 20, Time: 120" in result.output diff --git a/tests/test_record_scanning.py b/tests/test_record_scanning.py new file mode 100644 index 0000000..36f5d65 --- /dev/null +++ b/tests/test_record_scanning.py @@ -0,0 +1,287 @@ +import struct +import zlib + +import lz4.block +import numpy as np +import pytest + +from pyevio.evio_file import EvioFile + +MAGIC = 0xc0da0100 +LAST_RECORD_BIT = 1 << 9 + + +def make_event(n_payload_words, tag=0xFF60): + """A bank: length word, tag/type/num word, then payload words.""" + words = [n_payload_words + 1, (tag << 16) | (0x10 << 8)] + list(range(n_payload_words)) + return struct.pack("<%dI" % len(words), *words) + + +def padded(data): + """Pad bytes to whole words, as writers store user headers.""" + return data + bytes(-len(data) % 4) + + +def make_record(number, events, last, with_index, user_header=b"", compression=0): + """A 14-word-header record; ``user_header`` may have an odd length (its padding is implicit). + + ``compression`` 1 (LZ4), 2 (LZ4_BEST) or 3 (gzip) compresses index + padded + user header + events the way the evio writers do: the compressed length in + word 10 counts whole words and pad3 (bits 24—25 of word 5) counts the + filler bytes in the last word. + """ + index = struct.pack("<%dI" % len(events), *(len(e) for e in events)) if with_index else b"" + data = b"".join(events) + bit_info = LAST_RECORD_BIT if last else 0 + body = index + padded(user_header) + data + compression_word = 0 + if compression in (1, 2): + body = lz4.block.compress(body, store_size=False) + elif compression == 3: + body = zlib.compress(body, wbits=31) + if compression: + pad3 = -len(body) % 4 + body += bytes(pad3) + bit_info |= pad3 << 24 + compression_word = (compression << 28) | (len(body) // 4) + length_words = 14 + len(body) // 4 + header = struct.pack("<14I", length_words, number, 14, len(events), len(index), + bit_info | 6, len(user_header), MAGIC, len(data), compression_word, 0, 0, 0, 0) + return header + body + + +def make_trailer(number, records, with_index): + """A trailer record (header species 3): its index lists (length, event count) per record.""" + index = b"" + if with_index: + for record in records: + length_words, _, _, events = struct.unpack("<4I", record[:16]) + index += struct.pack("> stream_stats.TIME_BIN_SHIFT]) == sum(range(1, 13)) + assert int(stats.rate_counts.sum()) == expected_hits and int(stats.frames_per_rate_bin.sum()) == 12 + assert int(stats.hits_per_frame_hist.sum()) == 12 + + checks = stats.checks + assert checks.n_frames == 12 and checks.stride == STRIDE + assert checks.duplicate_frames == 1 and checks.missing_frames == 1 and checks.out_of_order == 0 + assert checks.frame_period_ticks == PERIOD and checks.dts_outliers == 0 + assert checks.first_frame == 1000 and checks.last_frame == max(frame_numbers) + assert stats.frames["frame"].tolist() == frame_numbers + assert stats.frames["n_hits"].tolist()[:2] == [17, 18] + # Every frame's AIS repeats one entry in its 24 slots + assert stats.frames_with_ais == stats.frames_with_degenerate_ais == 12 + assert stats.frames["n_ais_entries"].tolist() == [24] * 12 and stats.frames["n_ais_distinct"].tolist() == [1] * 12 + + # rate: every bin with frames reports hits / (frames × period); the sum over bins recovers the hits + rate = stats.rate_hz() + covered = stats.frames_per_rate_bin > 0 + assert np.allclose(rate[covered] * stats.frames_per_rate_bin[covered] * stats.frame_period_ns * 1e-9, stats.rate_counts.sum(axis=(0, 1, 2))[covered]) + assert rate[~covered].tolist() == [0.0] * int((~covered).sum()) + channel_rate = stats.rate_hz(roc=7, port=1, channel=0) + assert int(stats.rate_counts[0, 0, 0].sum()) == 12 and len(channel_rate) == 8 and channel_rate.sum() > 0 + + +@pytest.mark.parametrize("workers", [1, 2]) +def test_distinct_ais_entries_are_not_degenerate(tmp_path, workers): + path, _, _ = write_synthetic_file(tmp_path / "distinct_ais.evio", distinct_ais=True) + stats = stream_stats.compute_stream_stats(path, workers=workers, rate_bins=8, chunk_records=1) + assert stats.n_frames == stats.frames_with_ais == 12 and stats.frames_with_degenerate_ais == 0 + assert stats.frames["n_ais_entries"].tolist() == [24] * 12 and stats.frames["n_ais_distinct"].tolist() == [24] * 12 + + +def test_npz_round_trip_and_version(tmp_path): + path, expected_hits, _ = write_synthetic_file(tmp_path / "synthetic.evio", n_records=1) + stats = stream_stats.compute_stream_stats(path, workers=1, rate_bins=4) + out = tmp_path / "stats.npz" + stats.save(str(out)) + loaded = stream_stats.StreamStats.load(str(out)) + assert loaded.n_hits == expected_hits == stats.n_hits + assert loaded.checks == stats.checks + assert (loaded.frames_with_ais, loaded.frames_with_degenerate_ais) == (4, 4) + assert loaded.rocs.tolist() == stats.rocs.tolist() + assert np.array_equal(loaded.rate_counts, stats.rate_counts) and np.array_equal(loaded.frames, stats.frames) + assert loaded.version == stream_stats.STATS_VERSION + + stats.version = "0" + stats.save(str(out)) + with pytest.raises(ValueError, match="stats version"): + stream_stats.StreamStats.load(str(out)) + + +def test_stats_command_prints_summary(tmp_path): + from pyevio.cli import cli + + path, expected_hits, _ = write_synthetic_file(tmp_path / "synthetic.evio") + out = tmp_path / "stats.npz" + result = CliRunner().invoke(cli, ["stats", path, "--workers", "1", "--out", str(out)]) + assert result.exit_code == 0, result.output + assert f"{expected_hits:,}" in result.output and "stride 4" in result.output and "1 missing" in result.output + assert "DCRB" in result.output and out.exists() + # The CLI prints the same AIS check and warning count the viewer shows + flat = " ".join(result.output.split()) # rich wraps the table at 80 columns under CliRunner + assert "12 frames with an aggregation info segment, 12 repeat one entry in every slot" in flat + assert "Decode warnings 0" in flat + + +@pytest.mark.skipif(os.environ.get("PYEVIO_CORPUS") != "1", reason="set PYEVIO_CORPUS=1 to run on the SRO corpus") +def test_corpus_first_200_records_match_decoder(): + from pyevio import EvioFile + + stats = stream_stats.compute_stream_stats(CORPUS_FILE, workers=4, max_records=200) + + with EvioFile(CORPUS_FILE) as evio_file: + endian = evio_file.header.endian + chunks = [] + for record_i in range(200): + record = evio_file.get_record(record_i) + events = [fs.event_words(record.buffer, o, l, endian) for o, l in record.get_event_offsets()[:record.header.event_count]] + chunks.append(fs.decode_frames(events)) + decoded = fs.concatenate_stream_hits(chunks) + del events, chunks # views of the mmap must be gone before EvioFile.close() + + assert stats.n_frames == decoded.n_stream_frames == 2200 + assert stats.n_hits == decoded.n_hits + assert stats.non_fadc_words == decoded.non_fadc_words + assert stats.n_empty_payload_banks == decoded.n_empty_payload_banks + rocs, counts = np.unique(decoded.roc, return_counts=True) + assert stats.hits_per_roc() == {**{int(r): 0 for r in stats.rocs}, **dict(zip(rocs.tolist(), counts.tolist()))} + assert stats.checks.stride == 4 and stats.checks.missing_frames == 0 and stats.checks.duplicate_frames == 0 + assert stats.checks.frame_period_ticks == 65536 + assert int(stats.rate_counts.sum()) == decoded.n_hits and int(stats.occupancy.sum()) == decoded.n_hits + assert stats.errors == stats.warnings == 0 and stats.n_trailer_records == 0 + # Every frame of the run carries a 24-slot AIS holding one value + assert stats.frames_with_ais == stats.frames_with_degenerate_ais == 2200 + assert int(fs.ais_degenerate_mask(decoded.frames).sum()) == 2200 + assert np.unique(decoded.frames["n_ais_entries"]).tolist() == [24] and np.unique(decoded.frames["n_ais_distinct"]).tolist() == [1] + # rate_hz is hits per second of live time: its frame-weighted mean equals hits / (frames × period) + rate = stats.rate_hz() + weighted_mean = (rate * stats.frames_per_rate_bin).sum() / stats.frames_per_rate_bin.sum() + assert np.isclose(weighted_mean, stats.n_hits / stats.live_time_s, rtol=1e-9) + + +def test_pool_starts_under_a_captured_stderr(tmp_path): + """Textual replaces sys.stderr with an object whose fileno() is -1; the resource tracker's spawn then failed. + + Runs in a fresh interpreter because the tracker and the forkserver start once per process: + this pytest process has them running already, so the failure cannot show here. + """ + import subprocess + import sys + + path, expected_hits, _ = write_synthetic_file(tmp_path / "synthetic.evio") + script = tmp_path / "captured_stderr.py" + script.write_text( + "import io, sys\n" + "from pyevio import stream_stats\n" + "class Capture(io.TextIOBase):\n" + " def write(self, s): return len(s)\n" + " def fileno(self): return -1\n" + "if __name__ == '__main__':\n" + " sys.stderr = Capture()\n" + f" print(stream_stats.compute_stream_stats({str(path)!r}, workers=2).n_hits)\n" + ) + result = subprocess.run([sys.executable, str(script)], capture_output=True, text=True, timeout=120) + assert result.returncode == 0 and result.stdout.strip() == str(expected_hits), result.stdout + result.stderr diff --git a/tests/web/conftest.py b/tests/web/conftest.py new file mode 100644 index 0000000..7df0ce0 --- /dev/null +++ b/tests/web/conftest.py @@ -0,0 +1,80 @@ +"""Fixtures for the web server tests: a served root with a synthetic stream file, a golden file and traps. + +``tests/`` is added to ``sys.path`` so the synthetic-file builder of +``test_stream_stats.py`` can be reused; pytest puts only this folder on the path. +""" +import os +import shutil +import sys +import time +from pathlib import Path + +import pytest + +TESTS_DIR = Path(__file__).resolve().parents[1] +if str(TESTS_DIR) not in sys.path: + sys.path.insert(0, str(TESTS_DIR)) + +from test_stream_stats import write_synthetic_file # noqa: E402 + +fastapi = pytest.importorskip("fastapi") +from fastapi.testclient import TestClient # noqa: E402 + +from pyevio.web.app import create_app # noqa: E402 + +GOLDEN_FILE = Path("/data/evio-lib/golden/cases/cpp/basic_le.evio") +GOLDEN_LZ4_FILE = GOLDEN_FILE.with_name("lz4_le.evio") +CORPUS_FILE = Path("/data/evio-lib/corpus/sro_000791.evio.00000.v6") +TOKEN = "test-token-0123456789" +POLL_TIMEOUT_S = 60 + + +@pytest.fixture +def served_root(tmp_path): + """ROOT with ``runs/synthetic.evio`` (12 stream frames), a copy of a golden file, a secret outside and a symlink to it.""" + root = tmp_path / "root" + (root / "runs").mkdir(parents=True) + path, expected_hits, frame_numbers = write_synthetic_file(root / "runs" / "synthetic.evio") + if GOLDEN_FILE.is_file(): + shutil.copy(GOLDEN_FILE, root / "basic_le.evio") + if GOLDEN_LZ4_FILE.is_file(): + shutil.copy(GOLDEN_LZ4_FILE, root / "lz4_le.evio") + outside = tmp_path / "outside" + outside.mkdir() + (outside / "secret.evio").write_bytes(b"not for you") + os.symlink(outside / "secret.evio", root / "link_outside.evio") + os.symlink(outside, root / "dir_outside") + return {"root": root, "synthetic": "runs/synthetic.evio", "expected_hits": expected_hits, "frame_numbers": frame_numbers, + "golden": "basic_le.evio" if GOLDEN_FILE.is_file() else None, + "golden_lz4": "lz4_le.evio" if GOLDEN_LZ4_FILE.is_file() else None, "cache_dir": tmp_path / "cache"} + + +@pytest.fixture +def app(served_root): + return create_app(served_root["root"], TOKEN, served_root["cache_dir"], stats_workers=1) + + +@pytest.fixture +def client(app): + # TestClient's default Host is "testserver", which the Host check rejects by design. + return TestClient(app, base_url="http://localhost", headers={"X-Token": TOKEN}) + + +def wait_done(client, file_id, timeout_s=POLL_TIMEOUT_S): + """Poll /progress until the job leaves queued/running; return the last progress.""" + deadline = time.monotonic() + timeout_s + while True: + progress = client.get(f"/api/files/{file_id}/progress").json() + if progress["state"] not in ("queued", "running"): + return progress + assert time.monotonic() < deadline, f"analysis did not finish in {timeout_s} s: {progress}" + time.sleep(0.02) + + +def open_and_wait(client, path): + response = client.post("/api/open", json={"path": path}) + assert response.status_code == 200, response.text + file_id = response.json()["file_id"] + progress = wait_done(client, file_id) + assert progress["state"] == "done", progress + return file_id, progress diff --git a/tests/web/test_viewer.py b/tests/web/test_viewer.py new file mode 100644 index 0000000..9e19635 --- /dev/null +++ b/tests/web/test_viewer.py @@ -0,0 +1,230 @@ +"""Headless-browser smoke tests of the viewer, one per tab. + +The FastAPI app runs in a uvicorn thread on a free port; Playwright drives Chromium against it. Each test +asserts that the tab renders what the API returns: tile values equal /summary, charts hold a path, the records +table has one row per record, the tree expands to a payload leaf and the hex view equals /words. + +Skipped when Playwright or its Chromium is missing. Install both with +``uv add --dev playwright`` and ``uv run playwright install chromium``. + +Screenshots of each tab go to ``$PYEVIO_SCREENSHOT_DIR`` (default /data/pyevio/plots/phase4) when that folder +can be created; a machine without it loses nothing. +""" +import json +import os +import threading +import time +from pathlib import Path + +import pytest + +from conftest import TOKEN, open_and_wait + +playwright_api = pytest.importorskip("playwright.sync_api") +uvicorn = pytest.importorskip("uvicorn") + +SCREENSHOT_DIR = Path(os.environ.get("PYEVIO_SCREENSHOT_DIR", "/data/pyevio/plots/phase4")) +LOAD_TIMEOUT_MS = 30_000 + + +@pytest.fixture(scope="session") +def browser(): + with playwright_api.sync_playwright() as playwright: + try: + browser = playwright.chromium.launch() + except Exception as error: # the package is installed but Chromium is not: `playwright install chromium` + pytest.skip(f"Chromium not available for Playwright: {error}") + yield browser + browser.close() + + +@pytest.fixture +def server(app): + """The app on 127.0.0.1 with a free port; uvicorn skips signal handlers when it runs off the main thread.""" + instance = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=0, log_level="warning")) + thread = threading.Thread(target=instance.run, daemon=True) + thread.start() + deadline = time.monotonic() + 10 + while not instance.started: + assert time.monotonic() < deadline, "uvicorn did not start" + time.sleep(0.02) + port = instance.servers[0].sockets[0].getsockname()[1] + yield f"http://127.0.0.1:{port}" + instance.should_exit = True + thread.join(5) + + +@pytest.fixture +def page(browser, server): + """A page on the viewer with the token in the URL; console and page errors fail the test.""" + page = browser.new_page(viewport={"width": 1280, "height": 900}) + errors = [] + page.on("pageerror", lambda error: errors.append(f"pageerror: {error}")) + # Chromium logs every non-2xx response as "Failed to load resource"; a 400 the page displays is not a page error. + page.on("console", lambda message: errors.append(f"console.error: {message.text}") + if message.type == "error" and not message.text.startswith("Failed to load resource") else None) + page.goto(f"{server}/?token={TOKEN}") + page.wait_for_selector(".bitem", timeout=LOAD_TIMEOUT_MS) + yield page + page.close() + assert errors == [], errors + + +def open_in_viewer(page, relative_path): + """Open a file through the folder browser and wait until the tabs are enabled for it.""" + if page.get_attribute("#browser", "hidden") is not None: + page.click("#browseBtn") + while page.inner_text("#brPath") != "/": + shown = page.inner_text("#brPath") + page.click("#brUp") + page.wait_for_function("previous => document.querySelector('#brPath').textContent !== previous", arg=shown) + *folders, name = relative_path.split("/") + for folder in folders: + page.click(f".bitem.dir:has-text('{folder}')") + page.wait_for_function("folder => document.querySelector('#brPath').textContent.endsWith(folder)", arg=folder) + page.click(f".bitem.file:has-text('{name}')") + page.wait_for_selector("body[data-file-id]", timeout=LOAD_TIMEOUT_MS) + return page.get_attribute("body", "data-file-id") + + +def show_tab(page, name): + page.click(f".tab[data-tab={name}]") + page.wait_for_selector(f"#v-{name}:not([hidden])") + + +def screenshot(page, name): + try: + SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) + except OSError: + return + page.screenshot(path=str(SCREENSHOT_DIR / f"{name}.png"), full_page=True) + + +def test_overview_tiles_equal_summary(page, client, served_root): + file_id = open_in_viewer(page, served_root["synthetic"]) + api_file_id, _ = open_and_wait(client, served_root["synthetic"]) + assert file_id == api_file_id + summary = client.get(f"/api/files/{file_id}/summary").json() + show_tab(page, "overview") + tiles = {tile.get_attribute("data-key"): json.loads(tile.get_attribute("data-value")) for tile in page.query_selector_all(".tile")} + for key in ("n_records", "n_events", "n_frames", "n_hits", "hit_rate_hz", "hits_per_frame", "errors"): + assert tiles[key] == summary["totals"][key], key + assert page.inner_text(".tile[data-key=n_hits] .v") == str(served_root["expected_hits"]) + statuses = [row.get_attribute("data-status") for row in page.query_selector_all("#checks .ck")] + assert statuses == [check["status"] for check in summary["checks"]] + standout = page.inner_text("#standout") + assert "missing frames" in standout and "duplicate frames" in standout + assert "little-endian" in page.inner_text("#fileFacts") + screenshot(page, "overview") + + +def test_plots_render_charts_and_heatmap(page, served_root): + open_in_viewer(page, served_root["synthetic"]) + show_tab(page, "plots") + page.wait_for_selector("#chartChCharge path.series, #chartChCharge text", timeout=LOAD_TIMEOUT_MS) + for chart in ("chartHitsPerFrame", "chartRate", "chartRocRate", "chartRocCharge", "chartRocTime", "chartChRate", "chartChCharge", "chartChTime"): + assert page.query_selector(f"#{chart} path.series") is not None, chart + assert "time since the first frame (s)" in page.text_content("#chartRate") + live = page.query_selector_all("#chartOccupancy rect.cell") + dead = page.query_selector_all("#chartOccupancy rect.cell-dead") + assert len(live) + len(dead) == 2 * 5 and len(live) == 5 # ROCs 7 and 44 × ports 1, 3, 4, 5, 6; each ROC maps 2—3 ports + assert [option.inner_text() for option in page.query_selector_all("#selRoc option")] == ["7 (fADC250)", "44 (DCRB)"] + page.select_option("#selRoc", "44") + page.wait_for_function("() => document.querySelector('#rocInfo').textContent.includes('non-fADC')") + assert "60 non-fADC words" in page.inner_text("#rocInfo") + screenshot(page, "plots") + + +def test_records_table_rows_and_click(page, client, served_root): + file_id = open_in_viewer(page, served_root["synthetic"]) + open_and_wait(client, served_root["synthetic"]) + records = client.get(f"/api/files/{file_id}/records").json() + show_tab(page, "records") + page.wait_for_selector("#recordsTable tbody tr") + rows = page.query_selector_all("#recordsTable tbody tr") + assert len(rows) == records["total"] == 4 + assert [row.get_attribute("data-id") for row in rows] == ["r0", "r1", "r2", "t"] + assert "(trailer)" in rows[3].inner_text() and "set" in rows[3].inner_text() + screenshot(page, "records") + rows[1].click() + page.wait_for_selector("#v-structure:not([hidden]) .node[data-id='r1/e0']") + assert "record_number" in page.inner_text("#detail") + + +def test_structure_expands_to_hits_and_hex_equals_words(page, client, served_root): + file_id = open_in_viewer(page, served_root["synthetic"]) + open_and_wait(client, served_root["synthetic"]) + show_tab(page, "structure") + page.click(".node[data-id=r1] > .row") + page.click(".node[data-id='r1/e0'] > .row") + page.click(".node[data-role=roc_bank] > .row") + page.click(".node[data-role=payload_hits] > .row") + page.wait_for_selector(".node[data-id$='/h15']") + hits = page.query_selector_all(".node[data-role=payload_hits] .node") + assert len(hits) == 16 and "ch 15" in hits[15].inner_text() + payload_id = page.get_attribute(".node[data-role=payload_hits]", "data-id") + payload = client.get(f"/api/files/{file_id}/structure", params={"node": payload_id}).json()["node"] + words = client.get(f"/api/files/{file_id}/words", params={"offset": payload["offset"], "count": payload["size"] // 4, "record": 1}).json() + page.wait_for_selector("#detail .hex") + shown = [cell.inner_text() for cell in page.query_selector_all("#detail .hex span:not(.a)") if cell.inner_text()] + assert shown == words["words"] + assert len(page.query_selector_all("#detail .hex span.h")) == 2 # a bank's two header words are highlighted + page.click(".node[data-role=roc_stream_info] > .row") + page.click(".node[data-role=payload_map] > .row") + page.wait_for_selector("#detail table.t") + assert [row.inner_text().split("\t") for row in page.query_selector_all("#detail table.t tbody tr")] == [ + ["0", "1", "0", "fADC250"], ["1", "3", "0", "fADC250"], ["2", "5", "0", "fADC250"]] + screenshot(page, "structure") + + +def test_golden_file_shows_na_and_empty_plots(page, served_root): + if not served_root["golden_lz4"]: + pytest.skip("golden set absent") + open_in_viewer(page, served_root["golden_lz4"]) + show_tab(page, "overview") + assert page.inner_text(".tile[data-key=hit_rate_hz] .v") == "n/a" + assert page.inner_text(".tile[data-key=n_frames] .v") == "0" + show_tab(page, "plots") + page.wait_for_function("() => document.querySelector('#chartOccupancy').textContent.includes('no stream frames')") + assert page.query_selector("#chartRate path.series") is None + show_tab(page, "structure") + page.click(".node[data-id=r0] > .row") + page.click(".node[data-id='r0/e0'] > .row") + page.wait_for_selector("#detail .hex") + assert "compressed\tyes" in page.inner_text("#detail") + assert "in record 0's buffer" in page.inner_text("#detail") + + +def test_failed_open_is_reported(page, served_root): + """A file the server cannot read shows its error; the tabs stay disabled.""" + (served_root["root"] / "runs" / "garbage.evio").write_bytes(b"\0" * 64) + page.click(".bitem.dir:has-text('runs')") + page.click(".bitem.file:has-text('garbage.evio')") + page.wait_for_selector("#stErr:not([hidden])") + assert "EVIO" in page.inner_text("#stErr") + assert page.inner_text("#stMsg") == "Open failed." + assert all(button.get_attribute("disabled") is not None for button in page.query_selector_all(".tab")) + + +def test_second_open_replaces_the_first(page, served_root): + """The page shows the file opened last; the earlier file's content is gone.""" + first = open_in_viewer(page, served_root["synthetic"]) + if not served_root["golden"]: + pytest.skip("golden set absent") + second = open_in_viewer(page, served_root["golden"]) + assert second != first + show_tab(page, "overview") + assert page.inner_text(".tile[data-key=n_frames] .v") == "0" + assert page.inner_text("#stFile") == served_root["golden"] + + +def test_start_file_in_the_url_opens_on_load(browser, server, served_root): + """``pyevio web FILE`` puts ?open=FILE in the URL; the page opens it without a click.""" + page = browser.new_page(viewport={"width": 1280, "height": 900}) + try: + page.goto(f"{server}/?token={TOKEN}&open={served_root['synthetic']}") + page.wait_for_selector("body[data-file-id]", timeout=LOAD_TIMEOUT_MS) + assert page.inner_text("#stFile") == served_root["synthetic"] + assert page.get_attribute("#browser", "hidden") is not None + finally: + page.close() diff --git a/tests/web/test_web_api.py b/tests/web/test_web_api.py new file mode 100644 index 0000000..1066597 --- /dev/null +++ b/tests/web/test_web_api.py @@ -0,0 +1,360 @@ +"""The file API on the synthetic stream file and a golden file: numbers equal stream_stats called directly.""" +import threading +import time + +import numpy as np +import pytest + +from conftest import open_and_wait, wait_done +from pyevio import stream_stats +from pyevio.decoders import fadc250_stream as fs +from pyevio.web import jobs + + +def test_open_progress_summary_match_direct_stats(client, served_root): + file_id, progress = open_and_wait(client, served_root["synthetic"]) + assert progress["fraction"] == 1.0 and progress["records_done"] == progress["records_total"] == 3 + direct = stream_stats.compute_stream_stats(str(served_root["root"] / served_root["synthetic"]), workers=1) + + summary = client.get(f"/api/files/{file_id}/summary").json() + totals = summary["totals"] + assert totals["n_hits"] == direct.n_hits == served_root["expected_hits"] + assert totals["n_records"] == direct.n_records == 3 and totals["n_trailer_records"] == 1 + assert totals["n_events"] == 13 and totals["n_frames"] == 12 and totals["n_other_events"] == 1 + assert totals["non_fadc_words"] == direct.non_fadc_words and totals["n_empty_payload_banks"] == 24 + assert totals["hit_rate_hz"] == pytest.approx(direct.n_hits / direct.live_time_s) + assert summary["frames"]["stride"] == 4 and summary["frames"]["missing_frames"] == 1 and summary["frames"]["duplicate_frames"] == 1 + assert [row["roc"] for row in summary["rocs"]] == [7, 44] + assert summary["rocs"][0]["modules"] == ["fADC250"] and summary["rocs"][1]["modules"] == ["DCRB"] + assert summary["file"]["header_layout"] == "v6" and summary["file"]["record_count"] == 3 and summary["file"]["has_trailer"] + assert summary["records"] == {"count": 3, "has_trailer": True, "events": 13, "events_found": 13, "with_errors": 0, + "last_record_bit": 0, "compressed": 0, "without_index": 0, "truncated": 0, + "min_size": summary["records"]["min_size"], "max_size": summary["records"]["max_size"], + "mean_size": summary["records"]["mean_size"]} + checks = {row["name"]: row for row in summary["checks"]} + assert checks["missing frames"]["status"] == "warn" and checks["decode errors"]["status"] == "ok" + assert checks["decode warnings"] == {"name": "decode warnings", "status": "ok", "value": 0, "detail": ""} + assert totals["warnings"] == 0 + # Every synthetic frame's AIS repeats one entry, as the SRO hardware writes it + assert totals["frames_with_ais"] == totals["frames_with_degenerate_ais"] == 12 + assert checks["AIS entries identical"]["status"] == "info" and checks["AIS entries identical"]["value"] == 12 + assert "repeats one stream's entry in every slot" in checks["AIS entries identical"]["detail"] + assert summary["stats"]["cached"] is False + + +def test_distinct_ais_entries_pass_the_check(client, served_root): + """A file whose AIS lists 24 distinct streams gets an ok row with no detail.""" + from test_stream_stats import write_synthetic_file + write_synthetic_file(served_root["root"] / "runs" / "distinct_ais.evio", distinct_ais=True) + file_id, _ = open_and_wait(client, "runs/distinct_ais.evio") + + summary = client.get(f"/api/files/{file_id}/summary").json() + assert summary["totals"]["frames_with_ais"] == 12 and summary["totals"]["frames_with_degenerate_ais"] == 0 + checks = {row["name"]: row for row in summary["checks"]} + assert checks["AIS entries identical"] == {"name": "AIS entries identical", "status": "ok", "value": 0, "detail": ""} + + +def test_summary_before_done_is_409(client, served_root, monkeypatch): + release = threading.Event() + + def slow_compute(path, workers=None, progress=None, cancel=None, **_): + release.wait(5) + return stream_stats.compute_stream_stats(path, workers=1) + + monkeypatch.setattr(jobs, "compute_stream_stats", slow_compute) + file_id = client.post("/api/open", json={"path": served_root["synthetic"]}).json()["file_id"] + assert client.get(f"/api/files/{file_id}/summary").status_code == 409 + assert client.get(f"/api/files/{file_id}/hist/rate").status_code == 409 + release.set() + assert wait_done(client, file_id)["state"] == "done" + + +def test_records_paging(client, served_root): + file_id, _ = open_and_wait(client, served_root["synthetic"]) + first = client.get(f"/api/files/{file_id}/records", params={"from": 0, "count": 2}).json() + assert first["total"] == 4 and [row["index"] for row in first["rows"]] == [0, 1] + assert first["rows"][0]["event_count"] == 4 and first["rows"][0]["events_found"] == 4 and first["rows"][0]["errors"] == [] + assert first["rows"][0]["header_type"] == 0 and not first["rows"][0]["is_trailer"] + assert first["rows"][1]["offset"] == first["rows"][0]["offset"] + first["rows"][0]["size"] + rest = client.get(f"/api/files/{file_id}/records", params={"from": 2, "count": 10}).json() + assert [row["index"] for row in rest["rows"]] == [2, 3] + assert rest["rows"][1]["is_trailer"] and rest["rows"][1]["header_type"] == 3 and rest["rows"][1]["bit_info"]["is_last_record"] + assert first["rows"][1]["event_count"] == 5 and rest["rows"][0]["event_count"] == 4 # record 1 holds the extra non-stream event + assert client.get(f"/api/files/{file_id}/records", params={"from": 99}).json()["rows"] == [] + + +def test_structure_walk_to_hits(client, served_root): + file_id, _ = open_and_wait(client, served_root["synthetic"]) + root = client.get(f"/api/files/{file_id}/structure").json() + assert root["node"]["kind"] == "file" and root["total"] == 4 + assert [child["id"] for child in root["children"]] == ["r0", "r1", "r2", "t"] + assert root["children"][0]["has_children"] and not root["children"][3]["has_children"] + + record = client.get(f"/api/files/{file_id}/structure", params={"node": "r1"}).json() + assert record["node"]["kind"] == "record" and record["total"] == 5 + event = record["children"][0] + assert event["id"] == "r1/e0" and event["kind"] == "event" and event["tag"] == fs.STREAM_EVENT_TAG and event["has_children"] + assert record["children"][4]["tag"] == 0xFF50 # the non-stream event + + banks = client.get(f"/api/files/{file_id}/structure", params={"node": "r1/e0"}).json() + roles = [(child["tag"], child["role"]) for child in banks["children"]] + assert roles == [(fs.SIB_TAG, "stream_info"), (7, "roc_bank"), (44, "roc_bank")] + roc_7 = banks["children"][1] + assert roc_7["roc"] == 7 and roc_7["stream"] == 1 + + payloads = client.get(f"/api/files/{file_id}/structure", params={"node": roc_7["id"]}).json() + by_port = {child.get("port"): child for child in payloads["children"] if child["role"] != "roc_stream_info"} + assert payloads["children"][0]["role"] == "roc_stream_info" and payloads["children"][0]["tag"] == fs.ROC_STREAM_INFO_TAG + assert by_port[1]["role"] == "payload_hits" and by_port[1]["n_words"] == 16 and by_port[1]["module"] == "fADC250" and by_port[1]["has_children"] + assert by_port[5]["role"] == "payload_empty" and by_port[5]["n_words"] == 0 and not by_port[5]["has_children"] + + hits = client.get(f"/api/files/{file_id}/structure", params={"node": by_port[1]["id"]}).json() + assert hits["total"] == 16 and [hit["channel"] for hit in hits["children"]] == list(range(16)) + n = 1 * 4 # record 1, event 0 → frame index 4: the builder writes time_raw = 100 * ch + n, charge = 10 + n + assert [hit["time_raw"] for hit in hits["children"]] == [100 * ch + n for ch in range(16)] + assert [hit["charge"] for hit in hits["children"]] == [10 + n] * 16 + assert hits["children"][1]["t_ns"] == (100 + n) * fs.HIT_TIME_TICK_NS and not hits["children"][1]["flag"] + assert hits["children"][0]["word"] == f"{fs_hit_word(0, n, 10 + n):08x}" + + # The 0x41 payload-port map arrives decoded by the decoder (uint16 entries in memory order, zero entries kept). + info = client.get(f"/api/files/{file_id}/structure", params={"node": payloads["children"][0]["id"]}).json() + assert [child["role"] for child in info["children"]] == ["time_slice", "payload_map"] + assert info["children"][1]["entries"] == [{"port": 1, "module_id": 0, "module": "fADC250"}, {"port": 3, "module_id": 0, "module": "fADC250"}, + {"port": 5, "module_id": 0, "module": "fADC250"}] + sib = client.get(f"/api/files/{file_id}/structure", params={"node": banks["children"][0]["id"]}).json() + assert [child["role"] for child in sib["children"]] == ["time_slice", "aggregation_info"] and "entries" not in sib["children"][1] + assert record["node"]["compression"] == "none" + + dcrb = client.get(f"/api/files/{file_id}/structure", params={"node": banks["children"][2]["id"]}).json() + dcrb_ports = {child.get("port"): child for child in dcrb["children"] if child["role"] != "roc_stream_info"} + assert dcrb_ports[4]["role"] == "payload_words" and dcrb_ports[4]["module"] == "DCRB" and not dcrb_ports[4]["has_children"] + assert client.get(f"/api/files/{file_id}/structure", params={"node": dcrb_ports[4]["id"]}).json()["children"] == [] + + paged = client.get(f"/api/files/{file_id}/structure", params={"node": by_port[1]["id"], "from": 14, "count": 5}).json() + assert [hit["index"] for hit in paged["children"]] == [14, 15] and paged["total"] == 16 + + assert client.get(f"/api/files/{file_id}/structure", params={"node": "r9"}).status_code == 404 + assert client.get(f"/api/files/{file_id}/structure", params={"node": "r1/e0/b0"}).status_code == 404 + assert client.get(f"/api/files/{file_id}/structure", params={"node": "r1/e0/zz"}).status_code == 400 + assert client.get(f"/api/files/{file_id}/structure", params={"node": "t"}).json()["node"]["is_trailer"] + + +def fs_hit_word(channel, time_raw, charge): + return (time_raw << fs.HIT_TIME_SHIFT) | (channel << fs.HIT_CHANNEL_SHIFT) | charge + + +def test_words_are_hex_and_capped(client, served_root): + file_id, _ = open_and_wait(client, served_root["synthetic"]) + head = client.get(f"/api/files/{file_id}/words", params={"offset": 0, "count": 8}).json() + assert head["words"][0] == "4556494f" and head["words"][7] == "c0da0100" and head["count"] == 8 + capped = client.get(f"/api/files/{file_id}/words", params={"offset": 0, "count": 100000}).json() + size = (served_root["root"] / served_root["synthetic"]).stat().st_size + assert capped["count"] == min(4096, size // 4) and capped["max_count"] == 4096 + assert client.get(f"/api/files/{file_id}/words", params={"offset": 2, "count": 1}).status_code == 400 + assert client.get(f"/api/files/{file_id}/words", params={"offset": size + 4, "count": 1}).status_code == 404 + tail = client.get(f"/api/files/{file_id}/words", params={"offset": size - 8, "count": 10}).json() + assert tail["count"] == 2 + + +@pytest.mark.parametrize("name,total_field", [("hits_per_frame", "n_frames"), ("rate", "n_hits"), ("occupancy", "n_hits"), + ("charge", "n_hits"), ("time_in_frame", "n_hits")]) +def test_histogram_sums_equal_totals(client, served_root, name, total_field): + file_id, _ = open_and_wait(client, served_root["synthetic"]) + totals = client.get(f"/api/files/{file_id}/summary").json()["totals"] + hist = client.get(f"/api/files/{file_id}/hist/{name}").json() + assert int(np.sum(hist["counts"])) == totals[total_field] + edges_key = "edges" if "edges" in hist else "edges_ns" + if name != "occupancy": + assert len(hist[edges_key]) == len(hist["counts"]) + 1 + else: + assert np.asarray(hist["counts"]).shape == (2, 5, 16) + + +def test_histogram_selection(client, served_root): + file_id, _ = open_and_wait(client, served_root["synthetic"]) + roc_7 = client.get(f"/api/files/{file_id}/hist/charge", params={"roc": 7}).json() + assert sum(roc_7["counts"]) == served_root["expected_hits"] + port_3 = client.get(f"/api/files/{file_id}/hist/charge", params={"roc": 7, "port": 3}).json() + assert sum(port_3["counts"]) == sum(range(1, 13)) and port_3["counts"][-1] == sum(range(1, 13)) + channel_3 = client.get(f"/api/files/{file_id}/hist/time_in_frame", params={"roc": 7, "port": 3, "ch": 3}).json() + assert sum(channel_3["counts"]) == sum(range(1, 13)) + assert sum(client.get(f"/api/files/{file_id}/hist/time_in_frame", params={"roc": 7, "port": 3, "ch": 4}).json()["counts"]) == 0 + rate = client.get(f"/api/files/{file_id}/hist/rate", params={"roc": 7, "port": 1, "ch": 0}).json() + assert sum(rate["counts"]) == 12 and len(rate["rate_hz"]) == len(rate["counts"]) == len(rate["frames_per_bin"]) + occupancy = client.get(f"/api/files/{file_id}/hist/occupancy", params={"roc": 7, "port": 1}).json() + assert occupancy["counts"] == [12] * 16 + assert client.get(f"/api/files/{file_id}/hist/charge", params={"roc": 8}).status_code == 404 + assert client.get(f"/api/files/{file_id}/hist/charge", params={"roc": 7, "port": 2}).status_code == 404 + assert client.get(f"/api/files/{file_id}/hist/charge", params={"port": 1}).status_code == 400 + assert client.get(f"/api/files/{file_id}/hist/charge", params={"roc": 7, "port": 1, "ch": 16}).status_code == 404 + assert client.get(f"/api/files/{file_id}/hist/nonsense").status_code == 404 + + +def test_second_open_is_a_cache_hit(client, served_root, monkeypatch): + calls = [] + real_compute = stream_stats.compute_stream_stats + + def counting_compute(path, **kwargs): + calls.append(path) + return real_compute(path, **kwargs) + + monkeypatch.setattr(jobs, "compute_stream_stats", counting_compute) + file_id, _ = open_and_wait(client, served_root["synthetic"]) + assert len(calls) == 1 + cached_files = list(served_root["cache_dir"].glob("*.npz")) + assert len(cached_files) == 1 and cached_files[0].stem.startswith(file_id) # file_id is the cache key's prefix + + again = client.post("/api/open", json={"path": served_root["synthetic"]}).json() + assert again["file_id"] == file_id and again["state"] == "done" and len(calls) == 1 + assert client.get(f"/api/files/{file_id}/summary").json()["totals"]["n_hits"] == served_root["expected_hits"] + + # A fresh server (same cache dir) loads the result instead of recomputing. + from conftest import TOKEN + from fastapi.testclient import TestClient + from pyevio.web.app import create_app + second = TestClient(create_app(served_root["root"], TOKEN, served_root["cache_dir"], stats_workers=1), + base_url="http://localhost", headers={"X-Token": TOKEN}) + opened = second.post("/api/open", json={"path": served_root["synthetic"]}).json() + assert opened["state"] == "done" and opened["cached"] and len(calls) == 1 + assert second.get(f"/api/files/{file_id}/summary").json()["stats"]["cached"] is True + + +def test_stale_cache_entry_is_recomputed_in_place(client, served_root, monkeypatch): + """The key leaves the stats version out, so an old entry is overwritten, never orphaned.""" + from pyevio.web.cache import StatsCache, cache_key + + path = served_root["root"] / served_root["synthetic"] + key = cache_key(path.resolve()) + stale = stream_stats.compute_stream_stats(str(path), workers=1) + stale.version = "1" + StatsCache(served_root["cache_dir"]).save(key, stale) + assert StatsCache(served_root["cache_dir"]).load(key) is None # a miss, not an exception + + calls = [] + real_compute = stream_stats.compute_stream_stats + monkeypatch.setattr(jobs, "compute_stream_stats", lambda p, **kw: calls.append(p) or real_compute(p, **kw)) + file_id, _ = open_and_wait(client, served_root["synthetic"]) + assert key.startswith(file_id) and len(calls) == 1 + cached_files = list(served_root["cache_dir"].glob("*.npz")) + assert len(cached_files) == 1 and stream_stats.StreamStats.load(str(cached_files[0])).version == stream_stats.STATS_VERSION + + +def test_opening_another_file_cancels_the_running_job(client, served_root, monkeypatch): + def blocking_compute(path, workers=None, progress=None, cancel=None, **_): + """Blocks on the synthetic file until cancelled; any other file computes for real.""" + if not path.endswith("synthetic.evio"): + return stream_stats.compute_stream_stats(path, workers=1) + progress(0, 100) + deadline = time.monotonic() + 10 + while not cancel(): + assert time.monotonic() < deadline, "cancel flag never set" + time.sleep(0.005) + raise stream_stats.StatsCancelled("test") + + monkeypatch.setattr(jobs, "compute_stream_stats", blocking_compute) + first = client.post("/api/open", json={"path": served_root["synthetic"]}).json() + assert first["state"] in ("queued", "running") + + other_path = served_root["root"] / "runs" / "other.evio" + from test_stream_stats import write_synthetic_file + write_synthetic_file(other_path, n_records=1) + second = client.post("/api/open", json={"path": "runs/other.evio"}).json() + assert second["file_id"] != first["file_id"] + + assert wait_done(client, first["file_id"])["state"] == "cancelled" + assert wait_done(client, second["file_id"])["state"] == "done" + reopened = client.post("/api/open", json={"path": served_root["synthetic"]}).json() + assert reopened["state"] in ("queued", "running") # a cancelled file gets a new job on reopen + + +def test_errors_share_one_json_shape(client, served_root): + for response in (client.post("/api/open", json={}), client.get("/api/files/x/records", params={"from": "abc"}), + client.post("/api/open", json={"path": "runs/missing.evio"})): + assert response.status_code in (404, 422) and list(response.json()) == ["error"], response.text + + +def test_unwritable_cache_dir_still_finishes(served_root, tmp_path): + from conftest import TOKEN + from fastapi.testclient import TestClient + from pyevio.web.app import create_app + blocked = tmp_path / "blocked.file" + blocked.write_text("a file where the cache folder should be") + client = TestClient(create_app(served_root["root"], TOKEN, blocked, stats_workers=1), base_url="http://localhost", + headers={"X-Token": TOKEN}) + file_id, _ = open_and_wait(client, served_root["synthetic"]) + assert client.get(f"/api/files/{file_id}/summary").json()["totals"]["n_hits"] == served_root["expected_hits"] + + +def test_open_rejects_unreadable_files(client, served_root): + (served_root["root"] / "runs" / "garbage.evio").write_bytes(b"\0" * 64) + response = client.post("/api/open", json={"path": "runs/garbage.evio"}) + assert response.status_code == 400 and "EVIO" in response.json()["error"] + assert client.post("/api/open", json={"path": "runs/missing.evio"}).status_code == 404 + assert client.get("/api/files/nosuchid/progress").status_code == 404 + + +def test_golden_file_opens_with_no_frames(client, served_root): + if not served_root["golden"]: + pytest.skip("golden set absent") + file_id, _ = open_and_wait(client, served_root["golden"]) + summary = client.get(f"/api/files/{file_id}/summary").json() + assert summary["totals"]["n_frames"] == 0 and summary["totals"]["n_hits"] == 0 + assert summary["totals"]["hit_rate_hz"] == "n/a" and summary["totals"]["hits_per_frame"] == "n/a" + assert summary["records"]["events"] == summary["records"]["events_found"] > 0 + root = client.get(f"/api/files/{file_id}/structure").json() + events = client.get(f"/api/files/{file_id}/structure", params={"node": root["children"][0]["id"]}).json() + assert events["total"] == root["children"][0]["event_count"] + banks = client.get(f"/api/files/{file_id}/structure", params={"node": events["children"][0]["id"]}).json() + assert banks["node"]["kind"] == "event" and "children" in banks + hist = client.get(f"/api/files/{file_id}/hist/occupancy").json() + assert hist["counts"] == [] and hist["rocs"] == [] + + +def test_compressed_golden_file_events_come_from_the_record_buffer(client, served_root): + """Events of an LZ4 record live in the decompressed buffer; the tree and /words?record= read it.""" + if not served_root["golden_lz4"]: + pytest.skip("golden set absent") + file_id, _ = open_and_wait(client, served_root["golden_lz4"]) + summary = client.get(f"/api/files/{file_id}/summary").json() + assert summary["records"]["compressed"] >= 1 and summary["records"]["events_found"] == summary["records"]["events"] > 0 + root = client.get(f"/api/files/{file_id}/structure").json() + record = next(row for row in root["children"] if row["kind"] == "record" and row["compression_type"] != 0) + events = client.get(f"/api/files/{file_id}/structure", params={"node": record["id"]}).json() + assert events["total"] == record["event_count"] and events["children"][0]["compressed"] + event = events["children"][0] + banks = client.get(f"/api/files/{file_id}/structure", params={"node": event["id"]}).json() + assert banks["node"]["length"] == event["length"] + # The event's first word is its length word, readable only through the record buffer. + words = client.get(f"/api/files/{file_id}/words", params={"offset": event["offset"], "count": 2, "record": record["index"]}).json() + assert int(words["words"][0], 16) == event["length"] and words["record"] == record["index"] + for child in banks["children"]: + assert child["structure"] in ("Bank", "Segment", "TagSegment") + + +def test_truncated_tail_is_a_file_check(client, served_root): + """A final record cut by EOF keeps its complete events and shows up in the checks with byte counts.""" + whole = (served_root["root"] / served_root["synthetic"]).read_bytes() + from pyevio import EvioFile + with EvioFile(str(served_root["root"] / served_root["synthetic"])) as evio_file: + last = evio_file.get_record(evio_file.record_count - 1) + cut = last.offset + last.size - 300 # drop the trailer and the tail of the last record + (served_root["root"] / "runs" / "truncated.evio").write_bytes(whole[:cut]) + file_id, _ = open_and_wait(client, "runs/truncated.evio") + summary = client.get(f"/api/files/{file_id}/summary").json() + checks = {row["name"]: row for row in summary["checks"]} + assert checks["truncated tail"]["status"] == "error" and checks["truncated tail"]["value"] == 300 + assert "complete events kept" in checks["truncated tail"]["detail"] + assert summary["records"]["truncated"] == 1 and summary["records"]["has_trailer"] is False + assert checks["trailer"]["status"] == "warn" + assert summary["scan_checks"][0][0] == "truncated_tail" + rows = client.get(f"/api/files/{file_id}/records").json()["rows"] + assert rows[-1]["is_truncated"] and rows[-1]["events_found"] < rows[-1]["event_count"] + assert summary["totals"]["n_frames"] == summary["records"]["events_found"] - 1 # minus the non-stream event + + +def test_command_line_path_splits_into_root_and_start_file(served_root): + """``pyevio web FILE`` serves the file's folder and names the file; a folder is served as is.""" + from pyevio.web.app import served_root_and_start_file + root = served_root["root"] + assert served_root_and_start_file(root) == (root.resolve(), None) + assert served_root_and_start_file(root / "runs" / "synthetic.evio") == ((root / "runs").resolve(), "synthetic.evio") diff --git a/tests/web/test_web_auth.py b/tests/web/test_web_auth.py new file mode 100644 index 0000000..d3d4791 --- /dev/null +++ b/tests/web/test_web_auth.py @@ -0,0 +1,69 @@ +"""Token, Host and path confinement: the bad cases are rejected, the good ones pass.""" +from fastapi.testclient import TestClient + +from conftest import TOKEN + + +def test_missing_or_wrong_token_is_401(app): + anonymous = TestClient(app, base_url="http://localhost") + assert anonymous.get("/api/ls").status_code == 401 + assert anonymous.get("/api/ls", headers={"X-Token": "wrong"}).status_code == 401 + assert anonymous.get(f"/api/ls?token={TOKEN}").status_code == 200 + assert anonymous.get("/api/ls", headers={"X-Token": TOKEN}).status_code == 200 + + +def test_index_needs_no_token_but_a_local_host(app): + anonymous = TestClient(app, base_url="http://localhost") + assert anonymous.get("/").status_code == 200 + assert anonymous.get("/index.html").status_code == 200 + assert anonymous.get("/", headers={"Host": "viewer.example.com"}).status_code == 403 + + +def test_bad_host_is_403(client): + for host in ("evil.example", "evil.example:8765", "localhost.evil.example", ""): + response = client.get("/api/ls", headers={"Host": host}) + assert response.status_code == 403, (host, response.text) + for host in ("localhost", "localhost:8765", "127.0.0.1", "127.0.0.1:9000"): + assert client.get("/api/ls", headers={"Host": host}).status_code == 200, host + + +def test_paths_outside_root_are_403(client): + for bad in ("../outside/secret.evio", "runs/../../outside/secret.evio", "/etc/passwd", "link_outside.evio", + "dir_outside/secret.evio"): + response = client.post("/api/open", json={"path": bad}) + assert response.status_code == 403, (bad, response.text) + assert client.get("/api/ls", params={"dir": "../outside"}).status_code == 403 + assert client.get("/api/ls", params={"dir": "dir_outside"}).status_code == 403 + assert client.get("/api/ls", params={"dir": "/"}).status_code == 403 + + +def test_no_api_file_endpoint(client): + assert client.get("/api/file", params={"path": "runs/synthetic.evio"}).status_code == 404 + + +def test_asset_route_stays_inside_the_package(client): + assert client.get("/index.html").status_code == 200 + assert client.get("/__init__.py").status_code == 404 + assert client.get("/..%2F..%2Fpyproject.toml").status_code in (403, 404) + + +def test_favicon_is_served_at_both_names(client): + """Browsers ask for /favicon.ico before the page names its icon; both answers are the same SVG.""" + for name in ("/favicon.ico", "/favicon.svg"): + response = client.get(name) + assert response.status_code == 200 and response.headers["content-type"].startswith("image/svg+xml"), name + assert response.content.lstrip().startswith(b" 0 + if progress["state"] not in ("queued", "running"): + break + assert time.perf_counter() - t_open < 600 + time.sleep(0.2) + open_to_done_s = time.perf_counter() - t_open + assert progress["state"] == "done", progress + assert seen_running, "progress never reported a running fraction between 0 and 1" + print(f"\ncorpus open-to-done: {open_to_done_s:.1f} s, records {progress['records_total']}") + + summary = client.get(f"/api/files/{opened['file_id']}/summary").json() + assert summary["totals"]["n_frames"] == REFERENCE_FRAMES and summary["frames"]["stride"] == 4 + assert summary["totals"]["n_hits"] > 0 and summary["records"]["count"] == progress["records_total"] + rows = client.get(f"/api/files/{opened['file_id']}/records", params={"from": 7880, "count": 100}).json() + assert rows["total"] == summary["records"]["count"] + 1 and rows["rows"][-1]["is_trailer"] + hits = client.get(f"/api/files/{opened['file_id']}/hist/rate").json() + assert sum(hits["counts"]) == summary["totals"]["n_hits"] + + t_again = time.perf_counter() + again = client.post("/api/open", json={"path": CORPUS_FILE.name}).json() + assert again["file_id"] == opened["file_id"] and again["state"] == "done" + assert time.perf_counter() - t_again < 5 + fresh = TestClient(create_app(CORPUS_FILE.parent, TOKEN, tmp_path / "cache"), base_url="http://localhost", + headers={"X-Token": TOKEN}) + cached = fresh.post("/api/open", json={"path": CORPUS_FILE.name}).json() + assert cached["state"] == "done" and cached["cached"] diff --git a/tests/web/test_web_sro.py b/tests/web/test_web_sro.py new file mode 100644 index 0000000..1cd895d --- /dev/null +++ b/tests/web/test_web_sro.py @@ -0,0 +1,60 @@ +"""Raw SRO smoke: a JLab hardware file (8-word record headers, truncated tail) opens through the API. + +Runs with ``PYEVIO_SRO=1`` or ``PYEVIO_CORPUS=1``. Reference numbers, measured +independently of pyevio: 7,893 records of which the last is truncated, 86,794 +events (2 salvaged from the tail), 764,009,774 fADC250 hits. +""" +import os +import time +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from conftest import TOKEN +from pyevio.web.app import create_app + +SRO_FILE = Path("/data/sro_boyarinov_data_2026/sro_000791.evio.00000") +SRO_RECORDS = 7_893 +SRO_EVENTS = 86_794 +SRO_HITS = 764_009_774 + +pytestmark = [pytest.mark.slow, + pytest.mark.skipif(os.environ.get("PYEVIO_SRO") != "1" and os.environ.get("PYEVIO_CORPUS") != "1", + reason="set PYEVIO_SRO=1 (or PYEVIO_CORPUS=1) to run on the raw SRO file")] + + +def test_raw_sro_file_opens_with_truncated_tail(tmp_path): + if not SRO_FILE.is_file(): + pytest.skip(f"raw SRO file absent: {SRO_FILE}") + client = TestClient(create_app(SRO_FILE.parent, TOKEN, tmp_path / "cache"), base_url="http://localhost", + headers={"X-Token": TOKEN}) + t_open = time.perf_counter() + opened = client.post("/api/open", json={"path": SRO_FILE.name}).json() + assert "file_id" in opened, opened + while True: + progress = client.get(f"/api/files/{opened['file_id']}/progress").json() + if progress["state"] not in ("queued", "running"): + break + assert time.perf_counter() - t_open < 600 + time.sleep(0.2) + open_to_done_s = time.perf_counter() - t_open + assert progress["state"] == "done", progress + print(f"\nraw SRO open-to-done: {open_to_done_s:.1f} s, records {progress['records_total']}") + + summary = client.get(f"/api/files/{opened['file_id']}/summary").json() + assert summary["file"]["header_layout"] == "sro-8word" and summary["file"]["has_trailer"] is False + assert summary["records"]["count"] == SRO_RECORDS and summary["records"]["truncated"] == 1 + assert summary["totals"]["n_events"] == SRO_EVENTS and summary["totals"]["n_frames"] == SRO_EVENTS + assert summary["totals"]["n_hits"] == SRO_HITS + checks = {row["name"]: row for row in summary["checks"]} + assert checks["truncated tail"]["status"] == "error" and checks["truncated tail"]["value"] > 0 + assert summary["scan_checks"][0][0] == "truncated_tail" and summary["scan_checks"][0][3] == 2 + + rows = client.get(f"/api/files/{opened['file_id']}/records", params={"from": SRO_RECORDS - 2, "count": 10}).json() + assert rows["total"] == SRO_RECORDS and len(rows["rows"]) == 2 + last = rows["rows"][-1] + assert last["is_truncated"] and last["header_length"] == 8 and last["events_found"] == 2 + assert not rows["rows"][0]["is_truncated"] + rate = client.get(f"/api/files/{opened['file_id']}/hist/rate").json() + assert sum(rate["counts"]) == SRO_HITS diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f2841d2 --- /dev/null +++ b/uv.lock @@ -0,0 +1,3249 @@ +version = 1 +revision = 5 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version < '3.10'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "exceptiongroup" }, + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/f6/31a8f28b4a2a4fa0e01085e542f3081ab0588eff8e589d39d775172c9792/contourpy-1.3.0.tar.gz", hash = "sha256:7ffa0db17717a8ffb127efd0c95a4362d996b892c2904db72428d5b52e1938a4", size = 13464370, upload-time = "2024-08-27T21:00:03.328Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/e0/be8dcc796cfdd96708933e0e2da99ba4bb8f9b2caa9d560a50f3f09a65f3/contourpy-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:880ea32e5c774634f9fcd46504bf9f080a41ad855f4fef54f5380f5133d343c7", size = 265366, upload-time = "2024-08-27T20:50:09.947Z" }, + { url = "https://files.pythonhosted.org/packages/50/d6/c953b400219443535d412fcbbc42e7a5e823291236bc0bb88936e3cc9317/contourpy-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:76c905ef940a4474a6289c71d53122a4f77766eef23c03cd57016ce19d0f7b42", size = 249226, upload-time = "2024-08-27T20:50:16.1Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b4/6fffdf213ffccc28483c524b9dad46bb78332851133b36ad354b856ddc7c/contourpy-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92f8557cbb07415a4d6fa191f20fd9d2d9eb9c0b61d1b2f52a8926e43c6e9af7", size = 308460, upload-time = "2024-08-27T20:50:22.536Z" }, + { url = "https://files.pythonhosted.org/packages/cf/6c/118fc917b4050f0afe07179a6dcbe4f3f4ec69b94f36c9e128c4af480fb8/contourpy-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36f965570cff02b874773c49bfe85562b47030805d7d8360748f3eca570f4cab", size = 347623, upload-time = "2024-08-27T20:50:28.806Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a4/30ff110a81bfe3abf7b9673284d21ddce8cc1278f6f77393c91199da4c90/contourpy-1.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cacd81e2d4b6f89c9f8a5b69b86490152ff39afc58a95af002a398273e5ce589", size = 317761, upload-time = "2024-08-27T20:50:35.126Z" }, + { url = "https://files.pythonhosted.org/packages/99/e6/d11966962b1aa515f5586d3907ad019f4b812c04e4546cc19ebf62b5178e/contourpy-1.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69375194457ad0fad3a839b9e29aa0b0ed53bb54db1bfb6c3ae43d111c31ce41", size = 322015, upload-time = "2024-08-27T20:50:40.318Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e3/182383743751d22b7b59c3c753277b6aee3637049197624f333dac5b4c80/contourpy-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a52040312b1a858b5e31ef28c2e865376a386c60c0e248370bbea2d3f3b760d", size = 1262672, upload-time = "2024-08-27T20:50:55.643Z" }, + { url = "https://files.pythonhosted.org/packages/78/53/974400c815b2e605f252c8fb9297e2204347d1755a5374354ee77b1ea259/contourpy-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3faeb2998e4fcb256542e8a926d08da08977f7f5e62cf733f3c211c2a5586223", size = 1321688, upload-time = "2024-08-27T20:51:11.293Z" }, + { url = "https://files.pythonhosted.org/packages/52/29/99f849faed5593b2926a68a31882af98afbeac39c7fdf7de491d9c85ec6a/contourpy-1.3.0-cp310-cp310-win32.whl", hash = "sha256:36e0cff201bcb17a0a8ecc7f454fe078437fa6bda730e695a92f2d9932bd507f", size = 171145, upload-time = "2024-08-27T20:51:15.2Z" }, + { url = "https://files.pythonhosted.org/packages/a9/97/3f89bba79ff6ff2b07a3cbc40aa693c360d5efa90d66e914f0ff03b95ec7/contourpy-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:87ddffef1dbe5e669b5c2440b643d3fdd8622a348fe1983fad7a0f0ccb1cd67b", size = 216019, upload-time = "2024-08-27T20:51:19.365Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/9375917786cb39270b0ee6634536c0e22abf225825602688990d8f5c6c19/contourpy-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fa4c02abe6c446ba70d96ece336e621efa4aecae43eaa9b030ae5fb92b309ad", size = 266356, upload-time = "2024-08-27T20:51:24.146Z" }, + { url = "https://files.pythonhosted.org/packages/05/46/9256dd162ea52790c127cb58cfc3b9e3413a6e3478917d1f811d420772ec/contourpy-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:834e0cfe17ba12f79963861e0f908556b2cedd52e1f75e6578801febcc6a9f49", size = 250915, upload-time = "2024-08-27T20:51:28.683Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5d/3056c167fa4486900dfbd7e26a2fdc2338dc58eee36d490a0ed3ddda5ded/contourpy-1.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dbc4c3217eee163fa3984fd1567632b48d6dfd29216da3ded3d7b844a8014a66", size = 310443, upload-time = "2024-08-27T20:51:33.675Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c2/1a612e475492e07f11c8e267ea5ec1ce0d89971be496c195e27afa97e14a/contourpy-1.3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4865cd1d419e0c7a7bf6de1777b185eebdc51470800a9f42b9e9decf17762081", size = 348548, upload-time = "2024-08-27T20:51:39.322Z" }, + { url = "https://files.pythonhosted.org/packages/45/cf/2c2fc6bb5874158277b4faf136847f0689e1b1a1f640a36d76d52e78907c/contourpy-1.3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:303c252947ab4b14c08afeb52375b26781ccd6a5ccd81abcdfc1fafd14cf93c1", size = 319118, upload-time = "2024-08-27T20:51:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/03/33/003065374f38894cdf1040cef474ad0546368eea7e3a51d48b8a423961f8/contourpy-1.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:637f674226be46f6ba372fd29d9523dd977a291f66ab2a74fbeb5530bb3f445d", size = 323162, upload-time = "2024-08-27T20:51:49.683Z" }, + { url = "https://files.pythonhosted.org/packages/42/80/e637326e85e4105a802e42959f56cff2cd39a6b5ef68d5d9aee3ea5f0e4c/contourpy-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:76a896b2f195b57db25d6b44e7e03f221d32fe318d03ede41f8b4d9ba1bff53c", size = 1265396, upload-time = "2024-08-27T20:52:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3b/8cbd6416ca1bbc0202b50f9c13b2e0b922b64be888f9d9ee88e6cfabfb51/contourpy-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e1fd23e9d01591bab45546c089ae89d926917a66dceb3abcf01f6105d927e2cb", size = 1324297, upload-time = "2024-08-27T20:52:21.843Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/021a7afaa52fe891f25535506cc861c30c3c4e5a1c1ce94215e04b293e72/contourpy-1.3.0-cp311-cp311-win32.whl", hash = "sha256:d402880b84df3bec6eab53cd0cf802cae6a2ef9537e70cf75e91618a3801c20c", size = 171808, upload-time = "2024-08-27T20:52:25.163Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/804f02ff30a7fae21f98198828d0857439ec4c91a96e20cf2d6c49372966/contourpy-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:6cb6cc968059db9c62cb35fbf70248f40994dfcd7aa10444bbf8b3faeb7c2d67", size = 217181, upload-time = "2024-08-27T20:52:29.13Z" }, + { url = "https://files.pythonhosted.org/packages/c9/92/8e0bbfe6b70c0e2d3d81272b58c98ac69ff1a4329f18c73bd64824d8b12e/contourpy-1.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:570ef7cf892f0afbe5b2ee410c507ce12e15a5fa91017a0009f79f7d93a1268f", size = 267838, upload-time = "2024-08-27T20:52:33.911Z" }, + { url = "https://files.pythonhosted.org/packages/e3/04/33351c5d5108460a8ce6d512307690b023f0cfcad5899499f5c83b9d63b1/contourpy-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:da84c537cb8b97d153e9fb208c221c45605f73147bd4cadd23bdae915042aad6", size = 251549, upload-time = "2024-08-27T20:52:39.179Z" }, + { url = "https://files.pythonhosted.org/packages/51/3d/aa0fe6ae67e3ef9f178389e4caaaa68daf2f9024092aa3c6032e3d174670/contourpy-1.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0be4d8425bfa755e0fd76ee1e019636ccc7c29f77a7c86b4328a9eb6a26d0639", size = 303177, upload-time = "2024-08-27T20:52:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/56/c3/c85a7e3e0cab635575d3b657f9535443a6f5d20fac1a1911eaa4bbe1aceb/contourpy-1.3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c0da700bf58f6e0b65312d0a5e695179a71d0163957fa381bb3c1f72972537c", size = 341735, upload-time = "2024-08-27T20:52:51.05Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/20f7a211a7be966a53f474bc90b1a8202e9844b3f1ef85f3ae45a77151ee/contourpy-1.3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eb8b141bb00fa977d9122636b16aa67d37fd40a3d8b52dd837e536d64b9a4d06", size = 314679, upload-time = "2024-08-27T20:52:58.473Z" }, + { url = "https://files.pythonhosted.org/packages/6e/be/524e377567defac0e21a46e2a529652d165fed130a0d8a863219303cee18/contourpy-1.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3634b5385c6716c258d0419c46d05c8aa7dc8cb70326c9a4fb66b69ad2b52e09", size = 320549, upload-time = "2024-08-27T20:53:06.593Z" }, + { url = "https://files.pythonhosted.org/packages/0f/96/fdb2552a172942d888915f3a6663812e9bc3d359d53dafd4289a0fb462f0/contourpy-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0dce35502151b6bd35027ac39ba6e5a44be13a68f55735c3612c568cac3805fd", size = 1263068, upload-time = "2024-08-27T20:53:23.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/25/632eab595e3140adfa92f1322bf8915f68c932bac468e89eae9974cf1c00/contourpy-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea348f053c645100612b333adc5983d87be69acdc6d77d3169c090d3b01dc35", size = 1322833, upload-time = "2024-08-27T20:53:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/69738782e315a1d26d29d71a550dbbe3eb6c653b028b150f70c1a5f4f229/contourpy-1.3.0-cp312-cp312-win32.whl", hash = "sha256:90f73a5116ad1ba7174341ef3ea5c3150ddf20b024b98fb0c3b29034752c8aeb", size = 172681, upload-time = "2024-08-27T20:53:43.05Z" }, + { url = "https://files.pythonhosted.org/packages/0c/89/9830ba00d88e43d15e53d64931e66b8792b46eb25e2050a88fec4a0df3d5/contourpy-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b11b39aea6be6764f84360fce6c82211a9db32a7c7de8fa6dd5397cf1d079c3b", size = 218283, upload-time = "2024-08-27T20:53:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/53/a1/d20415febfb2267af2d7f06338e82171824d08614084714fb2c1dac9901f/contourpy-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3e1c7fa44aaae40a2247e2e8e0627f4bea3dd257014764aa644f319a5f8600e3", size = 267879, upload-time = "2024-08-27T20:53:51.597Z" }, + { url = "https://files.pythonhosted.org/packages/aa/45/5a28a3570ff6218d8bdfc291a272a20d2648104815f01f0177d103d985e1/contourpy-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:364174c2a76057feef647c802652f00953b575723062560498dc7930fc9b1cb7", size = 251573, upload-time = "2024-08-27T20:53:55.659Z" }, + { url = "https://files.pythonhosted.org/packages/39/1c/d3f51540108e3affa84f095c8b04f0aa833bb797bc8baa218a952a98117d/contourpy-1.3.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32b238b3b3b649e09ce9aaf51f0c261d38644bdfa35cbaf7b263457850957a84", size = 303184, upload-time = "2024-08-27T20:54:00.225Z" }, + { url = "https://files.pythonhosted.org/packages/00/56/1348a44fb6c3a558c1a3a0cd23d329d604c99d81bf5a4b58c6b71aab328f/contourpy-1.3.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d51fca85f9f7ad0b65b4b9fe800406d0d77017d7270d31ec3fb1cc07358fdea0", size = 340262, upload-time = "2024-08-27T20:54:05.234Z" }, + { url = "https://files.pythonhosted.org/packages/2b/23/00d665ba67e1bb666152131da07e0f24c95c3632d7722caa97fb61470eca/contourpy-1.3.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:732896af21716b29ab3e988d4ce14bc5133733b85956316fb0c56355f398099b", size = 313806, upload-time = "2024-08-27T20:54:09.889Z" }, + { url = "https://files.pythonhosted.org/packages/5a/42/3cf40f7040bb8362aea19af9a5fb7b32ce420f645dd1590edcee2c657cd5/contourpy-1.3.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d73f659398a0904e125280836ae6f88ba9b178b2fed6884f3b1f95b989d2c8da", size = 319710, upload-time = "2024-08-27T20:54:14.536Z" }, + { url = "https://files.pythonhosted.org/packages/05/32/f3bfa3fc083b25e1a7ae09197f897476ee68e7386e10404bdf9aac7391f0/contourpy-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c6c7c2408b7048082932cf4e641fa3b8ca848259212f51c8c59c45aa7ac18f14", size = 1264107, upload-time = "2024-08-27T20:54:29.735Z" }, + { url = "https://files.pythonhosted.org/packages/1c/1e/1019d34473a736664f2439542b890b2dc4c6245f5c0d8cdfc0ccc2cab80c/contourpy-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f317576606de89da6b7e0861cf6061f6146ead3528acabff9236458a6ba467f8", size = 1322458, upload-time = "2024-08-27T20:54:45.507Z" }, + { url = "https://files.pythonhosted.org/packages/22/85/4f8bfd83972cf8909a4d36d16b177f7b8bdd942178ea4bf877d4a380a91c/contourpy-1.3.0-cp313-cp313-win32.whl", hash = "sha256:31cd3a85dbdf1fc002280c65caa7e2b5f65e4a973fcdf70dd2fdcb9868069294", size = 172643, upload-time = "2024-08-27T20:55:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4a/fb3c83c1baba64ba90443626c228ca14f19a87c51975d3b1de308dd2cf08/contourpy-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:4553c421929ec95fb07b3aaca0fae668b2eb5a5203d1217ca7c34c063c53d087", size = 218301, upload-time = "2024-08-27T20:55:56.509Z" }, + { url = "https://files.pythonhosted.org/packages/76/65/702f4064f397821fea0cb493f7d3bc95a5d703e20954dce7d6d39bacf378/contourpy-1.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:345af746d7766821d05d72cb8f3845dfd08dd137101a2cb9b24de277d716def8", size = 278972, upload-time = "2024-08-27T20:54:50.347Z" }, + { url = "https://files.pythonhosted.org/packages/80/85/21f5bba56dba75c10a45ec00ad3b8190dbac7fd9a8a8c46c6116c933e9cf/contourpy-1.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3bb3808858a9dc68f6f03d319acd5f1b8a337e6cdda197f02f4b8ff67ad2057b", size = 263375, upload-time = "2024-08-27T20:54:54.909Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/084c86ab71d43149f91ab3a4054ccf18565f0a8af36abfa92b1467813ed6/contourpy-1.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:420d39daa61aab1221567b42eecb01112908b2cab7f1b4106a52caaec8d36973", size = 307188, upload-time = "2024-08-27T20:55:00.184Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/d61a4c288dc42da0084b8d9dc2aa219a850767165d7d9a9c364ff530b509/contourpy-1.3.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d63ee447261e963af02642ffcb864e5a2ee4cbfd78080657a9880b8b1868e18", size = 345644, upload-time = "2024-08-27T20:55:05.673Z" }, + { url = "https://files.pythonhosted.org/packages/ca/aa/00d2313d35ec03f188e8f0786c2fc61f589306e02fdc158233697546fd58/contourpy-1.3.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:167d6c890815e1dac9536dca00828b445d5d0df4d6a8c6adb4a7ec3166812fa8", size = 317141, upload-time = "2024-08-27T20:55:11.047Z" }, + { url = "https://files.pythonhosted.org/packages/8d/6a/b5242c8cb32d87f6abf4f5e3044ca397cb1a76712e3fa2424772e3ff495f/contourpy-1.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:710a26b3dc80c0e4febf04555de66f5fd17e9cf7170a7b08000601a10570bda6", size = 323469, upload-time = "2024-08-27T20:55:15.914Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a6/73e929d43028a9079aca4bde107494864d54f0d72d9db508a51ff0878593/contourpy-1.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:75ee7cb1a14c617f34a51d11fa7524173e56551646828353c4af859c56b766e2", size = 1260894, upload-time = "2024-08-27T20:55:31.553Z" }, + { url = "https://files.pythonhosted.org/packages/2b/1e/1e726ba66eddf21c940821df8cf1a7d15cb165f0682d62161eaa5e93dae1/contourpy-1.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:33c92cdae89ec5135d036e7218e69b0bb2851206077251f04a6c4e0e21f03927", size = 1314829, upload-time = "2024-08-27T20:55:47.837Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/b9f72758adb6ef7397327ceb8b9c39c75711affb220e4f53c745ea1d5a9a/contourpy-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a11077e395f67ffc2c44ec2418cfebed032cd6da3022a94fc227b6faf8e2acb8", size = 265518, upload-time = "2024-08-27T20:56:01.333Z" }, + { url = "https://files.pythonhosted.org/packages/ec/22/19f5b948367ab5260fb41d842c7a78dae645603881ea6bc39738bcfcabf6/contourpy-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e8134301d7e204c88ed7ab50028ba06c683000040ede1d617298611f9dc6240c", size = 249350, upload-time = "2024-08-27T20:56:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/26/76/0c7d43263dd00ae21a91a24381b7e813d286a3294d95d179ef3a7b9fb1d7/contourpy-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e12968fdfd5bb45ffdf6192a590bd8ddd3ba9e58360b29683c6bb71a7b41edca", size = 309167, upload-time = "2024-08-27T20:56:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/96/3b/cadff6773e89f2a5a492c1a8068e21d3fccaf1a1c1df7d65e7c8e3ef60ba/contourpy-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fd2a0fc506eccaaa7595b7e1418951f213cf8255be2600f1ea1b61e46a60c55f", size = 348279, upload-time = "2024-08-27T20:56:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/e1/86/158cc43aa549d2081a955ab11c6bdccc7a22caacc2af93186d26f5f48746/contourpy-1.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4cfb5c62ce023dfc410d6059c936dcf96442ba40814aefbfa575425a3a7f19dc", size = 318519, upload-time = "2024-08-27T20:56:21.813Z" }, + { url = "https://files.pythonhosted.org/packages/05/11/57335544a3027e9b96a05948c32e566328e3a2f84b7b99a325b7a06d2b06/contourpy-1.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68a32389b06b82c2fdd68276148d7b9275b5f5cf13e5417e4252f6d1a34f72a2", size = 321922, upload-time = "2024-08-27T20:56:26.983Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/02114f96543f4a1b694333b92a6dcd4f8eebbefcc3a5f3bbb1316634178f/contourpy-1.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:94e848a6b83da10898cbf1311a815f770acc9b6a3f2d646f330d57eb4e87592e", size = 1258017, upload-time = "2024-08-27T20:56:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3b/bfe4c81c6d5881c1c643dde6620be0b42bf8aab155976dd644595cfab95c/contourpy-1.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d78ab28a03c854a873787a0a42254a0ccb3cb133c672f645c9f9c8f3ae9d0800", size = 1316773, upload-time = "2024-08-27T20:56:58.58Z" }, + { url = "https://files.pythonhosted.org/packages/f1/17/c52d2970784383cafb0bd918b6fb036d98d96bbf0bc1befb5d1e31a07a70/contourpy-1.3.0-cp39-cp39-win32.whl", hash = "sha256:81cb5ed4952aae6014bc9d0421dec7c5835c9c8c31cdf51910b708f548cf58e5", size = 171353, upload-time = "2024-08-27T20:57:02.718Z" }, + { url = "https://files.pythonhosted.org/packages/53/23/db9f69676308e094d3c45f20cc52e12d10d64f027541c995d89c11ad5c75/contourpy-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:14e262f67bd7e6eb6880bc564dcda30b15e351a594657e55b7eec94b6ef72843", size = 211817, upload-time = "2024-08-27T20:57:06.328Z" }, + { url = "https://files.pythonhosted.org/packages/d1/09/60e486dc2b64c94ed33e58dcfb6f808192c03dfc5574c016218b9b7680dc/contourpy-1.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fe41b41505a5a33aeaed2a613dccaeaa74e0e3ead6dd6fd3a118fb471644fd6c", size = 261886, upload-time = "2024-08-27T20:57:10.863Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/b57f9f7174fcd439a7789fb47d764974ab646fa34d1790551de386457a8e/contourpy-1.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca7e17a65f72a5133bdbec9ecf22401c62bcf4821361ef7811faee695799779", size = 311008, upload-time = "2024-08-27T20:57:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/74/fc/5040d42623a1845d4f17a418e590fd7a79ae8cb2bad2b2f83de63c3bdca4/contourpy-1.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1ec4dc6bf570f5b22ed0d7efba0dfa9c5b9e0431aeea7581aa217542d9e809a4", size = 215690, upload-time = "2024-08-27T20:57:19.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/dc3dcd77ac7460ab7e9d2b01a618cb31406902e50e605a8d6091f0a8f7cc/contourpy-1.3.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:00ccd0dbaad6d804ab259820fa7cb0b8036bda0686ef844d24125d8287178ce0", size = 261894, upload-time = "2024-08-27T20:57:23.873Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/531642a01cfec39d1682e46b5457b07cf805e3c3c584ec27e2a6223f8f6c/contourpy-1.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ca947601224119117f7c19c9cdf6b3ab54c5726ef1d906aa4a69dfb6dd58102", size = 311099, upload-time = "2024-08-27T20:57:28.58Z" }, + { url = "https://files.pythonhosted.org/packages/38/1e/94bda024d629f254143a134eead69e21c836429a2a6ce82209a00ddcb79a/contourpy-1.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:c6ec93afeb848a0845a18989da3beca3eec2c0f852322efe21af1931147d12cb", size = 215838, upload-time = "2024-08-27T20:57:32.913Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "contourpy" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/5a/a55177dd22553a277388e8a1b3220e92de91bacb28356cdc73caa240121d/contourpy-1.4.0.tar.gz", hash = "sha256:20156f5a1ac4f8ce02656e39a61e82164a3d359796dc8026f75b062783d500e1", size = 13323726, upload-time = "2026-09-11T19:05:05.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/ba/01bde9753bdbba04a6da9d2bff3881f021bc708f654655e95fae26d3b3a3/contourpy-1.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:186ba929df36d61b6127da2e89cd1357e4cb79aca647381ab1a6feb0b152877b", size = 297302, upload-time = "2026-09-11T19:02:45.103Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f5/c5bf67522d49a2222f3154fe46879451d26f0e35ece41770758a64ea78cb/contourpy-1.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c76f3a5318164db7d9401132fc1b5364b784c613c93fa506e3b5e6d1bf353ec8", size = 284921, upload-time = "2026-09-11T19:02:48.049Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/cb989599eec12fda312e127eb8e04a8b21a7a6c94bf7ff1bc68273334a42/contourpy-1.4.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43c3ccbb32c6294b183dcc8e8c46dacf5ecef809497e3d48a5be298eef185ad0", size = 356332, upload-time = "2026-09-11T19:02:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/47/fb/6f620d7602507817b0b4c21dd790ed68688f1f4ae9c29aacf21f08db5cf1/contourpy-1.4.0-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86d05cec773c9507a3950122e0e40ce77c23c75ceeb2fc189514e71893cbb34b", size = 405878, upload-time = "2026-09-11T19:02:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/32/4e/693c6d6bdece679f0953775eef3a1b66d56be55478d936e7deb1f3004422/contourpy-1.4.0-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2deb580178ca19437a84bd77e4bc2cd91a8ccad212413a83c273900868b5978c", size = 408296, upload-time = "2026-09-11T19:02:56.528Z" }, + { url = "https://files.pythonhosted.org/packages/78/f9/b6831508960d559581c448532ba4df312217072975486dbf2b24fcc5b76f/contourpy-1.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:875f42444c9cf48d56f724f2637e60d0f73b3b12c9041e1484580a233edf9591", size = 383243, upload-time = "2026-09-11T19:02:58.638Z" }, + { url = "https://files.pythonhosted.org/packages/f2/21/52903825a0ae7bb625e8bd30a09816d4c3c5d6174a469c10a616166cf780/contourpy-1.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f863c6100bf926cf47d13f3cd75f9bb8ebd98aaab230eeb4468b91f98f39a6b3", size = 1357306, upload-time = "2026-09-11T19:03:01.036Z" }, + { url = "https://files.pythonhosted.org/packages/d8/79/d68b1ce8539e4071518fed9523f558398f34dcd078b8927b109c72dad2ef/contourpy-1.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3863ef2e2b13fe93f8c0ebb08ee400cb07153b8b0e91c5acb26e4537f283634f", size = 1427243, upload-time = "2026-09-11T19:03:03.866Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f0/b75a10e9d0616b97a30de277b6dfe83f1879880854c7330337309530eb48/contourpy-1.4.0-cp312-cp312-win32.whl", hash = "sha256:5450f091ac1be0be3ad3a2a3b3f23b5e443e78c670ced4fd347d626f92a28fd2", size = 347345, upload-time = "2026-09-11T19:03:06.192Z" }, + { url = "https://files.pythonhosted.org/packages/50/a9/dab08786bb4d77ef9a046b3c1be923be4e73c5d07d91f5a54e8ea9b09e41/contourpy-1.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e697d94e69f499ff6bebb899cae97a58d5d14f0e1fe9568b43a0248d2f9af8c", size = 234083, upload-time = "2026-09-11T19:03:08.122Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/3ba509755a970dbde5948e7142ac21f266be72f38017cc4087a05fdceee1/contourpy-1.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:0c7a4c2716a4e98342221954416a836cca77c996c14ddbf22b5f01d5d93ca09c", size = 559521, upload-time = "2026-09-11T19:03:10.047Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8b/62a6eacea08ea0b6ab0159a7783df7ed421ff9c1ff58fef7c31f58fa5e52/contourpy-1.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e21d1d4a9db5b3a793649c7ab0ddb4697f2818929eec871a7b9c45fce7b2a1ea", size = 297330, upload-time = "2026-09-11T19:03:11.809Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2f/103cdc5b7077cd569989d3ddb99ee3d4e96ee31bde8df2a6e426b2dc6195/contourpy-1.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:196759a3e4db60e1546167e361090ebb6d199aa3aafc9e34ea17a5ac3b23e814", size = 285037, upload-time = "2026-09-11T19:03:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/bf/25/97001d1fdf940e6f5adfc592d4f5899fff95daf7dceead3988fe09987d2d/contourpy-1.4.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c0e07c691f3b3321913ed9b8161c50ea5f77fadd006f52f5e755359b0dcbfc5", size = 356675, upload-time = "2026-09-11T19:03:15.374Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5e/9a7241a89e2f74402aefa2569e1349ad2750ff77823c04c788eaaedb7fdd/contourpy-1.4.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7f861af508fca2384eef392bc1d8377cfa349b2b854cb11b45d89c75780e2561", size = 406990, upload-time = "2026-09-11T19:03:17.44Z" }, + { url = "https://files.pythonhosted.org/packages/36/81/3a4fbbf6a806eada5453a6d16f6c7d96b9f4b3e57a3127368fb545986b8a/contourpy-1.4.0-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a03b32c2e7eda8b17757c022162c13c86f5c3d6f937dddf9ba3c3ff7b513953b", size = 407271, upload-time = "2026-09-11T19:03:19.523Z" }, + { url = "https://files.pythonhosted.org/packages/68/77/8f93ecbde1a4d11dfd460cd19a0c159f5487352e751fa87f5d78cc9db08e/contourpy-1.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1219a8898523cba821085f2da8a1b962cc696325763f09d7f93538ba43b2d70", size = 384707, upload-time = "2026-09-11T19:03:21.208Z" }, + { url = "https://files.pythonhosted.org/packages/05/07/e5cdf94d68961ee464de6b4fedd3d2f666584f59ccd124bb1290597122ab/contourpy-1.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:20da2f86bfaed60dbba729fe738c8241b1dd22b6cf7e8cbbf30df290bd04ba28", size = 1356741, upload-time = "2026-09-11T19:03:23.621Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8a/2301d07accb257ac08daeb631875fada06fedd019c20ff7b00ee07e9d211/contourpy-1.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6ced1670fafee703b8277ab1645072a226e74f167166eae146fb743916119b67", size = 1426793, upload-time = "2026-09-11T19:03:25.955Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5d/a62648f06259d15c139b14eb8cf3c58fe53cb970a55f2bb77d1070e80a41/contourpy-1.4.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:abd0f7e51ecc52bf8c95713bd80b1c17c516a71fa391c54475308a4377903c2b", size = 117961, upload-time = "2026-09-11T19:03:27.713Z" }, + { url = "https://files.pythonhosted.org/packages/71/31/c5b7771ba6982f319ec90c0cf5c425b36891adfeee2d9f667f90b2d0c166/contourpy-1.4.0-cp313-cp313-win32.whl", hash = "sha256:510f7d93d94cf6ebf4e2cd0640bea16ab8affac7547230175b6745b596ce0599", size = 347438, upload-time = "2026-09-11T19:03:29.829Z" }, + { url = "https://files.pythonhosted.org/packages/68/c1/a59df0754bf83a6f3e153cc19d550a5b01f607e389b1c4d57f436f9139e0/contourpy-1.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:6507a016976a75a15aee47809a34605de6244ccc056e6164bf9fb14b27eecad4", size = 234084, upload-time = "2026-09-11T19:03:31.657Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/97224f5decb1efa5a610f912195deb45321f73ec9b9dac485cd6fe2d5a6a/contourpy-1.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:423bd5f4b3f11d54a8c597234e513382e3454bb106b8ec7ae32ba4ed63f8d99b", size = 559559, upload-time = "2026-09-11T19:03:33.528Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/b548628c8dddf6e5e0a981b7fa8e4c008024df09c7d17c6a9e271edfa0b3/contourpy-1.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a3e67bc1a6a4618a1dac7c3053a9ffece5ddfa2046b2670b342cf430bb0b87d1", size = 297354, upload-time = "2026-09-11T19:03:35.017Z" }, + { url = "https://files.pythonhosted.org/packages/04/5a/513484208742f65af2648c519f0b7ab034616e183c4403538024c136a1ab/contourpy-1.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:072a702e2e178f4fcf96f04775d0c059e4c917925abf0d3208ebb6865df3c23d", size = 283729, upload-time = "2026-09-11T19:03:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/35/da/5a6562febc994b2c4bf9d01e57a50458ef8a2051bb3e7b35a00333e0fc26/contourpy-1.4.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:404dbcd9233513dfd1323b66ea593fef90e899f658f6b8a9ed8e93bd0ca669db", size = 357819, upload-time = "2026-09-11T19:03:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/6d/99/7b358ce888aaa426f755daa096c99c59d17c352037344e836f20e5d3b957/contourpy-1.4.0-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:61624f6722480aa7e168fd746164e7bfdf48f8ccb6542f1613741200dc37e2b1", size = 407795, upload-time = "2026-09-11T19:03:41.293Z" }, + { url = "https://files.pythonhosted.org/packages/be/02/34d7548adf08c60967435d79d7a90882eacc84f5093435287974c7e99943/contourpy-1.4.0-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e741a39dfc96babe1722561e81351aeec104526c57dbe91c167c1db606c2d55", size = 408552, upload-time = "2026-09-11T19:03:42.728Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8c/9677dad226e5aaac5b823d5b09d881ebc32ff1648af417597649841e516c/contourpy-1.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e439ab450c93455feb0218532ded3e946ec7a9b5f459069f122cfa44b89229f6", size = 384850, upload-time = "2026-09-11T19:03:44.402Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/e3fbeaa489c223629fbb434737cb3de4ecaca9cae4825e77b92490f640f8/contourpy-1.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:417be048f7e122cefbe2a34c51a1f2b0410f8eb35796f794d45475ddb7872d9d", size = 1357601, upload-time = "2026-09-11T19:03:46.746Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55fec293d342c222ed58a5d4056df4362e192af2e59527a3d5d822de8828/contourpy-1.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:43d80072a32299bf945de0a6dd4e034ea0162e832261de2e07a274a5adbba9c9", size = 1427340, upload-time = "2026-09-11T19:03:48.745Z" }, + { url = "https://files.pythonhosted.org/packages/86/40/0faabf453edf59b0e3633381d7ca72c3414fb9b72b5cbb08b06ed792d3e5/contourpy-1.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:fa1b787362a3856e63dd2b89449f6c384d55ee1beb8f94f4bcc871b40f02462c", size = 118355, upload-time = "2026-09-11T19:03:50.047Z" }, + { url = "https://files.pythonhosted.org/packages/38/0c/7ea75aa3559ae8a6805015e15a19c92b6480752ecc92526361e5776e88c5/contourpy-1.4.0-cp314-cp314-win32.whl", hash = "sha256:738c44fa71735a617f36da58e32810512407d283d5d1bb5b1cecb00d7eeba7cf", size = 356977, upload-time = "2026-09-11T19:04:11.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/41/3df8cc14bb572c8b447f9dfbc876703f0f2f2896058285b5c87a1d4a638d/contourpy-1.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:181bea01bc742734ae672fa00c717d855dd35e1f029536406538c52e7b0cd73d", size = 240101, upload-time = "2026-09-11T19:04:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/66/01/ee6830a5aa4565662a345172f6f35eeeed625d20f1c9d4ecd35318fe4ade/contourpy-1.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:64039341e2d8804f1a13bda8c69083eab935167bbd7e856cf096241f17d5bd45", size = 576359, upload-time = "2026-09-11T19:04:14.983Z" }, + { url = "https://files.pythonhosted.org/packages/51/af/49ee9c9cf012699e505c1b63c531cc99f19df50f205173092c4d541e3ec7/contourpy-1.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ef47fef9a912c77e3d84b014f701e5a9340f25703e9ed3bcebe37f91fb69dc49", size = 313601, upload-time = "2026-09-11T19:03:51.452Z" }, + { url = "https://files.pythonhosted.org/packages/ad/59/ce411ab2be0f805038626d8f4ca9ce563487ff51ff6c363e7512271564ed/contourpy-1.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:96019f059bcb3774acc0104be3360a57b36d33eaaa2e1d7f77c800ca8e07618b", size = 302812, upload-time = "2026-09-11T19:03:53.022Z" }, + { url = "https://files.pythonhosted.org/packages/95/b0/390915f9af14c1e2d3b2a9ad99d85b98df4352346a51e368cbb825f813c3/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddf5a2596d716fd3793434844caf31abc7a40b1e8718431420c89858268fb909", size = 348996, upload-time = "2026-09-11T19:03:54.594Z" }, + { url = "https://files.pythonhosted.org/packages/c7/85/e0952576d54322f3e9983b1e049a7a31f42ce82c5c29d01821e6dd3780d7/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd59df9e2fb0aff8bd7fd1adb9f349b7c835245a9d0f18c2f2deeb537190f2b1", size = 400272, upload-time = "2026-09-11T19:03:56.822Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/f13543a5e1598e4a4ad01d9d5e2d65f9c09c37660616429913d606c5977d/contourpy-1.4.0-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7794ea07cab575633daad8d6e963b662053391022294d3aacc1d9ba9ef54114", size = 412895, upload-time = "2026-09-11T19:03:58.487Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a8/6e5eaff53507dfd2dfc428240eecda32453067277d4b3e7ca71210b2d611/contourpy-1.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c8a74744fa746eeaa12f0f9ed7f8b4add9d8f1b14d95e3b5e133675eac888f", size = 377060, upload-time = "2026-09-11T19:04:00.122Z" }, + { url = "https://files.pythonhosted.org/packages/16/a3/d00e44d35511b3cead5bd794ee672d4eecf30d9de4ceac57a49d6204064f/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:107ec46f7aa1664266d69b1181aadc953d58d39f7d8d648fa5b795d4a060b0de", size = 1345688, upload-time = "2026-09-11T19:04:02.705Z" }, + { url = "https://files.pythonhosted.org/packages/1e/01/1e533b0cae32c8edd63fe40435ec3b29345dab87321afcd28722d7f957d1/contourpy-1.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a4c89c7f38a0d7a94356d74e3391073c4325c4d47ad2fd065c3fe7dbae16c0c", size = 1416140, upload-time = "2026-09-11T19:04:04.77Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0c/4ee66759f00d6a5d6909782f14f441d639fa18a4a43142e334351efc47f8/contourpy-1.4.0-cp314-cp314t-win32.whl", hash = "sha256:912c6afaa106e2f74ba22b30416b77ef7eb94e3bdc5a4df51ab147845dba9b6d", size = 371982, upload-time = "2026-09-11T19:04:06.233Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ac/b3e5324138c3e3741f526663f4f265f4f69dfdf26415a39428940864a5f6/contourpy-1.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ef9440f6f8506246269a82734f5ff9e2e4c5e775b3996fc883cc491c5257eca6", size = 262907, upload-time = "2026-09-11T19:04:07.882Z" }, + { url = "https://files.pythonhosted.org/packages/19/37/c9aa45e47819dc15a38fc5c81a2fb987fde55e9d3b991fbde514e3b6b5f5/contourpy-1.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:fc9feef8f1f001c5b87decadc67c4a5d1eebb62ca39c4763d1237ff62cf2b707", size = 587071, upload-time = "2026-09-11T19:04:09.898Z" }, + { url = "https://files.pythonhosted.org/packages/cc/74/66d4e6fbea3bcb3fd4e5ae21c039ba31b8fc408b35884b4bc09b88016dce/contourpy-1.4.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:79b06c60d5e569ce12c5da8f0c51217cdc484386e2349fa717c30fafb56d2871", size = 297358, upload-time = "2026-09-11T19:04:16.718Z" }, + { url = "https://files.pythonhosted.org/packages/2e/8d/8f7f25e71d73f15d7859943189900d2d348e740388c9ef52a0180d284762/contourpy-1.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f963ea9f0d68d2b6a02d861e3c0aae09ae25ba21f243fcf281e9fed468b078d", size = 283732, upload-time = "2026-09-11T19:04:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/f0/90/957549e3b03c407c670e3e191db63f4aef60dab6605bc967af3a806b543e/contourpy-1.4.0-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5545ff38229c15d11d822fc6f7415b932da9f4975f53e305b7b96ebfb01f889c", size = 357820, upload-time = "2026-09-11T19:04:20.077Z" }, + { url = "https://files.pythonhosted.org/packages/56/4c/fa8de87d907b41b05d03f4945a51675cd67319650d01b4c927d209ffa6ee/contourpy-1.4.0-cp315-cp315-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:630d32f06cedf37b7f1dd2b6a12e4492826bd477a4c9c7b2e2fee6ce3a2cea76", size = 407804, upload-time = "2026-09-11T19:04:21.781Z" }, + { url = "https://files.pythonhosted.org/packages/04/e7/96bd4f4a4ae285727befbdb4b332d53a2654cc5da4585d2525b47a063e1f/contourpy-1.4.0-cp315-cp315-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:be85d160e7c795113c2a93254dde2fc7e86b375322570eee78b420969426973c", size = 408559, upload-time = "2026-09-11T19:04:23.432Z" }, + { url = "https://files.pythonhosted.org/packages/ae/94/39660e8dc309f96bf7971eb4ec5659f272f84e42b34c9990a96ecce8442c/contourpy-1.4.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:018227e134b73090b06f911e773a526c77c9af426be29065500ce096a5accd4c", size = 384861, upload-time = "2026-09-11T19:04:25.198Z" }, + { url = "https://files.pythonhosted.org/packages/ab/7b/411dccbf73d5d19da49bf7735786b3baf4ddb1456412d48ae4d4ab1fc761/contourpy-1.4.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4bffcb2e8cc5c0e837631acab59dd04265fdac376550e7c3e310523877c68cb7", size = 1357612, upload-time = "2026-09-11T19:04:27.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/39/bf2442130e7d5aae75a4de4504f0c8aca3ef3da3feac3940ee27ca985d83/contourpy-1.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc87f2ffa84a49a77d76cb792f28a48ff3b05a222aa0b2bddbd839ba78e1d5ca", size = 1427345, upload-time = "2026-09-11T19:04:29.646Z" }, + { url = "https://files.pythonhosted.org/packages/96/60/e5d9196dd83ccd7dd103762982b10ef19a4f72e8ef67ae7157699b9be0bb/contourpy-1.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:c8be3392b84cf43373a488874dc1ebf46f60784a70977f92586e18019d71c7ed", size = 118157, upload-time = "2026-09-11T19:04:31.091Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b2/ae3d90f08ba2ed82189d93b55ffbd41e559760e89af4f9ef54d51869f41d/contourpy-1.4.0-cp315-cp315-win32.whl", hash = "sha256:618137ec5778fb76d494c9ec854b104a377fddba0d128bef7136cce2c3bf0df8", size = 356982, upload-time = "2026-09-11T19:04:52.913Z" }, + { url = "https://files.pythonhosted.org/packages/02/a0/36648fff407dcf8b8482985d33367ffc0fe90be72383869312cd0bd00a1b/contourpy-1.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:ea09dc704029930cbd097f75cb0cc1db9852adbcf1a151fac7e9559f7bcbfd19", size = 240101, upload-time = "2026-09-11T19:04:54.587Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/49e987a96706b8f877596cee00d558d7880793a833addc6b33f80805e5dc/contourpy-1.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:a5e2bb871b90cd7a52bdee63bf80ef7acb46398d27efc66d7679240016efc5aa", size = 576375, upload-time = "2026-09-11T19:04:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/e3/18/820ba24a070fe2a834af73e52245c501960746f5c1777ebc64ec0c0dec12/contourpy-1.4.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:432e89f835cf01f8d2123a130a3126b17ee6e0348e4d62c0973872b30aa873bf", size = 313543, upload-time = "2026-09-11T19:04:32.582Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4c/f61a63d8107e0c1c3fa47d30ce45abad90b2ad48a1a9a4510332db7e0176/contourpy-1.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e0a2392533e5e2abdf277c951047516acc8d2f3c42b2dd9dc34c907c9ebabf2", size = 302702, upload-time = "2026-09-11T19:04:34.257Z" }, + { url = "https://files.pythonhosted.org/packages/9d/93/5af95cb5113ecc6e780ca0c6c79756a2cea8833f971bbae112dd4ad73ded/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:758e7496cec3195fc28a28945eab3ceef348c47fb95c7afc2eca2f3901209c8c", size = 354370, upload-time = "2026-09-11T19:04:36.158Z" }, + { url = "https://files.pythonhosted.org/packages/47/75/5a9942446647202beb7b4d150891bfcb78fb112c73862484bbe4cc031092/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bee6ff96653e0807cd0f8eaef00174a031e3a2effa5c20f49a5a8d574b05af23", size = 402340, upload-time = "2026-09-11T19:04:37.914Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4f/bfdb0c67775a584f957211936661289789c4d0ec3e988bca2c8c02f8090e/contourpy-1.4.0-cp315-cp315t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a58b5f130afec61a093d743cdda435cc4047748b6791627e34a68f41ec3a9b07", size = 410721, upload-time = "2026-09-11T19:04:39.925Z" }, + { url = "https://files.pythonhosted.org/packages/65/d3/dddf13d6bb26e145c99debd1f2695018b5f3e24452dd9bfc9904c9d2ec89/contourpy-1.4.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9dc493e7924e5aa32d89d0b8e50986280f3d4b284968a792787bd7bcffd3cad", size = 382604, upload-time = "2026-09-11T19:04:41.793Z" }, + { url = "https://files.pythonhosted.org/packages/37/b4/89f0a5f6c6ccd322eae2704c7e933c82a4e594a61773933b4b0a18646866/contourpy-1.4.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c927ec747633e68c9920bcaad48b0bcefe648812b822f18c34e2d0e2270a3d2a", size = 1354996, upload-time = "2026-09-11T19:04:43.856Z" }, + { url = "https://files.pythonhosted.org/packages/5d/29/fc91cc1ac6f3b591a269f4357b656d6ed0c39eb17c148f41f6da2f9729b9/contourpy-1.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:80c7fc8e7ac217777ce7ba2db3cf8478652b5a4b335283111012ff9eaac9d842", size = 1425030, upload-time = "2026-09-11T19:04:45.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b8/bb129f524210c5d3eb8616876e02af280fba8b5554e0ac1afad80b51e06b/contourpy-1.4.0-cp315-cp315t-win32.whl", hash = "sha256:90feb8da006803a95ba573fddc9536357d0c3faddc3a4c9e22816ca49af52878", size = 371854, upload-time = "2026-09-11T19:04:48.106Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b1/bc774d9d200cfc4f073bba78b1bb1cb3b55bf1c8c345eae41fc2db510459/contourpy-1.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:de503609fdb71f597634ca64fb4bd2f13d27c97f140e912b1e8ed93544840df0", size = 262450, upload-time = "2026-09-11T19:04:49.502Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/eb4ff492e1c3d027617b5a20e4e6d63a035b41b8cf77a95950159556a3a5/contourpy-1.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:78ed5c5f962b3e156109f0531b174a65ba80d13cc681a70227b12524c162e184", size = 587064, upload-time = "2026-09-11T19:04:51.148Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.8" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic", version = "2.13.5", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette", version = "0.49.3", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, + { name = "typing-inspection", version = "0.4.2", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/72/0df5c58c954742f31a7054e2dd1143bae0b408b7f36b59b85f928f9b456c/fastapi-0.128.8.tar.gz", hash = "sha256:3171f9f328c4a218f0a8d2ba8310ac3a55d1ee12c28c949650288aee25966007", size = 375523, upload-time = "2026-02-11T15:19:36.69Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/37/37b07e276f8923c69a5df266bfcb5bac4ba8b55dfe4a126720f8c48681d1/fastapi-0.128.8-py3-none-any.whl", hash = "sha256:5618f492d0fe973a778f8fec97723f598aa9deee495040a8d51aaf3cf123ecf1", size = 103630, upload-time = "2026-02-11T15:19:35.209Z" }, +] + +[[package]] +name = "fastapi" +version = "0.143.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic", version = "2.14.0", source = { registry = "https://pypi.org/simple" } }, + { name = "starlette", version = "1.7.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, + { name = "typing-inspection", version = "0.4.4", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" }, +] + +[[package]] +name = "fonttools" +version = "4.60.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/c4/db6a7b5eb0656534c3aa2596c2c5e18830d74f1b9aa5aa8a7dff63a0b11d/fonttools-4.60.2.tar.gz", hash = "sha256:d29552e6b155ebfc685b0aecf8d429cb76c14ab734c22ef5d3dea6fdf800c92c", size = 3562254, upload-time = "2025-12-09T13:38:11.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/de/9e10a99fb3070accb8884886a41a4ce54e49bf2fa4fc63f48a6cf2061713/fonttools-4.60.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e36fadcf7e8ca6e34d490eef86ed638d6fd9c55d2f514b05687622cfc4a7050", size = 2850403, upload-time = "2025-12-09T13:35:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/e4/40/d5b369d1073b134f600a94a287e13b5bdea2191ba6347d813fa3da00e94a/fonttools-4.60.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e500fc9c04bee749ceabfc20cb4903f6981c2139050d85720ea7ada61b75d5c", size = 2398629, upload-time = "2025-12-09T13:35:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b5/123819369aaf99d1e4dc49f1de1925d4edc7379114d15a56a7dd2e9d56e6/fonttools-4.60.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22efea5e784e1d1cd8d7b856c198e360a979383ebc6dea4604743b56da1cbc34", size = 4893471, upload-time = "2025-12-09T13:35:58.927Z" }, + { url = "https://files.pythonhosted.org/packages/24/29/f8f8acccb9716b899be4be45e9ce770d6aa76327573863e68448183091b0/fonttools-4.60.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:677aa92d84d335e4d301d8ba04afca6f575316bc647b6782cb0921943fcb6343", size = 4854686, upload-time = "2025-12-09T13:36:01.767Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0d/f3f51d7519f44f2dd5c9a60d7cd41185ebcee4348f073e515a3a93af15ff/fonttools-4.60.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:edd49d3defbf35476e78b61ff737ff5efea811acff68d44233a95a5a48252334", size = 4871233, upload-time = "2025-12-09T13:36:06.094Z" }, + { url = "https://files.pythonhosted.org/packages/cc/3f/4d4fd47d3bc40ab4d76718555185f8adffb5602ea572eac4bbf200c47d22/fonttools-4.60.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:126839492b69cecc5baf2bddcde60caab2ffafd867bbae2a88463fce6078ca3a", size = 4988936, upload-time = "2025-12-09T13:36:08.42Z" }, + { url = "https://files.pythonhosted.org/packages/01/6f/83bbdefa43f2c3ae206fd8c4b9a481f3c913eef871b1ce9a453069239e39/fonttools-4.60.2-cp310-cp310-win32.whl", hash = "sha256:ffcab6f5537136046ca902ed2491ab081ba271b07591b916289b7c27ff845f96", size = 2278044, upload-time = "2025-12-09T13:36:10.641Z" }, + { url = "https://files.pythonhosted.org/packages/d4/04/7d9a137e919d6c9ef26704b7f7b2580d9cfc5139597588227aacebc0e3b7/fonttools-4.60.2-cp310-cp310-win_amd64.whl", hash = "sha256:9c68b287c7ffcd29dd83b5f961004b2a54a862a88825d52ea219c6220309ba45", size = 2326522, upload-time = "2025-12-09T13:36:12.981Z" }, + { url = "https://files.pythonhosted.org/packages/e0/80/b7693d37c02417e162cc83cdd0b19a4f58be82c638b5d4ce4de2dae050c4/fonttools-4.60.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a2aed0a7931401b3875265717a24c726f87ecfedbb7b3426c2ca4d2812e281ae", size = 2847809, upload-time = "2025-12-09T13:36:14.884Z" }, + { url = "https://files.pythonhosted.org/packages/f9/9a/9c2c13bf8a6496ac21607d704e74e9cc68ebf23892cf924c9a8b5c7566b9/fonttools-4.60.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dea6868e9d2b816c9076cfea77754686f3c19149873bdbc5acde437631c15df1", size = 2397302, upload-time = "2025-12-09T13:36:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/56/f6/ce38ff6b2d2d58f6fd981d32f3942365bfa30eadf2b47d93b2d48bf6097f/fonttools-4.60.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fa27f34950aa1fe0f0b1abe25eed04770a3b3b34ad94e5ace82cc341589678a", size = 5054418, upload-time = "2025-12-09T13:36:19.062Z" }, + { url = "https://files.pythonhosted.org/packages/88/06/5353bea128ff39e857c31de3dd605725b4add956badae0b31bc9a50d4c8e/fonttools-4.60.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13a53d479d187b09bfaa4a35ffcbc334fc494ff355f0a587386099cb66674f1e", size = 5031652, upload-time = "2025-12-09T13:36:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/71/05/ebca836437f6ebd57edd6428e7eff584e683ff0556ddb17d62e3b731f46c/fonttools-4.60.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fac5e921d3bd0ca3bb8517dced2784f0742bc8ca28579a68b139f04ea323a779", size = 5030321, upload-time = "2025-12-09T13:36:23.515Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/eb9d2a2ce30c99f840c1cc3940729a970923cf39d770caf88909d98d516b/fonttools-4.60.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:648f4f9186fd7f1f3cd57dbf00d67a583720d5011feca67a5e88b3a491952cfb", size = 5154255, upload-time = "2025-12-09T13:36:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/08/a2/088b6ceba8272a9abb629d3c08f9c1e35e5ce42db0ccfe0c1f9f03e60d1d/fonttools-4.60.2-cp311-cp311-win32.whl", hash = "sha256:3274e15fad871bead5453d5ce02658f6d0c7bc7e7021e2a5b8b04e2f9e40da1a", size = 2276300, upload-time = "2025-12-09T13:36:27.772Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/8e4c3d908cc5dade7bb1316ce48589f6a24460c1056fd4b8db51f1fa309a/fonttools-4.60.2-cp311-cp311-win_amd64.whl", hash = "sha256:91d058d5a483a1525b367803abb69de0923fbd45e1f82ebd000f5c8aa65bc78e", size = 2327574, upload-time = "2025-12-09T13:36:30.89Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/530c9eddcd1c39219dc0aaede2b5a4c8ab80e0bb88d1b3ffc12944c4aac3/fonttools-4.60.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e0164b7609d2b5c5dd4e044b8085b7bd7ca7363ef8c269a4ab5b5d4885a426b2", size = 2847196, upload-time = "2025-12-09T13:36:33.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2f/4077a482836d5bbe3bc9dac1c004d02ee227cf04ed62b0a2dfc41d4f0dfd/fonttools-4.60.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1dd3d9574fc595c1e97faccae0f264dc88784ddf7fbf54c939528378bacc0033", size = 2395842, upload-time = "2025-12-09T13:36:35.47Z" }, + { url = "https://files.pythonhosted.org/packages/dd/05/aae5bb99c5398f8ed4a8b784f023fd9dd3568f0bd5d5b21e35b282550f11/fonttools-4.60.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98d0719f1b11c2817307d2da2e94296a3b2a3503f8d6252a101dca3ee663b917", size = 4949713, upload-time = "2025-12-09T13:36:37.874Z" }, + { url = "https://files.pythonhosted.org/packages/b4/37/49067349fc78ff0efbf09fadefe80ddf41473ca8f8a25400e3770da38328/fonttools-4.60.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d3ea26957dd07209f207b4fff64c702efe5496de153a54d3b91007ec28904dd", size = 4999907, upload-time = "2025-12-09T13:36:39.853Z" }, + { url = "https://files.pythonhosted.org/packages/16/31/d0f11c758bd0db36b664c92a0f9dfdcc2d7313749aa7d6629805c6946f21/fonttools-4.60.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ee301273b0850f3a515299f212898f37421f42ff9adfc341702582ca5073c13", size = 4939717, upload-time = "2025-12-09T13:36:43.075Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bc/1cff0d69522e561bf1b99bee7c3911c08c25e919584827c3454a64651ce9/fonttools-4.60.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c6eb4694cc3b9c03b7c01d65a9cf35b577f21aa6abdbeeb08d3114b842a58153", size = 5089205, upload-time = "2025-12-09T13:36:45.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e6/fb174f0069b7122e19828c551298bfd34fdf9480535d2a6ac2ed37afacd3/fonttools-4.60.2-cp312-cp312-win32.whl", hash = "sha256:57f07b616c69c244cc1a5a51072eeef07dddda5ebef9ca5c6e9cf6d59ae65b70", size = 2264674, upload-time = "2025-12-09T13:36:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/75/57/6552ffd6b582d3e6a9f01780c5275e6dfff1e70ca146101733aa1c12a129/fonttools-4.60.2-cp312-cp312-win_amd64.whl", hash = "sha256:310035802392f1fe5a7cf43d76f6ff4a24c919e4c72c0352e7b8176e2584b8a0", size = 2314701, upload-time = "2025-12-09T13:36:51.09Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e4/8381d0ca6b6c6c484660b03517ec5b5b81feeefca3808726dece36c652a9/fonttools-4.60.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2bb5fd231e56ccd7403212636dcccffc96c5ae0d6f9e4721fa0a32cb2e3ca432", size = 2842063, upload-time = "2025-12-09T13:36:53.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2c/4367117ee8ff4f4374787a1222da0bd413d80cf3522111f727a7b8f80d1d/fonttools-4.60.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:536b5fab7b6fec78ccf59b5c59489189d9d0a8b0d3a77ed1858be59afb096696", size = 2393792, upload-time = "2025-12-09T13:36:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/49/b7/a76b6dffa193869e54e32ca2f9abb0d0e66784bc8a24e6f86eb093015481/fonttools-4.60.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b9288fc38252ac86a9570f19313ecbc9ff678982e0f27c757a85f1f284d3400", size = 4924020, upload-time = "2025-12-09T13:36:58.229Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/0078200e2259f0061c86a74075f507d64c43dd2ab38971956a5c0012d344/fonttools-4.60.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93fcb420791d839ef592eada2b69997c445d0ce9c969b5190f2e16828ec10607", size = 4980070, upload-time = "2025-12-09T13:37:00.311Z" }, + { url = "https://files.pythonhosted.org/packages/85/1f/d87c85a11cb84852c975251581862681e4a0c1c3bd456c648792203f311b/fonttools-4.60.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7916a381b094db4052ac284255186aebf74c5440248b78860cb41e300036f598", size = 4921411, upload-time = "2025-12-09T13:37:02.345Z" }, + { url = "https://files.pythonhosted.org/packages/75/c0/7efad650f5ed8e317c2633133ef3c64917e7adf2e4e2940c798f5d57ec6e/fonttools-4.60.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58c8c393d5e16b15662cfc2d988491940458aa87894c662154f50c7b49440bef", size = 5063465, upload-time = "2025-12-09T13:37:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/18/a8/750518c4f8cdd79393b386bc81226047ade80239e58c6c9f5dbe1fdd8ea1/fonttools-4.60.2-cp313-cp313-win32.whl", hash = "sha256:19c6e0afd8b02008caa0aa08ab896dfce5d0bcb510c49b2c499541d5cb95a963", size = 2263443, upload-time = "2025-12-09T13:37:06.762Z" }, + { url = "https://files.pythonhosted.org/packages/b8/22/026c60376f165981f80a0e90bd98a79ae3334e9d89a3d046c4d2e265c724/fonttools-4.60.2-cp313-cp313-win_amd64.whl", hash = "sha256:6a500dc59e11b2338c2dba1f8cf11a4ae8be35ec24af8b2628b8759a61457b76", size = 2313800, upload-time = "2025-12-09T13:37:08.713Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ab/7cf1f5204e1366ddf9dc5cdc2789b571feb9eebcee0e3463c3f457df5f52/fonttools-4.60.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9387c532acbe323bbf2a920f132bce3c408a609d5f9dcfc6532fbc7e37f8ccbb", size = 2841690, upload-time = "2025-12-09T13:37:10.696Z" }, + { url = "https://files.pythonhosted.org/packages/00/3c/0bf83c6f863cc8b934952567fa2bf737cfcec8fc4ffb59b3f93820095f89/fonttools-4.60.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6f1c824185b5b8fb681297f315f26ae55abb0d560c2579242feea8236b1cfef", size = 2392191, upload-time = "2025-12-09T13:37:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/40090d148b8907fbea12e9bdf1ff149f30cdf1769e3b2c3e0dbf5106b88d/fonttools-4.60.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:55a3129d1e4030b1a30260f1b32fe76781b585fb2111d04a988e141c09eb6403", size = 4873503, upload-time = "2025-12-09T13:37:15.142Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e0/d8b13f99e58b8c293781288ba62fe634f1f0697c9c4c0ae104d3215f3a10/fonttools-4.60.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b196e63753abc33b3b97a6fd6de4b7c4fef5552c0a5ba5e562be214d1e9668e0", size = 4968493, upload-time = "2025-12-09T13:37:18.272Z" }, + { url = "https://files.pythonhosted.org/packages/46/c5/960764d12c92bc225f02401d3067048cb7b282293d9e48e39fe2b0ec38a9/fonttools-4.60.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:de76c8d740fb55745f3b154f0470c56db92ae3be27af8ad6c2e88f1458260c9a", size = 4920015, upload-time = "2025-12-09T13:37:20.334Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ab/839d8caf253d1eef3653ef4d34427d0326d17a53efaec9eb04056b670fff/fonttools-4.60.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ba6303225c95998c9fda2d410aa792c3d2c1390a09df58d194b03e17583fa25", size = 5031165, upload-time = "2025-12-09T13:37:23.57Z" }, + { url = "https://files.pythonhosted.org/packages/de/bf/3bc862796a6841cbe0725bb5512d272239b809dba631a4b0301df885e62d/fonttools-4.60.2-cp314-cp314-win32.whl", hash = "sha256:0a89728ce10d7c816fedaa5380c06d2793e7a8a634d7ce16810e536c22047384", size = 2267526, upload-time = "2025-12-09T13:37:25.821Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a1/c1909cacf00c76dc37b4743451561fbaaf7db4172c22a6d9394081d114c3/fonttools-4.60.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa8446e6ab8bd778b82cb1077058a2addba86f30de27ab9cc18ed32b34bc8667", size = 2319096, upload-time = "2025-12-09T13:37:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/29/b3/f66e71433f08e3a931b2b31a665aeed17fcc5e6911fc73529c70a232e421/fonttools-4.60.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4063bc81ac5a4137642865cb63dd270e37b3cd1f55a07c0d6e41d072699ccca2", size = 2925167, upload-time = "2025-12-09T13:37:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/2e/13/eeb491ff743594bbd0bee6e49422c03a59fe9c49002d3cc60eeb77414285/fonttools-4.60.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebfdb66fa69732ed604ab8e2a0431e6deff35e933a11d73418cbc7823d03b8e1", size = 2430923, upload-time = "2025-12-09T13:37:32.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/db609f785e460796e53c4dbc3874a5f4948477f27beceb5e2d24b2537666/fonttools-4.60.2-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50b10b3b1a72d1d54c61b0e59239e1a94c0958f4a06a1febf97ce75388dd91a4", size = 4877729, upload-time = "2025-12-09T13:37:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d6/85e4484dd4bfb03fee7bd370d65888cccbd3dee2681ee48c869dd5ccb23f/fonttools-4.60.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:beae16891a13b4a2ddec9b39b4de76092a3025e4d1c82362e3042b62295d5e4d", size = 5096003, upload-time = "2025-12-09T13:37:37.862Z" }, + { url = "https://files.pythonhosted.org/packages/30/49/1a98e44b71030b83d2046f981373b80571868259d98e6dae7bc20099dac6/fonttools-4.60.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:522f017fdb3766fd5d2d321774ef351cc6ce88ad4e6ac9efe643e4a2b9d528db", size = 4974410, upload-time = "2025-12-09T13:37:40.166Z" }, + { url = "https://files.pythonhosted.org/packages/42/07/d6f775d950ee8a841012472c7303f8819423d8cc3b4530915de7265ebfa2/fonttools-4.60.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82cceceaf9c09a965a75b84a4b240dd3768e596ffb65ef53852681606fe7c9ba", size = 5002036, upload-time = "2025-12-09T13:37:42.639Z" }, + { url = "https://files.pythonhosted.org/packages/73/f6/ba6458f83ce1a9f8c3b17bd8f7b8a2205a126aac1055796b7e7cfebbd38f/fonttools-4.60.2-cp314-cp314t-win32.whl", hash = "sha256:bbfbc918a75437fe7e6d64d1b1e1f713237df1cf00f3a36dedae910b2ba01cee", size = 2330985, upload-time = "2025-12-09T13:37:45.157Z" }, + { url = "https://files.pythonhosted.org/packages/91/24/fea0ba4d3a32d4ed1103a1098bfd99dc78b5fe3bb97202920744a37b73dc/fonttools-4.60.2-cp314-cp314t-win_amd64.whl", hash = "sha256:0e5cd9b0830f6550d58c84f3ab151a9892b50c4f9d538c5603c0ce6fff2eb3f1", size = 2396226, upload-time = "2025-12-09T13:37:47.355Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/a6d9446cb258d3fe87e311c2d7bacf8e8da3e5809fbdc3a8306db4f6b14e/fonttools-4.60.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a3c75b8b42f7f93906bdba9eb1197bb76aecbe9a0a7cf6feec75f7605b5e8008", size = 2857184, upload-time = "2025-12-09T13:37:49.96Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f3/1b41d0b6a8b908aa07f652111155dd653ebbf0b3385e66562556c5206685/fonttools-4.60.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0f86c8c37bc0ec0b9c141d5e90c717ff614e93c187f06d80f18c7057097f71bc", size = 2401877, upload-time = "2025-12-09T13:37:52.307Z" }, + { url = "https://files.pythonhosted.org/packages/71/57/048fd781680c38b05c5463657d0d95d5f2391a51972176e175c01de29d42/fonttools-4.60.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe905403fe59683b0e9a45f234af2866834376b8821f34633b1c76fb731b6311", size = 4878073, upload-time = "2025-12-09T13:37:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/363364f052a893cebd3d449588b21244a9d873620fda03ad92702d2e1bc7/fonttools-4.60.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38ce703b60a906e421e12d9e3a7f064883f5e61bb23e8961f4be33cfe578500b", size = 4835385, upload-time = "2025-12-09T13:37:58.882Z" }, + { url = "https://files.pythonhosted.org/packages/1c/38/e392bb930b2436287e6021672345db26441bf1f85f1e98f8b9784334e41d/fonttools-4.60.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9e810c06f3e79185cecf120e58b343ea5a89b54dd695fd644446bcf8c026da5e", size = 4853084, upload-time = "2025-12-09T13:38:01.578Z" }, + { url = "https://files.pythonhosted.org/packages/65/60/0d77faeaecf7a3276a8a6dc49e2274357e6b3ed6a1774e2fdb2a7f142db0/fonttools-4.60.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:38faec8cc1d12122599814d15a402183f5123fb7608dac956121e7c6742aebc5", size = 4971144, upload-time = "2025-12-09T13:38:03.748Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c7/6d3ac3afbcd598631bce24c3ecb919e7d0644a82fea8ddc4454312fc0be6/fonttools-4.60.2-cp39-cp39-win32.whl", hash = "sha256:80a45cf7bf659acb7b36578f300231873daba67bd3ca8cce181c73f861f14a37", size = 1499411, upload-time = "2025-12-09T13:38:05.586Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/9dedf6420e23f9fa630bb97941839dddd2e1e57d1b2b85a902378dbe0bd2/fonttools-4.60.2-cp39-cp39-win_amd64.whl", hash = "sha256:c355d5972071938e1b1e0f5a1df001f68ecf1a62f34a3407dc8e0beccf052501", size = 1547943, upload-time = "2025-12-09T13:38:07.604Z" }, + { url = "https://files.pythonhosted.org/packages/79/6c/10280af05b44fafd1dff69422805061fa1af29270bc52dce031ac69540bf/fonttools-4.60.2-py3-none-any.whl", hash = "sha256:73cf92eeda67cf6ff10c8af56fc8f4f07c1647d989a979be9e388a49be26552a", size = 1144610, upload-time = "2025-12-09T13:38:09.5Z" }, +] + +[[package]] +name = "fonttools" +version = "4.65.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/77/51/d63c7e52163ac14393a35bd14bd7c0da95f8f74be5d7cc988092f9965129/fonttools-4.65.0.tar.gz", hash = "sha256:762ba5431358d0dbd4a01982484a1d494fb267e91f974cdcf20b80eab8560f6f", size = 3674467, upload-time = "2026-09-10T15:35:54.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/d9/1caaa015dd207da7ccd3feba87289997bd88334ea3e74a367cdc7b0e50a3/fonttools-4.65.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:93a73af2075036d36d7fbf856779c56a1b3b86ffcdae6abede7596604c42c156", size = 3095449, upload-time = "2026-09-10T15:33:04.814Z" }, + { url = "https://files.pythonhosted.org/packages/20/d6/988cd9b33ae2d92b15a51d73eb77c991f7a0fe90a7bc74526e9669247789/fonttools-4.65.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c130be2232e3caf8d2b476854ea78421ec1642917ff5ab695284bac31bbb072b", size = 2587697, upload-time = "2026-09-10T15:33:07.653Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6a/36f465a1c277131f9569f6a56fe99cf135391b4860b9c4f4b23e5b1cd5df/fonttools-4.65.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3944e0bdba42effb71959e43d91b599326b02b59c78310d5675e8a75525e7d8", size = 5313308, upload-time = "2026-09-10T15:33:09.975Z" }, + { url = "https://files.pythonhosted.org/packages/ee/56/151b5e81d20c63834f48ad37a0cbbbe2f9b248e38f8d10387f0cf5219244/fonttools-4.65.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb53892b570f7f1f0055e75fc4de32673e32f749c4c8a606b63d5c436650e634", size = 5253649, upload-time = "2026-09-10T15:33:12.225Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/6389215da9d4f98623aacdca9479c1030bbd516cb658e893bc54b61098ce/fonttools-4.65.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a6c8d184e523580a7c55d21cde37176a3c91cb539cf06c2aa36ffc634fd75296", size = 5277745, upload-time = "2026-09-10T15:33:14.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/e3/c1037a1dfb7c8efe6f2a7d1951ebdde40cbbf82e9c5d796fcb03077e4790/fonttools-4.65.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e5ceccaf2e57d83b753a2b5db5d94aa0a8071886d4afebd2d520c9683e6bef0e", size = 5413259, upload-time = "2026-09-10T15:33:17.619Z" }, + { url = "https://files.pythonhosted.org/packages/44/b9/7dd72330168d39635c329f23a98279393b1e825e42a6db362e09897aad7b/fonttools-4.65.0-cp310-cp310-win32.whl", hash = "sha256:aff640a4fcb021fa83f9879d5bfa115b6931522dae991a24faa75888bd6aeff6", size = 1577124, upload-time = "2026-09-10T15:33:19.864Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c2/e959385b4626989b25b82b9f4f99e7c3ac68377d6846b376239b6f126966/fonttools-4.65.0-cp310-cp310-win_amd64.whl", hash = "sha256:5c1700a60e4ff23a0425d5a64abf43d092e6b55071354825781faf255904dcb4", size = 1633354, upload-time = "2026-09-10T15:33:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/62/9e/58250cdc54d96fcfacb544e12997a6390fa4e6b71ae2241cfcfe5b341803/fonttools-4.65.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:06273c71e692caf5989c0437ca50875a5e49e216ddf653228fe9bb35bdc82c0f", size = 3089141, upload-time = "2026-09-10T15:33:24.509Z" }, + { url = "https://files.pythonhosted.org/packages/3e/67/0f0416069e38da0a1327a847a2e8dd1edb425d0043d8a3e63eb940070209/fonttools-4.65.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ca2b02d74e9ad7e21a1d11e4701425800a4b0c63cf90486e60258262feccbcbf", size = 2584298, upload-time = "2026-09-10T15:33:26.598Z" }, + { url = "https://files.pythonhosted.org/packages/99/0d/7e40e9957359afc0bab081131c215370a6d2d203361bb6f945ab595e924c/fonttools-4.65.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7830e9fa3bebc44dbc27ff44d8201def30ea5c48a773696d58e69e6bcd9cd5d4", size = 5498163, upload-time = "2026-09-10T15:33:29.071Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e6/e48cf0a272a5d4d17a09d44f92727e67f975ddfa94acc8464763d19a654d/fonttools-4.65.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3991732c87b3f054a2a8cf86dd0d602833fa8cb37c911503173771646e1013d", size = 5457456, upload-time = "2026-09-10T15:33:31.918Z" }, + { url = "https://files.pythonhosted.org/packages/e4/8a/a5c67ddeda82ee5e4ec3bc52ac1cbb685f0bb7a0516badc55643b454ab0d/fonttools-4.65.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6031e77b3fb8c765055ba2b8bd8dcb17030f3bf2484c448b472fdedf4460ba80", size = 5464334, upload-time = "2026-09-10T15:33:34.605Z" }, + { url = "https://files.pythonhosted.org/packages/d7/16/294e77383b2d39c9f8f25144a7ba23fe1cbbc05227cc72545097785ff07c/fonttools-4.65.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6813cc1e2e883bd6c15b3e04f72c78dc65fdc4ca861063adf5f341fbaec2ca62", size = 5595597, upload-time = "2026-09-10T15:33:37.591Z" }, + { url = "https://files.pythonhosted.org/packages/80/01/8e74ce8626c734959c782f2d89af8e9f14d078fd3d4ddf8b5a51401ae475/fonttools-4.65.0-cp311-cp311-win32.whl", hash = "sha256:4a5db8442453da4b6f43ad325879381b726bf2238a2253efd9584be21a2cefc2", size = 2441114, upload-time = "2026-09-10T15:33:40.592Z" }, + { url = "https://files.pythonhosted.org/packages/37/3e/835dc6c658426e2670b7f38c38295492fcbaeb06080e9dce89ce8105993c/fonttools-4.65.0-cp311-cp311-win_amd64.whl", hash = "sha256:9f201796c8e24e657be77c16fa664e798a46122144217f90838982937a964f0a", size = 2498823, upload-time = "2026-09-10T15:33:43.402Z" }, + { url = "https://files.pythonhosted.org/packages/58/db/242fa4fce7f632c5f7ab15585343393b25792510c0c32bd218ad24d59f1c/fonttools-4.65.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e844a45c9e5ced6536f184cf1a65b5d65e8f7e711993b413e10500a8223622e5", size = 3097120, upload-time = "2026-09-10T15:33:46Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b6/42fa4d373416675f74446421cf0b2badb82a4245c60745f05f424f75c649/fonttools-4.65.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b30e953de049bf43fc0a63c7d0c44d205c923e4bbf24716aae1518c0e65f977c", size = 2584658, upload-time = "2026-09-10T15:33:48.473Z" }, + { url = "https://files.pythonhosted.org/packages/75/6f/d589b9d62280a846c77a2c383d852c6dcb79ae8aa02bf0fa46c8577af145/fonttools-4.65.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09c34bdeed8915bfb53bee0c8ed2254dbd8ec69c0014b7f3702f347c049bf358", size = 5425737, upload-time = "2026-09-10T15:33:51.473Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/de2c0c20a42c18e565a2617932beb08c06697bbdd0d3f62b108262e11583/fonttools-4.65.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:05595385ae99f4b9626cebb973bf171b8fe38a8f40708e6e42abba0ed7537778", size = 5402463, upload-time = "2026-09-10T15:33:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/2e/bc0f5c9dce21821454bb5812d3b23410bca33c8bbd5468386d0327aa0cff/fonttools-4.65.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d95b34dd68fbfc0e4a1740c421597656117f979ed8dc85de66e08f9f9981806e", size = 5362168, upload-time = "2026-09-10T15:33:57.481Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0d/2116763ade7e71e0e5d421babe1785d745be9b3d605bf914792ce1c97f79/fonttools-4.65.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:924d06e6130429168318db71c40174a765ad016fc4b56ca811287e3d7373b3a6", size = 5524117, upload-time = "2026-09-10T15:34:00.021Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5e/f9600553b9f645e3068553831685ff1dab6259536a23b38d2e048de38f17/fonttools-4.65.0-cp312-cp312-win32.whl", hash = "sha256:04f73dd01005752a6e75cf4a8dc6b70dc724d1d4bc34cc89522153f4a2f07680", size = 2432089, upload-time = "2026-09-10T15:34:02.803Z" }, + { url = "https://files.pythonhosted.org/packages/3a/02/e436a6a1863b9862bab9f82d6da33055dd7aa3738017edd902a163525dc2/fonttools-4.65.0-cp312-cp312-win_amd64.whl", hash = "sha256:3b5d9ba89edf778b376e669b879ae33a198bf45cf5a23c3f6514f935cf9d0d9d", size = 2483475, upload-time = "2026-09-10T15:34:05.09Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5c/343a4225e83eb06f82c1d8bf41fc5e5f71eab2f62bc7ca215c764722c0a9/fonttools-4.65.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8b7bb52817a24731d2e4f4df0e71fdde05e6c806c8f8f1517b015d142fdacfa5", size = 3094663, upload-time = "2026-09-10T15:34:07.235Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c9/49b2401be932741d9218181c08948c96db32eab21ecaaf79283b752e13e7/fonttools-4.65.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e2c21772fcf70325189707b19f346812690bb1b0bd7e207e6ac205244806b303", size = 2584521, upload-time = "2026-09-10T15:34:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/51/c9/48b07e6c5cf44fa56f758a04c3772a66c0e9ba82bbe22077e4076746a62b/fonttools-4.65.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c9b26816415b5e3d899e9077109d327b22140fe3c4066644d8cdbad5bb1569", size = 5395399, upload-time = "2026-09-10T15:34:12.38Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2d/5cc5a10c8ed56079d6c2e9e3e5920622529a2ae045c9f47a6055ccb1a319/fonttools-4.65.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6dd6243f60e2d6160c2966e1e14020dc261ffd741b69a2e4ca8bfd051592e4b7", size = 5376659, upload-time = "2026-09-10T15:34:15.239Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6f/ccf33739d936bb3afa1655a225be7ee5d63d6d3c8b7d0e570bc5a2a7b899/fonttools-4.65.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:834962fd7cf21c58e81ac50a59e6ed2306f9df5e3dd481dad1cd7d2c4c60b773", size = 5336795, upload-time = "2026-09-10T15:34:17.817Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c1/ffd17483f2094f0f4118974295b514b5b82afd4f6e3c80c23f01684e97a6/fonttools-4.65.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:580eb68ff7bd6954a7a76afddd864bfc66eaaf5f5c20dd6ead9186d0055a4ffe", size = 5496530, upload-time = "2026-09-10T15:34:20.241Z" }, + { url = "https://files.pythonhosted.org/packages/b1/19/9aca7712d0676ba5f8d1530ce20478a9bb09cccd0153d56693337379cdcf/fonttools-4.65.0-cp313-cp313-win32.whl", hash = "sha256:7a18b2ffd44249fe84289253197aa65ad4f2de554c0d381f18b1f5939bc6bc60", size = 2430392, upload-time = "2026-09-10T15:34:22.906Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6f/f015dea0f4354e0798751b657cea2dee482914737f88bf1a078349ce92cf/fonttools-4.65.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ae1846b0f192fd485d26a455af19b8f5cf05aff08f9836f533913d8fcea133c", size = 2481620, upload-time = "2026-09-10T15:34:25.816Z" }, + { url = "https://files.pythonhosted.org/packages/64/29/606365ef601668bfebed14cfe3dc72bb7fcd1e23011bbb2833f17fea3065/fonttools-4.65.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:dc87a9f846bec83c3795804f62b4632716d46e3522869a3dd9cd44a5d245b006", size = 3098393, upload-time = "2026-09-10T15:34:28.472Z" }, + { url = "https://files.pythonhosted.org/packages/c7/61/11412939d6b7abf5ac7ce0d61d7f94a0a4fbabc9f1ab0a04fa622e0fc11c/fonttools-4.65.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa50dd7b9baf75e2bbd43401fc0d237f7a94a8ad2e0c57ea97160fc631af5eb0", size = 2585766, upload-time = "2026-09-10T15:34:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/db/17/734921d8aee8309801da42590375d32d4d46f771b73373ec9520d5d4220b/fonttools-4.65.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d2a9892fdb3b7e2d0f4174e3b907d226ff83698249762eeefce08ec5b2de1dd", size = 5380679, upload-time = "2026-09-10T15:34:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/cf/eb/2a4d78d60d978e694cfa04c98e4d8ddbf7f028fd768ddef470bb9da5d69e/fonttools-4.65.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6d815734e7fede0ad1f233f23f0f191cbe8fc64762ff041e589bc0f78e0b2397", size = 5321833, upload-time = "2026-09-10T15:34:36.295Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ca/1cd48b5c11ef9658732787bf2362e1bf3871dad5945d2f6cc8f675ca769c/fonttools-4.65.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:71e4c67b6196a2f447f46476fd2302604721617f5e0a21b0988bdd87b6bb9687", size = 5320938, upload-time = "2026-09-10T15:34:38.992Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0d/90e6051bded926cccabe9dd0bce3b6ca012f4d5779d61167afbf4989ceb6/fonttools-4.65.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b11d8a4a0c3ca74bbd4c105b7ef82501945c939e6096d9934ec7d288cdf5aaa9", size = 5451747, upload-time = "2026-09-10T15:34:41.387Z" }, + { url = "https://files.pythonhosted.org/packages/18/74/23e0268e48029ff0752083f69312c49b738163d5af919add9dc4ac81e907/fonttools-4.65.0-cp314-cp314-win32.whl", hash = "sha256:8e44a34d91b3c793879767eb115867ced74d2eb94974e64e72fe9e2eea71cf1a", size = 2434246, upload-time = "2026-09-10T15:34:44.385Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2d/ee69affecd4bc81cb932a213438d4199fb48bf8ca6d664438ccc7f623c2a/fonttools-4.65.0-cp314-cp314-win_amd64.whl", hash = "sha256:0aa8901db22875c831d6a91796549590d7e747da37438f38b69d771b668be445", size = 2486706, upload-time = "2026-09-10T15:34:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9c/edee5f785198ce3327e1ddeace91c773122d47f086a67e0a84b800f4a940/fonttools-4.65.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2e4a380ca40d3a5372e31b340f0da0d53b4583aadbb8e41f6a516afa69c509a4", size = 3172027, upload-time = "2026-09-10T15:34:49.269Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c6/252ec9884381089bc30da75978b072593920249de60219817f16cbc9145f/fonttools-4.65.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:661bd91c4be13721408b2d4b67a9b3fa7736713adc9a6c9780c9c60fc7959f90", size = 2619020, upload-time = "2026-09-10T15:34:51.453Z" }, + { url = "https://files.pythonhosted.org/packages/42/79/f71b0d202b8473bb45b07876c08a474de9fe560ed2c0cde642812a81e22a/fonttools-4.65.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62c5e42c79449def957adf8a9a65a43018efa7e2a6bc6baa3afe955e0d5fb2ab", size = 5545942, upload-time = "2026-09-10T15:34:54.804Z" }, + { url = "https://files.pythonhosted.org/packages/4a/bd/52e1bf33e0aebfe22ecc9a85c634db707c1dd9f6b1b438efeed98c55b959/fonttools-4.65.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:36fca8efc46b5adfca327c666e739fc05b7a7a6ef17840230f81b22f53230f61", size = 5351477, upload-time = "2026-09-10T15:34:57.514Z" }, + { url = "https://files.pythonhosted.org/packages/5c/76/8c6b2ad20beec95cd446f3a8bdc753c7e4a4fd69ef3e66704c0f7c8cb0b5/fonttools-4.65.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa1291e4c767abf1b0b79ca2d6895f7c0b661d9d95d03b5791c883a9d1e1f08", size = 5412271, upload-time = "2026-09-10T15:35:00.895Z" }, + { url = "https://files.pythonhosted.org/packages/79/49/fadbf11bbbd2d699d88a5498a0634280206e01e3bd5da9a4e0c504953ce9/fonttools-4.65.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fcf39949f56911348514b466714efa9118bec3d2be249e1c487263f7cda6edab", size = 5446450, upload-time = "2026-09-10T15:35:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/df/77/5fda646d3a6d5ee26465865c00319cc0925cf484a3b42dd8232d5b39f973/fonttools-4.65.0-cp314-cp314t-win32.whl", hash = "sha256:ffc918702661f1d74d2fbb2f5551036b64f6d2d743139e105289b694bcd16f54", size = 2467858, upload-time = "2026-09-10T15:35:05.744Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0f/afa0f3de70ebe02bba46b32cccb30b1de52624472b14ac2e7cd403d08db9/fonttools-4.65.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5a977e3645dbffaee924209828aa702a215f7ff68bc08010740c10c723787e62", size = 2518248, upload-time = "2026-09-10T15:35:07.846Z" }, + { url = "https://files.pythonhosted.org/packages/86/54/b273cf5712b36a381c13284fbf244e811e1e1d7081aed20f200f0a191ffa/fonttools-4.65.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:7aa0518b45ff5286ad56f063938db3add3816e899aab58d782b3f9a252523caa", size = 3092770, upload-time = "2026-09-10T15:35:10.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/d3e4511475954d4ec4f3c254e81f0fcc1ade504cb7381eeca48b8c44b8a6/fonttools-4.65.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:673e2b3ac4ac8e4f3607d390ecc5a606e5db5c4e88fb4cb2999593efb65afea2", size = 2584364, upload-time = "2026-09-10T15:35:13.54Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3f/7cfaba467bdd1d3d04be21ba7b5bd75c6ca58ba280e0519707ca96a43c4d/fonttools-4.65.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a03cff943b204a90bf3d1c04c97b9509a8aa0ee99e2e544084ca43ad995975b", size = 5377819, upload-time = "2026-09-10T15:35:15.839Z" }, + { url = "https://files.pythonhosted.org/packages/25/17/a68d9b19a97bb2ee37e8098fea50657073df97c7e5392afbe1b9d7c0c581/fonttools-4.65.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:41f684ee6212e411196ab054f8308faf6605f154950e6f4686fb8f2103d624b0", size = 5341296, upload-time = "2026-09-10T15:35:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8d/d744653ed607a241339015d4af6743ca3d85a74a2045a02cc06ea0383c71/fonttools-4.65.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:52ea9d2a8385075770db74d5e5718fa80b2222bb4fc62856a377dd2865ca8848", size = 5315310, upload-time = "2026-09-10T15:35:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c8/1ca6dc69cbaa0e394ff70d9e266777124d3ce3c6433026a6b4de50b890ae/fonttools-4.65.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d0d25027ade65ec46b13c0436e51bcb7c5171a4ea255a5e7a8d0d1d3ab4cffd7", size = 5463840, upload-time = "2026-09-10T15:35:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/07/97/d374df38a14f2ac04ba9ed96bca89d4988e7d63aa5ed85c54fd8f2badd7d/fonttools-4.65.0-cp315-cp315-win32.whl", hash = "sha256:22cb846d35d278235ef3b7e947c6040b2057d72e8305a314f21d5342eca49040", size = 2433149, upload-time = "2026-09-10T15:35:26.59Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/eb8f7506faf6a70c5a8f2eccbeea3cd826c748a6fd78c7b4b3effb5a3a11/fonttools-4.65.0-cp315-cp315-win_amd64.whl", hash = "sha256:aecc899fdbf9ecbf728f8977977e2e1043ee4d70c257124c8fa4cbcf796fcd83", size = 2485725, upload-time = "2026-09-10T15:35:28.945Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a4/2df0d97514feb8d857d5de854cd8d660d9a7ee1fd081bc98497124f515a8/fonttools-4.65.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:6275863dad195ee34b6e0ca3fc61c74096bc37e5d6fb8e049f4d68d65865a2b7", size = 3164065, upload-time = "2026-09-10T15:35:31.197Z" }, + { url = "https://files.pythonhosted.org/packages/15/33/e09661c09e6c8a3b0bd50da0e72918df7576dede31f39c948cc245011434/fonttools-4.65.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:d8ffd2f62b402180b0edae8f86a071f583970e2177143117db5cf4c52da60079", size = 2615195, upload-time = "2026-09-10T15:35:33.569Z" }, + { url = "https://files.pythonhosted.org/packages/64/d7/114b05f4193679d0935272220083ec43de7f918b5a777018c5ac5c1ae39e/fonttools-4.65.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:830f91327ca83bfc1278e7060068a498938f84d05dc4869675486f84f55d4fe1", size = 5521239, upload-time = "2026-09-10T15:35:36.291Z" }, + { url = "https://files.pythonhosted.org/packages/ba/10/67d615939f859ffe75663a67bca3593966febbcd0109ec72f20f73cbb4cc/fonttools-4.65.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9db2cb95847c18eef74a4ef0fe257a893ae3f4b0395f4866e2f426ab07f3d804", size = 5343802, upload-time = "2026-09-10T15:35:38.82Z" }, + { url = "https://files.pythonhosted.org/packages/d1/06/faa793a806da03acce862fbed353f76026b28103d43860d69be9a6a1f0fd/fonttools-4.65.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:be9b9a95ed0af03375e99020e921c4bc6b41fad10e053dea7acad370521a3c46", size = 5388482, upload-time = "2026-09-10T15:35:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/53/d2/eb7258df60e634db60c9a8cd72bc9eaf8dea409d1b1ceec3b9fb49531ad2/fonttools-4.65.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:bbd9faf777a9deb6790df4f2b0be611857c45fe86605e840d7154a028d828af7", size = 5435088, upload-time = "2026-09-10T15:35:44.639Z" }, + { url = "https://files.pythonhosted.org/packages/00/6a/58597f16e1265fe9205de3069e338a339e3eef0b1daf049ee08269458091/fonttools-4.65.0-cp315-cp315t-win32.whl", hash = "sha256:c779d838815b91889c95ed64c9be5950ad5a683279f91aeb23384cb757ddc6a3", size = 2464925, upload-time = "2026-09-10T15:35:47.224Z" }, + { url = "https://files.pythonhosted.org/packages/4b/95/122fc172006db747f4968e08c710f52a94f55eb007173b859b3f80b5b810/fonttools-4.65.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d9484b7ee1b49b6b8a0231c849f3983723dec29e3a7366d9b1b02f4036f71944", size = 2513945, upload-time = "2026-09-10T15:35:49.895Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/f894ceb867118c0261d0f69a9bd516b045a3754238f76c88a49513ac7a83/fonttools-4.65.0-py3-none-any.whl", hash = "sha256:3060b8c1fc2329fa20265b7c138614143ea7c1624e26c5c180c76aeb74deae6f", size = 1196441, upload-time = "2026-09-10T15:35:52.347Z" }, +] + +[[package]] +name = "fonttools" +version = "4.66.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/87/b6/126c659ab7e0e03e01a5f5d223abf7b2c0691ae92718085a212a3924a2a3/fonttools-4.66.1.tar.gz", hash = "sha256:64967c6ddb0d4c610dfd8cb1485981b2d27972ddfb7d4bbbd9e199d2a089c450", size = 3694174, upload-time = "2026-09-29T16:11:53.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/2e/2c6d3daaa5152bbb2fc2b44037399366af7eb5fb2fbf7d9a236813753f77/fonttools-4.66.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d4f76868aea9cc4ce47fdbeaa904c02ee7d85dd0ad095071ae77f0bda6e62cf5", size = 3089406, upload-time = "2026-09-29T16:09:50.871Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1c/500fbc0fd5b6d9cb701c1107a6f38ec4d3319057681e520014c1b9beb009/fonttools-4.66.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34378db9a398b59de18cc79d942f0a907c6fc6301945e065ec888202f607aa3f", size = 2586728, upload-time = "2026-09-29T16:09:53.654Z" }, + { url = "https://files.pythonhosted.org/packages/38/f2/f3ac6374058bc93bd8a685c4849815b598bbd5e3ec5f706f3f4944a060e1/fonttools-4.66.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05c0fff6b4a5d872ed89cab2c4f81060b86ace263903eb4e8d0edcac47a60dfa", size = 5484428, upload-time = "2026-09-29T16:09:55.987Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e0/ed45f50fe7a7320656ac7dde60f26afa3a92a21e14749135b4c03f3385b7/fonttools-4.66.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72299346b96b9244dabcc051b24e4653da4edfda6105544cfb10ce856a1afaac", size = 5447695, upload-time = "2026-09-29T16:09:58.02Z" }, + { url = "https://files.pythonhosted.org/packages/3c/c0/919293f7b38ff81a6014a7fce45fbcc113aaf473f22180a7f7897c702a67/fonttools-4.66.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c724e56213494c6695335577822b2d1628d102e71614de8b7eb8e30886d6a314", size = 5450236, upload-time = "2026-09-29T16:10:00.105Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2a/00864b96e013a05df55b347b3eb9b1726803267d52345676cd86e928ddf1/fonttools-4.66.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b913b8e9f7ca9bec44d1eb919f591c596c61041aa357c96be55ff93169859e91", size = 5588056, upload-time = "2026-09-29T16:10:02.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/7b0259de6d874686532a781059fd85796dcd3e07a12146361092141169f3/fonttools-4.66.1-cp311-cp311-win32.whl", hash = "sha256:e7ea7a08547a453fa000db96ed5714a3dc7e2b4255b9243f897921f8c10c169a", size = 1577702, upload-time = "2026-09-29T16:10:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/39/ff/ccaddfb8ac343e90f40f86fa460f832cd45460c65ee9288b6da923c72728/fonttools-4.66.1-cp311-cp311-win_amd64.whl", hash = "sha256:36bb24d4b98faacaff04af1d5e0a4285feba6ed1da6728cd34b6b6deb6bbb934", size = 1635298, upload-time = "2026-09-29T16:10:06.026Z" }, + { url = "https://files.pythonhosted.org/packages/06/1b/fcb22638f2f5918c855abfbab203701e4b03739953d26a59f3e6e59b8f30/fonttools-4.66.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8526b2b7ec4db6b81efb83438be52b1264eda9a4994d867163cfe8c65581ce8d", size = 3097433, upload-time = "2026-09-29T16:10:07.869Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0d/f51141407f9a64efc9fb39b94b0194c4a99c1ffff50834e7ca7a3f1cdd53/fonttools-4.66.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6946fe7bfb28590a1fd4061a17609c9a843952deb65dcf30d1fe725070c3e7a4", size = 2587566, upload-time = "2026-09-29T16:10:10.099Z" }, + { url = "https://files.pythonhosted.org/packages/75/c0/5810d73f9102eb1a08f26b8f7a6c22498622b43e05e684cd5ec602be8ffd/fonttools-4.66.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09ae73bd219e1245debd8376077a0fa6e03175e255c4f51bae5f6a271bfe384a", size = 5416460, upload-time = "2026-09-29T16:10:12.182Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6e/babde908b879a3b51ffc230919d06a804912559dd4cf8c94f3fa2af69fec/fonttools-4.66.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b8ff9e0edbcee2fbf7dff0c41b9041c1901c26acf64e23adb67495012df11de", size = 5397326, upload-time = "2026-09-29T16:10:14.133Z" }, + { url = "https://files.pythonhosted.org/packages/e6/98/8522cc7a5e5ad64a2b2b6e9598489809ed4956c532b887c65b131b8500b7/fonttools-4.66.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38ce8f5fbd5c17dd2153d47d7c8d4108f3deda3f2b4a79b60ddc470a58faded3", size = 5353857, upload-time = "2026-09-29T16:10:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/f9/ab87d67c23178886e57f24397b3113b4ac35297f086467c2c4d4231671a8/fonttools-4.66.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8aed2bbcd6216253ef1b015763593365ee8084f621dfa53bb957c19d5e05f7cd", size = 5517604, upload-time = "2026-09-29T16:10:18.791Z" }, + { url = "https://files.pythonhosted.org/packages/d4/20/e126062610aea31919310b0b3de4d0bbe73ccbdcb3d0409cc52027db93ee/fonttools-4.66.1-cp312-cp312-win32.whl", hash = "sha256:9ea6c93091cbf83161a544388746a0911550bd98cb911faca3591cf5ead166ac", size = 2435084, upload-time = "2026-09-29T16:10:21.145Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/5c245a0587e5b7b3dd209f640b9f70d8e63404ad8dc93819ba578d685985/fonttools-4.66.1-cp312-cp312-win_amd64.whl", hash = "sha256:261d8dc95845e751f975fe8d6075600593ee253470d46d1b84801688051b09f6", size = 2486987, upload-time = "2026-09-29T16:10:22.924Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f4/e410b8c913da5b3fdbb4d16db0f2d2a0952f59c4db8d52dcf2d421d82044/fonttools-4.66.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:53e5854ea8003efec34adc0863c18ce91da923018354d27366f7fee7db928d7a", size = 3095249, upload-time = "2026-09-29T16:10:25.261Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6a/275108baf41d9f2f4d1d77cf5f1e22200fe47efd5099dafabc3eba0b6197/fonttools-4.66.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:60f5ea17aed4262630afa43f26997ceabd6417fa05dcedf54c665f5a29193e18", size = 2587288, upload-time = "2026-09-29T16:10:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/db/e7/11e5e6beb7e336d80f0ca870ae080033a91ebfe34fd5390dbcf78f8df56f/fonttools-4.66.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1801fdad5600118327171e0e8aa79f7cc48831dd55ab36998c9de03bd5ffe6cd", size = 5389836, upload-time = "2026-09-29T16:10:28.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/1c/6ec22372362b03350fe3da7bf33491a07cc9a553a36dd2383b76ec1741eb/fonttools-4.66.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:83572afe48733bad7a4a9c11721d3a726c2e976d82b063fc9bdd049d76955abd", size = 5372059, upload-time = "2026-09-29T16:10:31.011Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4a/cb7971f1c0f40f891028ee8c46dadc6897ef61e44aa925a23fba2ef06e2a/fonttools-4.66.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:08d8956e3ec990c75230d92f1630b215e8f3738c83a003421c22b31ebfd0ce15", size = 5332824, upload-time = "2026-09-29T16:10:33.563Z" }, + { url = "https://files.pythonhosted.org/packages/e0/86/563e671f1d43fa8ffb2518d7fe16630fb16c7faf0420cc39f8e80181f486/fonttools-4.66.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fdf4afd75c643e60ef4a96fe64fc8a9def27d2a542112332371a9e5066885f9a", size = 5490659, upload-time = "2026-09-29T16:10:35.788Z" }, + { url = "https://files.pythonhosted.org/packages/79/f7/2573ddfd256be6503458f8523e2893443e66257fc17f6055d7e0f0e721b7/fonttools-4.66.1-cp313-cp313-win32.whl", hash = "sha256:dbb7b950f8c02deaffb6968994691e8589d671b7ef8396bc9d5b5c0dfbb7292f", size = 2433450, upload-time = "2026-09-29T16:10:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/68bc2be04b83535607fbb70ebb2ba02380bf4286d79597c4515b7d247187/fonttools-4.66.1-cp313-cp313-win_amd64.whl", hash = "sha256:43d1284c1964666ee833f2badd3017dc138f53d4889043ffca66c5ce4188f188", size = 2485146, upload-time = "2026-09-29T16:10:39.772Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/c745b210ec49379ebfe627e166b527f44671a1f6ec5e1e219d91caa8964d/fonttools-4.66.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b18803cbdef248e7ee1be59cb277fbbe1da1faaa6f726fa5d3557904e6a3d967", size = 3099264, upload-time = "2026-09-29T16:10:41.998Z" }, + { url = "https://files.pythonhosted.org/packages/35/af/dd698f10bf0f743873077259e8a6fce075861dde3bb01eb22b2c4f7aefe8/fonttools-4.66.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f08ab7f8461c37ecfdd29ad97fb0c0780b50501bd664bb0f46b6e83ed2b9d2a7", size = 2588769, upload-time = "2026-09-29T16:10:43.933Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/10b5caa2aa779e62411b67949bda9741d4d7532ba0b6dea647b715131260/fonttools-4.66.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cf4f996f9b1cb549bff9ea4c50813988a26ec922c95cfa85c7e4f1270447e06", size = 5374278, upload-time = "2026-09-29T16:10:45.727Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/9ac5c6773feec1b40e57eac106d869886f66a1e44082d343ac1e1e1fb773/fonttools-4.66.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9261ef507f2dd74203443a472b65b5a26429eb378f975016dec7dc7305b24898", size = 5317826, upload-time = "2026-09-29T16:10:48.056Z" }, + { url = "https://files.pythonhosted.org/packages/04/0a/69beb11f6b714ac90ee73ad4600ac91d7dd4e1ce361d087c8425bb8472de/fonttools-4.66.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e1cde50b3ec84ca6fe63ca815de183dbecb88e8adf8ada82d8ea130ef12b2b43", size = 5316239, upload-time = "2026-09-29T16:10:50.201Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/7a77359e469d3a638df91d3e225cef4a3c1184c20e98381238042f7835fa/fonttools-4.66.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f0a8f16c4f3a5a87ca971de2631792d8cb4d570951f2000acf712f157d40db", size = 5449423, upload-time = "2026-09-29T16:10:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/ea0b2f1ef90431b1879d6e6c680a7fde497129cf511ab995ade0ff8e19a7/fonttools-4.66.1-cp314-cp314-win32.whl", hash = "sha256:b878c78b2af11b879bd4f26bb0d8bda2a4c64543fdd3f28efe2c80f97f043885", size = 2437673, upload-time = "2026-09-29T16:10:54.281Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/629dbb4a40c4a7b3de61442c6b4430d36ab6e0e8cf941c547f4fd66f3337/fonttools-4.66.1-cp314-cp314-win_amd64.whl", hash = "sha256:05aeb146451f37289f782c3c861f3d0f4b86c2dd2e4620b46683544c7406640e", size = 2489763, upload-time = "2026-09-29T16:10:56.262Z" }, + { url = "https://files.pythonhosted.org/packages/0e/59/342e5fce9438f88882524128d1feb0311d4014cb6f8bdeb4607fcc00713f/fonttools-4.66.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:66fad3b7874062c2a2692f0ae6dea56d24f01b778c7f191950ca3ff997e25a88", size = 3172931, upload-time = "2026-09-29T16:10:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/50/92/96196ebfd02676f28fa9b3776d85e18281bca0c8450d7e214c40e346bf92/fonttools-4.66.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eef76d5796e604f9d6753fa6d323c4eb9f4e0e43f1dcca553f3e6914f1667b64", size = 2621937, upload-time = "2026-09-29T16:11:00.845Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c3/3f4b761037ebc2e5597c52c218a9e95dbc4a2cab572828654f6004f422f5/fonttools-4.66.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c47299bca4b5acaaeb32100f77b944feea151de9ef1773365a410dc3d49b945b", size = 5546932, upload-time = "2026-09-29T16:11:03.126Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d1/3f506cc79608becbc287785db8c44eb3f93079b49752266eb9f57700ecc4/fonttools-4.66.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dfba62cc93199ba62c376f90f2a9147d92730d301e44f88e013e50ff5edf6193", size = 5353546, upload-time = "2026-09-29T16:11:05.394Z" }, + { url = "https://files.pythonhosted.org/packages/6d/27/6534d84430ba1641185f8a0c9e2c7ecd395b15ff98f96967e3fb3c728b09/fonttools-4.66.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2c7340497cf53490293e0c2b61011e0191633022ede0a0a964a68157a98b0fb4", size = 5414313, upload-time = "2026-09-29T16:11:07.618Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/831ceca06d78855b11dc203b0e3ba5e6fd8a63a71ee0343ea8bd367fda55/fonttools-4.66.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c666fefdd5613a0e99aa4516e6ff4ef87aa86cf1c7ba12a73550f4770e46b750", size = 5449661, upload-time = "2026-09-29T16:11:09.997Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e4/21dc18bcbc8d0354814f6ea58af3d76d3bcd9b0d7246df454cb9e00c1740/fonttools-4.66.1-cp314-cp314t-win32.whl", hash = "sha256:2ce4c93160535761f22c80b2afbc96cabc09855363a5d1a5554265b8a4c85901", size = 2471431, upload-time = "2026-09-29T16:11:12.237Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/eb0489e7d58ac0d3387584afc7f3e505f60f60fe4b4f5a0274f013d444a2/fonttools-4.66.1-cp314-cp314t-win_amd64.whl", hash = "sha256:b13c8c541ce0b794add3211b3641cc0e113d707f73e06235e6fe9731bd7c45a9", size = 2521288, upload-time = "2026-09-29T16:11:14.52Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/235679d5fe4265c251418cd02321de069281a700415389e14c4cce442e3d/fonttools-4.66.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:2d637468dac23aac0e223bd52e66f8faa3b0dfcef57435460fa2107e830226cd", size = 3093646, upload-time = "2026-09-29T16:11:16.809Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/7bcd4046b3b5644c563059cce6421b488fe57f65c59171ef01ed11b66d3a/fonttools-4.66.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:90de3477394c73481d27d2b86091c1c736053ee13ff52c42f0e151948e8578c6", size = 2587335, upload-time = "2026-09-29T16:11:18.721Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b6/05a093ec04fa2ad449ecc67638aad0f8d60df380df2471b68b549fe2a4b2/fonttools-4.66.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d84ac0bf776b68396185bd919dd29e633d94300660335efc40b55b294b886903", size = 5371509, upload-time = "2026-09-29T16:11:20.742Z" }, + { url = "https://files.pythonhosted.org/packages/65/a9/55effa83e64b9ff4f379d9186236d50d03f6d4770d8346805c1b6620c370/fonttools-4.66.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0dc6fd99cb8c30941036308b148da9432640442a6f26f36d71dad9be24cbd0e9", size = 5334853, upload-time = "2026-09-29T16:11:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/af/a8/44bb4021c585b76f8e480116e1f3fca62eb7d88fe5794e2ec84c10d2da76/fonttools-4.66.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d3b5403e82d0c7659ff1d9f956e29a3a68d094f043e9f5bc0442796fc3a4fb58", size = 5311704, upload-time = "2026-09-29T16:11:25.393Z" }, + { url = "https://files.pythonhosted.org/packages/63/dd/dd482902fb7fd8b71d3b6508431a57938b5e41b29bf6fb252ed3cfce065f/fonttools-4.66.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b8b71db96d605784e2c5ebf0788a406018ea8fdd80338491f4c83613d5cd1fec", size = 5459477, upload-time = "2026-09-29T16:11:27.536Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a5/07611ba4d4b298b90908cb15005a6d730c334e25548f5175a09907b2eea6/fonttools-4.66.1-cp315-cp315-win32.whl", hash = "sha256:668f092bc0de8902167df6a0d5c5aedc3b4f9e43cf88eea92e9b46a2bd3968f5", size = 2436532, upload-time = "2026-09-29T16:11:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/42/a5/5c39a05bf7c518743c6072cd75b63cd27285c58a70b1086e923fc071fb84/fonttools-4.66.1-cp315-cp315-win_amd64.whl", hash = "sha256:7f49f2834f5d006fe0f3bb10fec73b261806c50941f0cfbc08294074ffc32210", size = 2488769, upload-time = "2026-09-29T16:11:31.967Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a6/1205f7a7dd746581498457e55bfbcdfbea87105a454a7b3465259816bb79/fonttools-4.66.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:71c7ca1b5f46f5dd549f56b47d47c0b709217675c23d3a7bc6aa1a69b6d9bbae", size = 3164459, upload-time = "2026-09-29T16:11:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/42/915ff8f3c5d3bc9877007e708774e52f7ec431f9e59f607a86e50fe1864c/fonttools-4.66.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2d320483928c7831f0139ecb361954a26b2e2a8995681200155835dd8cd4a7d5", size = 2618183, upload-time = "2026-09-29T16:11:36.067Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c6/cae2f6ebe38f8927a8d0978a349b202047268016344991a14ae978c2aee3/fonttools-4.66.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2aeb745f2664eb811026997c95628071137a777ea2ad296deec9cb393f0b23cf", size = 5524861, upload-time = "2026-09-29T16:11:38.099Z" }, + { url = "https://files.pythonhosted.org/packages/f2/14/1941629956b526d6fb46ee764cf0942221f0238581adb94de0ac229fe67f/fonttools-4.66.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3087a430722aba8de429c2539fd2a58a9cf05238cdfefd8626460001052ca878", size = 5346561, upload-time = "2026-09-29T16:11:40.366Z" }, + { url = "https://files.pythonhosted.org/packages/62/1f/b7e7f4757dcae74285f4ecd8453d870d63c7ba38a3d46bd9175a124c350b/fonttools-4.66.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:058cd823b80bac59e64dfad9e3b6fcd677852f9a3804971bbf6b48cc611e785c", size = 5392826, upload-time = "2026-09-29T16:11:42.653Z" }, + { url = "https://files.pythonhosted.org/packages/d9/71/76db3cbdcfac0e9b3ba26e1e6e8740040cfe5f7b5199dfb9b854bc8da2c3/fonttools-4.66.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:56d41d650cb8fc6cfe1d85ed7c62a0a56cbeed07bc65ca795475b914d401312a", size = 5439233, upload-time = "2026-09-29T16:11:45.088Z" }, + { url = "https://files.pythonhosted.org/packages/10/37/cdc6b213c9fbabdf36e9169f845e8596b419c7e0cceba48e5594b952d2cf/fonttools-4.66.1-cp315-cp315t-win32.whl", hash = "sha256:c258eba62260beb33c110b03a6912cefa3635239c4ab5615b7225fb6f7b85238", size = 2468614, upload-time = "2026-09-29T16:11:47.363Z" }, + { url = "https://files.pythonhosted.org/packages/fb/35/e2247e7e29e8da213e02691a6ada7a30592c7bc0d1db8d2786ebb9bea138/fonttools-4.66.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5de5d80fbc0e50ff794c244e8fb7afd3eadfe0fa232ba8b162b8c551df22fcb4", size = 2516977, upload-time = "2026-09-29T16:11:49.425Z" }, + { url = "https://files.pythonhosted.org/packages/f6/10/d45b74135d5d642cb3a4fb0a957c1613ef93de4c8548671dfc3a5bf38299/fonttools-4.66.1-py3-none-any.whl", hash = "sha256:7234ae9e28db64273fbbfa72caebd0a97e3bdba6b05064114741b9539ef339d0", size = 1202222, upload-time = "2026-09-29T16:11:51.678Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/f5/3e9eafb4030588337b2a2ae4df46212956854e9069c07b53aa3caabafd47/greenlet-3.2.5.tar.gz", hash = "sha256:c816554eb33e7ecf9ba4defcb1fd8c994e59be6b4110da15480b3e7447ea4286", size = 191501, upload-time = "2026-02-20T20:08:51.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/d6/b3db928fc329b1b19ba32ffe143d2305f3aaafc583f5e1074c74ec445189/greenlet-3.2.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:34cc7cf8ab6f4b85298b01e13e881265ee7b3c1daf6bc10a2944abc15d4f87c3", size = 275803, upload-time = "2026-02-20T20:06:42.541Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/ab0ad4ff3d9e1faa266de4f6c79763b33fccd9265995f2940192494cc0ec/greenlet-3.2.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c11fe0cfb0ce33132f0b5d27eeadd1954976a82e5e9b60909ec2c4b884a55382", size = 633556, upload-time = "2026-02-20T20:30:41.594Z" }, + { url = "https://files.pythonhosted.org/packages/da/dd/7b3ac77099a1671af8077ecedb12c9a1be1310e4c35bb69fd34c18ab6093/greenlet-3.2.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a145f4b1c4ed7a2c94561b7f18b4beec3d3fb6f0580db22f7ed1d544e0620b34", size = 644943, upload-time = "2026-02-20T20:37:23.084Z" }, + { url = "https://files.pythonhosted.org/packages/56/f0/bea7e7909ea9045b0c5055dad1ec9b81c82b761b4567e625f4f8349acfa1/greenlet-3.2.5-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:edbf4ab9a7057ee430a678fe2ef37ea5d69125d6bdc7feb42ed8d871c737e63b", size = 640849, upload-time = "2026-02-20T20:43:57.305Z" }, + { url = "https://files.pythonhosted.org/packages/0f/36/84630e9ff1dfc8b7690957c0f77834a84eabdbd9c4977c3a2d0cbd5325c2/greenlet-3.2.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1d01bdd67db3e5711e6246e451d7a0f75fae7bbf40adde129296a7f9aa7cc9", size = 639841, upload-time = "2026-02-20T20:07:17.473Z" }, + { url = "https://files.pythonhosted.org/packages/12/c4/6a2ee6c676dea7a05a3c3c1291fbc8ea44f26456b0accc891471293825af/greenlet-3.2.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd593db7ee1fa8a513a48a404f8cc4126998a48025e3f5cbbc68d51be0a6bf66", size = 588813, upload-time = "2026-02-20T20:07:56.171Z" }, + { url = "https://files.pythonhosted.org/packages/01/c0/75e75c2c993aa850292561ec80f5c263e3924e5843aa95a38716df69304c/greenlet-3.2.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ac8db07bced2c39b987bba13a3195f8157b0cfbce54488f86919321444a1cc3c", size = 1117377, upload-time = "2026-02-20T20:32:48.452Z" }, + { url = "https://files.pythonhosted.org/packages/ee/03/e38ebf9024a0873fe8f60f5b7bc36bfb3be5e13efe4d798240f2d1f0fb73/greenlet-3.2.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4544ab2cfd5912e42458b13516429e029f87d8bbcdc8d5506db772941ae12493", size = 1141246, upload-time = "2026-02-20T20:06:23.576Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7b/c6e1192c795c0c12871e199237909a6bd35757d92c8472c7c019959b8637/greenlet-3.2.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:acabf468466d18017e2ae5fbf1a5a88b86b48983e550e1ae1437b69a83d9f4ac", size = 276916, upload-time = "2026-02-20T20:06:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b6/9887b559f3e1952d23052ec352e9977e808a2246c7cb8282a38337221e88/greenlet-3.2.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:472841de62d60f2cafd60edd4fd4dd7253eb70e6eaf14b8990dcaf177f4af957", size = 636107, upload-time = "2026-02-20T20:30:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/8a/be/e3e48b63bbc27d660fa1d98aecb64906b90a12e686a436169c1330ef34b2/greenlet-3.2.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d951e7d628a6e8b68af469f0fe4f100ef64c4054abeb9cdafbfaa30a920c950", size = 648240, upload-time = "2026-02-20T20:37:24.608Z" }, + { url = "https://files.pythonhosted.org/packages/17/f6/2cbe999683f759f14f598234f04ae8ba6f22953a624b3a7a630003e6bfff/greenlet-3.2.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:87b791dd0e031a574249af717ac36f7031b18c35329561c1e0368201c18caf1f", size = 644170, upload-time = "2026-02-20T20:43:59.002Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ac/e731ed62576e91e533b36d0d97325adc2786674ab9e48ed8a6a24f4ef4e9/greenlet-3.2.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8317d732e2ae0935d9ed2af2ea876fa714cf6f3b887a31ca150b54329b0a6e9", size = 643313, upload-time = "2026-02-20T20:07:19.012Z" }, + { url = "https://files.pythonhosted.org/packages/70/64/99e5cdceb494bd4c1341c45b93f322601d2c8a5e1e4d1c7a2d24c5ed0570/greenlet-3.2.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce8aed6fdd5e07d3cbb988cbdc188266a4eb9e1a52db9ef5c6526e59962d3933", size = 591295, upload-time = "2026-02-20T20:07:57.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e9/968e11f388c2b8792d3b8b40a57984c894a3b4745dae3662dce722653bc5/greenlet-3.2.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:60c06b502d56d5451f60ca665691da29f79ed95e247bcf8ce5024d7bbe64acb9", size = 1120277, upload-time = "2026-02-20T20:32:50.103Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2c/b5f2c4c68d753dce08218dc5a6b21d82238fdfdc44309032f6fe24d285e6/greenlet-3.2.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d2a78e6f1bf3f1672df91e212a2f8314e1e7c922f065d14cbad4bc815059467", size = 1145746, upload-time = "2026-02-20T20:06:26.296Z" }, + { url = "https://files.pythonhosted.org/packages/ad/32/022b21523eee713e7550162d5ca6aed23f913cc2c6232b154b9fd9badc07/greenlet-3.2.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2acb30e77042f747ca81f0a10cc153296567e92e666c5e1b117f4595afd43352", size = 278412, upload-time = "2026-02-20T20:03:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/90/c5/8a3b0ed3cc34d8b988a44349437dfa0941f9c23ac108175f7b4ccea97111/greenlet-3.2.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:393c03c26c865f17f31d8db2f09603fadbe0581ad85a5d5908b131549fc38217", size = 644616, upload-time = "2026-02-20T20:30:44.823Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/2627bea183554695016af6cae93d7474fa90f61e5a6601a84ae7841cb720/greenlet-3.2.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:04e6a202cde56043fd355fefd1552c4caa5c087528121871d950eb4f1b51fa99", size = 658813, upload-time = "2026-02-20T20:37:26.255Z" }, + { url = "https://files.pythonhosted.org/packages/44/c6/a80fc96f7cca7962dd972875d12c52dfabc94cb02bfeb19f3e7e169fca44/greenlet-3.2.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d5583b2ffa677578a384337ee13125bdf9a427485d689014b39d638a4f3d8dbe", size = 653512, upload-time = "2026-02-20T20:44:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/2f/1b/75a5aeff487a26ba427a3837da6372f1fe6f2a9c6b2898e28ac99d491c11/greenlet-3.2.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:45fcea7b697b91290b36eafc12fff479aca6ba6500d98ef6f34d5634c7119cbe", size = 655426, upload-time = "2026-02-20T20:07:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/53/91/9b5dfb4f3c88f8247c7a8f4c3759f0740bfa6bb0c59a9f6bf938e913df56/greenlet-3.2.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96e2bb8a56b7e1aed1dbfbbe0050cb2ecca99c7c91892fd1771e3afab63b3e3", size = 611138, upload-time = "2026-02-20T20:07:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/b4/8d/d0b086410512d9859c84e9242a9b341de9f5566011ddf3a3f6886b842b61/greenlet-3.2.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d7456e67b0be653dfe643bb37d9566cd30939c80f858e2ce6d2d54951f75b14a", size = 1126896, upload-time = "2026-02-20T20:32:52.198Z" }, + { url = "https://files.pythonhosted.org/packages/ef/37/59fe12fe456e84ced6ba71781e28cde52a3124d1dd2077bc1727021f49fd/greenlet-3.2.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5ceb29d1f74c7280befbbfa27b9bf91ba4a07a1a00b2179a5d953fc219b16c42", size = 1154779, upload-time = "2026-02-20T20:06:27.583Z" }, + { url = "https://files.pythonhosted.org/packages/dd/95/d5d332fb73affaf7a1fbe80e49c2c7eae4f17c645af24a3b3fa25736d6f0/greenlet-3.2.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f2cc88b50b9006b324c1b9f5f3552f9d4564c78af57cdfb4c7baf4f0aa089146", size = 277166, upload-time = "2026-02-20T20:03:57.077Z" }, + { url = "https://files.pythonhosted.org/packages/6c/77/89458e20db5a4f1c64f9a0191561227e76d809941ca2d7529006d17d3450/greenlet-3.2.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e66872daffa360b2537170b73ad530f14fa31785b1bc78080125d92edf0a6def", size = 644674, upload-time = "2026-02-20T20:30:46.118Z" }, + { url = "https://files.pythonhosted.org/packages/90/f8/9962175d2f2eaa629a7fd7545abacc8c4deda3baa4e52c1526d2eb5f5546/greenlet-3.2.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c5445ddb7b586d870dad32ca9fc47c287d6022a528d194efdb8912093c5303ad", size = 658834, upload-time = "2026-02-20T20:37:27.466Z" }, + { url = "https://files.pythonhosted.org/packages/81/71/52c21a7106ce5218aa6fa59ec32825b2655f875a09b69f68bd3e5d01feb3/greenlet-3.2.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd904626b8779810062cb455514594776e3cba3b8c0ba4939894df9f7b384971", size = 653091, upload-time = "2026-02-20T20:44:01.927Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d7/826d0e080f0a7ad5ec47c8d143bbd3ca0887657bb806595fe2434d12938a/greenlet-3.2.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:752c896a8c976548faafe8a306d446c6a4c68d4fd24699b84d4393bd9ac69a8e", size = 655760, upload-time = "2026-02-20T20:07:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/41/cc/33bd4c2f816be8c8e16f71740c4130adf3a66a3dd2ba29de72b9d8dd1096/greenlet-3.2.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:499b809e7738c8af0ff9ac9d5dd821cb93f4293065a9237543217f0b252f950a", size = 614132, upload-time = "2026-02-20T20:08:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/48/79/f3891dcfc59097474a53cc3c624f2f2465e431ab493bda043b8c873fb20a/greenlet-3.2.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2c7429f6e9cea7cbf2637d86d3db12806ba970f7f972fcab39d6b54b4457cbaf", size = 1125286, upload-time = "2026-02-20T20:32:54.032Z" }, + { url = "https://files.pythonhosted.org/packages/ca/47/212b47e6d2d7a04c4083db1af2fdd291bc8fe99b7e3571bfa560b65fc361/greenlet-3.2.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:a5e4b25e855800fba17713020c5c33e0a4b7a1829027719344f0c7c8870092a2", size = 1152825, upload-time = "2026-02-20T20:06:29Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9d/4e9b941be05f8da7ba804c6413761d2c11cca05994cbf0a015bd729419f0/greenlet-3.2.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7123b29e6bad2f3f89681be4ef316480fca798ebe8d22fbaced9cc3775007a4f", size = 277627, upload-time = "2026-02-20T20:06:04.798Z" }, + { url = "https://files.pythonhosted.org/packages/23/cb/a73625c9a35138330014ecf3740c0d62e0c2b5e7279bb7f2586b1b199fac/greenlet-3.2.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e8fe0c72603201a86b2e038daf9b6c8570715f8779566419cff543b6ace88de", size = 690001, upload-time = "2026-02-20T20:30:47.754Z" }, + { url = "https://files.pythonhosted.org/packages/83/49/6d1531109507bce7dfb23acf57a87013627ed3ac058851176e443a6a9134/greenlet-3.2.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:050703a60603db0e817364d69e048c70af299040c13a7e67792b9e62d4571196", size = 702953, upload-time = "2026-02-20T20:37:29.125Z" }, + { url = "https://files.pythonhosted.org/packages/90/ac/6d8fff3b273fc60ad4b46f8411fe91c1e4cca064dfff68d096bc982fa6d0/greenlet-3.2.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:04633da773ae432649a3f092a8e4add390732cc9e1ab52c8ff2c91b8dc86f202", size = 698353, upload-time = "2026-02-20T20:44:03.547Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/f958ee90fab93529b30cc1e4a59b27c1112b640570043a84af84da3b3b98/greenlet-3.2.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6712bfd520530eb67331813f7112d3ee18e206f48b3d026d8a96cd2d2ad20251", size = 698995, upload-time = "2026-02-20T20:07:22.663Z" }, + { url = "https://files.pythonhosted.org/packages/51/c1/a603906e79716d61f08afedaf8aed62017661457aef233d62d6e57ecd511/greenlet-3.2.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bc06a78fa3ffbe2a75f1ebc7e040eacf6fa1050a9432953ab111fbbbf0d03c1", size = 661175, upload-time = "2026-02-20T20:08:01.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8f/f880ff4587d236b4d06893fb34da6b299aa0d00f6c8259673f80e1b6d63c/greenlet-3.2.5-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:dbe0e81e24982bb45907ca20152b31c2e3300ca352fdc4acbd4956e4a2cbc195", size = 274946, upload-time = "2026-02-20T20:05:21.979Z" }, + { url = "https://files.pythonhosted.org/packages/3c/50/f6c78b8420187fdfe97fcf2e6d1dd243a7742d272c32fd4d4b1095474b37/greenlet-3.2.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:15871afc0d78ec87d15d8412b337f287fc69f8f669346e391585824970931c48", size = 631781, upload-time = "2026-02-20T20:30:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/26/d6/3277f92e1961e6e9f41d9f173ea74b5c1f7065072637669f761626f26cc0/greenlet-3.2.5-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5bf0d7d62e356ef2e87e55e46a4e930ac165f9372760fb983b5631bb479e9d3a", size = 643740, upload-time = "2026-02-20T20:37:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/c37b87659378759f158dbe03eaeb7ed002a8968f1c649b2972f5323f99b2/greenlet-3.2.5-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:e3f03ddd7142c758ab41c18089a1407b9959bd276b4e6dfbd8fd06403832c87a", size = 639098, upload-time = "2026-02-20T20:44:07.287Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6a/4f79d2e7b5ef3723fc5ffea0d6cb22627e5f95e0f19c973fa12bf1cf7891/greenlet-3.2.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6dff6433742073e5b6ad40953a78a0e8cddcb3f6869e5ea635d29a810ca5e7d0", size = 638382, upload-time = "2026-02-20T20:07:23.883Z" }, + { url = "https://files.pythonhosted.org/packages/4d/59/7aadf33f23c65dbf4db27e7f5b60c414797a61e954352ae4a86c5c8b0553/greenlet-3.2.5-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdd67619cefe1cc9fcab57c8853d2bb36eca9f166c0058cc0d428d471f7c785c", size = 587516, upload-time = "2026-02-20T20:08:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/1d/46/b3422959f830de28a4eea447414e6bd7b980d755892f66ab52ad805da1c4/greenlet-3.2.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3828b309dfb1f117fe54867512a8265d8d4f00f8de6908eef9b885f4d8789062", size = 1115818, upload-time = "2026-02-20T20:32:55.786Z" }, + { url = "https://files.pythonhosted.org/packages/54/4a/3d1c9728f093415637cf3696909fa10852632e33e68238fb8ca60eb90de1/greenlet-3.2.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:67725ae9fea62c95cf1aa230f1b8d4dc38f7cd14f6103d1df8a5a95657eb8e54", size = 1140219, upload-time = "2026-02-20T20:06:30.334Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/4f/4af258e1ce388eb64be82d6466b7d0b3bc3eede154e14c3360a76a759b71/greenlet-3.5.6-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:95e7c44d072db623a1aab04ce488cf9533294a77ed9d072cd503a3596f4106ac", size = 292967, upload-time = "2026-09-14T14:26:34.475Z" }, + { url = "https://files.pythonhosted.org/packages/1d/05/dc0d54e90af2f192724936799cee990687792ff54a4638d3ae0e0a0145c0/greenlet-3.5.6-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7d501d5eb5d4f67207df364752ad697465b834268744be7581c18d81d35d41d", size = 609284, upload-time = "2026-09-14T15:11:58.49Z" }, + { url = "https://files.pythonhosted.org/packages/c1/51/826b4fe7bc39c3f910c95e889a981c827f72a611fc035f31816a256cf043/greenlet-3.5.6-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a364c1ea75dc51b83a17f52fe0c79cf8bc4ddf740403bebd4581c7666eea017d", size = 622648, upload-time = "2026-09-14T15:20:39.198Z" }, + { url = "https://files.pythonhosted.org/packages/55/3b/f2d36fc38934588dff6d6ec9288934c9611abac7a79c866c2002fc707698/greenlet-3.5.6-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5599b380c1f28efeb724e81569eac80cd92f99a85bd9775456caaf3225d40b11", size = 629557, upload-time = "2026-09-14T15:25:03.036Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/092682ae7ca1aadd44aa36b0bc35c48c9ed55f9ffebb5794980b1e2ae74b/greenlet-3.5.6-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eed88b64a5e5da72d6a71cdc5aaeefaa5ced9b748f8d19f89800b339961dad39", size = 622813, upload-time = "2026-09-14T14:35:54.845Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/4167f0e44e795840cbc219b2e5d8f576492192cd9e3e17e827db20287daa/greenlet-3.5.6-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:5bbda3c70dd35d60671bc33b01916802707a052130d9e50cdb871d34594d35cb", size = 425482, upload-time = "2026-09-14T15:28:34.144Z" }, + { url = "https://files.pythonhosted.org/packages/07/59/9c6b723a559b53bee0980a5dc57577d434891dd454f0fe54ed6e22d4e4ce/greenlet-3.5.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:874cea8bb1ec1ddccbacbd027856f6bf496f6bc18aba97a918c20e067edab236", size = 1585817, upload-time = "2026-09-14T15:10:03.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/d4/2bceaa305ff0ecf99511480da8f0a866eb2469a68da8715008a0d3e8f65f/greenlet-3.5.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:128813fc29f2336a21b4d06eedd5e16bcc7ea46f59e9ff1cb30ea70e48195d88", size = 1650177, upload-time = "2026-09-14T14:35:46.736Z" }, + { url = "https://files.pythonhosted.org/packages/7a/33/c57855a6abada0c7bfb9c6c0f33df1fbed8e5224708478dbcdff6c5db490/greenlet-3.5.6-cp310-cp310-win_amd64.whl", hash = "sha256:dad3d233d441a022c1f7155f0fb9d5aff7b97c1ea8c7dfa02cce586b16ab2d0b", size = 322896, upload-time = "2026-09-14T14:24:52.308Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d7/41511ee2696f14be4200b524d9553dc4295e2bdeb20aa8962c3cb25e71c6/greenlet-3.5.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a6a4b98a9132e0f45c9fc245a63894cfd8c45fb7a0d6bffc5eab3ec327cf7324", size = 294075, upload-time = "2026-09-14T14:25:16.922Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7b/b509624970909294cd064ff7346148ca9941c21bec9026d7873dd254e9fa/greenlet-3.5.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45bfd2b51e38aaa5f9849f114d9c7c1d75f69187c849b3549cd64c465283abfa", size = 613429, upload-time = "2026-09-14T15:12:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5c/d2eb503067f9ba20875ef8c87681f29a64f53bbbbe4059a5d7c53179d442/greenlet-3.5.6-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c6dede9133e1da41d561bc3fb14e92b47e2ce39ae60edefaad145658ea7c5e2", size = 625405, upload-time = "2026-09-14T15:20:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/9b071d11c8bb9f5f38cccacc38fcc234d91997a4c395cc2bf43ecae89642/greenlet-3.5.6-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4fb8e59f68845d56c23c031dcd79c329f345e4a9d2ffac91c3d1ab366bdc457b", size = 633270, upload-time = "2026-09-14T15:25:04.864Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d3/63d4477ce31dff2fd802a9a20240f6606aac85977e0fb18443aae33de3f6/greenlet-3.5.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c20ea32a73d17b9b60e3371240e17b0068120c98a5ec01a224a7dd8c89733ba", size = 624428, upload-time = "2026-09-14T14:35:56.895Z" }, + { url = "https://files.pythonhosted.org/packages/88/17/ac11883ecc9da19c681c8b763ee39e6f7dca2aa81874eb11a075d3cbeb00/greenlet-3.5.6-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d701eab36200c36224833d07dbdb709adb7fd4253429548ddb5e547b8ed40586", size = 428068, upload-time = "2026-09-14T15:28:35.872Z" }, + { url = "https://files.pythonhosted.org/packages/ad/aa/9cde4e00688eaa2a03b91d12e4681439a87e6aad860399e0847af6a014ca/greenlet-3.5.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a0b2791239c99992a86c1b635b787fe2a877d9eaaa26f8891ce943832b585ae", size = 1588385, upload-time = "2026-09-14T15:10:05.386Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/24632b5ec186b64e21e07a8f53ce5e15a7e9cb33eddee99a5fe16379afa5/greenlet-3.5.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:188bf333769b7145e2b0b4a7f09615ec550ed44d3a2a8395fb7b36f0e9901e13", size = 1653119, upload-time = "2026-09-14T14:35:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6c/019d2ef898f4b9ac845167f1c6f73229e9a4e2439362a5e2ce50205a19b0/greenlet-3.5.6-cp311-cp311-win_amd64.whl", hash = "sha256:a6b4ff33f7e011bbaa148238d131c4fd4f8afbab3c104ddfbdb2b12b74ff7016", size = 323317, upload-time = "2026-09-14T14:22:38.836Z" }, + { url = "https://files.pythonhosted.org/packages/5a/7a/439df999455e3bdf02b1c68f3848d4020385ef0a01f89f706b07bf148a65/greenlet-3.5.6-cp311-cp311-win_arm64.whl", hash = "sha256:59deccd347735a7774223b05a93773fddbb298aba3cea21be4337fb4752dbe32", size = 307739, upload-time = "2026-09-14T14:23:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, + { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627, upload-time = "2026-09-14T14:24:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356, upload-time = "2026-09-14T15:12:03.399Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756, upload-time = "2026-09-14T15:20:44.269Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632, upload-time = "2026-09-14T15:25:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779, upload-time = "2026-09-14T14:36:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933, upload-time = "2026-09-14T15:28:38.858Z" }, + { url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930, upload-time = "2026-09-14T15:10:08.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590, upload-time = "2026-09-14T14:35:51.173Z" }, + { url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086, upload-time = "2026-09-14T14:23:48.428Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211, upload-time = "2026-09-14T14:28:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221, upload-time = "2026-09-14T14:25:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992, upload-time = "2026-09-14T15:12:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428, upload-time = "2026-09-14T15:20:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688, upload-time = "2026-09-14T15:25:09.279Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773, upload-time = "2026-09-14T14:36:02.577Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475, upload-time = "2026-09-14T15:28:40.741Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900, upload-time = "2026-09-14T15:10:09.745Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740, upload-time = "2026-09-14T14:35:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473, upload-time = "2026-09-14T14:28:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095, upload-time = "2026-09-14T14:28:00.7Z" }, + { url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352, upload-time = "2026-09-14T14:21:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671, upload-time = "2026-09-14T15:12:06.347Z" }, + { url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489, upload-time = "2026-09-14T15:20:47.291Z" }, + { url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303, upload-time = "2026-09-14T15:25:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608, upload-time = "2026-09-14T14:36:03.959Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112, upload-time = "2026-09-14T15:28:42.112Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479, upload-time = "2026-09-14T15:10:11.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758, upload-time = "2026-09-14T14:35:54.336Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574, upload-time = "2026-09-14T14:27:18.451Z" }, + { url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926, upload-time = "2026-09-14T14:27:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910, upload-time = "2026-09-14T15:12:07.901Z" }, + { url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415, upload-time = "2026-09-14T15:20:48.817Z" }, + { url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337, upload-time = "2026-09-14T15:25:12.812Z" }, + { url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927, upload-time = "2026-09-14T14:36:05.34Z" }, + { url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339, upload-time = "2026-09-14T15:28:43.497Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236, upload-time = "2026-09-14T15:10:12.442Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526, upload-time = "2026-09-14T14:35:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739, upload-time = "2026-09-14T14:23:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715, upload-time = "2026-09-14T14:28:25.154Z" }, + { url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244, upload-time = "2026-09-14T14:27:57.565Z" }, + { url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578, upload-time = "2026-09-14T15:12:09.468Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203, upload-time = "2026-09-14T15:20:50.261Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023, upload-time = "2026-09-14T15:25:14.528Z" }, + { url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253, upload-time = "2026-09-14T14:36:06.742Z" }, + { url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058, upload-time = "2026-09-14T15:28:44.924Z" }, + { url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988, upload-time = "2026-09-14T15:10:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383, upload-time = "2026-09-14T14:35:58.143Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845, upload-time = "2026-09-14T14:27:41.723Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070, upload-time = "2026-09-14T14:22:21.476Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.4.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/85/4d/2255e1c76304cbd60b48cee302b66d1dde4468dc5b1160e4b7cb43778f2a/kiwisolver-1.4.7.tar.gz", hash = "sha256:9893ff81bd7107f7b685d3017cc6583daadb4fc26e4a888350df530e41980a60", size = 97286, upload-time = "2024-09-04T09:39:44.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/14/fc943dd65268a96347472b4fbe5dcc2f6f55034516f80576cd0dd3a8930f/kiwisolver-1.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8a9c83f75223d5e48b0bc9cb1bf2776cf01563e00ade8775ffe13b0b6e1af3a6", size = 122440, upload-time = "2024-09-04T09:03:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/1e/46/e68fed66236b69dd02fcdb506218c05ac0e39745d696d22709498896875d/kiwisolver-1.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:58370b1ffbd35407444d57057b57da5d6549d2d854fa30249771775c63b5fe17", size = 65758, upload-time = "2024-09-04T09:03:46.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fa/65de49c85838681fc9cb05de2a68067a683717321e01ddafb5b8024286f0/kiwisolver-1.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:aa0abdf853e09aff551db11fce173e2177d00786c688203f52c87ad7fcd91ef9", size = 64311, upload-time = "2024-09-04T09:03:47.973Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/cc8d90f6ef550f65443bad5872ffa68f3dee36de4974768628bea7c14979/kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:8d53103597a252fb3ab8b5845af04c7a26d5e7ea8122303dd7a021176a87e8b9", size = 1637109, upload-time = "2024-09-04T09:03:49.281Z" }, + { url = "https://files.pythonhosted.org/packages/55/91/0a57ce324caf2ff5403edab71c508dd8f648094b18cfbb4c8cc0fde4a6ac/kiwisolver-1.4.7-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:88f17c5ffa8e9462fb79f62746428dd57b46eb931698e42e990ad63103f35e6c", size = 1617814, upload-time = "2024-09-04T09:03:51.444Z" }, + { url = "https://files.pythonhosted.org/packages/12/5d/c36140313f2510e20207708adf36ae4919416d697ee0236b0ddfb6fd1050/kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a9ca9c710d598fd75ee5de59d5bda2684d9db36a9f50b6125eaea3969c2599", size = 1400881, upload-time = "2024-09-04T09:03:53.357Z" }, + { url = "https://files.pythonhosted.org/packages/56/d0/786e524f9ed648324a466ca8df86298780ef2b29c25313d9a4f16992d3cf/kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f4d742cb7af1c28303a51b7a27aaee540e71bb8e24f68c736f6f2ffc82f2bf05", size = 1512972, upload-time = "2024-09-04T09:03:55.082Z" }, + { url = "https://files.pythonhosted.org/packages/67/5a/77851f2f201e6141d63c10a0708e996a1363efaf9e1609ad0441b343763b/kiwisolver-1.4.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e28c7fea2196bf4c2f8d46a0415c77a1c480cc0724722f23d7410ffe9842c407", size = 1444787, upload-time = "2024-09-04T09:03:56.588Z" }, + { url = "https://files.pythonhosted.org/packages/06/5f/1f5eaab84355885e224a6fc8d73089e8713dc7e91c121f00b9a1c58a2195/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e968b84db54f9d42046cf154e02911e39c0435c9801681e3fc9ce8a3c4130278", size = 2199212, upload-time = "2024-09-04T09:03:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/b5/28/9152a3bfe976a0ae21d445415defc9d1cd8614b2910b7614b30b27a47270/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0c18ec74c0472de033e1bebb2911c3c310eef5649133dd0bedf2a169a1b269e5", size = 2346399, upload-time = "2024-09-04T09:04:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/f6/453d1904c52ac3b400f4d5e240ac5fec25263716723e44be65f4d7149d13/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8f0ea6da6d393d8b2e187e6a5e3fb81f5862010a40c3945e2c6d12ae45cfb2ad", size = 2308688, upload-time = "2024-09-04T09:04:02.216Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9a/d4968499441b9ae187e81745e3277a8b4d7c60840a52dc9d535a7909fac3/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:f106407dda69ae456dd1227966bf445b157ccc80ba0dff3802bb63f30b74e895", size = 2445493, upload-time = "2024-09-04T09:04:04.571Z" }, + { url = "https://files.pythonhosted.org/packages/07/c9/032267192e7828520dacb64dfdb1d74f292765f179e467c1cba97687f17d/kiwisolver-1.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:84ec80df401cfee1457063732d90022f93951944b5b58975d34ab56bb150dfb3", size = 2262191, upload-time = "2024-09-04T09:04:05.969Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ad/db0aedb638a58b2951da46ddaeecf204be8b4f5454df020d850c7fa8dca8/kiwisolver-1.4.7-cp310-cp310-win32.whl", hash = "sha256:71bb308552200fb2c195e35ef05de12f0c878c07fc91c270eb3d6e41698c3bcc", size = 46644, upload-time = "2024-09-04T09:04:07.408Z" }, + { url = "https://files.pythonhosted.org/packages/12/ca/d0f7b7ffbb0be1e7c2258b53554efec1fd652921f10d7d85045aff93ab61/kiwisolver-1.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:44756f9fd339de0fb6ee4f8c1696cfd19b2422e0d70b4cefc1cc7f1f64045a8c", size = 55877, upload-time = "2024-09-04T09:04:08.869Z" }, + { url = "https://files.pythonhosted.org/packages/97/6c/cfcc128672f47a3e3c0d918ecb67830600078b025bfc32d858f2e2d5c6a4/kiwisolver-1.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:78a42513018c41c2ffd262eb676442315cbfe3c44eed82385c2ed043bc63210a", size = 48347, upload-time = "2024-09-04T09:04:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77429fa0a58f941d6e1c58da9efe08597d2e86bf2b2cce6626834f49d07b/kiwisolver-1.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d2b0e12a42fb4e72d509fc994713d099cbb15ebf1103545e8a45f14da2dfca54", size = 122442, upload-time = "2024-09-04T09:04:11.432Z" }, + { url = "https://files.pythonhosted.org/packages/e5/20/8c75caed8f2462d63c7fd65e16c832b8f76cda331ac9e615e914ee80bac9/kiwisolver-1.4.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2a8781ac3edc42ea4b90bc23e7d37b665d89423818e26eb6df90698aa2287c95", size = 65762, upload-time = "2024-09-04T09:04:12.468Z" }, + { url = "https://files.pythonhosted.org/packages/f4/98/fe010f15dc7230f45bc4cf367b012d651367fd203caaa992fd1f5963560e/kiwisolver-1.4.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:46707a10836894b559e04b0fd143e343945c97fd170d69a2d26d640b4e297935", size = 64319, upload-time = "2024-09-04T09:04:13.635Z" }, + { url = "https://files.pythonhosted.org/packages/8b/1b/b5d618f4e58c0675654c1e5051bcf42c776703edb21c02b8c74135541f60/kiwisolver-1.4.7-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef97b8df011141c9b0f6caf23b29379f87dd13183c978a30a3c546d2c47314cb", size = 1334260, upload-time = "2024-09-04T09:04:14.878Z" }, + { url = "https://files.pythonhosted.org/packages/b8/01/946852b13057a162a8c32c4c8d2e9ed79f0bb5d86569a40c0b5fb103e373/kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ab58c12a2cd0fc769089e6d38466c46d7f76aced0a1f54c77652446733d2d02", size = 1426589, upload-time = "2024-09-04T09:04:16.514Z" }, + { url = "https://files.pythonhosted.org/packages/70/d1/c9f96df26b459e15cf8a965304e6e6f4eb291e0f7a9460b4ad97b047561e/kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:803b8e1459341c1bb56d1c5c010406d5edec8a0713a0945851290a7930679b51", size = 1541080, upload-time = "2024-09-04T09:04:18.322Z" }, + { url = "https://files.pythonhosted.org/packages/d3/73/2686990eb8b02d05f3de759d6a23a4ee7d491e659007dd4c075fede4b5d0/kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9a9e8a507420fe35992ee9ecb302dab68550dedc0da9e2880dd88071c5fb052", size = 1470049, upload-time = "2024-09-04T09:04:20.266Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4b/2db7af3ed3af7c35f388d5f53c28e155cd402a55432d800c543dc6deb731/kiwisolver-1.4.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18077b53dc3bb490e330669a99920c5e6a496889ae8c63b58fbc57c3d7f33a18", size = 1426376, upload-time = "2024-09-04T09:04:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/05/83/2857317d04ea46dc5d115f0df7e676997bbd968ced8e2bd6f7f19cfc8d7f/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6af936f79086a89b3680a280c47ea90b4df7047b5bdf3aa5c524bbedddb9e545", size = 2222231, upload-time = "2024-09-04T09:04:24.526Z" }, + { url = "https://files.pythonhosted.org/packages/0d/b5/866f86f5897cd4ab6d25d22e403404766a123f138bd6a02ecb2cdde52c18/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3abc5b19d24af4b77d1598a585b8a719beb8569a71568b66f4ebe1fb0449460b", size = 2368634, upload-time = "2024-09-04T09:04:25.899Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ee/73de8385403faba55f782a41260210528fe3273d0cddcf6d51648202d6d0/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:933d4de052939d90afbe6e9d5273ae05fb836cc86c15b686edd4b3560cc0ee36", size = 2329024, upload-time = "2024-09-04T09:04:28.523Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/cd101d8cd2cdfaa42dc06c433df17c8303d31129c9fdd16c0ea37672af91/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:65e720d2ab2b53f1f72fb5da5fb477455905ce2c88aaa671ff0a447c2c80e8e3", size = 2468484, upload-time = "2024-09-04T09:04:30.547Z" }, + { url = "https://files.pythonhosted.org/packages/e1/72/84f09d45a10bc57a40bb58b81b99d8f22b58b2040c912b7eb97ebf625bf2/kiwisolver-1.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3bf1ed55088f214ba6427484c59553123fdd9b218a42bbc8c6496d6754b1e523", size = 2284078, upload-time = "2024-09-04T09:04:33.218Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d4/71828f32b956612dc36efd7be1788980cb1e66bfb3706e6dec9acad9b4f9/kiwisolver-1.4.7-cp311-cp311-win32.whl", hash = "sha256:4c00336b9dd5ad96d0a558fd18a8b6f711b7449acce4c157e7343ba92dd0cf3d", size = 46645, upload-time = "2024-09-04T09:04:34.371Z" }, + { url = "https://files.pythonhosted.org/packages/a1/65/d43e9a20aabcf2e798ad1aff6c143ae3a42cf506754bcb6a7ed8259c8425/kiwisolver-1.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:929e294c1ac1e9f615c62a4e4313ca1823ba37326c164ec720a803287c4c499b", size = 56022, upload-time = "2024-09-04T09:04:35.786Z" }, + { url = "https://files.pythonhosted.org/packages/35/b3/9f75a2e06f1b4ca00b2b192bc2b739334127d27f1d0625627ff8479302ba/kiwisolver-1.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:e33e8fbd440c917106b237ef1a2f1449dfbb9b6f6e1ce17c94cd6a1e0d438376", size = 48536, upload-time = "2024-09-04T09:04:37.525Z" }, + { url = "https://files.pythonhosted.org/packages/97/9c/0a11c714cf8b6ef91001c8212c4ef207f772dd84540104952c45c1f0a249/kiwisolver-1.4.7-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:5360cc32706dab3931f738d3079652d20982511f7c0ac5711483e6eab08efff2", size = 121808, upload-time = "2024-09-04T09:04:38.637Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d8/0fe8c5f5d35878ddd135f44f2af0e4e1d379e1c7b0716f97cdcb88d4fd27/kiwisolver-1.4.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942216596dc64ddb25adb215c3c783215b23626f8d84e8eff8d6d45c3f29f75a", size = 65531, upload-time = "2024-09-04T09:04:39.694Z" }, + { url = "https://files.pythonhosted.org/packages/80/c5/57fa58276dfdfa612241d640a64ca2f76adc6ffcebdbd135b4ef60095098/kiwisolver-1.4.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:48b571ecd8bae15702e4f22d3ff6a0f13e54d3d00cd25216d5e7f658242065ee", size = 63894, upload-time = "2024-09-04T09:04:41.6Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e9/26d3edd4c4ad1c5b891d8747a4f81b1b0aba9fb9721de6600a4adc09773b/kiwisolver-1.4.7-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad42ba922c67c5f219097b28fae965e10045ddf145d2928bfac2eb2e17673640", size = 1369296, upload-time = "2024-09-04T09:04:42.886Z" }, + { url = "https://files.pythonhosted.org/packages/b6/67/3f4850b5e6cffb75ec40577ddf54f7b82b15269cc5097ff2e968ee32ea7d/kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:612a10bdae23404a72941a0fc8fa2660c6ea1217c4ce0dbcab8a8f6543ea9e7f", size = 1461450, upload-time = "2024-09-04T09:04:46.284Z" }, + { url = "https://files.pythonhosted.org/packages/52/be/86cbb9c9a315e98a8dc6b1d23c43cffd91d97d49318854f9c37b0e41cd68/kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e838bba3a3bac0fe06d849d29772eb1afb9745a59710762e4ba3f4cb8424483", size = 1579168, upload-time = "2024-09-04T09:04:47.91Z" }, + { url = "https://files.pythonhosted.org/packages/0f/00/65061acf64bd5fd34c1f4ae53f20b43b0a017a541f242a60b135b9d1e301/kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22f499f6157236c19f4bbbd472fa55b063db77a16cd74d49afe28992dff8c258", size = 1507308, upload-time = "2024-09-04T09:04:49.465Z" }, + { url = "https://files.pythonhosted.org/packages/21/e4/c0b6746fd2eb62fe702118b3ca0cb384ce95e1261cfada58ff693aeec08a/kiwisolver-1.4.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693902d433cf585133699972b6d7c42a8b9f8f826ebcaf0132ff55200afc599e", size = 1464186, upload-time = "2024-09-04T09:04:50.949Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0f/529d0a9fffb4d514f2782c829b0b4b371f7f441d61aa55f1de1c614c4ef3/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4e77f2126c3e0b0d055f44513ed349038ac180371ed9b52fe96a32aa071a5107", size = 2247877, upload-time = "2024-09-04T09:04:52.388Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e1/66603ad779258843036d45adcbe1af0d1a889a07af4635f8b4ec7dccda35/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:657a05857bda581c3656bfc3b20e353c232e9193eb167766ad2dc58b56504948", size = 2404204, upload-time = "2024-09-04T09:04:54.385Z" }, + { url = "https://files.pythonhosted.org/packages/8d/61/de5fb1ca7ad1f9ab7970e340a5b833d735df24689047de6ae71ab9d8d0e7/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4bfa75a048c056a411f9705856abfc872558e33c055d80af6a380e3658766038", size = 2352461, upload-time = "2024-09-04T09:04:56.307Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d2/0edc00a852e369827f7e05fd008275f550353f1f9bcd55db9363d779fc63/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:34ea1de54beef1c104422d210c47c7d2a4999bdecf42c7b5718fbe59a4cac383", size = 2501358, upload-time = "2024-09-04T09:04:57.922Z" }, + { url = "https://files.pythonhosted.org/packages/84/15/adc15a483506aec6986c01fb7f237c3aec4d9ed4ac10b756e98a76835933/kiwisolver-1.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90da3b5f694b85231cf93586dad5e90e2d71b9428f9aad96952c99055582f520", size = 2314119, upload-time = "2024-09-04T09:04:59.332Z" }, + { url = "https://files.pythonhosted.org/packages/36/08/3a5bb2c53c89660863a5aa1ee236912269f2af8762af04a2e11df851d7b2/kiwisolver-1.4.7-cp312-cp312-win32.whl", hash = "sha256:18e0cca3e008e17fe9b164b55735a325140a5a35faad8de92dd80265cd5eb80b", size = 46367, upload-time = "2024-09-04T09:05:00.804Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/c05f0a6d825c643779fc3c70876bff1ac221f0e31e6f701f0e9578690d70/kiwisolver-1.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:58cb20602b18f86f83a5c87d3ee1c766a79c0d452f8def86d925e6c60fbf7bfb", size = 55884, upload-time = "2024-09-04T09:05:01.924Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f9/3828d8f21b6de4279f0667fb50a9f5215e6fe57d5ec0d61905914f5b6099/kiwisolver-1.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:f5a8b53bdc0b3961f8b6125e198617c40aeed638b387913bf1ce78afb1b0be2a", size = 48528, upload-time = "2024-09-04T09:05:02.983Z" }, + { url = "https://files.pythonhosted.org/packages/c4/06/7da99b04259b0f18b557a4effd1b9c901a747f7fdd84cf834ccf520cb0b2/kiwisolver-1.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2e6039dcbe79a8e0f044f1c39db1986a1b8071051efba3ee4d74f5b365f5226e", size = 121913, upload-time = "2024-09-04T09:05:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/97/f5/b8a370d1aa593c17882af0a6f6755aaecd643640c0ed72dcfd2eafc388b9/kiwisolver-1.4.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a1ecf0ac1c518487d9d23b1cd7139a6a65bc460cd101ab01f1be82ecf09794b6", size = 65627, upload-time = "2024-09-04T09:05:05.119Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fc/6c0374f7503522539e2d4d1b497f5ebad3f8ed07ab51aed2af988dd0fb65/kiwisolver-1.4.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ab9ccab2b5bd5702ab0803676a580fffa2aa178c2badc5557a84cc943fcf750", size = 63888, upload-time = "2024-09-04T09:05:06.191Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3e/0b7172793d0f41cae5c923492da89a2ffcd1adf764c16159ca047463ebd3/kiwisolver-1.4.7-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f816dd2277f8d63d79f9c8473a79fe54047bc0467754962840782c575522224d", size = 1369145, upload-time = "2024-09-04T09:05:07.919Z" }, + { url = "https://files.pythonhosted.org/packages/77/92/47d050d6f6aced2d634258123f2688fbfef8ded3c5baf2c79d94d91f1f58/kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf8bcc23ceb5a1b624572a1623b9f79d2c3b337c8c455405ef231933a10da379", size = 1461448, upload-time = "2024-09-04T09:05:10.01Z" }, + { url = "https://files.pythonhosted.org/packages/9c/1b/8f80b18e20b3b294546a1adb41701e79ae21915f4175f311a90d042301cf/kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dea0bf229319828467d7fca8c7c189780aa9ff679c94539eed7532ebe33ed37c", size = 1578750, upload-time = "2024-09-04T09:05:11.598Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fe/fe8e72f3be0a844f257cadd72689c0848c6d5c51bc1d60429e2d14ad776e/kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c06a4c7cf15ec739ce0e5971b26c93638730090add60e183530d70848ebdd34", size = 1507175, upload-time = "2024-09-04T09:05:13.22Z" }, + { url = "https://files.pythonhosted.org/packages/39/fa/cdc0b6105d90eadc3bee525fecc9179e2b41e1ce0293caaf49cb631a6aaf/kiwisolver-1.4.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:913983ad2deb14e66d83c28b632fd35ba2b825031f2fa4ca29675e665dfecbe1", size = 1463963, upload-time = "2024-09-04T09:05:15.925Z" }, + { url = "https://files.pythonhosted.org/packages/6e/5c/0c03c4e542720c6177d4f408e56d1c8315899db72d46261a4e15b8b33a41/kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5337ec7809bcd0f424c6b705ecf97941c46279cf5ed92311782c7c9c2026f07f", size = 2248220, upload-time = "2024-09-04T09:05:17.434Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ee/55ef86d5a574f4e767df7da3a3a7ff4954c996e12d4fbe9c408170cd7dcc/kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c26ed10c4f6fa6ddb329a5120ba3b6db349ca192ae211e882970bfc9d91420b", size = 2404463, upload-time = "2024-09-04T09:05:18.997Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6d/73ad36170b4bff4825dc588acf4f3e6319cb97cd1fb3eb04d9faa6b6f212/kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c619b101e6de2222c1fcb0531e1b17bbffbe54294bfba43ea0d411d428618c27", size = 2352842, upload-time = "2024-09-04T09:05:21.299Z" }, + { url = "https://files.pythonhosted.org/packages/0b/16/fa531ff9199d3b6473bb4d0f47416cdb08d556c03b8bc1cccf04e756b56d/kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:073a36c8273647592ea332e816e75ef8da5c303236ec0167196793eb1e34657a", size = 2501635, upload-time = "2024-09-04T09:05:23.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/7e/aa9422e78419db0cbe75fb86d8e72b433818f2e62e2e394992d23d23a583/kiwisolver-1.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ce6b2b0231bda412463e152fc18335ba32faf4e8c23a754ad50ffa70e4091ee", size = 2314556, upload-time = "2024-09-04T09:05:25.907Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/15f7f556df0a6e5b3772a1e076a9d9f6c538ce5f05bd590eca8106508e06/kiwisolver-1.4.7-cp313-cp313-win32.whl", hash = "sha256:f4c9aee212bc89d4e13f58be11a56cc8036cabad119259d12ace14b34476fd07", size = 46364, upload-time = "2024-09-04T09:05:27.184Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/32e897e43a330eee8e4770bfd2737a9584b23e33587a0812b8e20aac38f7/kiwisolver-1.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:8a3ec5aa8e38fc4c8af308917ce12c536f1c88452ce554027e55b22cbbfbff76", size = 55887, upload-time = "2024-09-04T09:05:28.372Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a4/df2bdca5270ca85fd25253049eb6708d4127be2ed0e5c2650217450b59e9/kiwisolver-1.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:76c8094ac20ec259471ac53e774623eb62e6e1f56cd8690c67ce6ce4fcb05650", size = 48530, upload-time = "2024-09-04T09:05:30.225Z" }, + { url = "https://files.pythonhosted.org/packages/11/88/37ea0ea64512997b13d69772db8dcdc3bfca5442cda3a5e4bb943652ee3e/kiwisolver-1.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f9362ecfca44c863569d3d3c033dbe8ba452ff8eed6f6b5806382741a1334bd", size = 122449, upload-time = "2024-09-04T09:05:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/4e/45/5a5c46078362cb3882dcacad687c503089263c017ca1241e0483857791eb/kiwisolver-1.4.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e8df2eb9b2bac43ef8b082e06f750350fbbaf2887534a5be97f6cf07b19d9583", size = 65757, upload-time = "2024-09-04T09:05:56.906Z" }, + { url = "https://files.pythonhosted.org/packages/8a/be/a6ae58978772f685d48dd2e84460937761c53c4bbd84e42b0336473d9775/kiwisolver-1.4.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f32d6edbc638cde7652bd690c3e728b25332acbadd7cad670cc4a02558d9c417", size = 64312, upload-time = "2024-09-04T09:05:58.384Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/18ef6f452d311e1e1eb180c9bf5589187fa1f042db877e6fe443ef10099c/kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:e2e6c39bd7b9372b0be21456caab138e8e69cc0fc1190a9dfa92bd45a1e6e904", size = 1626966, upload-time = "2024-09-04T09:05:59.855Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/40655f6c3fa11ce740e8a964fa8e4c0479c87d6a7944b95af799c7a55dfe/kiwisolver-1.4.7-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dda56c24d869b1193fcc763f1284b9126550eaf84b88bbc7256e15028f19188a", size = 1607044, upload-time = "2024-09-04T09:06:02.16Z" }, + { url = "https://files.pythonhosted.org/packages/fd/93/af67dbcfb9b3323bbd2c2db1385a7139d8f77630e4a37bb945b57188eb2d/kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79849239c39b5e1fd906556c474d9b0439ea6792b637511f3fe3a41158d89ca8", size = 1391879, upload-time = "2024-09-04T09:06:03.908Z" }, + { url = "https://files.pythonhosted.org/packages/40/6f/d60770ef98e77b365d96061d090c0cd9e23418121c55fff188fa4bdf0b54/kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e3bc157fed2a4c02ec468de4ecd12a6e22818d4f09cde2c31ee3226ffbefab2", size = 1504751, upload-time = "2024-09-04T09:06:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/fa/3a/5f38667d313e983c432f3fcd86932177519ed8790c724e07d77d1de0188a/kiwisolver-1.4.7-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3da53da805b71e41053dc670f9a820d1157aae77b6b944e08024d17bcd51ef88", size = 1436990, upload-time = "2024-09-04T09:06:08.126Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/1520301a47326e6a6043b502647e42892be33b3f051e9791cc8bb43f1a32/kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8705f17dfeb43139a692298cb6637ee2e59c0194538153e83e9ee0c75c2eddde", size = 2191122, upload-time = "2024-09-04T09:06:10.345Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c4/eb52da300c166239a2233f1f9c4a1b767dfab98fae27681bfb7ea4873cb6/kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:82a5c2f4b87c26bb1a0ef3d16b5c4753434633b83d365cc0ddf2770c93829e3c", size = 2338126, upload-time = "2024-09-04T09:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/1a/cb/42b92fd5eadd708dd9107c089e817945500685f3437ce1fd387efebc6d6e/kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce8be0466f4c0d585cdb6c1e2ed07232221df101a4c6f28821d2aa754ca2d9e2", size = 2298313, upload-time = "2024-09-04T09:06:14.562Z" }, + { url = "https://files.pythonhosted.org/packages/4f/eb/be25aa791fe5fc75a8b1e0c965e00f942496bc04635c9aae8035f6b76dcd/kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:409afdfe1e2e90e6ee7fc896f3df9a7fec8e793e58bfa0d052c8a82f99c37abb", size = 2437784, upload-time = "2024-09-04T09:06:16.767Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/30a66be7f3368d76ff95689e1c2e28d382383952964ab15330a15d8bfd03/kiwisolver-1.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5b9c3f4ee0b9a439d2415012bd1b1cc2df59e4d6a9939f4d669241d30b414327", size = 2253988, upload-time = "2024-09-04T09:06:18.705Z" }, + { url = "https://files.pythonhosted.org/packages/35/d3/5f2ecb94b5211c8a04f218a76133cc8d6d153b0f9cd0b45fad79907f0689/kiwisolver-1.4.7-cp39-cp39-win32.whl", hash = "sha256:a79ae34384df2b615eefca647a2873842ac3b596418032bef9a7283675962644", size = 46980, upload-time = "2024-09-04T09:06:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/ef/17/cd10d020578764ea91740204edc6b3236ed8106228a46f568d716b11feb2/kiwisolver-1.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:cf0438b42121a66a3a667de17e779330fc0f20b0d97d59d2f2121e182b0505e4", size = 55847, upload-time = "2024-09-04T09:06:21.407Z" }, + { url = "https://files.pythonhosted.org/packages/91/84/32232502020bd78d1d12be7afde15811c64a95ed1f606c10456db4e4c3ac/kiwisolver-1.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:764202cc7e70f767dab49e8df52c7455e8de0df5d858fa801a11aa0d882ccf3f", size = 48494, upload-time = "2024-09-04T09:06:22.648Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/741b79775d67ab67ced9bb38552da688c0305c16e7ee24bba7a2be253fb7/kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:94252291e3fe68001b1dd747b4c0b3be12582839b95ad4d1b641924d68fd4643", size = 59491, upload-time = "2024-09-04T09:06:24.188Z" }, + { url = "https://files.pythonhosted.org/packages/58/cc/fb239294c29a5656e99e3527f7369b174dd9cc7c3ef2dea7cb3c54a8737b/kiwisolver-1.4.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5b7dfa3b546da08a9f622bb6becdb14b3e24aaa30adba66749d38f3cc7ea9706", size = 57648, upload-time = "2024-09-04T09:06:25.559Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2f009ac1f7aab9f81efb2d837301d255279d618d27b6015780115ac64bdd/kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd3de6481f4ed8b734da5df134cd5a6a64fe32124fe83dde1e5b5f29fe30b1e6", size = 84257, upload-time = "2024-09-04T09:06:27.038Z" }, + { url = "https://files.pythonhosted.org/packages/81/e1/c64f50987f85b68b1c52b464bb5bf73e71570c0f7782d626d1eb283ad620/kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a91b5f9f1205845d488c928e8570dcb62b893372f63b8b6e98b863ebd2368ff2", size = 80906, upload-time = "2024-09-04T09:06:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/1687c5c0a0be2cee39a5c9c389e546f9c6e215e46b691d00d9f646892083/kiwisolver-1.4.7-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fa14dbd66b8b8f470d5fc79c089a66185619d31645f9b0773b88b19f7223c4", size = 79951, upload-time = "2024-09-04T09:06:29.966Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8b/d7497df4a1cae9367adf21665dd1f896c2a7aeb8769ad77b662c5e2bcce7/kiwisolver-1.4.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:eb542fe7933aa09d8d8f9d9097ef37532a7df6497819d16efe4359890a2f417a", size = 55715, upload-time = "2024-09-04T09:06:31.489Z" }, + { url = "https://files.pythonhosted.org/packages/d5/df/ce37d9b26f07ab90880923c94d12a6ff4d27447096b4c849bfc4339ccfdf/kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8b01aac285f91ca889c800042c35ad3b239e704b150cfd3382adfc9dcc780e39", size = 58666, upload-time = "2024-09-04T09:06:43.756Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d3/e4b04f43bc629ac8e186b77b2b1a251cdfa5b7610fa189dc0db622672ce6/kiwisolver-1.4.7-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:48be928f59a1f5c8207154f935334d374e79f2b5d212826307d072595ad76a2e", size = 57088, upload-time = "2024-09-04T09:06:45.406Z" }, + { url = "https://files.pythonhosted.org/packages/30/1c/752df58e2d339e670a535514d2db4fe8c842ce459776b8080fbe08ebb98e/kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f37cfe618a117e50d8c240555331160d73d0411422b59b5ee217843d7b693608", size = 84321, upload-time = "2024-09-04T09:06:47.557Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/fe6484e847bc6e238ec9f9828089fb2c0bb53f2f5f3a79351fde5b565e4f/kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:599b5c873c63a1f6ed7eead644a8a380cfbdf5db91dcb6f85707aaab213b1674", size = 80776, upload-time = "2024-09-04T09:06:49.235Z" }, + { url = "https://files.pythonhosted.org/packages/9b/57/d7163c0379f250ef763aba85330a19feefb5ce6cb541ade853aaba881524/kiwisolver-1.4.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:801fa7802e5cfabe3ab0c81a34c323a319b097dfb5004be950482d882f3d7225", size = 79984, upload-time = "2024-09-04T09:06:51.336Z" }, + { url = "https://files.pythonhosted.org/packages/8c/95/4a103776c265d13b3d2cd24fb0494d4e04ea435a8ef97e1b2c026d43250b/kiwisolver-1.4.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:0c6c43471bc764fad4bc99c5c2d6d16a676b1abf844ca7c8702bdae92df01ee0", size = 55811, upload-time = "2024-09-04T09:06:53.078Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/07/bd78e6a8fae171ea041ef5bba3ed21a003522fa088834b069b1909981f30/kiwisolver-1.5.1.tar.gz", hash = "sha256:f1303ef2eec81262a4b708c3e858afe58d7c75ad91c1c05266eda7673369859a", size = 104395, upload-time = "2026-08-28T10:28:27.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/54/1b8d2bc580414cc75b3a5b3d195981a6facacd8a8d986e589d0a3b51a709/kiwisolver-1.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1acc7e5b7ef05e9da8bb70cd6c7c4513090213d2e1ad9720f599f0bf6c52aec5", size = 123362, upload-time = "2026-08-28T10:24:43.402Z" }, + { url = "https://files.pythonhosted.org/packages/57/19/92c30f540dcfff86ff625389427c39b53a9aaea16420ddcc09b2ab8d1073/kiwisolver-1.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bad20d4c69c851c982a1e3606f4c293edfd5a87885786c50082412240c4b1ffd", size = 66551, upload-time = "2026-08-28T10:24:44.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ae/30b94088722e7bce8b821cd5ee935a87f80023e7243125f1accf98e39bf7/kiwisolver-1.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0a4faea5c6db201c6a21391d2ac926ea97acf7dacdbc3c417189e1adb1a00837", size = 64076, upload-time = "2026-08-28T10:24:46.184Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/9f394d693be549fa3fab62498c2778294595991047adc3dcf10aa99b91c0/kiwisolver-1.5.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e05c2f7925f1d88778e53cb44f14e0223204a3bdd09a41664750363acfb1f2ef", size = 1628782, upload-time = "2026-08-28T10:24:47.91Z" }, + { url = "https://files.pythonhosted.org/packages/64/96/26efc04348c0f332b6e6c471dff132bb8b18b2df494a1b65826601674e62/kiwisolver-1.5.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3a4e41e3096bf1f0f1b76e2ffd6d828d6547f574f702d59bdbef7acfa59db9c", size = 1228112, upload-time = "2026-08-28T10:24:49.503Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ac/df9ddc19ec972cd97b60b262dd2c4be28c6192b5a779cad429c6657ee680/kiwisolver-1.5.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1d56ec54d257d05e0b50f5780d967540cd07beeaf9e5f645b26d50cce79f4d8", size = 1246756, upload-time = "2026-08-28T10:24:51.225Z" }, + { url = "https://files.pythonhosted.org/packages/cf/de/c38a246b8a4f4b293c7291aa650917b05c5549b0d532749bc523296e90c4/kiwisolver-1.5.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8de6f2a4ce7e7bd27d23dd94abf0ccafe0e0e5cc9c764b0577191f2c25f08f26", size = 1295555, upload-time = "2026-08-28T10:24:52.953Z" }, + { url = "https://files.pythonhosted.org/packages/b6/48/3c95a5232d8b5783050d8c8089b58bf1f57018f9135232d72c5aa6b0c01b/kiwisolver-1.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:16895f553ee6620a827d2da56b871f835fb70b9216cca5d188e885caf6e3bd23", size = 2179476, upload-time = "2026-08-28T10:24:54.568Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e9/b9e999336afc561b154e93682f597832bdc7446f2ee18b467c5b0e924867/kiwisolver-1.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6b92f60017dda7d877fdc546438b5e28f31c523264f49cf5a48c1d0ce1a0dfbc", size = 2275258, upload-time = "2026-08-28T10:24:56.186Z" }, + { url = "https://files.pythonhosted.org/packages/76/cd/c1c550796ada4a59644b8264b425fe332686d2a086c4729fcc640ececdfe/kiwisolver-1.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:7824b5e8bdbf0bccb4ccd37bbb115849a1dc45437fb4de8351385ed07c437ee0", size = 2443424, upload-time = "2026-08-28T10:24:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/30f641eb8e10f6ad5bb9af8a15ec8eea386162a6ef491e68995b8680ef4e/kiwisolver-1.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:aa7d00b1700966d2917e54d278aba86897890ca9276dd8b76cf6446b6c181b92", size = 2249457, upload-time = "2026-08-28T10:25:00.223Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d9/211e016f8aa5b5bf496dd3bd7ebab4b73b22d91f025f25e20156a44a77fc/kiwisolver-1.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:96c30002424670b5e1e46495c2b8cbffef39cf77c1d79e76462029d50339785b", size = 70643, upload-time = "2026-08-28T10:25:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8e/ae7007266dea1fd93fcc48541042c1f6583958055de3b424a59ec16def25/kiwisolver-1.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:f0f4a42db92d6ec7677ab9d12830a2a8ec145a9c6d15db2b593466bc875c78d7", size = 68215, upload-time = "2026-08-28T10:25:03.136Z" }, + { url = "https://files.pythonhosted.org/packages/94/7b/2de6908edc668427c149af5f93112e931f87e1fa4cab80bac32c5844dccc/kiwisolver-1.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b3d78f7bb2b9d9a30345be1474b9aaa8685430b54afb51ba3639b5c6c11e9ed6", size = 123364, upload-time = "2026-08-28T10:25:04.359Z" }, + { url = "https://files.pythonhosted.org/packages/8a/24/e70914415c77c97be7e22c80a0740869cb7428768cc380fdcdf6703e7084/kiwisolver-1.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5025e36fb4fb275cef0a4e30dbb11cb4ae61d1c83deb90189cb5d7e4cafd6b55", size = 66558, upload-time = "2026-08-28T10:25:05.506Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2b/8b08b11833db4d475b8ef1f36174f8d8a7abd31bedd7e794be78e8814b48/kiwisolver-1.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc1a26b8e53395a01c2c611e58602fa47461f136fba7cd5542e6db6d64be1839", size = 64071, upload-time = "2026-08-28T10:25:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/89/00/05c2d0369ac322d22d5c05f84b5c4a6856fa6207fbae42869108a28f0383/kiwisolver-1.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:95a02752aa032eef4aed01cda6d9b687c669bd0396bf4519eef8bba22a286720", size = 1438206, upload-time = "2026-08-28T10:25:08.254Z" }, + { url = "https://files.pythonhosted.org/packages/c0/05/c941a139f27438c1910d630fdc3ccfdab7c8407c72052299ead12ece086e/kiwisolver-1.5.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:719a35fa1156db3640555f95ebb94f60a444e64d1c69626b0edef5df78eba225", size = 1248975, upload-time = "2026-08-28T10:25:10.053Z" }, + { url = "https://files.pythonhosted.org/packages/58/a1/2669ee5512e39b9d4de25faacaedf788c957f93730c5f7c63993ec4f5933/kiwisolver-1.5.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febcce10f2bcdbb80b4ea919238a6a4ac13dbc4c7cadbe8d5d75c3682f8b5404", size = 1266301, upload-time = "2026-08-28T10:25:11.754Z" }, + { url = "https://files.pythonhosted.org/packages/28/b8/353f52f2c7f861a9e90cd2e8f90f85b3ad03060835f823e08298d094c463/kiwisolver-1.5.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1d852545c4d0e35a72728d072cbaa59e2fa7dd84bdf01e068d670dd0ceb58eb6", size = 1319708, upload-time = "2026-08-28T10:25:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/21/0e/14b83200eadc2c1d63b76bac01c1813bf072aecf567429f303e00b70258e/kiwisolver-1.5.1-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:2e10ae1bba1899188b33557c10d73affcc12033edd18adddb57d209039976a4c", size = 971720, upload-time = "2026-08-28T10:25:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/86/91/9d43d84d23b1cbff72a142d387ead1ea03db0cba8ff86ed5335addad3cc9/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b69602970994a2ed8bbfa78c2f0394a7435226c6040489702d9f0a0ad0c07052", size = 2200119, upload-time = "2026-08-28T10:25:16.636Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/f2bd9616f27ffb5e17cecc0baa5d0bbcee7e55aeddc0ccc871d69e2fc3ee/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d50de98e8d807dc31822fff96f50293163a62418eb65487a21b42713d72ed0b7", size = 2295005, upload-time = "2026-08-28T10:25:18.374Z" }, + { url = "https://files.pythonhosted.org/packages/ba/17/ee671b72bf8f46a08379d4392c65582541759a542428197562f2898294ad/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3221f78211074f561c44ca42eac0619828171bec15a2c4cf6f7747d07df76e8e", size = 1960982, upload-time = "2026-08-28T10:25:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/2e/f7/0e26b4c05bee3bdb0f048dfa305e4fe701999ea17b51e9c616ef91035bbe/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0ba9527afc80ae3d7814ed98b6572d02bf85eaf48065678342c5f0c6dab7a8c7", size = 2464918, upload-time = "2026-08-28T10:25:21.65Z" }, + { url = "https://files.pythonhosted.org/packages/ae/62/6eb431133d30ce656ac1e5ff72fac70dd34d54c3984f4011b9ac8bf77d54/kiwisolver-1.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e12dfea7f5fc2a34a9080efbf79c4c44eb380ec5b9c6fea09407e08f0d1e941d", size = 2270967, upload-time = "2026-08-28T10:25:23.643Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9f/6f9e489c188200e6fb3193935501894811e8c97577c8ffe9033589bf3521/kiwisolver-1.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:1a7587dc335f2c0f5bd577fd0540bd16c66006bdb60f759a1059f025e6c4f071", size = 70744, upload-time = "2026-08-28T10:25:25.061Z" }, + { url = "https://files.pythonhosted.org/packages/c6/6d/dfc430d1d43957061599adea3f08ea982bb6f4ab601a8c974bedcf2ba850/kiwisolver-1.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:e4e4523d6f336708d732516e6cfca7796cf3d96c9474eb5aecf6165f2f1fefc3", size = 68404, upload-time = "2026-08-28T10:25:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9b/65b302742389c6f96f2956bef5decf26011309feb2fc5d79613af18adea4/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63fb7294b768f444eb4b068965f2662f28c2fd4161e23bd60fcf3ff27b74c046", size = 123876, upload-time = "2026-08-28T10:25:27.44Z" }, + { url = "https://files.pythonhosted.org/packages/71/74/c21f339956f6f691b2ed7e31d5f3ae767304df6c460192739fc830853051/kiwisolver-1.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0ebdef3eae5336568147c39a55be6a2036ffde53faa9ca2d978989ae7c2da12c", size = 66487, upload-time = "2026-08-28T10:25:28.728Z" }, + { url = "https://files.pythonhosted.org/packages/84/e5/bdb34e21523e01dceda064d63713f3bdec91388af24fba1eca7ea5e85864/kiwisolver-1.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1798e83840c3f627246104c4d8a9639c60fa068adf9ce92b61791781fa8a68c1", size = 64660, upload-time = "2026-08-28T10:25:30.071Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f4/dadfec469313c7f428efa7e84b4aba9732f813c13ea7131a24b7b008ef57/kiwisolver-1.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34633ecf50d16187ab8e5528b7a2530f2feb4e23f300db4672538b51cfc5cd38", size = 1477929, upload-time = "2026-08-28T10:25:31.495Z" }, + { url = "https://files.pythonhosted.org/packages/6f/35/09c58daac34e6f6ea5c6dee0094b422118e5a7c265586008a95fd135ac5f/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d27c2123977cb9269c30a49ba45f03a4323017ef693e19db4ec9dbe1299a3002", size = 1278499, upload-time = "2026-08-28T10:25:33.375Z" }, + { url = "https://files.pythonhosted.org/packages/19/32/739765e24fbad29d13f83e546ea4abc215a78cea9d677ca09025b027724d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6a797a1cefc8b9c93170db580337e1fe3d011ad18b1299943231279406342048", size = 1296677, upload-time = "2026-08-28T10:25:35.059Z" }, + { url = "https://files.pythonhosted.org/packages/df/32/03304d1010e2cc45e5b3b52cef7e43fed3a2a5cd6c87a89b4a88e1d85b5d/kiwisolver-1.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2551cf9917af48ee7c4b29cc82320489508cf96fd26a51f6fc124de661cd44c7", size = 1346037, upload-time = "2026-08-28T10:25:36.705Z" }, + { url = "https://files.pythonhosted.org/packages/3e/57/4c49377bfd274450dd72ecaa13eaac32ea804a03363e4d1db0c5aa999ceb/kiwisolver-1.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:38f6e0deb4d0a4615efe0c4efc5990b06ae450ab50a0b321c0b078b6d238c083", size = 988248, upload-time = "2026-08-28T10:25:38.299Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/38df144a08b6c5d75ca4504e5cc3141bb3bfef64c04f4ef48204f42711b6/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bfd1de989b3330420e29de39352f5c049905c9e3ee67233a50d550e3d652c148", size = 2228722, upload-time = "2026-08-28T10:25:40.038Z" }, + { url = "https://files.pythonhosted.org/packages/e7/11/3221838a89cd64d9b386353e000cd8a296069a20fbe3584507fdfd5bebae/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1209042a623ddfda5497e4066c7b77651dde8e1d3a9dd97599dc7e97f3b9b78c", size = 2325216, upload-time = "2026-08-28T10:25:41.699Z" }, + { url = "https://files.pythonhosted.org/packages/83/d4/075c219230697bb5db910d37262b9bacf880f92b4811a02ab81ed073a253/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:26e8268480be5061d509e29669d59103c067a26377a56491630ece11762e3858", size = 1977689, upload-time = "2026-08-28T10:25:43.559Z" }, + { url = "https://files.pythonhosted.org/packages/bb/08/1d219c3c2dd960983d0d4da623d916e9de6385df2b0bab3d1af0e9b8fccc/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d79308fa689fac89cbcfbd4dbfc80b5f95c54c5a7fd4d194be221f9d33d026e6", size = 2491443, upload-time = "2026-08-28T10:25:45.242Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d3/024208ec1079d273f1047468d1bdffbf38bb75b7b268090fd3a0301b9d9a/kiwisolver-1.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b03af77d77e50edba2030fd5f7c352ff209314b09030a3cba7c14edf9a09a444", size = 2295200, upload-time = "2026-08-28T10:25:46.984Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7c/7b210498f9f92e1cd7855f260fa69ef056881087b199ee20c208f0e4189a/kiwisolver-1.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:06a6917674de9e0fe3f66f5430787f59a9f2ddb64af9b714eaec547e29ef5c19", size = 70748, upload-time = "2026-08-28T10:25:48.444Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/ef0daa157c8bb23672f7423e0d14c39db1dc6ef8ed47e6bc54c9c1bef3bf/kiwisolver-1.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:ad8b9671348d7c8716715652ae11f85ed0eb99e265a2df2ca490577d69860b2c", size = 68324, upload-time = "2026-08-28T10:25:49.81Z" }, + { url = "https://files.pythonhosted.org/packages/08/c1/88018321d976f53c421e379c43bc6993e70ce0c8a3ec5edc4bfe102257f6/kiwisolver-1.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b6ae6a0328f0bc035741820fdeecdcd67bf4694eee03972e843663107122f450", size = 62272, upload-time = "2026-08-28T10:25:51.02Z" }, + { url = "https://files.pythonhosted.org/packages/85/d2/712bc17ea4f1d216034928069d612defbc6c95a471c55a7203a39faecb1a/kiwisolver-1.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:886fc26012f0e8b5f69d1cfe6d711f6b11f194621539bf8e6bb1c25c5dc82724", size = 64481, upload-time = "2026-08-28T10:25:52.22Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e6/6c5380d676f43b6d918033962ea5e72360ca69e5a404154bc496b598ffdb/kiwisolver-1.5.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aefe930d113798330e9462f7874542977869c0613cba3262e2de3a8d5dee8f3a", size = 66260, upload-time = "2026-08-28T10:25:53.387Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/7087f5822cc8bb272679641404b1966a42504dac9ee74e2b33840475a0aa/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5ca5aebae78a0bc13c1943af4af615d4966c5b650b05d5aa83b50e427196fee", size = 123876, upload-time = "2026-08-28T10:25:54.644Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4b/9f385087ca09ee5ab9c09c6832561a7d2f7c78d3e5661d511e669f70e439/kiwisolver-1.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1ed0f5e49d0ceff8b72190824d9e59c062fbbc02c231b853112c78474b3f5ec2", size = 66487, upload-time = "2026-08-28T10:25:55.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/8b/40d33ffd2f378094ed462e9a9a0907e59d4de9845e65a59561272da350d4/kiwisolver-1.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:77a4c8187a5948d7f8795adb765a3c7b553d07d86d88e43038fc32fc1fb9a3f3", size = 64673, upload-time = "2026-08-28T10:25:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/ab/43/86aacc027959108b4c66eeae8b73cedb057dfa6eb3a335d05ad65197081c/kiwisolver-1.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:74ad5c3dad54a4641b4c28cd15ded70899d04459c6c7aeacafea716be97cce6d", size = 1477992, upload-time = "2026-08-28T10:25:58.479Z" }, + { url = "https://files.pythonhosted.org/packages/ed/40/b1d0369048c79733a32c8abb0f2718532e6630641368e33a81384246e844/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e46b23a2da695c364124817bc01d970effd5483147f8d66a6a7167e3f6b851", size = 1278821, upload-time = "2026-08-28T10:26:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/a1/fa71c1792272ff9461432715ae60ffb7e11a4d7ac3bf68961b9cab6c60cf/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75d9b1cf8258462dbdc1eeda718c96ea7f079324c09067f6daabfcf37712b7fe", size = 1296805, upload-time = "2026-08-28T10:26:01.868Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3f0bd94af8e06ecc47eb834b195a06f05d48711ceb2352c56d6835160f0e/kiwisolver-1.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fca690b00c4c48f6c2a547b0160ed511357093a4e4c9b47e0fadf3128066d89", size = 1346109, upload-time = "2026-08-28T10:26:03.59Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/3afe6ef9cf06d61220953a8963e94eca978491be1d9547cb01d82a1efa08/kiwisolver-1.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:876bbfd276473d3daffe30e8c975df4ed9429967b41a6cb362dbb5155b6f13ad", size = 988252, upload-time = "2026-08-28T10:26:05.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/30/a12bd7a7285a211e1747c3eec77b8c614dbfcc1dad942f7611a1a6921ae5/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f942903fde7363d1d879057ec5de01310efda2597161784d752fa9953a01a71a", size = 2228846, upload-time = "2026-08-28T10:26:07.312Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/233e2958abf0dab7b18d07e52f286e02e519d7651bfbbe97af9347564109/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c90d3022d8a94778939cda8638c6c8da8fa757b8958dad7ec868ce29c87681b8", size = 2325583, upload-time = "2026-08-28T10:26:09.093Z" }, + { url = "https://files.pythonhosted.org/packages/42/8e/7673060a27b01405b580058510adef34687069d229800239f5e44682d4d0/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8a34616dc2521cc8dc1d7d081734da63539f021ac0450ce950908340c6e7aa2f", size = 1978221, upload-time = "2026-08-28T10:26:11.127Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/1b882fc1a8b4a0bb8084e7d1d85004116d08c92c0705d31f2928dec607f2/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8bf4df63592c2a66b4f8edc5df2544998c288aa02f96ce0acd880cd1de8c8127", size = 2491819, upload-time = "2026-08-28T10:26:13.348Z" }, + { url = "https://files.pythonhosted.org/packages/39/9c/426deb49e62c5f69464b64bbeca064d3b758a7506b8913d986ef34f4619c/kiwisolver-1.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d09037ca068d784ebc4aec290ef952ca27ac15dd9c0b5801a88c6e1096b83e6b", size = 2295520, upload-time = "2026-08-28T10:26:15.042Z" }, + { url = "https://files.pythonhosted.org/packages/f5/22/deabbb3ad6d918d74b7831b2d8ae7151b09d21c87974e5ee8a456f58c94c/kiwisolver-1.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:dc23390afe9f4ef9ac3bcc72a03a56eebbde03f4c571a32cb38f859cff9a6524", size = 70758, upload-time = "2026-08-28T10:26:16.504Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/02fa5c2fd92068bb8952847e70ee6c5cb280e7febe11653d17812acc53dd/kiwisolver-1.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:186884a58486651e3c217b6acea0a53eaa9498fdd472057c46f2f0fb5c25aad5", size = 68329, upload-time = "2026-08-28T10:26:17.658Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/2d5dfad0daf17dcc18d98c48ed2332fc3f051cf599e60be6182a30dd4cf1/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0324cd2567259b7a095f6cf18a52b0ffc6f3de9e69528ff1bc0e7a37bd43ff1a", size = 62337, upload-time = "2026-08-28T10:26:18.778Z" }, + { url = "https://files.pythonhosted.org/packages/08/c8/83e1624f15d6262b470dbcc80b09979fd4d5b2ea3ddfc6b6e3327e235726/kiwisolver-1.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:74ea337e0ec3f6f342a36a4f1b5cd94dd9affddcd28ba9aae2905af932ee8c6b", size = 64513, upload-time = "2026-08-28T10:26:19.909Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2d/827ec30eb07f528c08d8459ffb318ae91a56d793ee8acbea8b491f0ff906/kiwisolver-1.5.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ee9df1f0d77b9c6e94f4ac0fec533fbddd5ea3a327807f18d7b069ae019ded80", size = 66287, upload-time = "2026-08-28T10:26:21.078Z" }, + { url = "https://files.pythonhosted.org/packages/53/11/5c43a562529dad8def4b81e5e1877c612a7e0298105a5939b3b409d2079c/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fc271a6f0a2126958f4090e5507b9da5848927dae331f8f763bd4aa642b3d2cd", size = 123940, upload-time = "2026-08-28T10:26:22.475Z" }, + { url = "https://files.pythonhosted.org/packages/64/db/9bd6c505c95128c258a55236bfbb3a7a3fb6023f863316b6d7d9f3c69052/kiwisolver-1.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9b3092d8992a1d69b7a59c3e39f35e1b9be327a17f68a7c35fc17329e337d6f2", size = 66493, upload-time = "2026-08-28T10:26:23.743Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f4/b3007a3ed5c9be73f81161140684cf7d9bdb9c4b632f5f484d2a1c713fb9/kiwisolver-1.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c2306e8bb53601979fcb3fa09cc65e031876d9ae01eff2fcbcd7a84ef94d5bc1", size = 64720, upload-time = "2026-08-28T10:26:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/a1/13/08188f0cafa3a800403e4ff62b9aad4e7a17f9c4c7e080dc8f18c64794cf/kiwisolver-1.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:18a0cfb124546a4c2e6087c5f3029c7f44b37c85b142e0ced71f73a7599ac208", size = 1475867, upload-time = "2026-08-28T10:26:26.393Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3e/053bdc3c9abdb8f2606225eda398adca25c0c91ab90add8222a69db65ee0/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34ec467940442c9943016fb2d4c81d1ba84351eeca2f1a78f8bc87f1ba0d414c", size = 1282865, upload-time = "2026-08-28T10:26:28.118Z" }, + { url = "https://files.pythonhosted.org/packages/5e/64/a44c341b36b610588cc2f1e89b3cae072a3119aa8be578e90987cd640751/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a83ee7107df13abe42a54a6654670eef9bb39425cf2e27f65e0007465e1286ab", size = 1300865, upload-time = "2026-08-28T10:26:30.125Z" }, + { url = "https://files.pythonhosted.org/packages/60/5e/7e7d716dca38c714478b741257a5b4a321d9932b8d851551a136dcaf3984/kiwisolver-1.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bebb89489b279b2f5661bbbb2abcc87bcd4a46607bb4a5c966f04f1db6b8df9a", size = 1348071, upload-time = "2026-08-28T10:26:31.829Z" }, + { url = "https://files.pythonhosted.org/packages/10/1a/2b98fdda8bf45b7be317e48ed12393d44334394d315c74b81f4a14c0e31b/kiwisolver-1.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:509735237ae0d849e8a843551d423d2500d2e0a9ac1611a145658b29c0fb9f85", size = 992191, upload-time = "2026-08-28T10:26:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/d893f5ede0e50f9e3fcf01f6015f42ec7d9cd221e26772701fe4a98745f9/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:824c3d763a05ea9e9003610145186b0e9848c7584a5575c79bac5a8e7cd80bad", size = 2233854, upload-time = "2026-08-28T10:26:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/b1/82/f85f6279555a6ee1639fef7bfe83adb037a03e11a6fc9eaa54b8d0380339/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1fff05e239575b1481b6ed1a782f6fad616efbf1f0b1f44e6e85c4dfe426e483", size = 2330621, upload-time = "2026-08-28T10:26:36.9Z" }, + { url = "https://files.pythonhosted.org/packages/56/31/e11aea078f66fc2fffcc179d38ca90d9da97652a241b64519169742ba46a/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0627b9bceb9c3cdcf12b8a18655eedfed2692b038df27423383c120d0b7dc2d6", size = 1982848, upload-time = "2026-08-28T10:26:39.01Z" }, + { url = "https://files.pythonhosted.org/packages/af/ea/2956b63bf5140ca46aa2c2818e6aa03e2d5754dd2fa41db1c6b28922940c/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8a708a47ade1fe19e8371d5da076bac0dd4b0a5a7985ad6c637f7f7e361b6baa", size = 2494850, upload-time = "2026-08-28T10:26:40.837Z" }, + { url = "https://files.pythonhosted.org/packages/11/d1/3829542258d8b3fc0898d221e7ef0e2c83eca0d348709bb8dbe54f3d4005/kiwisolver-1.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:007a5553dfc4f4e8d184f588a0200e2cd4b63a59cc8796df3c39909e679dc7a0", size = 2298067, upload-time = "2026-08-28T10:26:42.803Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/49522e1ab5788cbaf63a26fbd3b851f9028616828c961b8a31b35cb96df8/kiwisolver-1.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:f4167e87b397f273dc2356fcf1eaf50a6bac51e6105f45103ef7129c8efb0255", size = 72282, upload-time = "2026-08-28T10:26:44.268Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b6/22e7ca5315d363e6f81c9f37c9472e12e7b298731e77c0428e6a911a2c39/kiwisolver-1.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5c490db2168a508088f59140dd392556a54b8bd1048fc6383c8baff13c359673", size = 69855, upload-time = "2026-08-28T10:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/30/8c/03a9cfbe871964c8758a816eb03ac96c806da2795a9a7cd9bf9648bfb594/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4d4ca09bf13cff792b1884f64b98ee6c2467930d632233be25c56b442d99f10e", size = 126289, upload-time = "2026-08-28T10:26:47.023Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e3/14ce3041ca79dff9c9d884ca00c7bf32374e76028a865a9ecd99b4f5a517/kiwisolver-1.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:44b8faef94f1857e77fa0238f3390ff1ac51d2ea20a487e2e452a59fd2b5f5ca", size = 67709, upload-time = "2026-08-28T10:26:48.268Z" }, + { url = "https://files.pythonhosted.org/packages/af/c4/45030471a66ec8ef042e9f96ffe1d522c9ab12da180186a0898966fc1385/kiwisolver-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ae70bc59790d2af72a3f76f24b272403e135070340281108b447cb77ea70819", size = 65909, upload-time = "2026-08-28T10:26:49.523Z" }, + { url = "https://files.pythonhosted.org/packages/42/73/17dce073a6ae259bb32cf9d686c4079d2e538868bc45967462bf33df914a/kiwisolver-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:43844c1a7ad6d723d5b5b4c4fc7f5bd399c40e288120d16257c7c9e8765c6e85", size = 1584907, upload-time = "2026-08-28T10:26:50.933Z" }, + { url = "https://files.pythonhosted.org/packages/8c/84/ae3c75909f507283cbfcc7e916c7e822579ef962020b97e6882b27b4478f/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22d5e5aaad6be121f2515765e3b1c444352cb8eb4c86510801db8f2e50757316", size = 1392474, upload-time = "2026-08-28T10:26:52.638Z" }, + { url = "https://files.pythonhosted.org/packages/34/31/8bcc83caad5bce8fa4577152389848bf6bc110e51e573a2b4e7c2aa34c89/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3fa5855898f6d3d01b72ccd48a2d65cbdee301251603fefe34e2025bddba219c", size = 1405246, upload-time = "2026-08-28T10:26:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/55/72/220345537d790cf4ae54f8acfff4b5cc2468e0702a384d651cf7a771c63e/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d66a64dd5dec136040ec2ae94aa026a912ee60fdd45bc28d3db30037fd809e88", size = 1456099, upload-time = "2026-08-28T10:26:56.042Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/3725fd2398f66d18c34b4e0f81a8d03764cd4f4f089f58a527f0b4428086/kiwisolver-1.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:9e51c119992ea8820706871c30a4642ec76de20ae82f9b50b9a45517d8e9f810", size = 1073695, upload-time = "2026-08-28T10:26:57.658Z" }, + { url = "https://files.pythonhosted.org/packages/86/cb/28d6e09e66b93e4588b2e6b7d84d020ccefea09e2f4de788510a07efeab7/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:70ed9a45c7484d2b30cdacf60d220f494a1763b9fec1ad03285c6553fa0889f2", size = 2335355, upload-time = "2026-08-28T10:26:59.202Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b3/a0f31d5e4e40af7dc97c36b8a74fdd3a36cf3c8bbd098da9a23466ff6a94/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:98b208a7cc42c803445ef551d6753cc42a5ea13e9cab1ee66cd8b9cb70195330", size = 2426524, upload-time = "2026-08-28T10:27:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c9/728f63bd58c72cafdc79fc306abeeac7391bec03b757a48dadeb30906521/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c6834b92dd2428e2dd85ef3d85f723d3c12f20aaf43a2ddd4f944ca25d833408", size = 2063430, upload-time = "2026-08-28T10:27:03.06Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/ce188b96f92f9a2c958231da140768918cba53c9713dc887b82f85462118/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5d142e352eb13facc7dd047489aebdff6ba78576c239f1ea04931979caaf0567", size = 2597513, upload-time = "2026-08-28T10:27:05.072Z" }, + { url = "https://files.pythonhosted.org/packages/69/d6/76947c8203768968382e5bd74d9cc95654746703a61ea53015f2c74a2e06/kiwisolver-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9b1c4900736e489a812c529100de4b8fb617d4db075e931e213c57424b83d9b", size = 2394488, upload-time = "2026-08-28T10:27:07.423Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d4/14b21e4eb203c4d15425e8b6a2c625a320b4a1f2f7557eead63ffc30ffb7/kiwisolver-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5978c3340f16a35c30f8ab2fa7bcf559973c55f1a5ef6970e1f621acf3c4db13", size = 75404, upload-time = "2026-08-28T10:27:08.892Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f5/53157899fc7f45f76421b77b99eb1639dd0f83f26ff9d76300c96bb4a3b0/kiwisolver-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ca307d6c259e5c98d3cb9ade55342b47a6839762caf2536f3d7b46ee660cc82e", size = 72946, upload-time = "2026-08-28T10:27:10.944Z" }, + { url = "https://files.pythonhosted.org/packages/75/62/f786c3a27f181fa339d851a77e266d208e776b9883cabc40a5b041a31b5a/kiwisolver-1.5.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:bb7c99f0673c03017a3ee01e54a5c2617a05468b11eabe513b0080e063ed95b1", size = 62420, upload-time = "2026-08-28T10:27:12.308Z" }, + { url = "https://files.pythonhosted.org/packages/02/cd/58a91ed25fbad0facdf503297b03768efab04bdf3141e5e3b49a34be7443/kiwisolver-1.5.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:0d8924877ce22e17326a99a418c3c82037da078df3c6a260b13eca677444e6e7", size = 64577, upload-time = "2026-08-28T10:27:13.502Z" }, + { url = "https://files.pythonhosted.org/packages/f6/5c/d501ef5a0958b226eac28306d24d5e5f114be0ace50e19cabae7b6b3b197/kiwisolver-1.5.1-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:534f02c1abb31ed6dbd3515545285c330b2f12d00fdb1fdb71658b9ca5a13a6a", size = 66284, upload-time = "2026-08-28T10:27:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a6/8fbecaf4fc18c02f31f05e47a84c010a80e3ec391ed2f0bdade1d62b5954/kiwisolver-1.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:cea20da04494e662b83c872683bf4ff2345206043d036315ed0e924b652e7294", size = 124031, upload-time = "2026-08-28T10:27:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c6/bf3090d2983b4204347cbdbe952116e7c3b2abf62b4e33e50167a13e75ee/kiwisolver-1.5.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:7fd82debf43c6acd0a94359d232f6bb516ee13f269a7993736a9ac9f988bb5d9", size = 66489, upload-time = "2026-08-28T10:27:17.506Z" }, + { url = "https://files.pythonhosted.org/packages/85/de/562dddef55fdd7c291da8626d6619e72b5fc0870e6ccca0e149a5731e7f3/kiwisolver-1.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:18170a77ddfecf40ec60d0928268dc95880c881864e015a8f34094ed18b9b9ad", size = 64806, upload-time = "2026-08-28T10:27:18.673Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/ba7b9c0164ce1cf62bf2872db63b8483289cf0f3110d6f9390eb09e409ed/kiwisolver-1.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ca7f6fe0f37ca978a1e5eb7a3a68e6413f417e78e838324947ffd420202b198b", size = 1482211, upload-time = "2026-08-28T10:27:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/13/dc/34da54efb4976616d45c20aae32d70e89d6e7395ed908029154d1609ef22/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b973887ff782cfd6b67c9904ad8ca542e0bc5e4961503408b423b5a688b4d38", size = 1283739, upload-time = "2026-08-28T10:27:21.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3a/30ffb62bee646e266e98a1b5cd276d9c75b6116fbfcb87c1190838c1b6df/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f76fc85bd054c806960f917ec0f329e24e436f1712267d90588e4c39890caa63", size = 1301681, upload-time = "2026-08-28T10:27:23.876Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8d/13c70be22a8506880b35fdc38dca36629613bc493405c79f4037f2cd2bb9/kiwisolver-1.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:828f75af2b0080c8a972e75f649ab46af008e92c6104a57a759157200b835b75", size = 1349159, upload-time = "2026-08-28T10:27:25.899Z" }, + { url = "https://files.pythonhosted.org/packages/82/0e/993972b8ec6767f47cd69818fb3a5ff14510557d29f7d1a839be7574fa1b/kiwisolver-1.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:431dc224a1a92a5c8f582d96e505196a3b5997a7271076678da2dfde67b77e9a", size = 997613, upload-time = "2026-08-28T10:27:27.507Z" }, + { url = "https://files.pythonhosted.org/packages/36/82/ca26eddd2eda2420dfc56693449c1f821f78b485da9cbde9904c03af3f93/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:61e9a64c7635095a6bfe483e2ff055d437c59bd45f3617a228b37277f0185d62", size = 2235109, upload-time = "2026-08-28T10:27:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c7/84/97d920881e10840b8d7c7185620298e3e4c88820b05514e3a15a258b08a6/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3c24cd69455e1b00ddf770c13b6e2c33e07d6dc3f2d34add0bf9277c5c6bbd46", size = 2331207, upload-time = "2026-08-28T10:27:32.795Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ce/34d74b8f25acc58800f4c09268371e8d6159cf0f1206f1e4dc7835629b48/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:27add358abe374ebaa3b8763ef380bc99051b5a4b18d94878366a9e4f59efef0", size = 1986696, upload-time = "2026-08-28T10:27:34.628Z" }, + { url = "https://files.pythonhosted.org/packages/0e/01/f892644014612527aef7031d3306a2ffc60b3cb044f802c1561f8e5e14f3/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:255605693a483db7bd5c79f60437f7bf658f7f520d61aa42722e32257c941951", size = 2496400, upload-time = "2026-08-28T10:27:36.818Z" }, + { url = "https://files.pythonhosted.org/packages/32/6d/d8284e66e697026536e5f418b9cfe56567bffd3c775e3ecfbae373605854/kiwisolver-1.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:7d38b0c279c3032e8c9cc013b405c6df8e1668dbf15465779aa7f15f61201812", size = 2302967, upload-time = "2026-08-28T10:27:38.803Z" }, + { url = "https://files.pythonhosted.org/packages/46/0a/69a355e27f32ba50d5b6369949b6a1702e122f5277c89bc76d452b81c1c4/kiwisolver-1.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:958254518717542d02d0688d0d20cbf771da5e415e6f49543f92481c850a4540", size = 72283, upload-time = "2026-08-28T10:27:40.491Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/7a95be90c33dcdd52204d4aa6384d731443225b887283bbd8b61e7931f6c/kiwisolver-1.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:da3275833be0edbaf4830fae08bae3dc7219f40ce0c37eaa6c25825957e06612", size = 69860, upload-time = "2026-08-28T10:27:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/31/f8/9bc493e7f5707788ba7f621902c68f82dc3a7ba03c78fbd337b026cef1ed/kiwisolver-1.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:470d420f98d368d6f010633a20659b544c5fdfa5329e6b70219f2ef08fd4a7ef", size = 126336, upload-time = "2026-08-28T10:27:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d1/82/3aea86b3f99712db825e9ac5631bf99571e818a8b8961ff98cebd798413e/kiwisolver-1.5.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:83f78128fa28705fa85d01c59771c72fe81c11bd0e6155edbb9f818983a7d761", size = 67698, upload-time = "2026-08-28T10:27:44.613Z" }, + { url = "https://files.pythonhosted.org/packages/84/7d/8daafc5d2e7f9c47a4f78f8865d86d2a9cf399c2a85f86c44a993594410c/kiwisolver-1.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:9506e892bcc3b409831d363c6f53e5985e1c8d1f6f6b0256d00358684ff85378", size = 65945, upload-time = "2026-08-28T10:27:45.932Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c9/51dc974d9130da70a8c47a96160123443d387ffe1b6b833d6f91d9429339/kiwisolver-1.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cea90547bfd93807e0013a004dc76552be44fad3bc1cc2b38610a9e889ed098f", size = 1588030, upload-time = "2026-08-28T10:27:47.678Z" }, + { url = "https://files.pythonhosted.org/packages/e6/86/f3e1a730e7a995149d8d3ff9e313b6d8a17b2cf1d98a8eff139dc30463fb/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8e4d953faaded9ec7ede36824e9814082d22d4c7b1eafbfa079ecba8cd0d076", size = 1390760, upload-time = "2026-08-28T10:27:49.676Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/8ba25a2b5a0d2375e046f1b72de5179513f0be95aba6e7b094c89303929f/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e9c01d3dd7ceba4d1d436cc021d40d592466e40b9bc7f5d83dc4e98a5c9cd8c", size = 1403279, upload-time = "2026-08-28T10:27:51.242Z" }, + { url = "https://files.pythonhosted.org/packages/18/d0/278d5cb8be812740027d5ca0a7eda0c375488a88d6dce0fa60fcc2591ad2/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37f801b5d7cc0e5a548921308e059fd2b057bb42972b591cfa3049f95423c4ed", size = 1454429, upload-time = "2026-08-28T10:27:53.213Z" }, + { url = "https://files.pythonhosted.org/packages/94/37/bcbab41063ec284c1d200efe5087cf087798c2f8916960aa8a20dd303290/kiwisolver-1.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:e68e151428b5384f766cd25739bf77c7e4a3dc93b5ded7a12118d9fbfdf78ab6", size = 1073225, upload-time = "2026-08-28T10:27:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c5/ab79dcdf5ae28909a51210ae0a1c579e97ff997b3466414f0d04c0994583/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8f8fddb8e323bd6eee4e54e69a39243beab22689070f4c66b472c4cc88bb89d8", size = 2334335, upload-time = "2026-08-28T10:27:56.78Z" }, + { url = "https://files.pythonhosted.org/packages/8d/8e/71d047468a189041d9c93f3b76844b924f9793b188c44bd149fa258912da/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3cc210010fd2f438a3ed430b45f1b501fd13a8618bf984dc2c5ce5b69b78752e", size = 2424982, upload-time = "2026-08-28T10:27:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/1347461bbea6d0e1f0580b94ef603b18e72c2be5f667fa1653867361a00b/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b5664603a253efd3a75716d793d1d3a6a82723b61dc6db767b2460bbbeec4c0f", size = 2062857, upload-time = "2026-08-28T10:28:00.407Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/c7f0976c9cd0d127643351bc0c9929e0b8899d7f49d4ec238cd909e39c42/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a7b85b2cc6ea45e5f7e8c9a30bc9fabd47cda09106cbb4b967335c3e6c43b69d", size = 2596022, upload-time = "2026-08-28T10:28:02.183Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9a/59a6f6ae6f938c15076be2c21b6cedea973d71bb1349ec84fa485fab82cf/kiwisolver-1.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ab620eb663952455271ac37f9aaad86b73c969c02f11f53cea405b38e96a4300", size = 2395634, upload-time = "2026-08-28T10:28:03.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/2d/8982c1fb7926da5bb7ed60318c3665b5c3f941447271ac982960a11b8637/kiwisolver-1.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:cb6fae641357ed2f6e533c0d3c6504a4a5703621a50c89459e46051d56b61140", size = 75379, upload-time = "2026-08-28T10:28:06.307Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/ba7b6dfa1708b82b373ac056928a30c545d5c1a627df9839dcec3c6c1881/kiwisolver-1.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:b390aec180a7c054919c04898835e1c77bced23ea8383eb2c570213bf25d1a86", size = 73011, upload-time = "2026-08-28T10:28:07.578Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/1407df7512a5b36cc79840e01710dc575733c461b13ab866cae77eaf87f3/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:482676e5bd48d70ac99d9fc78863469845421e01184fa83f1f9366dc49f7e974", size = 134002, upload-time = "2026-08-28T10:28:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/16/45/c37a21ad5c0ab581a93c55ad544721aaa1f0ae94edb29c6a678a23d013e6/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:072bdb15a3c19a5b5dbc8f8fb1f4e1884bf4f3507eeb4cc6334401274d37a5c0", size = 194292, upload-time = "2026-08-28T10:28:11.06Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/69f00d627949580e43d57af0aa465df46868d7c29801c137a55374101294/kiwisolver-1.5.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:a5a00665d1a0e26763a7338d7e911d4598fbc1d50dd0d6b7919b7dc6c5d6569f", size = 73362, upload-time = "2026-08-28T10:28:12.449Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f1/8bae9fac0f1837679ed59a5dd7e97e7eb943b738defa7cc0117ea0d107dc/kiwisolver-1.5.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:a5716a33bfabb2c6ce27b6cf03253467b3804f83e215f4d202685cf93c6c9874", size = 59543, upload-time = "2026-08-28T10:28:13.794Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3e/42e3639d32ef3ac6ede80c39dc1d1df6224927409960d8f762b6cb504efe/kiwisolver-1.5.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:685929988b208a911f1285e2f8ed54210b0d681a3dc0f03e00d599d291986e7e", size = 57504, upload-time = "2026-08-28T10:28:15.074Z" }, + { url = "https://files.pythonhosted.org/packages/e1/12/f4beaaceb740b96c363a8fa6f34dbdf37a58d9e5f15416427d7bf89c552a/kiwisolver-1.5.1-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4e49f7e1a4e7191bdf9dc67a974db714501b1fc52c24324103d06a86abd5c08", size = 79885, upload-time = "2026-08-28T10:28:16.345Z" }, + { url = "https://files.pythonhosted.org/packages/02/24/18d8a755acdae79c37ca2f1a925795aab67b9456c30c8569c377ea3abc77/kiwisolver-1.5.1-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a887b6565bbfe80efde2b7f6e8890d7d9bbdb11bdb17028a3690c32fe0621f", size = 77582, upload-time = "2026-08-28T10:28:17.719Z" }, + { url = "https://files.pythonhosted.org/packages/df/a9/b86dde4f553ef74db0f9c32398614e24b2e8ec1d29a6ca04ae080d7dd29b/kiwisolver-1.5.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1983f0974a750a6f6556f368ba11105d1d8369c735b944747c9f12ae5aea7aae", size = 98321, upload-time = "2026-08-28T10:28:19.087Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1d/59ba570b1774e95e97fde3a0981b2e22118a7a495f73bf74cedc538566a0/kiwisolver-1.5.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:416ba7ff9f233b7036689bb5a3783537e838ad483f63558d2a800f75afe738b1", size = 59450, upload-time = "2026-08-28T10:28:20.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/98/a6849f04dc18b5400e8b98affa2cd8fd86ed583085f036e57b32e571f4fa/kiwisolver-1.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8af9b142ad719ae3a911ebf616bc4b78b32bbab84d6a40d3ad2f129670509957", size = 57400, upload-time = "2026-08-28T10:28:21.632Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ce/a7dc71353dd06a4cbe02222773f52d4a28c81e5a452a75797f8ed113dc99/kiwisolver-1.5.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5daa1f19e097050b9c4d9a78fcc9263cb96c9dfae08037ddc1b7c4ad1889f2a2", size = 79891, upload-time = "2026-08-28T10:28:22.936Z" }, + { url = "https://files.pythonhosted.org/packages/10/b1/d61c61a84ff85d1a36a99df2c152b59ffedb1d356c598902aba44abcdb60/kiwisolver-1.5.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdaeeb6c350106df6bf9d873395973e5f066a9713200b72cd64f55d0a3eafab6", size = 77605, upload-time = "2026-08-28T10:28:24.322Z" }, + { url = "https://files.pythonhosted.org/packages/d3/52/5aef56f21a460a6e43ab3cdfc7697d59d7b87deb0ec97a0f7b91aa4a521b/kiwisolver-1.5.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:17851e5dad4484be0cbccbde3b15331deae036de9aebd45eed964487802b172f", size = 98465, upload-time = "2026-08-28T10:28:25.696Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/45/2466d73d79e3940cad4b26761f356f19fd33f4409c96f100e01a5c566909/lz4-4.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d221fa421b389ab2345640a508db57da36947a437dfe31aeddb8d5c7b646c22d", size = 207396, upload-time = "2025-11-03T13:01:24.965Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/7da96077a7e8918a5a57a25f1254edaf76aefb457666fcc1066deeecd609/lz4-4.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7dc1e1e2dbd872f8fae529acd5e4839efd0b141eaa8ae7ce835a9fe80fbad89f", size = 207154, upload-time = "2025-11-03T13:01:26.922Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/0fb54f84fd1890d4af5bc0a3c1fa69678451c1a6bd40de26ec0561bb4ec5/lz4-4.4.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e928ec2d84dc8d13285b4a9288fd6246c5cde4f5f935b479f50d986911f085e3", size = 1291053, upload-time = "2025-11-03T13:01:28.396Z" }, + { url = "https://files.pythonhosted.org/packages/15/45/8ce01cc2715a19c9e72b0e423262072c17d581a8da56e0bd4550f3d76a79/lz4-4.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daffa4807ef54b927451208f5f85750c545a4abbff03d740835fc444cd97f758", size = 1278586, upload-time = "2025-11-03T13:01:29.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/34/7be9b09015e18510a09b8d76c304d505a7cbc66b775ec0b8f61442316818/lz4-4.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a2b7504d2dffed3fd19d4085fe1cc30cf221263fd01030819bdd8d2bb101cf1", size = 1367315, upload-time = "2025-11-03T13:01:31.054Z" }, + { url = "https://files.pythonhosted.org/packages/2a/94/52cc3ec0d41e8d68c985ec3b2d33631f281d8b748fb44955bc0384c2627b/lz4-4.4.5-cp310-cp310-win32.whl", hash = "sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc", size = 88173, upload-time = "2025-11-03T13:01:32.643Z" }, + { url = "https://files.pythonhosted.org/packages/ca/35/c3c0bdc409f551404355aeeabc8da343577d0e53592368062e371a3620e1/lz4-4.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:7c4e7c44b6a31de77d4dc9772b7d2561937c9588a734681f70ec547cfbc51ecd", size = 99492, upload-time = "2025-11-03T13:01:33.813Z" }, + { url = "https://files.pythonhosted.org/packages/1d/02/4d88de2f1e97f9d05fd3d278fe412b08969bc94ff34942f5a3f09318144a/lz4-4.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:15551280f5656d2206b9b43262799c89b25a25460416ec554075a8dc568e4397", size = 91280, upload-time = "2025-11-03T13:01:35.081Z" }, + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/508f2ee73c126e4de53a3b8523ad14d666aeb00a6795425315f770dbf2f4/lz4-4.4.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f6538aaaedd091d6e5abdaa19b99e6e82697d67518f114721b5248709b639fad", size = 207384, upload-time = "2025-11-03T13:02:27.043Z" }, + { url = "https://files.pythonhosted.org/packages/64/84/da7fda86dcc7b6d40d45dd28201fc136adfc390815126db41411bf1e5205/lz4-4.4.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13254bd78fef50105872989a2dc3418ff09aefc7d0765528adc21646a7288294", size = 207137, upload-time = "2025-11-03T13:02:28.021Z" }, + { url = "https://files.pythonhosted.org/packages/01/95/fb9c5bffed0f985eab70daf2087a94ad55cbbf83024175f39ff663f48b22/lz4-4.4.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e64e61f29cf95afb43549063d8433b46352baf0c8a70aa45e2585618fcf59d86", size = 1290508, upload-time = "2025-11-03T13:02:29.485Z" }, + { url = "https://files.pythonhosted.org/packages/57/6e/6a39b5ca9b9538cc9d61248c431065ad76cc0f10b40cb07d60b5bdde7750/lz4-4.4.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff1b50aeeec64df5603f17984e4b5be6166058dcf8f1e26a3da40d7a0f6ab547", size = 1278102, upload-time = "2025-11-03T13:02:30.878Z" }, + { url = "https://files.pythonhosted.org/packages/73/57/551a7f95825c9721d8bee4ec02d8b139b1a44796e63d09a737ca0d67b6b1/lz4-4.4.5-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dd4d91d25937c2441b9fc0f4af01704a2d09f30a38c5798bc1d1b5a15ec9581", size = 1366651, upload-time = "2025-11-03T13:02:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/4f/85/daa1ae5695ce40924813257d7f5a8990ba5dd78a9170f912dd85c498f97c/lz4-4.4.5-cp39-cp39-win32.whl", hash = "sha256:d64141085864918392c3159cdad15b102a620a67975c786777874e1e90ef15ce", size = 88165, upload-time = "2025-11-03T13:02:33.413Z" }, + { url = "https://files.pythonhosted.org/packages/df/db/3e84e506fdd5e04c9e8564d30bb08b0f3103dd9a2fb863c86bd46accb99a/lz4-4.4.5-cp39-cp39-win_amd64.whl", hash = "sha256:f32b9e65d70f3684532358255dc053f143835c5f5991e28a5ac4c93ce94b9ea7", size = 99487, upload-time = "2025-11-03T13:02:34.246Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/40aa9d006fdebc4ae868c86ce2108a9453c2b524284817427de1284b5b00/lz4-4.4.5-cp39-cp39-win_arm64.whl", hash = "sha256:f9b8bde9909a010c75b3aea58ec3910393b758f3c219beed67063693df854db0", size = 91275, upload-time = "2025-11-03T13:02:35.117Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py", version = "2.0.3", source = { registry = "https://pypi.org/simple" } }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py", version = "2.2.0", source = { registry = "https://pypi.org/simple" } }, +] + +[[package]] +name = "matplotlib" +version = "3.9.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "contourpy", version = "1.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "cycler" }, + { name = "fonttools", version = "4.60.2", source = { registry = "https://pypi.org/simple" } }, + { name = "importlib-resources" }, + { name = "kiwisolver", version = "1.4.7", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pillow", version = "11.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/17/1747b4154034befd0ed33b52538f5eb7752d05bb51c5e2a31470c3bc7d52/matplotlib-3.9.4.tar.gz", hash = "sha256:1e00e8be7393cbdc6fedfa8a6fba02cf3e83814b285db1c60b906a023ba41bc3", size = 36106529, upload-time = "2024-12-13T05:56:34.184Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/94/27d2e2c30d54b56c7b764acc1874a909e34d1965a427fc7092bb6a588b63/matplotlib-3.9.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c5fdd7abfb706dfa8d307af64a87f1a862879ec3cd8d0ec8637458f0885b9c50", size = 7885089, upload-time = "2024-12-13T05:54:24.224Z" }, + { url = "https://files.pythonhosted.org/packages/c6/25/828273307e40a68eb8e9df832b6b2aaad075864fdc1de4b1b81e40b09e48/matplotlib-3.9.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d89bc4e85e40a71d1477780366c27fb7c6494d293e1617788986f74e2a03d7ff", size = 7770600, upload-time = "2024-12-13T05:54:27.214Z" }, + { url = "https://files.pythonhosted.org/packages/f2/65/f841a422ec994da5123368d76b126acf4fc02ea7459b6e37c4891b555b83/matplotlib-3.9.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ddf9f3c26aae695c5daafbf6b94e4c1a30d6cd617ba594bbbded3b33a1fcfa26", size = 8200138, upload-time = "2024-12-13T05:54:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/272aca07a38804d93b6050813de41ca7ab0e29ba7a9dd098e12037c919a9/matplotlib-3.9.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18ebcf248030173b59a868fda1fe42397253f6698995b55e81e1f57431d85e50", size = 8312711, upload-time = "2024-12-13T05:54:34.396Z" }, + { url = "https://files.pythonhosted.org/packages/98/37/f13e23b233c526b7e27ad61be0a771894a079e0f7494a10d8d81557e0e9a/matplotlib-3.9.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:974896ec43c672ec23f3f8c648981e8bc880ee163146e0312a9b8def2fac66f5", size = 9090622, upload-time = "2024-12-13T05:54:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8c/b1f5bd2bd70e60f93b1b54c4d5ba7a992312021d0ddddf572f9a1a6d9348/matplotlib-3.9.4-cp310-cp310-win_amd64.whl", hash = "sha256:4598c394ae9711cec135639374e70871fa36b56afae17bdf032a345be552a88d", size = 7828211, upload-time = "2024-12-13T05:54:40.596Z" }, + { url = "https://files.pythonhosted.org/packages/74/4b/65be7959a8fa118a3929b49a842de5b78bb55475236fcf64f3e308ff74a0/matplotlib-3.9.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d4dd29641d9fb8bc4492420c5480398dd40a09afd73aebe4eb9d0071a05fbe0c", size = 7894430, upload-time = "2024-12-13T05:54:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e9/18/80f70d91896e0a517b4a051c3fd540daa131630fd75e02e250365353b253/matplotlib-3.9.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30e5b22e8bcfb95442bf7d48b0d7f3bdf4a450cbf68986ea45fca3d11ae9d099", size = 7780045, upload-time = "2024-12-13T05:54:46.414Z" }, + { url = "https://files.pythonhosted.org/packages/a2/73/ccb381026e3238c5c25c3609ba4157b2d1a617ec98d65a8b4ee4e1e74d02/matplotlib-3.9.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bb0030d1d447fd56dcc23b4c64a26e44e898f0416276cac1ebc25522e0ac249", size = 8209906, upload-time = "2024-12-13T05:54:49.459Z" }, + { url = "https://files.pythonhosted.org/packages/ab/33/1648da77b74741c89f5ea95cbf42a291b4b364f2660b316318811404ed97/matplotlib-3.9.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aca90ed222ac3565d2752b83dbb27627480d27662671e4d39da72e97f657a423", size = 8322873, upload-time = "2024-12-13T05:54:53.066Z" }, + { url = "https://files.pythonhosted.org/packages/57/d3/8447ba78bc6593c9044c372d1609f8ea10fb1e071e7a9e0747bea74fc16c/matplotlib-3.9.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a181b2aa2906c608fcae72f977a4a2d76e385578939891b91c2550c39ecf361e", size = 9099566, upload-time = "2024-12-13T05:54:55.522Z" }, + { url = "https://files.pythonhosted.org/packages/23/e1/4f0e237bf349c02ff9d1b6e7109f1a17f745263809b9714a8576dc17752b/matplotlib-3.9.4-cp311-cp311-win_amd64.whl", hash = "sha256:1f6882828231eca17f501c4dcd98a05abb3f03d157fbc0769c6911fe08b6cfd3", size = 7838065, upload-time = "2024-12-13T05:54:58.337Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2b/c918bf6c19d6445d1cefe3d2e42cb740fb997e14ab19d4daeb6a7ab8a157/matplotlib-3.9.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dfc48d67e6661378a21c2983200a654b72b5c5cdbd5d2cf6e5e1ece860f0cc70", size = 7891131, upload-time = "2024-12-13T05:55:02.837Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e5/b4e8fc601ca302afeeabf45f30e706a445c7979a180e3a978b78b2b681a4/matplotlib-3.9.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47aef0fab8332d02d68e786eba8113ffd6f862182ea2999379dec9e237b7e483", size = 7776365, upload-time = "2024-12-13T05:55:05.158Z" }, + { url = "https://files.pythonhosted.org/packages/99/06/b991886c506506476e5d83625c5970c656a491b9f80161458fed94597808/matplotlib-3.9.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fba1f52c6b7dc764097f52fd9ab627b90db452c9feb653a59945de16752e965f", size = 8200707, upload-time = "2024-12-13T05:55:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e2/556b627498cb27e61026f2d1ba86a78ad1b836fef0996bef5440e8bc9559/matplotlib-3.9.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:173ac3748acaac21afcc3fa1633924609ba1b87749006bc25051c52c422a5d00", size = 8313761, upload-time = "2024-12-13T05:55:12.95Z" }, + { url = "https://files.pythonhosted.org/packages/58/ff/165af33ec766ff818306ea88e91f9f60d2a6ed543be1eb122a98acbf3b0d/matplotlib-3.9.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320edea0cadc07007765e33f878b13b3738ffa9745c5f707705692df70ffe0e0", size = 9095284, upload-time = "2024-12-13T05:55:16.199Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8b/3d0c7a002db3b1ed702731c2a9a06d78d035f1f2fb0fb936a8e43cc1e9f4/matplotlib-3.9.4-cp312-cp312-win_amd64.whl", hash = "sha256:a4a4cfc82330b27042a7169533da7991e8789d180dd5b3daeaee57d75cd5a03b", size = 7841160, upload-time = "2024-12-13T05:55:19.991Z" }, + { url = "https://files.pythonhosted.org/packages/49/b1/999f89a7556d101b23a2f0b54f1b6e140d73f56804da1398f2f0bc0924bc/matplotlib-3.9.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:37eeffeeca3c940985b80f5b9a7b95ea35671e0e7405001f249848d2b62351b6", size = 7891499, upload-time = "2024-12-13T05:55:22.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/7b/06a32b13a684977653396a1bfcd34d4e7539c5d55c8cbfaa8ae04d47e4a9/matplotlib-3.9.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3e7465ac859ee4abcb0d836137cd8414e7bb7ad330d905abced457217d4f0f45", size = 7776802, upload-time = "2024-12-13T05:55:25.947Z" }, + { url = "https://files.pythonhosted.org/packages/65/87/ac498451aff739e515891bbb92e566f3c7ef31891aaa878402a71f9b0910/matplotlib-3.9.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4c12302c34afa0cf061bea23b331e747e5e554b0fa595c96e01c7b75bc3b858", size = 8200802, upload-time = "2024-12-13T05:55:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/9eb761c00e1cb838f6c92e5f25dcda3f56a87a52f6cb8fdfa561e6cf6a13/matplotlib-3.9.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b8c97917f21b75e72108b97707ba3d48f171541a74aa2a56df7a40626bafc64", size = 8313880, upload-time = "2024-12-13T05:55:30.965Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/c8eaa600e2085eec7e38cbbcc58a30fc78f8224939d31d3152bdafc01fd1/matplotlib-3.9.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0229803bd7e19271b03cb09f27db76c918c467aa4ce2ae168171bc67c3f508df", size = 9094637, upload-time = "2024-12-13T05:55:33.701Z" }, + { url = "https://files.pythonhosted.org/packages/71/1f/c6e1daea55b7bfeb3d84c6cb1abc449f6a02b181e7e2a5e4db34c3afb793/matplotlib-3.9.4-cp313-cp313-win_amd64.whl", hash = "sha256:7c0d8ef442ebf56ff5e206f8083d08252ee738e04f3dc88ea882853a05488799", size = 7841311, upload-time = "2024-12-13T05:55:36.737Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3a/2757d3f7d388b14dd48f5a83bea65b6d69f000e86b8f28f74d86e0d375bd/matplotlib-3.9.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a04c3b00066a688834356d196136349cb32f5e1003c55ac419e91585168b88fb", size = 7919989, upload-time = "2024-12-13T05:55:39.024Z" }, + { url = "https://files.pythonhosted.org/packages/24/28/f5077c79a4f521589a37fe1062d6a6ea3534e068213f7357e7cfffc2e17a/matplotlib-3.9.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04c519587f6c210626741a1e9a68eefc05966ede24205db8982841826af5871a", size = 7809417, upload-time = "2024-12-13T05:55:42.412Z" }, + { url = "https://files.pythonhosted.org/packages/36/c8/c523fd2963156692916a8eb7d4069084cf729359f7955cf09075deddfeaf/matplotlib-3.9.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308afbf1a228b8b525fcd5cec17f246bbbb63b175a3ef6eb7b4d33287ca0cf0c", size = 8226258, upload-time = "2024-12-13T05:55:47.259Z" }, + { url = "https://files.pythonhosted.org/packages/f6/88/499bf4b8fa9349b6f5c0cf4cead0ebe5da9d67769129f1b5651e5ac51fbc/matplotlib-3.9.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ddb3b02246ddcffd3ce98e88fed5b238bc5faff10dbbaa42090ea13241d15764", size = 8335849, upload-time = "2024-12-13T05:55:49.763Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9f/20a4156b9726188646a030774ee337d5ff695a965be45ce4dbcb9312c170/matplotlib-3.9.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8a75287e9cb9eee48cb79ec1d806f75b29c0fde978cb7223a1f4c5848d696041", size = 9102152, upload-time = "2024-12-13T05:55:51.997Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/237f9c3a4e8d810b1759b67ff2da7c32c04f9c80aa475e7beb36ed43a8fb/matplotlib-3.9.4-cp313-cp313t-win_amd64.whl", hash = "sha256:488deb7af140f0ba86da003e66e10d55ff915e152c78b4b66d231638400b1965", size = 7896987, upload-time = "2024-12-13T05:55:55.941Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/501b465c9fef28f158e414ea3a417913dc2ac748564c7ed41535f23445b4/matplotlib-3.9.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:3c3724d89a387ddf78ff88d2a30ca78ac2b4c89cf37f2db4bd453c34799e933c", size = 7885919, upload-time = "2024-12-13T05:55:59.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/36/236fbd868b6c91309a5206bd90c3f881f4f44b2d997cd1d6239ef652f878/matplotlib-3.9.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d5f0a8430ffe23d7e32cfd86445864ccad141797f7d25b7c41759a5b5d17cfd7", size = 7771486, upload-time = "2024-12-13T05:56:04.264Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4b/105caf2d54d5ed11d9f4335398f5103001a03515f2126c936a752ccf1461/matplotlib-3.9.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bb0141a21aef3b64b633dc4d16cbd5fc538b727e4958be82a0e1c92a234160e", size = 8201838, upload-time = "2024-12-13T05:56:06.792Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a7/bb01188fb4013d34d274caf44a2f8091255b0497438e8b6c0a7c1710c692/matplotlib-3.9.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57aa235109e9eed52e2c2949db17da185383fa71083c00c6c143a60e07e0888c", size = 8314492, upload-time = "2024-12-13T05:56:09.964Z" }, + { url = "https://files.pythonhosted.org/packages/33/19/02e1a37f7141fc605b193e927d0a9cdf9dc124a20b9e68793f4ffea19695/matplotlib-3.9.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b18c600061477ccfdd1e6fd050c33d8be82431700f3452b297a56d9ed7037abb", size = 9092500, upload-time = "2024-12-13T05:56:13.55Z" }, + { url = "https://files.pythonhosted.org/packages/57/68/c2feb4667adbf882ffa4b3e0ac9967f848980d9f8b5bebd86644aa67ce6a/matplotlib-3.9.4-cp39-cp39-win_amd64.whl", hash = "sha256:ef5f2d1b67d2d2145ff75e10f8c008bfbf71d45137c4b648c87193e7dd053eac", size = 7822962, upload-time = "2024-12-13T05:56:16.358Z" }, + { url = "https://files.pythonhosted.org/packages/0c/22/2ef6a364cd3f565442b0b055e0599744f1e4314ec7326cdaaa48a4d864d7/matplotlib-3.9.4-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:44e0ed786d769d85bc787b0606a53f2d8d2d1d3c8a2608237365e9121c1a338c", size = 7877995, upload-time = "2024-12-13T05:56:18.805Z" }, + { url = "https://files.pythonhosted.org/packages/87/b8/2737456e566e9f4d94ae76b8aa0d953d9acb847714f9a7ad80184474f5be/matplotlib-3.9.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:09debb9ce941eb23ecdbe7eab972b1c3e0276dcf01688073faff7b0f61d6c6ca", size = 7769300, upload-time = "2024-12-13T05:56:21.315Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1f/e709c6ec7b5321e6568769baa288c7178e60a93a9da9e682b39450da0e29/matplotlib-3.9.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcc53cf157a657bfd03afab14774d54ba73aa84d42cfe2480c91bd94873952db", size = 8313423, upload-time = "2024-12-13T05:56:26.719Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/5a1f868782cd13f053a679984e222007ecff654a9bfbac6b27a65f4eeb05/matplotlib-3.9.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ad45da51be7ad02387801fd154ef74d942f49fe3fcd26a64c94842ba7ec0d865", size = 7854624, upload-time = "2024-12-13T05:56:29.359Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" } }, + { name = "cycler" }, + { name = "fonttools", version = "4.65.0", source = { registry = "https://pypi.org/simple" } }, + { name = "kiwisolver", version = "1.5.1", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pillow", version = "12.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/6f/340b04986e67aac6f66c5145ce68bf72c64bed30f92c8913499a6e6b8f99/matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217", size = 8296625, upload-time = "2026-04-24T00:11:43.376Z" }, + { url = "https://files.pythonhosted.org/packages/bb/2f/127081eb83162053ebb9678ceac64220b93a663e0167432566e9c7c82aab/matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b", size = 8188790, upload-time = "2026-04-24T00:11:46.556Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b7/d8bcec2626c35f96972bff656299fef4578113ea6193c8fdad324710410c/matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37", size = 8769389, upload-time = "2026-04-24T00:11:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/b78e214a527ea732033b7f4d37f7afb504d74ba9d134bd47938230dfb8b1/matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294", size = 9589657, upload-time = "2026-04-24T00:11:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/5f/15/5246f7b43beae19c74dfee651d58d6cc8112e06f77adb4e88cc04f2e3a23/matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65", size = 9651983, upload-time = "2026-04-24T00:11:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/5acecfe672ba0fa1b8c0454f69ce155d1e6fc5852fa7206bf9afaf767121/matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda", size = 8199701, upload-time = "2026-04-24T00:11:58.389Z" }, + { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2b/0e92ad0ac446633f928a1563db4aa8add407e1924faf0ded5b95b35afb27/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b", size = 8293058, upload-time = "2026-04-24T00:13:56.339Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/74682fd369f5299ceda438fea2a0662e6383b85c9383fb9cdfcf04713e07/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f", size = 8186627, upload-time = "2026-04-24T00:13:58.623Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e8/368aab88f3c4cd8992800f31abfe0670c3e47540ba20a97e9fdbcde594b3/matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", size = 8764117, upload-time = "2026-04-24T00:14:01.684Z" }, + { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "contourpy", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "cycler" }, + { name = "fonttools", version = "4.66.1", source = { registry = "https://pypi.org/simple" } }, + { name = "kiwisolver", version = "1.5.1", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pillow", version = "12.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/c8/9aa712a0afb882649424dd8de8ad9aa6235e796e84c6052e8f6dc1598d0d/matplotlib-3.11.2.tar.gz", hash = "sha256:cec596316640f2b394b8f0daa0ea61a8eae82d017b620b9f202befb972a59ea4", size = 32660610, upload-time = "2026-09-11T19:05:31.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/a9/a7fc5a63c427aeb9a4b99d6a0449f891be0952d3aee2b85229099f7b9f54/matplotlib-3.11.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5e1e923a3fc3326b99ec0a6ff1ab1338ac6c6cc62ad9d8a9c944197c7f8c6221", size = 9461046, upload-time = "2026-09-11T19:02:59.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/59d8ee63e4dee845d953515c50d67e6b8cefa953b453c6b803f197ae556e/matplotlib-3.11.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c27e577ece613ea12a0790e00b4eb80d901c59a3e16cad474f31d8b1529690b9", size = 9290872, upload-time = "2026-09-11T19:03:02.295Z" }, + { url = "https://files.pythonhosted.org/packages/2e/a4/1ca765c63a60e3096ae2ad132eb25f6f7eab0377418cd0472808befd9665/matplotlib-3.11.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:07d9b9fa60cd4c393692f50d0bb03123242ddf61c99bb0e95e75feb354e7c1a8", size = 9854405, upload-time = "2026-09-11T19:03:04.796Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/f28ca971a4ad0ed83ebdd60230b09d99de8b91aba47964fac3a81eb2e2ba/matplotlib-3.11.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30ec15d7eefee71de16b7c689b42ba49715a4644650b76a6c7c70d79daf24e91", size = 10660507, upload-time = "2026-09-11T19:03:07.402Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0c/a743556bc64a9b57d2246b14d91b713078adfd2df4199f542df1861737a9/matplotlib-3.11.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:116cdb0eb0eb5644fc98eb2975d4b4dd4ad35e5c8e6b851c22e6976f371f7ab5", size = 10801141, upload-time = "2026-09-11T19:03:11.357Z" }, + { url = "https://files.pythonhosted.org/packages/66/54/91fe3fdfa29100b4668cbf9721577a10acde6e009694618230e4e7a97527/matplotlib-3.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:a24d5fd36e4f0e742c3851dcd20810e56a95633a342e4bf6cb591c678e8fe61f", size = 9329982, upload-time = "2026-09-11T19:03:14.25Z" }, + { url = "https://files.pythonhosted.org/packages/49/c1/13b70871c98d7e93f0d2ed71f226b8d6f873f1372124046068a6b4902fb5/matplotlib-3.11.2-cp311-cp311-win_arm64.whl", hash = "sha256:57b9ea60a835937c2012861923cbb91f47db8565775d326d1c42fc926aa10351", size = 9023542, upload-time = "2026-09-11T19:03:16.878Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ce/1bfcc4873b121597791ad74032943b123218c0613af0b97e8dd05e916fb1/matplotlib-3.11.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef752769cd962f39ea0b6ffc82d1ea43a0012c5a6157c7a075212fa509cfcff2", size = 9476466, upload-time = "2026-09-11T19:03:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c4/7f5f3601ee69baf072c0c7d3ce60c03e0618621c5a56c34a62a460e29d11/matplotlib-3.11.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ef31985c4dedb5f1424e1aec6849a47dd37689cb7fa3c20b1b82187f26806261", size = 9305583, upload-time = "2026-09-11T19:03:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/b8/90/2b3fd67ee273163faeda6d514be70b5596eaa0fd77b60ffc294ad0b34f5f/matplotlib-3.11.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df4f7784aca81a94f254c0a2767d592ee25f407e488f5fa7203e51093fb6ca27", size = 9860353, upload-time = "2026-09-11T19:03:25.049Z" }, + { url = "https://files.pythonhosted.org/packages/f4/84/32549e7a462dc311aed2ab62e5d2538028840b5c53a8be0195c789937c3d/matplotlib-3.11.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b9a7ad579856284135e401ecc918c5f8a017ee30539298862a109f51b971710", size = 10670348, upload-time = "2026-09-11T19:03:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/84/39/02e21b74f7439bd643d717ea846006d46e309e6d29b632b57751265311d6/matplotlib-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3aa4b8516fd26659e4363abbf317c703d9116496c5db2e9d0609a2866dd39dd2", size = 10810580, upload-time = "2026-09-11T19:03:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/04/93/0d239bde12b308262265c2d98909b2f0ecad2b5deeae96241642e822e9c7/matplotlib-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:c5c1c68ee401fc98271263410f0e5ce88285abacf7627132914e8adf3d70ff43", size = 9349409, upload-time = "2026-09-11T19:03:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/49/a8/06baf901c02246c8a222b655cc4540ef9c15e1549a04e07927f9cde716c3/matplotlib-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:643ff850d8e0f5b8319337f87ed3cb59506afb3df3cc48de777d85871233be7b", size = 9028084, upload-time = "2026-09-11T19:03:37.032Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ce/fc58855a3d17f6cb8a87b6f9ccd100f65432a3a7ffed57309cbf14c78987/matplotlib-3.11.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d43ff8cebb50840648cb6429b2228621dbd709010f3117b3740abb20abf21c0", size = 9476987, upload-time = "2026-09-11T19:03:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/a9/29/2a47b700545aca050e0ed73c89ba21e08407e11f5f42a567421b220de6c1/matplotlib-3.11.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2a8285cea8ef4d92aa041d1c33788bcae82248503300f93f1ca2136b9049452f", size = 9306050, upload-time = "2026-09-11T19:03:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/a9/df/e7c10292f348cf55178a5b3c0092a38ea68891a3945f199ae8b7b8ac9dc1/matplotlib-3.11.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1944895967f87c84c9b4bad29a707b31f5b36df4a3a2339ea1f8ea3ce5105539", size = 9861060, upload-time = "2026-09-11T19:03:46.044Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b9/b978de6d43d47f1d4d8e653fac32c16d8d2e5f46bbbb822e26d7cb1adece/matplotlib-3.11.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af2661f6ac6bbd1d081996f54fdd9385715c625ace8cec0869055c0cfbf38981", size = 10670885, upload-time = "2026-09-11T19:03:48.942Z" }, + { url = "https://files.pythonhosted.org/packages/a3/80/d72f61bb2631ac15d17fada1f17e0f99fecba69d8376d34f17cf93b7eddd/matplotlib-3.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7e5a90f8a707ebb6004a713b1cff091a40cc5df4c7c1cb165e5a505ebc11c292", size = 10811222, upload-time = "2026-09-11T19:03:51.665Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/6d1d33f242c705b5c6c6fb3f0fc141add51cb1abb62912e59de0835d28c4/matplotlib-3.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:bc067c462a86f0e57bf52fc6e058d90171a5420007dac00c46e90153050c69a7", size = 9349539, upload-time = "2026-09-11T19:03:54.534Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bb/c0aa86a7f4c0e62f1c3c0711e361f6f23f87eb9018732a5e11994bd1b7ec/matplotlib-3.11.2-cp313-cp313-win_arm64.whl", hash = "sha256:3e8576f7c47e02fd4f21f44171302d2d1d58d4471d46da3d71fe8899d19539d9", size = 9028295, upload-time = "2026-09-11T19:03:57.261Z" }, + { url = "https://files.pythonhosted.org/packages/54/b4/4facdb700bc236e4c676495ca3798d8134ab8668cbfe0f6b2e9ebf962a38/matplotlib-3.11.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c9721f81275499da1feeb36a2cf8192ea086283b3bd16b7dc4c9d7aedb7396d6", size = 9478976, upload-time = "2026-09-11T19:04:00.12Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a0/bba05f0a25bfeb0475c2f2a87958b31103aa6b7a5386017afb1a96212825/matplotlib-3.11.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a3040b209f3968b4e84161df7b174f07a9fad33b0f2d7e48ea3bbd3075e2863", size = 9307932, upload-time = "2026-09-11T19:04:02.976Z" }, + { url = "https://files.pythonhosted.org/packages/d4/42/81d5cba4bb39b41b0998efc880e129f2d7cdb94256ca49678fdcd9c21be5/matplotlib-3.11.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3fe71fbfb8ec0e310e0bc8537c3405a01f38f25f9394ed2135e6202fed542b", size = 10674370, upload-time = "2026-09-11T19:04:06.912Z" }, + { url = "https://files.pythonhosted.org/packages/29/5e/52f56f93d5b20815ee0ae352f7a38eff25580edb180b4fa7df87cfeba68f/matplotlib-3.11.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8c8255de28f986d935a64c9ca71c0ec2d2f41d355691f5ea684725dc91413f71", size = 10954597, upload-time = "2026-09-11T19:04:09.948Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f8918a1b564db49c53cd603eb3c8748c5767e4ce0fb2cf1d7c38653e4c5d/matplotlib-3.11.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a8756cc73d9af9a7fe0deb54ea2e75ef73b01d9e575877e72acad5458e660943", size = 10812583, upload-time = "2026-09-11T19:04:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/4315ef0d63937fe56f451887b4a0be3b8e8b22cf1d92a8d157caac54570d/matplotlib-3.11.2-cp314-cp314-win_amd64.whl", hash = "sha256:ecea603dd2fbf8242fd31a305a8b12a4ece2de28096870c65fdd0d1e35b8d9a6", size = 9505972, upload-time = "2026-09-11T19:04:15.788Z" }, + { url = "https://files.pythonhosted.org/packages/d5/00/02398b0a1a62ef73d9af0a957fe1773363eec9a6ca7e433c72ae2790fbce/matplotlib-3.11.2-cp314-cp314-win_arm64.whl", hash = "sha256:01dc8eaaab5a9fce9ff615eca82345728f289e4715b186ee10c6d85272fc26bb", size = 9184534, upload-time = "2026-09-11T19:04:18.691Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c5/1e6ca10baa7e04f113c37856aadb4adc3dbb1c55fc7da6dc830bba1292d7/matplotlib-3.11.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79a258f58253dfa025af80a9e9bb228d75fced007f0e93ae7423fefbde81a74d", size = 9526415, upload-time = "2026-09-11T19:04:21.39Z" }, + { url = "https://files.pythonhosted.org/packages/e0/93/7561727af07ccc84747953c6a33eee3981cc436d04f465032e707ca0e429/matplotlib-3.11.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c0b83f044ce10a98027b105b3931548719a6e8c7ef986b4362651e0b5367c8dc", size = 9361064, upload-time = "2026-09-11T19:04:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/c1/40/ce270ef2a6794d94a409fe28b8dabedffc07c07f9e5dbb89e0f605bd157d/matplotlib-3.11.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e43b188f0a5b75447bcc197728258166aa64365770ed1caa36595a5e1ca4bbba", size = 10684626, upload-time = "2026-09-11T19:04:27.201Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/396e0307dbf1c1f2d0a3654caa6dbc780dd5379f5f11257369cf8d21d9fe/matplotlib-3.11.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3304eb5a59442a8867f6920d484591c0fa09ffc29e9260be2feec3351e25869", size = 10960602, upload-time = "2026-09-11T19:04:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1c/97/4defb84695477340aa88b27c9482cc1ca4de11f36f66b6c8c0ddd685def1/matplotlib-3.11.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:02329432ae5c6af87cf208ee575b701d698bdf0b1a3bb28cc6d53c36e967e575", size = 10822603, upload-time = "2026-09-11T19:04:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/7d/e7/eab54551c1ec6b72dcd678a078b6c6da8d4c85ec1ff0016e81fe52866304/matplotlib-3.11.2-cp314-cp314t-win_amd64.whl", hash = "sha256:f2ac30cf5eb5dff1b584627ae0b0e1186551a4f69ae3c75073911da497a29170", size = 9551206, upload-time = "2026-09-11T19:04:35.811Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/04698032cb4d8fb1e30d8ffec5ded20f6f582b03c8c58b2385c95245f158/matplotlib-3.11.2-cp314-cp314t-win_arm64.whl", hash = "sha256:cf41ecd1b0c0b6f7177ed965a54c2afbe888715c7cf6054dc12d53bc1494002c", size = 9234977, upload-time = "2026-09-11T19:04:38.972Z" }, + { url = "https://files.pythonhosted.org/packages/44/a9/ca827c184b87364378964e36e7c0ae23651d146fdbc4000635205490cf82/matplotlib-3.11.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:894a9cbbecbe30ae6787d464df2e8fc7a8d475cfc68f87c3029f7c11152899b3", size = 9478997, upload-time = "2026-09-11T19:04:41.914Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/01aaafc9c3159f3fae9a65bbcddaa4a6d4d72b0aeffdd636d7f6d00a5c8d/matplotlib-3.11.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:bbf1062991d826ed27e2144f3afa4461afbb8ff56e8f703043e191a8163b1ee9", size = 9307947, upload-time = "2026-09-11T19:04:44.834Z" }, + { url = "https://files.pythonhosted.org/packages/1e/a0/6aa72c7cd7cf4dfac652744d7a3310007cec3ce8e5ab9be607bec7096471/matplotlib-3.11.2-cp315-cp315-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:399fef672f7046ef7d6a57572b2a6f9845f3f5afcff04e7b2df7a363a9f42190", size = 10674370, upload-time = "2026-09-11T19:04:47.745Z" }, + { url = "https://files.pythonhosted.org/packages/a1/97/d7e90225ac145046787a2ee0fa4cdc7e4d5cb6772ea232130b10f8a4095a/matplotlib-3.11.2-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc82dde2a0d3e3ad472edce04897ad7146b8d8bfd1df8a32992eebb81af18fdc", size = 10954581, upload-time = "2026-09-11T19:04:50.712Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/89cf056a9d2cae01f4b8baf01c7e809d613244fcdfa2a2acf28c1e0d79fa/matplotlib-3.11.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:930efb28f59fda124e39265d177bab302625297bb147d2910de725a2fc2aef54", size = 10812605, upload-time = "2026-09-11T19:04:53.552Z" }, + { url = "https://files.pythonhosted.org/packages/9d/4d/c61b9f4c34af802d4d84e58803e009013d626b5f468bafe1416131863b5b/matplotlib-3.11.2-cp315-cp315-win_amd64.whl", hash = "sha256:3da3bc0cbf7245e7db72cc6d29d12c5abef72cb73059c73b945f14e3545f3eb2", size = 9506068, upload-time = "2026-09-11T19:04:56.519Z" }, + { url = "https://files.pythonhosted.org/packages/03/7b/047f2fa8708d47430d8e7fa45e9fffb0aee5d4e300f978e087be2ee11fbc/matplotlib-3.11.2-cp315-cp315-win_arm64.whl", hash = "sha256:f25446b2981717dca9786bac841cb3fd7efb568e3e3c755dd980481c5cb9228d", size = 9184576, upload-time = "2026-09-11T19:04:59.318Z" }, + { url = "https://files.pythonhosted.org/packages/03/6e/ad9c5989950785a98642667df265a47c6637e924cb69b5dcf68c3299ae7c/matplotlib-3.11.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:fea03cf56568cc1cba08b470be6a0559e71c3a5b688d54b7179bb35ba23d0821", size = 9525988, upload-time = "2026-09-11T19:05:02.304Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c3/25c61701fb655dd412428b15eee2591ce554cdd593e39a045a153373f428/matplotlib-3.11.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:254d4ddb2fa8df3b4c689c0c306063aee10521df82cfb438185e499c75fe37c1", size = 9360774, upload-time = "2026-09-11T19:05:05.092Z" }, + { url = "https://files.pythonhosted.org/packages/58/5a/4552878fce8f9d249a5e264c16f5c267c27eac08fb039fa3e207a06feff5/matplotlib-3.11.2-cp315-cp315t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60cf3047a51edecdc4535a9196bbd6a732936b8e9f8184aabf4d16165884aaf", size = 10685892, upload-time = "2026-09-11T19:05:07.686Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/b3b19ec0124fc4aafd8a4b3bae859c562a7ff0fd8ae3b2099947f78bde19/matplotlib-3.11.2-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3712dc9b464793de0a4e42a7313d50293c751f94bddaf7332a1bc71bccdda9", size = 10964946, upload-time = "2026-09-11T19:05:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/7b/30/687ccce22f66a57fed85942450e88062773bcbf1a42914e70c7148ec71dc/matplotlib-3.11.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:75b6d88402770e181b5d06a67dda4129c31da7d05004d63a21c310ec78d1b83c", size = 10824960, upload-time = "2026-09-11T19:05:13.9Z" }, + { url = "https://files.pythonhosted.org/packages/d3/34/117fe35a8d05054b4b34d2d29287099927c24c3841d36e06d9fa2f20c1cf/matplotlib-3.11.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a6939df7567114b6bac7f4c5e06c84a67f197c1b2f2e4b234d4eecb3bfec9482", size = 9551204, upload-time = "2026-09-11T19:05:16.48Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/9f440e7bec8b80d0af2a65d1165b2f4e718982dad785531f62255d9ff19b/matplotlib-3.11.2-cp315-cp315t-win_arm64.whl", hash = "sha256:d480038c83691532ed52ff3147db51fa902fc78cb2d8349993a1cdb684435bff", size = 9233946, upload-time = "2026-09-11T19:05:19.063Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d9/a1218a31fa2d8082eb893548990ad09a2005ebda1b2b89dad88a8308e51a/matplotlib-3.11.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:854df8d7dfe9fdffcbaa6f39e44a6c24b409cb4d7561fbc09213b157d833f6a6", size = 9457702, upload-time = "2026-09-11T19:05:22.086Z" }, + { url = "https://files.pythonhosted.org/packages/68/d7/a9fde05d87c3c853f233ec20c5b5c3a1c86035e1ed4bfa397cd54d46ec90/matplotlib-3.11.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eac4b07d4e3743b172451e122ea964f72f152879d4f9adcf3f3d33e518f12ead", size = 9287753, upload-time = "2026-09-11T19:05:24.677Z" }, + { url = "https://files.pythonhosted.org/packages/ec/79/ea2ae4eab84f011c1dc8145864f78e5a63adda8efb95557ef36cc38e6da2/matplotlib-3.11.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ef7a53b66780e5d942923724f08577fbc5be1f7322da0f0f3f9dcaa45dd803d", size = 9848259, upload-time = "2026-09-11T19:05:27.455Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/03/a2ecab526543b152300717cf232bb4bb8605b6edb946c845016fa9c9c9fd/mdit_py_plugins-0.4.2.tar.gz", hash = "sha256:5f2cd1fdb606ddf152d37ec30e46101a60512bc0e5fa1a7002c36647b09e26b5", size = 43542, upload-time = "2024-09-09T20:27:49.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/f7/7782a043553ee469c1ff49cfa1cdace2d6bf99a1f333cf38676b3ddf30da/mdit_py_plugins-0.4.2-py3-none-any.whl", hash = "sha256:0c673c3f889399a33b95e88d2f0d111b4447bdfea7f237dab2d488f459835636", size = 55316, upload-time = "2024-09-09T20:27:48.397Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "numpy" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" }, + { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" }, + { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" }, + { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" }, + { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, + { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, + { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, + { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, + { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" }, + { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" }, + { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" }, + { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" }, + { url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" }, + { url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" }, + { url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" }, + { url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" }, + { url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" }, + { url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" }, + { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, + { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, + { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, + { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554, upload-time = "2025-07-01T09:13:39.342Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548, upload-time = "2025-07-01T09:13:41.835Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742, upload-time = "2025-07-03T13:09:47.439Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087, upload-time = "2025-07-03T13:09:51.796Z" }, + { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350, upload-time = "2025-07-01T09:13:43.865Z" }, + { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840, upload-time = "2025-07-01T09:13:46.161Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005, upload-time = "2025-07-01T09:13:47.829Z" }, + { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372, upload-time = "2025-07-01T09:13:52.145Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090, upload-time = "2025-07-01T09:13:53.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988, upload-time = "2025-07-01T09:13:55.699Z" }, + { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899, upload-time = "2025-07-01T09:13:57.497Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8e/9c089f01677d1264ab8648352dcb7773f37da6ad002542760c80107da816/pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f", size = 5316478, upload-time = "2025-07-01T09:15:52.209Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a9/5749930caf674695867eb56a581e78eb5f524b7583ff10b01b6e5048acb3/pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081", size = 4686522, upload-time = "2025-07-01T09:15:54.162Z" }, + { url = "https://files.pythonhosted.org/packages/43/46/0b85b763eb292b691030795f9f6bb6fcaf8948c39413c81696a01c3577f7/pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4", size = 5853376, upload-time = "2025-07-03T13:11:01.066Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/1a230ec0067243cbd60bc2dad5dc3ab46a8a41e21c15f5c9b52b26873069/pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc", size = 7626020, upload-time = "2025-07-03T13:11:06.479Z" }, + { url = "https://files.pythonhosted.org/packages/63/dd/f296c27ffba447bfad76c6a0c44c1ea97a90cb9472b9304c94a732e8dbfb/pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06", size = 5956732, upload-time = "2025-07-01T09:15:56.111Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a0/98a3630f0b57f77bae67716562513d3032ae70414fcaf02750279c389a9e/pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a", size = 6624404, upload-time = "2025-07-01T09:15:58.245Z" }, + { url = "https://files.pythonhosted.org/packages/de/e6/83dfba5646a290edd9a21964da07674409e410579c341fc5b8f7abd81620/pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978", size = 6067760, upload-time = "2025-07-01T09:16:00.003Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/15ab268fe6ee9a2bc7391e2bbb20a98d3974304ab1a406a992dcb297a370/pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d", size = 6700534, upload-time = "2025-07-01T09:16:02.29Z" }, + { url = "https://files.pythonhosted.org/packages/64/79/6d4f638b288300bed727ff29f2a3cb63db054b33518a95f27724915e3fbc/pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71", size = 6277091, upload-time = "2025-07-01T09:16:04.4Z" }, + { url = "https://files.pythonhosted.org/packages/46/05/4106422f45a05716fd34ed21763f8ec182e8ea00af6e9cb05b93a247361a/pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada", size = 6986091, upload-time = "2025-07-01T09:16:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/287fd55c2c12761d0591549d48885187579b7c257bef0c6660755b0b59ae/pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb", size = 2422632, upload-time = "2025-07-01T09:16:08.142Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556, upload-time = "2025-07-01T09:16:09.961Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625, upload-time = "2025-07-01T09:16:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207, upload-time = "2025-07-03T13:11:10.201Z" }, + { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939, upload-time = "2025-07-03T13:11:15.68Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166, upload-time = "2025-07-01T09:16:13.74Z" }, + { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482, upload-time = "2025-07-01T09:16:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596, upload-time = "2025-07-01T09:16:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.12.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/90/a1/d5f9002a70298c64a789779077d8dd90c10aa1f47fe40c86802df874f2a6/platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250", size = 60010, upload-time = "2026-10-07T23:30:32.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/ba/223e00b885e960edd5d4b4d178c88acce019bd5b83786093681b8c393492/platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7", size = 32596, upload-time = "2026-10-07T23:30:30.825Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/80/a8/66d45abadff219e36e2a824181b8f6a67e7ed4572934d6252c71c29d5731/platformdirs-4.13.0.tar.gz", hash = "sha256:1aa0b0d3f224c1f07c295121e312a5a24a180d6ae5a8425ea1784b3e3863e9c0", size = 61094, upload-time = "2026-10-11T02:05:24.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl", hash = "sha256:3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1", size = 32724, upload-time = "2026-10-11T02:05:22.776Z" }, +] + +[[package]] +name = "playwright" +version = "1.60.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "greenlet", version = "3.2.5", source = { registry = "https://pypi.org/simple" } }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/f0/832bd9677194908da118064eef20082f2791e3d18215cc6d9391ee2c5a67/playwright-1.60.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:6a8cd0fec171fb3089e95e898c8bc8a6f35dea0b78b399e12fcc19427e91b1d7", size = 43474635, upload-time = "2026-05-18T12:00:31.969Z" }, + { url = "https://files.pythonhosted.org/packages/59/7b/e1d32ae8a3ed937ec2be3721c5f728b13d731a0b7c6442e0b3bec5094ac0/playwright-1.60.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:39b5420ba6145045b69ced4c5c47d4d9fe5bddfc8ff816c518913afcb25ec7a5", size = 42261327, upload-time = "2026-05-18T12:00:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bc/23de499ded6411c188a20c5a0dea6f0cd4ed5d2b3cc6042a5dbd3ed609aa/playwright-1.60.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:2581d0e6a3392c71f91b27460c7fd093356818dc430f48153896c8aeeaef7705", size = 43474636, upload-time = "2026-05-18T12:00:39.294Z" }, + { url = "https://files.pythonhosted.org/packages/22/7b/1d679f4fced4ea94efadd17103856d8c565384f68382a1681264e46f5925/playwright-1.60.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:1c2bfae7884fb3fb05b853290eab8f343d524e5016f2f1def702acbbdf14c93e", size = 47467220, upload-time = "2026-05-18T12:00:43.179Z" }, + { url = "https://files.pythonhosted.org/packages/84/c2/1528d267d4442bd2c6b8eaeab819dd52c2030bf80e89293f0ba1f687473b/playwright-1.60.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43e66564125ee31b07a58cefb21e256d62d67d8d1713e6858df7a3019d8ed353", size = 47154856, upload-time = "2026-05-18T12:00:46.715Z" }, + { url = "https://files.pythonhosted.org/packages/bb/4e/b008b6440a7a1624378041da94829956d4b8f7ab9ef5aad22d0dc3f2e26d/playwright-1.60.0-py3-none-win32.whl", hash = "sha256:ec94e416ea320711e0ad4bf185dcbf41833672961e90773e1885255d7db7b7e7", size = 37902157, upload-time = "2026-05-18T12:00:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/55/f0/0541524133104f9cc20bf900870ff4a736b76a23483f3a55295ddfa58409/playwright-1.60.0-py3-none-win_amd64.whl", hash = "sha256:9566821ce6030a1f9e7146a24e19355ab0d98805fd0f9be50bb3d8fef1750c02", size = 37902159, upload-time = "2026-05-18T12:00:53.728Z" }, + { url = "https://files.pythonhosted.org/packages/80/c8/210f282d278e4709cdd71b12a31af45a30a22ab3207b387e29b37e478713/playwright-1.60.0-py3-none-win_arm64.whl", hash = "sha256:6e4f6700a4c2250efff8e690a81d66e3855754fb587b6b87cf5c784014f91537", size = 34037981, upload-time = "2026-05-18T12:00:57.584Z" }, +] + +[[package]] +name = "playwright" +version = "1.63.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "greenlet", version = "3.5.6", source = { registry = "https://pypi.org/simple" } }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/dd/fbb3d34228ad753bc14464d5f2252585b73367539775a88d0a3949cf5a45/playwright-1.63.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:84c540759e8e7f01e690e197e04060f48899d37cea322299f255843273d3385d", size = 44278407, upload-time = "2026-09-15T16:49:06.045Z" }, + { url = "https://files.pythonhosted.org/packages/f5/9a/948b930b1a8c4ee869e5a139a2b7747caa06aab56a3f09a2f0abdcbda221/playwright-1.63.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd1aa00631d44d55e56e0975bf3f3f285fac4a9fd2813183f0c12a488f1a1b24", size = 42944679, upload-time = "2026-09-15T16:49:10.039Z" }, + { url = "https://files.pythonhosted.org/packages/94/11/dc5c13fa1602371603acd461be47529c1b3513815d3a0dc98f642c291a10/playwright-1.63.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:c89fc4736502a1f0fac2c8ca5d10c0cbc1c669f1f4774a2d8507a43140e4d53f", size = 44278410, upload-time = "2026-09-15T16:49:13.861Z" }, + { url = "https://files.pythonhosted.org/packages/27/9c/103a5037789062bdab27c7dca53f3ca6b075b572ab2cd96eec825b3aec4e/playwright-1.63.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad21bc07516b187965a7521c5cf0df0bd657b17482eaad74335272d35a2b07de", size = 48217159, upload-time = "2026-09-15T16:49:17.404Z" }, + { url = "https://files.pythonhosted.org/packages/f3/82/3d85505284c5a210f2da6c07b8f757524e79d1fba9cfdafe1eafb766ae59/playwright-1.63.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:354e15b29503565fc598b89f16fbe070459343bef9d7498a93e304864000c6a7", size = 47902708, upload-time = "2026-09-15T16:49:21.055Z" }, + { url = "https://files.pythonhosted.org/packages/69/8d/f74ff6b52751859f4b69caf66aa7d4a3c8d6c1f0c7dc9920da103612ee39/playwright-1.63.0-py3-none-win32.whl", hash = "sha256:660c00c62639e31b16700ba5456b351ddba55bb766b7ce8261223aa34928e482", size = 38606075, upload-time = "2026-09-15T16:49:29.546Z" }, + { url = "https://files.pythonhosted.org/packages/76/eb/d6b8d92658038e260dbc7dd69fb3fdbe245aac283953de8b50b02bfe5f61/playwright-1.63.0-py3-none-win_amd64.whl", hash = "sha256:2f9a707a6c6c91157ed77bff2b8caeb04b3c8d46e70d585fc134298cbe4b5cc6", size = 38606082, upload-time = "2026-09-15T16:49:32.838Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/2432b3e3c7c62103b72d4c4cc8b16a56383ada372bbb0d1278591e988f71/playwright-1.63.0-py3-none-win_arm64.whl", hash = "sha256:1e4a3a838ce22fb68ad17193fcd142a19610d9d70fb9966d2239f5dddc0cc05b", size = 34523554, upload-time = "2026-09-15T16:49:36.455Z" }, +] + +[[package]] +name = "plotext" +version = "5.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/d7/f75f397af966fe252d0d34ffd3cae765317fce2134f925f95e7d6725d1ce/plotext-5.3.2.tar.gz", hash = "sha256:52d1e932e67c177bf357a3f0fe6ce14d1a96f7f7d5679d7b455b929df517068e", size = 61967, upload-time = "2024-09-24T15:13:37.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/1e/12fe7c40cd2099a1f454518754ed229b01beaf3bbb343127f0cc13ce6c22/plotext-5.3.2-py3-none-any.whl", hash = "sha256:394362349c1ddbf319548cfac17ca65e6d5dfc03200c40dfdc0503b3e95a2283", size = 64047, upload-time = "2024-09-24T15:13:36.296Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pyarrow" +version = "21.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/c2/ea068b8f00905c06329a3dfcd40d0fcc2b7d0f2e355bdb25b65e0a0e4cd4/pyarrow-21.0.0.tar.gz", hash = "sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc", size = 1133487, upload-time = "2025-07-18T00:57:31.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d9/110de31880016e2afc52d8580b397dbe47615defbf09ca8cf55f56c62165/pyarrow-21.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e563271e2c5ff4d4a4cbeb2c83d5cf0d4938b891518e676025f7268c6fe5fe26", size = 31196837, upload-time = "2025-07-18T00:54:34.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/5f/c1c1997613abf24fceb087e79432d24c19bc6f7259cab57c2c8e5e545fab/pyarrow-21.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79", size = 32659470, upload-time = "2025-07-18T00:54:38.329Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ed/b1589a777816ee33ba123ba1e4f8f02243a844fed0deec97bde9fb21a5cf/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:7be45519b830f7c24b21d630a31d48bcebfd5d4d7f9d3bdb49da9cdf6d764edb", size = 41055619, upload-time = "2025-07-18T00:54:42.172Z" }, + { url = "https://files.pythonhosted.org/packages/44/28/b6672962639e85dc0ac36f71ab3a8f5f38e01b51343d7aa372a6b56fa3f3/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:26bfd95f6bff443ceae63c65dc7e048670b7e98bc892210acba7e4995d3d4b51", size = 42733488, upload-time = "2025-07-18T00:54:47.132Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/de02c3614874b9089c94eac093f90ca5dfa6d5afe45de3ba847fd950fdf1/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd04ec08f7f8bd113c55868bd3fc442a9db67c27af098c5f814a3091e71cc61a", size = 43329159, upload-time = "2025-07-18T00:54:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3e/99473332ac40278f196e105ce30b79ab8affab12f6194802f2593d6b0be2/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9b0b14b49ac10654332a805aedfc0147fb3469cbf8ea951b3d040dab12372594", size = 45050567, upload-time = "2025-07-18T00:54:56.679Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f5/c372ef60593d713e8bfbb7e0c743501605f0ad00719146dc075faf11172b/pyarrow-21.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d9f8bcb4c3be7738add259738abdeddc363de1b80e3310e04067aa1ca596634", size = 26217959, upload-time = "2025-07-18T00:55:00.482Z" }, + { url = "https://files.pythonhosted.org/packages/94/dc/80564a3071a57c20b7c32575e4a0120e8a330ef487c319b122942d665960/pyarrow-21.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c077f48aab61738c237802836fc3844f85409a46015635198761b0d6a688f87b", size = 31243234, upload-time = "2025-07-18T00:55:03.812Z" }, + { url = "https://files.pythonhosted.org/packages/ea/cc/3b51cb2db26fe535d14f74cab4c79b191ed9a8cd4cbba45e2379b5ca2746/pyarrow-21.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:689f448066781856237eca8d1975b98cace19b8dd2ab6145bf49475478bcaa10", size = 32714370, upload-time = "2025-07-18T00:55:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/24/11/a4431f36d5ad7d83b87146f515c063e4d07ef0b7240876ddb885e6b44f2e/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:479ee41399fcddc46159a551705b89c05f11e8b8cb8e968f7fec64f62d91985e", size = 41135424, upload-time = "2025-07-18T00:55:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/74/dc/035d54638fc5d2971cbf1e987ccd45f1091c83bcf747281cf6cc25e72c88/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:40ebfcb54a4f11bcde86bc586cbd0272bac0d516cfa539c799c2453768477569", size = 42823810, upload-time = "2025-07-18T00:55:16.301Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3b/89fced102448a9e3e0d4dded1f37fa3ce4700f02cdb8665457fcc8015f5b/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8d58d8497814274d3d20214fbb24abcad2f7e351474357d552a8d53bce70c70e", size = 43391538, upload-time = "2025-07-18T00:55:23.82Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/ea7f1bd08978d39debd3b23611c293f64a642557e8141c80635d501e6d53/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:585e7224f21124dd57836b1530ac8f2df2afc43c861d7bf3d58a4870c42ae36c", size = 45120056, upload-time = "2025-07-18T00:55:28.231Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0b/77ea0600009842b30ceebc3337639a7380cd946061b620ac1a2f3cb541e2/pyarrow-21.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:555ca6935b2cbca2c0e932bedd853e9bc523098c39636de9ad4693b5b1df86d6", size = 26220568, upload-time = "2025-07-18T00:55:32.122Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d4/d4f817b21aacc30195cf6a46ba041dd1be827efa4a623cc8bf39a1c2a0c0/pyarrow-21.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd", size = 31160305, upload-time = "2025-07-18T00:55:35.373Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9c/dcd38ce6e4b4d9a19e1d36914cb8e2b1da4e6003dd075474c4cfcdfe0601/pyarrow-21.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876", size = 32684264, upload-time = "2025-07-18T00:55:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/2a2d9f8d7a59b639523454bec12dba35ae3d0a07d8ab529dc0809f74b23c/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d", size = 41108099, upload-time = "2025-07-18T00:55:42.889Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e", size = 42829529, upload-time = "2025-07-18T00:55:47.069Z" }, + { url = "https://files.pythonhosted.org/packages/33/27/1a93a25c92717f6aa0fca06eb4700860577d016cd3ae51aad0e0488ac899/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82", size = 43367883, upload-time = "2025-07-18T00:55:53.069Z" }, + { url = "https://files.pythonhosted.org/packages/05/d9/4d09d919f35d599bc05c6950095e358c3e15148ead26292dfca1fb659b0c/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623", size = 45133802, upload-time = "2025-07-18T00:55:57.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/f3795b6e192c3ab881325ffe172e526499eb3780e306a15103a2764916a2/pyarrow-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18", size = 26203175, upload-time = "2025-07-18T00:56:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/16/ca/c7eaa8e62db8fb37ce942b1ea0c6d7abfe3786ca193957afa25e71b81b66/pyarrow-21.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e99310a4ebd4479bcd1964dff9e14af33746300cb014aa4a3781738ac63baf4a", size = 31154306, upload-time = "2025-07-18T00:56:04.42Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e8/e87d9e3b2489302b3a1aea709aaca4b781c5252fcb812a17ab6275a9a484/pyarrow-21.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:d2fe8e7f3ce329a71b7ddd7498b3cfac0eeb200c2789bd840234f0dc271a8efe", size = 32680622, upload-time = "2025-07-18T00:56:07.505Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/79095d73a742aa0aba370c7942b1b655f598069489ab387fe47261a849e1/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f522e5709379d72fb3da7785aa489ff0bb87448a9dc5a75f45763a795a089ebd", size = 41104094, upload-time = "2025-07-18T00:56:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/89/4b/7782438b551dbb0468892a276b8c789b8bbdb25ea5c5eb27faadd753e037/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:69cbbdf0631396e9925e048cfa5bce4e8c3d3b41562bbd70c685a8eb53a91e61", size = 42825576, upload-time = "2025-07-18T00:56:15.569Z" }, + { url = "https://files.pythonhosted.org/packages/b3/62/0f29de6e0a1e33518dec92c65be0351d32d7ca351e51ec5f4f837a9aab91/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:731c7022587006b755d0bdb27626a1a3bb004bb56b11fb30d98b6c1b4718579d", size = 43368342, upload-time = "2025-07-18T00:56:19.531Z" }, + { url = "https://files.pythonhosted.org/packages/90/c7/0fa1f3f29cf75f339768cc698c8ad4ddd2481c1742e9741459911c9ac477/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc56bc708f2d8ac71bd1dcb927e458c93cec10b98eb4120206a4091db7b67b99", size = 45131218, upload-time = "2025-07-18T00:56:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/01/63/581f2076465e67b23bc5a37d4a2abff8362d389d29d8105832e82c9c811c/pyarrow-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:186aa00bca62139f75b7de8420f745f2af12941595bbbfa7ed3870ff63e25636", size = 26087551, upload-time = "2025-07-18T00:56:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ab/357d0d9648bb8241ee7348e564f2479d206ebe6e1c47ac5027c2e31ecd39/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:a7a102574faa3f421141a64c10216e078df467ab9576684d5cd696952546e2da", size = 31290064, upload-time = "2025-07-18T00:56:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8a/5685d62a990e4cac2043fc76b4661bf38d06efed55cf45a334b455bd2759/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:1e005378c4a2c6db3ada3ad4c217b381f6c886f0a80d6a316fe586b90f77efd7", size = 32727837, upload-time = "2025-07-18T00:56:33.935Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/c0828ee09525c2bafefd3e736a248ebe764d07d0fd762d4f0929dbc516c9/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:65f8e85f79031449ec8706b74504a316805217b35b6099155dd7e227eef0d4b6", size = 41014158, upload-time = "2025-07-18T00:56:37.528Z" }, + { url = "https://files.pythonhosted.org/packages/6e/26/a2865c420c50b7a3748320b614f3484bfcde8347b2639b2b903b21ce6a72/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3a81486adc665c7eb1a2bde0224cfca6ceaba344a82a971ef059678417880eb8", size = 42667885, upload-time = "2025-07-18T00:56:41.483Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f9/4ee798dc902533159250fb4321267730bc0a107d8c6889e07c3add4fe3a5/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fc0d2f88b81dcf3ccf9a6ae17f89183762c8a94a5bdcfa09e05cfe413acf0503", size = 43276625, upload-time = "2025-07-18T00:56:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/5a/da/e02544d6997037a4b0d22d8e5f66bc9315c3671371a8b18c79ade1cefe14/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6299449adf89df38537837487a4f8d3bd91ec94354fdd2a7d30bc11c48ef6e79", size = 44951890, upload-time = "2025-07-18T00:56:52.568Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4e/519c1bc1876625fe6b71e9a28287c43ec2f20f73c658b9ae1d485c0c206e/pyarrow-21.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:222c39e2c70113543982c6b34f3077962b44fca38c0bd9e68bb6781534425c10", size = 26371006, upload-time = "2025-07-18T00:56:56.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cc/ce4939f4b316457a083dc5718b3982801e8c33f921b3c98e7a93b7c7491f/pyarrow-21.0.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:a7f6524e3747e35f80744537c78e7302cd41deee8baa668d56d55f77d9c464b3", size = 31211248, upload-time = "2025-07-18T00:56:59.7Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c2/7a860931420d73985e2f340f06516b21740c15b28d24a0e99a900bb27d2b/pyarrow-21.0.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:203003786c9fd253ebcafa44b03c06983c9c8d06c3145e37f1b76a1f317aeae1", size = 32676896, upload-time = "2025-07-18T00:57:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/197f989b9a75e59b4ca0db6a13c56f19a0ad8a298c68da9cc28145e0bb97/pyarrow-21.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:3b4d97e297741796fead24867a8dabf86c87e4584ccc03167e4a811f50fdf74d", size = 41067862, upload-time = "2025-07-18T00:57:07.587Z" }, + { url = "https://files.pythonhosted.org/packages/fa/82/6ecfa89487b35aa21accb014b64e0a6b814cc860d5e3170287bf5135c7d8/pyarrow-21.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:898afce396b80fdda05e3086b4256f8677c671f7b1d27a6976fa011d3fd0a86e", size = 42747508, upload-time = "2025-07-18T00:57:13.917Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b7/ba252f399bbf3addc731e8643c05532cf32e74cebb5e32f8f7409bc243cf/pyarrow-21.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:067c66ca29aaedae08218569a114e413b26e742171f526e828e1064fcdec13f4", size = 43345293, upload-time = "2025-07-18T00:57:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0a/a20819795bd702b9486f536a8eeb70a6aa64046fce32071c19ec8230dbaa/pyarrow-21.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0c4e75d13eb76295a49e0ea056eb18dbd87d81450bfeb8afa19a7e5a75ae2ad7", size = 45060670, upload-time = "2025-07-18T00:57:24.477Z" }, + { url = "https://files.pythonhosted.org/packages/10/15/6b30e77872012bbfe8265d42a01d5b3c17ef0ac0f2fae531ad91b6a6c02e/pyarrow-21.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdc4c17afda4dab2a9c0b79148a43a7f4e1094916b3e18d8975bfd6d6d52241f", size = 26227521, upload-time = "2025-07-18T00:57:29.119Z" }, +] + +[[package]] +name = "pyarrow" +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/3e/5cd70becb51e1d044c54ba5e627424a6e87df5b98008cbd22cc6abd409ca/pyarrow-25.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0b1edbb2f385a6a65e9711b62ba86ac54a7816a3f8d17bb3e8a5929d65fb2485", size = 35954271, upload-time = "2026-08-10T12:36:33.857Z" }, + { url = "https://files.pythonhosted.org/packages/64/be/17599e086df264ea7dc221d1101e3131e181e00da428a2f9bd0358f0d06b/pyarrow-25.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:a4dd8bf99a8fac133efc0ed6a92f5fddbe2adba0d0f6dd720e39ba9855cea85c", size = 37647543, upload-time = "2026-08-10T12:36:39.486Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/e138b451fd3970a6eda4599f68ae3b2b32b661bc958de3239d54a0bf6575/pyarrow-25.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bddd0c4f7630c2a3ddf6347c1bdaa79d97bcf6bd445f9e60c816b7d77c85a5ae", size = 46837120, upload-time = "2026-08-10T12:36:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/57/5c/f8fc0eb2de03464a557d5a4d0c15e972d73362414696618833b771f7eddd/pyarrow-25.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a4d6d5e9a3d1879a97c08ded0c797579b7965eafd0f0c26c30b45ccc06db939b", size = 50066460, upload-time = "2026-08-10T12:36:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d1/0dd64fd06de0333b808a02f60981635f067b71aad3a30698a9a104fae778/pyarrow-25.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:514ddb60285631af068875550c90eddc181db3e8e63a032b1559be189e82f056", size = 49937892, upload-time = "2026-08-10T12:37:00.349Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3c/f89d1bd76d5f3284c2a44d7d7ebbd8204535e5ae2b41f4077069b4ff2ec6/pyarrow-25.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cab40b1edfef0262e0e5251aa2c58d75630f24d06dd7794480243acc001a1d7d", size = 53107240, upload-time = "2026-08-10T12:37:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/b554a8e09f3f3decccf405eb8fbe86696321cbcb5b62d18b4a5057a4c113/pyarrow-25.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:60e89d8f13861a1f7f8d950fa54aebb8023b30734d0ac51ffa80beabe2df4bba", size = 27848683, upload-time = "2026-08-10T12:37:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8b/0d23b47702fcfe8b3618d5292035099675c5a1c48258932350c08020f7b5/pyarrow-25.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:51093dd9e10325fbdb3c10a2ae7c4806e5c822d94e74ae4938b26524a3323fee", size = 35946180, upload-time = "2026-08-10T12:37:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/707d17a5476c55a9541fde0db8213ac30979a792864d72415f176ba50c45/pyarrow-25.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:eb6203482ff3746a5632303a7279ae0b5a304c46985b49ed1378cb350ea6728d", size = 37644787, upload-time = "2026-08-10T12:37:25.795Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/cdc98ecf1a6408280bc3a6a07054cdd99a3f4670acc0545d383ce113e87d/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:880523be3d29efcf83d3998835d206118ccf35e3871dbd2fb60408cf6b007a80", size = 46834633, upload-time = "2026-08-10T12:37:33.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/6e/d3fafc41f378b2c65be43b827798c0fae42049a641c8526633ed3eb573e2/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:25f8720bf6387d5dc2ebd2622112de630760419e4b66134405dd24110d15f37e", size = 50065507, upload-time = "2026-08-10T12:37:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/d5/12/8d0698954b8c3001844a898e0a6900bebe83d7ee40c11195174c5122f324/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4facd65742a024a4a366328a1d2292062d72d6e023c1b7dda8d4c37544933a25", size = 49955690, upload-time = "2026-08-10T12:37:46.644Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/1ecb936ac6409e90a34d58eea1c7cec09a9ae6d2141b9e49ad01a2b1ea47/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa0559502e1cd6254d6814614085dd9c5a3dd0419362978a936a3f68a9e5c3df", size = 53128198, upload-time = "2026-08-10T12:37:52.531Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/5236033550633c9b7377b2a53660b2bbb06cb06dc09c4356332d67643ca1/pyarrow-25.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:62cd0d785b8aa6675ee355f9fc02252a340f4441257c42674937826fd7594325", size = 27857263, upload-time = "2026-08-10T12:37:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" }, + { url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" }, + { url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" }, + { url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" }, + { url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" }, + { url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" }, + { url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" }, + { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" }, + { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" }, +] + +[[package]] +name = "pyarrow" +version = "26.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/34/17c34cb38e5d940e38f0f0d9fdfa0e8a506676409ea9b85aff7e3079f831/pyarrow-26.0.0.tar.gz", hash = "sha256:0cccd36e00ea3afeb52ded61f2721ce71f604853d70c45365c58324eb773d6ae", size = 1239433, upload-time = "2026-10-09T08:26:25.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/68/e0707097cee93be7f693e7e89495fabfeb8bf95ee30619063f8b30fffc29/pyarrow-26.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:fcdd1e04982637c6042337d3e24d472f938f01fdc502e2b994844b726d12c3f4", size = 36370896, upload-time = "2026-10-09T08:13:28.874Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/591211c00612aef83236daff1620412b24aeb07c646de08c18a8a6c95a39/pyarrow-26.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:f800e9e722c145ccd18012d82a864cb21bfee4ba4ceffde77100d25eced511a9", size = 38709806, upload-time = "2026-10-09T08:13:33.417Z" }, + { url = "https://files.pythonhosted.org/packages/50/ea/9b035a9d1556e06e64ea86169d9a985d0fc092d427ac5edbb3af7183289c/pyarrow-26.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:7aa12ab8e236789b1ecd2d6ecaef036b4e63d675ddf1864a43c6799d18f2d028", size = 50885975, upload-time = "2026-10-09T08:13:37.737Z" }, + { url = "https://files.pythonhosted.org/packages/e1/81/8e685683897a6d3d5887c3e2fd24f3c14bc5d6d6bb3a2387484e665c580e/pyarrow-26.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:6e89dee53aaeb50505ed6152ea55bc7ddfd4f4df264f5427ea255288d8f0e580", size = 53904793, upload-time = "2026-10-09T08:13:42.984Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ad/d474a0b1b00110f3a879aa5df654f857c81929a32b2a4222869240de5220/pyarrow-26.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1c1b4263fd13abbc339a16f2bf19f3a5cbf2a620853d812b1256f03c5342cb8", size = 54458010, upload-time = "2026-10-09T08:13:47.778Z" }, + { url = "https://files.pythonhosted.org/packages/d4/86/2c2861e905810c59fed4d98c85b994c21e8613730c5c3b436781d89110f2/pyarrow-26.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ff1e816af7abff71f289242e109217036723ce36aca74ad6691e52d964a74afa", size = 57368406, upload-time = "2026-10-09T08:13:52.651Z" }, + { url = "https://files.pythonhosted.org/packages/0e/02/823e606633c15155bb965c7a0f3750c4f20dd47c4ab48213c7693df0e0ba/pyarrow-26.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b0972a3dc71b642050d1bc72664a3916e14f59c943d8c1368154d6e4b0c2d5", size = 28522657, upload-time = "2026-10-09T08:13:56.513Z" }, + { url = "https://files.pythonhosted.org/packages/b3/60/6793778f2617cce469383dac0ba08c4f2401cf342df0c7b9ca53939d9b46/pyarrow-26.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:90ddaf7c625307ad52f31a9b25c34fe5e4897c7529ee3481135822b2b6842ff1", size = 36333953, upload-time = "2026-10-09T08:14:00.387Z" }, + { url = "https://files.pythonhosted.org/packages/db/81/f944cc63ce8a753e5fbff25de6d1d475ebd7fffdf9cf98c65130294fc896/pyarrow-26.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:ee341973f78a0b46e073d065e88e75026a9c584051e97f98a0d05d96c6bac7dd", size = 38688456, upload-time = "2026-10-09T08:14:04.344Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7e5c722fa5d5d9f3b75e62fe11694b34217664d4f05ac88031197166b277/pyarrow-26.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:01c863a18bd9c8412453dd0d92de6d0ee7b2b3d6fb079d9734a4b2a3c8bd4453", size = 50867603, upload-time = "2026-10-09T08:14:09.115Z" }, + { url = "https://files.pythonhosted.org/packages/88/e4/9cd356d906e71bd79b0c3fc5c9a54e01a0020dcf14c152ccfbcb503c7298/pyarrow-26.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6a628922ba20705fa964ca73e4ef959c2fb2f14b9bbec5589a6a1e68e6257c85", size = 53931932, upload-time = "2026-10-09T08:14:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e4/5bae3133b7fe04c24907a20f3bc1fba388cbbde659199e7b76445982047a/pyarrow-26.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:954d971b363b16ee41f89389a4053315dc71265f2ce5c2468eb0a910b1166268", size = 54444720, upload-time = "2026-10-09T08:14:31.214Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b4/ee422493bb6dafdbef776cfe2c2a73106a1063a79bf4e78d1e5f51176885/pyarrow-26.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5d5768d03426abe6526d5274adefa00abf00a7f81118c46e98b5a46390f5549e", size = 57388949, upload-time = "2026-10-09T08:14:38.964Z" }, + { url = "https://files.pythonhosted.org/packages/54/3c/1783aab1dac28e175dcf26dfc7123725efc474caecaed91e8a34cb89cad0/pyarrow-26.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cc903e1069e9dd5e9dcf780324c0112e27e051e422ecfaff574fb33ed65d9160", size = 28567581, upload-time = "2026-10-09T08:14:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/4d/35/ca95493712af97c46a312945c8e9d16b21c5fe2f148be5466168d0290505/pyarrow-26.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a6ca849f90cf73fe361f08a5762c783ead9671e4548c1f558cc637b54c9103f2", size = 36336700, upload-time = "2026-10-09T08:14:51.399Z" }, + { url = "https://files.pythonhosted.org/packages/69/ef/b1a675f79c9babfd4fcd99af62141d3c2d1a78a524e311b0c6b80110445a/pyarrow-26.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:c2ba350957076b1b3a22f549261dc3e9c67ca20816d8bd5f79d7b9c69be4c4c2", size = 38698502, upload-time = "2026-10-09T08:14:57.114Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7c/cea852a832a327a8de797b3a68e5c25ce0f5aa1d20503807671bd90ec642/pyarrow-26.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e3b190ba1d3d22a5a8758597f797111b77d433473744352a184a5ee0a42d672e", size = 50865064, upload-time = "2026-10-09T08:20:01.614Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d6/e95834b29360092376fe4da9956ba41bb7b021869efe6ee9d4172d05cb15/pyarrow-26.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:240bd18a7487f8767616a948a69dd4e740a8bc36a1c9da49e4dc9a32c5c2faed", size = 53926722, upload-time = "2026-10-09T08:23:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7f/98257444e2aea2e1fddceee3af3bd2077236d550428413f80393bd1f888d/pyarrow-26.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2b5fcd69c0e1107b79e55839877db5a6ed04651b73fd6fec581d09e230bed5e4", size = 54443093, upload-time = "2026-10-09T08:23:16.971Z" }, + { url = "https://files.pythonhosted.org/packages/88/ca/dac99cfb25cfa62bf7194600cc99abc14a6bd2af50d7fdb7f15eeaf6e202/pyarrow-26.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f7444ea6975c49a857c68f9bd8fa11acae96dede63d120ffb3bf0a603ea82516", size = 57381937, upload-time = "2026-10-09T08:23:24.95Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ed/138d29fddaf803b90f4527e124bb6aaddc18aaf4a6c50fd0a5f577c94989/pyarrow-26.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:3de30a7432b48b98b9decbd9e25a53bb9251d202c2e6c5a29a50869592ccb117", size = 28478571, upload-time = "2026-10-09T08:23:30.535Z" }, + { url = "https://files.pythonhosted.org/packages/8c/32/01858422a37f083911c2bb4d15cc32c5eeaa9d9b2bf5ddedee995a7146a6/pyarrow-26.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5780d487ff6c6ed7b42298609680d87fe0036e529a9dc2e1105364bce9697f50", size = 36378402, upload-time = "2026-10-09T08:23:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/00/85/f6b5976c2878b752d0804d371684e0495a71de296b6dc6559e6fbaa4311a/pyarrow-26.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:a0e4e92eeb088f1d7c2c04d6c7de8434c75abb4b4ccf0bbcd045aa7164c68d93", size = 38733074, upload-time = "2026-10-09T08:23:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/81/bc/c90fcbbcf893631e23dab1b0fb3fa29a508a8614326571b03c0894eda00b/pyarrow-26.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:eaf9e7cc7ab59f6c760232bbde18f64d559bbc50544841303bfb32be53533297", size = 50929201, upload-time = "2026-10-09T08:23:50.507Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c1/0c1ff38ab7df1b2cf54cf0ad9f19a516c4e416c6c9b4c966cc2c9d587f77/pyarrow-26.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:ab6914db225d7f399652ae1f08588dfbc9efe617612715701e3d9d5cfa5ca19f", size = 53951865, upload-time = "2026-10-09T08:23:57.692Z" }, + { url = "https://files.pythonhosted.org/packages/9f/70/6a6b170496925472adad45a32528770fc8632db35fc60d4edd1e9ce1be0b/pyarrow-26.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41dd3661ef40790a78870052ad7a58ad827b27c67a4511f06962eb9e9b74d19b", size = 54496388, upload-time = "2026-10-09T08:24:05.23Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/033ef9dba80976820190e292a10a5a23e9406572b76bbeb4d685d90e5c8d/pyarrow-26.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6e949744dcfc2d379808f7013c5f9cafaf0f817656dff7d46c6931528dd1784b", size = 57411588, upload-time = "2026-10-09T08:24:12.043Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ff/a74892c50aaf1f9f744a84493e08a2f99221e77c39d2d4a926de21a99edf/pyarrow-26.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:4a5fa8dc70dd50808990ff36faf44088e357b353d86c7682dd92d4b78d4c97d5", size = 29237858, upload-time = "2026-10-09T08:24:58.106Z" }, + { url = "https://files.pythonhosted.org/packages/03/10/f0ee0976ef08a851a743c57608917ac9a47623f688b9ee0efe5429975ba1/pyarrow-26.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:e2a1856e9565fe2679863b372478c681806aebbf7d0a6e72f33e77f804e647d6", size = 36495870, upload-time = "2026-10-09T08:24:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/27/ca/0bc431a509bf10b4472dbb94f4184752ecbbddeb7f467152dac0fdaed469/pyarrow-26.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:4bcba83299cb2b8f8e443d36c6ba6269a5034431879015fb0719495df8a14de2", size = 38819754, upload-time = "2026-10-09T08:24:20.875Z" }, + { url = "https://files.pythonhosted.org/packages/61/59/2be41d26af7a07fb71581fb753cae396403ba1a2978355fd553929d44a9a/pyarrow-26.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3a4d235876f14b4136b4d616ec42eb469ea0d6ead336cae631aa1dd29b21c962", size = 50933671, upload-time = "2026-10-09T08:24:27.199Z" }, + { url = "https://files.pythonhosted.org/packages/4b/cb/b6d5048cf3178be9678f5c9c60040199894b2f69c3439c87ced91fd24da9/pyarrow-26.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:210cc9b83888b87cdc8f793eebb264f22b20d0dedbedefc73b9687a7047b4747", size = 53906419, upload-time = "2026-10-09T08:24:33.536Z" }, + { url = "https://files.pythonhosted.org/packages/09/2b/23e30fbd776c81d18d134d2592eb60daca13e8a57ab087d0fa042f9d9f3d/pyarrow-26.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca77c43ca55bfc9a4eeb1f0cd5f093f08731b77c24cdba0829035f084959b0bb", size = 54527960, upload-time = "2026-10-09T08:24:41.292Z" }, + { url = "https://files.pythonhosted.org/packages/e2/23/fce251cd6b0546dfc181b00d5c8ef1c95a8c4cae83266bc3dfd5f719c62c/pyarrow-26.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:290a74c48e9491b436fd5edacfadf357943f82aa45c81110bd83a69aab33d1cf", size = 57388010, upload-time = "2026-10-09T08:24:48.186Z" }, + { url = "https://files.pythonhosted.org/packages/44/a5/0126fb0ef8d59bf257bdd68bb41623b72afc6e81790a0b4ac863a0f58861/pyarrow-26.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:515a10dae2a1d236bc9c9209d0317acb6746ea63cd4f98704904af7156d90ed1", size = 29406123, upload-time = "2026-10-09T08:24:53.387Z" }, + { url = "https://files.pythonhosted.org/packages/ed/66/8ada1b5165359d84b4b9b5384742304d1081da670f77d458fd9c9b8a2161/pyarrow-26.0.0-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:e890816e5ee89c74a0f8b9379fe8b5ba83f46132b2a0bbb9b1c21359ec30dfda", size = 36373215, upload-time = "2026-10-09T08:25:03.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/83/74f10c3d803a6834b2acab21847724d4bdbc74d246eb17321432844707f3/pyarrow-26.0.0-cp315-cp315-macosx_12_0_x86_64.whl", hash = "sha256:9db18a9dc0af52135c9eac549d80a7a882696efbe5406cf882b044525d4ecc2e", size = 38730866, upload-time = "2026-10-09T08:25:07.924Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5a/ea2fa2163b1bd8ff73efd39c4060be63fd6ddec03e7887a471acd1e042a4/pyarrow-26.0.0-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:734312d3d99088d9ec28c5b17bad40389bd8373a1afc10acb60b83fd217af087", size = 50924443, upload-time = "2026-10-09T08:25:13.864Z" }, + { url = "https://files.pythonhosted.org/packages/78/80/8c47b6cf8cfd42826df65193eff026c1cc81fa6cb213a3c3f5d203e6f67a/pyarrow-26.0.0-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:24f892fdf1ae1942d69d3f7742e2f49960ec95277cfb1a70b8a1d91f4a96d935", size = 53948540, upload-time = "2026-10-09T08:25:19.305Z" }, + { url = "https://files.pythonhosted.org/packages/69/1f/3a506a76d944ec5c5e4b7f01d8d0446b392a6fb384de627a12e503f616b4/pyarrow-26.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:879331ddea2a26479fa18fade71e6facf684a6cf19f67daec3775c871569e8e5", size = 54494863, upload-time = "2026-10-09T08:25:24.517Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/08c4bb04d651788d2eaca78065743f4f6ded974d4ef96ae3c473993e9d0c/pyarrow-26.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:5b827650e874f1f9f9392524ea3e9e3e8a245de5ba64acca1f81ab188090afb9", size = 57409877, upload-time = "2026-10-09T08:25:31.157Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f3/c64781fbd7b6d3c07993b698c14944d0d195f07e800fa931c486ae6ab36a/pyarrow-26.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:8e8e28c464552b5ca03e30d4504168c4425ce383884f8611b00e972f9fd933fc", size = 29236658, upload-time = "2026-10-09T08:26:22.607Z" }, + { url = "https://files.pythonhosted.org/packages/06/55/2ee3729daea999f19f061f03898d4895a242c4cd94f26e1324e5fdfbfe10/pyarrow-26.0.0-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:ce28748cbeb0f29c3ce9603782979c7117580fc76f16aa3ca448b38a22281adb", size = 36489011, upload-time = "2026-10-09T08:25:37.64Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7d/3eb17f601f2bf13eda5f2ed28956379ca628b4dda97619cbb1cb1721622d/pyarrow-26.0.0-cp315-cp315t-macosx_12_0_x86_64.whl", hash = "sha256:106bb9290fc6fd9a84138a9440038ef184bac86463543c5ff099229cb30d996c", size = 38808480, upload-time = "2026-10-09T08:25:43.579Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e3/f0047360b0f4bfc031b256dc0aec3837a61f245b2fb70f8363438e2db665/pyarrow-26.0.0-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:2e4a413046eba9896e632925066c74095182200ba32e19ff0166bf64d2f936ac", size = 50923273, upload-time = "2026-10-09T08:25:51.445Z" }, + { url = "https://files.pythonhosted.org/packages/38/d9/56d9fb91210407df31cbeb9b91138601c88c7c8fb5f6bf773b20d65509bf/pyarrow-26.0.0-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:d58798c4d8d629700058e9afc1e16b9801023f3ce4dc1c92d945e79b5ffe4e98", size = 53900905, upload-time = "2026-10-09T08:25:59.554Z" }, + { url = "https://files.pythonhosted.org/packages/cf/40/8e8a7e9e027c731520c7eb179dd00a153b76ebf0bc11d213c6c8f8502851/pyarrow-26.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:645917e976671debabf854abab6e2b75c571ca4f82adc33a2d338697f7c27d93", size = 54518345, upload-time = "2026-10-09T08:26:07.125Z" }, + { url = "https://files.pythonhosted.org/packages/be/89/1e768a3fdb88d34e708ad2dc00dbf8e4e30290784eb84198d59308963bea/pyarrow-26.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7c3fda041e7078802589cf257750323ee3d0cd1e56e53a9b20ec845697fb3d28", size = 57379403, upload-time = "2026-10-09T08:26:13.624Z" }, + { url = "https://files.pythonhosted.org/packages/96/be/7b81a44d6a8e70581dcc1d6f01541f9000a973b1e5d75394aec91e7b179a/pyarrow-26.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:68cd662e9e2b00876a131950cf32336ace2d0865e1f9418763e3d3be8481dfa4", size = 29389953, upload-time = "2026-10-09T08:26:18.277Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "annotated-types", version = "0.7.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic-core", version = "2.46.5", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, + { name = "typing-inspection", version = "0.4.2", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "annotated-types", version = "0.8.0", source = { registry = "https://pypi.org/simple" } }, + { name = "pydantic-core", version = "2.50.0", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, + { name = "typing-inspection", version = "0.4.4", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/96/cc/4c88abc035cc0d8b2646a715d8c4145fad7d95817eb5f18297066b21e20e/pydantic_core-2.46.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed", size = 2078970, upload-time = "2026-08-28T10:00:18.938Z" }, + { url = "https://files.pythonhosted.org/packages/b4/59/fa3ef009cc1b2ca3753fd6869ee461b0b5b67c420cf659e32a12be027a6a/pydantic_core-2.46.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0", size = 1917185, upload-time = "2026-08-28T10:00:20.891Z" }, + { url = "https://files.pythonhosted.org/packages/15/5e/b3d8901f9775ad928077c3155c36f56fc1c813285e3986ed736a5fbf538e/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655", size = 1955266, upload-time = "2026-08-28T10:00:23.135Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9e/5522b09d12e8720013f2e4ac174999f40a05501bd15ad2bfa197bc136198/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a", size = 2023466, upload-time = "2026-08-28T10:00:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/89/2a/a5267bf2c6c7ded3f282b315e5f0cf2c58008c15b917a652fc32f92d6775/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d", size = 2198448, upload-time = "2026-08-28T10:00:27.676Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/f72c192aba23924065946728e4fba96f73939b90e5aa4f7d41e728aea8d4/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8", size = 2240121, upload-time = "2026-08-28T10:00:30.168Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/0c0cc24149429c030bef1a5c1776150e7e61fcbbfc068c6d1f9de90eb259/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf", size = 2067262, upload-time = "2026-08-28T10:00:32.285Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/a2b8690a11d909d9ec9c4eb4d084b4d2e1b227e9b2e74f5926cd39096245/pydantic_core-2.46.5-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464", size = 2095116, upload-time = "2026-08-28T10:00:35.556Z" }, + { url = "https://files.pythonhosted.org/packages/bf/7e/d3088a2717b7bb316d8d0e64a4b0caf994769e88c56df79df547d75c1dc0/pydantic_core-2.46.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64", size = 2134727, upload-time = "2026-08-28T10:00:37.829Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a4/55a9e0ef61cfd1cbf4289059eb68a3eab765fca8ead6f9991d7de027d42e/pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168", size = 2147932, upload-time = "2026-08-28T10:00:40.123Z" }, + { url = "https://files.pythonhosted.org/packages/2d/25/d2fbc9d59f91f6c50c0d2ec032041c5e3295d68325ade06ec93fa82da43c/pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e", size = 2301528, upload-time = "2026-08-28T10:00:42.339Z" }, + { url = "https://files.pythonhosted.org/packages/9d/76/eccc0528d1421e298b42f85650cf021f0f7c42f502c7e58808db4a672bdb/pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13", size = 2322431, upload-time = "2026-08-28T10:00:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0c/ffab5a9a0fb82825c44f00dea8ec9d540d2e1e4ab2f1c4f0c32bb8b37fd9/pydantic_core-2.46.5-cp39-cp39-win32.whl", hash = "sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7", size = 1958681, upload-time = "2026-08-28T10:00:46.786Z" }, + { url = "https://files.pythonhosted.org/packages/86/89/8bb47660fed8c16adf1aae301ba149442e8fd220c126bbea2d24b987abb8/pydantic_core-2.46.5-cp39-cp39-win_amd64.whl", hash = "sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0", size = 2046649, upload-time = "2026-08-28T10:00:48.99Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/4d/94bedc3c507275a60d172d2d1d8877f4d69e8d0403f83fb51a03e746ee07/pydantic_core-2.50.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:f8df36f964c21168e345f914855e3b428e2cb6e1f739c8ef5963b77d86dd84ae", size = 2154414, upload-time = "2026-10-08T14:25:49.983Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/b2f891cd8d325031b6ded8ac886f3e3726562857f90cf261cb8b0e83f947/pydantic_core-2.50.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eadf95aab7301ac651628f097e521d742d0fb5f732155ebfd00d07933045d81c", size = 2048392, upload-time = "2026-10-08T14:25:52.906Z" }, + { url = "https://files.pythonhosted.org/packages/cb/94/d0386e10ebb0befe1385f483e3ee12b417ea328234c685004bfc8397f14b/pydantic_core-2.50.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02ec3675d606a355523e1c52bf5aa4116776f8d273dba03d8336a5a3ae984e15", size = 2052185, upload-time = "2026-10-08T14:25:54.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/65/c75b43d813965a929baf77f8610b7e42834bed88b32e2f88a44e3505828f/pydantic_core-2.50.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3e8ee6386f6b68e2f818ccf422ccfafc59bcff10862b52c7819db3aa0352dfc", size = 2191576, upload-time = "2026-10-08T14:25:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/89/d2/4cee448b2d9f2befe656f8e382d352abfdad7ed5bcef5cdfb75653f35b84/pydantic_core-2.50.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93502b3c762149a5904316ef52f0ed1ea721e0f4c8431f964f140ed15fa61926", size = 2390913, upload-time = "2026-10-08T14:25:57.487Z" }, + { url = "https://files.pythonhosted.org/packages/05/77/1e4f29fecf8d6d64224a4f021f74fb0f797501ba5e1a51d3f853587ada4e/pydantic_core-2.50.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8cc61ad94215ab98e5cbcdea2cfde45e11bb0136ee980e1240b721635e55790", size = 2432980, upload-time = "2026-10-08T14:25:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/dbb45e48fdb846e04ccf5e3a61bd0b48aeaf4f8f0e1449cfc8c31bd3fceb/pydantic_core-2.50.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbce1b5a2a88a465d7865df61b7e4f8829ddc49b4fa841e9b241815c9d67c3b2", size = 2160717, upload-time = "2026-10-08T14:26:00.662Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f4/d8c80e51cf12ee82fa1f4f87e5302bc47429de1a71fb8678e6647a5d57c3/pydantic_core-2.50.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:2568b190075acd058513dca34f7fddc7252fc958c1591cb67a554338fc9a81b2", size = 2257069, upload-time = "2026-10-08T14:26:02.156Z" }, + { url = "https://files.pythonhosted.org/packages/73/59/a2cc3bede29a8fd7446db4fa6e628268cffd5bfe082c15a7d6ebe4dc6125/pydantic_core-2.50.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:468f7e67cfbd8231f442af6726449efe46f2be0e5a57b44fba7ccd92c6f121c4", size = 2271467, upload-time = "2026-10-08T14:26:03.594Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/52538b9729767a1cffe08494d6d97057d5ad8a2c5741ccfbe7e9ddbaa47d/pydantic_core-2.50.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c19500343957f254ecf3e60174f4a4cdd21690e0e1677740a8017d28ab2a2a4b", size = 2305629, upload-time = "2026-10-08T14:26:05.005Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f5/553e1c9641946cf967f57e678460386713289ae221cb4515a8ed7add76ad/pydantic_core-2.50.0-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d14d04058923d526a552fc3ebe8b0b0353c19516431cee196502b53119278fd9", size = 2467811, upload-time = "2026-10-08T14:26:06.69Z" }, + { url = "https://files.pythonhosted.org/packages/81/9f/6d0cea0bd42459db3e5030a0bc1f9b82dcb90b3dad57a367db32e9e3a667/pydantic_core-2.50.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5d0b210c871486f5acb56d87bf00e3c5d83aab6a1ef3042629fa6ae0172fcf47", size = 2480934, upload-time = "2026-10-08T14:26:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/fda6d8366010c762089feb5675618ce634394466a7fbb7e945b7689e0277/pydantic_core-2.50.0-cp310-cp310-win32.whl", hash = "sha256:6e1ec4176c3b56017745937dfd4a3ec6df55f7f0e66991124499f3f79f8f8d1f", size = 2124054, upload-time = "2026-10-08T14:26:09.71Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/232cae06119357f3f0184e3d5ff409c52dab34a7481e7ea16e97d41ac8ca/pydantic_core-2.50.0-cp310-cp310-win_amd64.whl", hash = "sha256:56178c4116fc0c859786875ff4acc1c8d52678f095c312b0eff2a2dd9d6f8d5f", size = 2179027, upload-time = "2026-10-08T14:26:11.288Z" }, + { url = "https://files.pythonhosted.org/packages/f1/99/114c2e71405da7610c884c64f8370f85b2fbb69bd427158a3f714515a803/pydantic_core-2.50.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:55684bc850059fc85ff8dc21a3e342b722d3178993c49ebdfdee8b698a432720", size = 2152583, upload-time = "2026-10-08T14:26:12.913Z" }, + { url = "https://files.pythonhosted.org/packages/75/a7/fabcfea81a5c3525507af4347fa3a50e51036d5db6611a63e47379776e56/pydantic_core-2.50.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3ae59461625518449f800acc4f874753ae96fcf993c47a27be07beb651371fd", size = 2045652, upload-time = "2026-10-08T14:26:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/226019008a041da423dc59d34f980b2b6e60a71971d07a8f52e10fc4747a/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7080928078ff56c07da393392f772054e93a39c8033dc9c04547bd949f9b614", size = 2050325, upload-time = "2026-10-08T14:26:16.081Z" }, + { url = "https://files.pythonhosted.org/packages/b9/29/11c98cd9c49338f64aeb3d6e166c99b65d8949f00f285c5b66de75ade316/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b1bae96f41806b14dd8bb1568d39b5e33b5af7fb27f7be36c78b8fa84708917", size = 2189027, upload-time = "2026-10-08T14:26:17.581Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b7/af7d9c4de715cc13e947d1135b73f0c181bce2f01e9f1d9e3c428f9ee31c/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8facaf0a16121ac82cc403a71399a061b74624c1ae2034eec9ed6169c5d16ecb", size = 2388815, upload-time = "2026-10-08T14:26:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/cd95dcd4d066180b2e22eff3dca01ac3af56607f9ecc43b7ba0748496f47/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:610e9f483ac53c5cc59c2d3687224029b54eb494feec2d40a2bcfdd187635192", size = 2431821, upload-time = "2026-10-08T14:26:20.958Z" }, + { url = "https://files.pythonhosted.org/packages/f0/14/7122b78e9915e7dd50f9dcec6041a4e32874907b41b9e0880db2f1c7ae1a/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac223ea031905319a64d0df116979ae438b42d6265862a3d63c9f9808c2905a7", size = 2157622, upload-time = "2026-10-08T14:26:22.947Z" }, + { url = "https://files.pythonhosted.org/packages/97/86/fcddead5bda54fc8efa0d80dbba5962e9a91193570e227404c7593707833/pydantic_core-2.50.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:75b451ec5e64a1d4b803317f34710131742ff2e42f4bf5403f597d0af857d7d8", size = 2254921, upload-time = "2026-10-08T14:26:24.607Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e8/041d1c3e3e1f203a656ce592f8c1548e1ef2b0939d19c5ce66432c30e4bd/pydantic_core-2.50.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7eee44c3f1f8acc220a743a5ed5588949a4f18b33df4cfbef2b1277470285401", size = 2268163, upload-time = "2026-10-08T14:26:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ac/f515994ef1351e883276e85af0a50d839ead9d0d22743fa2c6dfd552f7ce/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f44107b5fdfecc03438feb165431652c8006650471326e53dd86d4c19124f5c5", size = 2302005, upload-time = "2026-10-08T14:26:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/b970c4c87d92c4c974a5c5dc16bcf747f89b4e5729c5c45131267ef0421b/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:718d05d4e078c7828f40ad3e310870fe4b837d93a32e7e8497a080c1f1040490", size = 2465387, upload-time = "2026-10-08T14:26:29.529Z" }, + { url = "https://files.pythonhosted.org/packages/4a/84/c0a92f97f9de7f133dfe694d0e8ca033a1c90fdf692dc69dfee3bf8a2e7b/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:031867348c98ab49c6d3a16ad39738369ea48da59900d76d71c1e64de71fe79b", size = 2478673, upload-time = "2026-10-08T14:26:31.898Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/88e851761351cfef90382f175b07cede48edafc62a1509575dbc879934ef/pydantic_core-2.50.0-cp311-cp311-win32.whl", hash = "sha256:e41f9d1d9240e8e0d8a670ad3e66c0c00f0b1f7150a31bc6c445a4f87c1cb3ba", size = 2121424, upload-time = "2026-10-08T14:26:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/c4/1f/89184f13b9ebaf6d8710cd0d1cfb466d7dc829de23468ab471b543dd07eb/pydantic_core-2.50.0-cp311-cp311-win_amd64.whl", hash = "sha256:90874428bc6678b26434336c77931c9734ecebedf50132c179205e5a9908761d", size = 2176278, upload-time = "2026-10-08T14:26:35.388Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7a/5a630fff51218a860aa4e7699f781a7c9bf41c031987b613629c98f16618/pydantic_core-2.50.0-cp311-cp311-win_arm64.whl", hash = "sha256:b9be297ffe1015bfb2db4a23b6e1fec7e48361cf7c7b4f4f6bbdd008451b0a7b", size = 2183464, upload-time = "2026-10-08T14:26:37.485Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d8/e0fe374bc0082dfd337ca319505c9302ce383aeeeacc33d93383edd159e5/pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a", size = 2222860, upload-time = "2026-10-08T14:26:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7d/0a2f829e3e1d809393faab907e3d9307245cd6043ebf54fad439f72f0003/pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30", size = 2025752, upload-time = "2026-10-08T14:26:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/67/d4/e2808af12de4809dd6a4b532e3877baa5f1000df51bd8ebb8756450c6c92/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5", size = 2048432, upload-time = "2026-10-08T14:26:43.593Z" }, + { url = "https://files.pythonhosted.org/packages/18/58/46ad42a321051585d89f4e563b115015332caf79ba4e96f2785715c7f52d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f", size = 2173565, upload-time = "2026-10-08T14:26:45.299Z" }, + { url = "https://files.pythonhosted.org/packages/92/69/541206e657ecea865e5f6d49a4a0d8062586d05da482d9d7ed77fdcdfc86/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2", size = 2361974, upload-time = "2026-10-08T14:26:47.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c0/cf4850441d0d3d3736abe5e8b6fcb49a50166aa0651ed8444172f81f4b4f/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145", size = 2440817, upload-time = "2026-10-08T14:26:48.937Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e2/2f793fa2f1b338aa03124efd4362956c84d7bf1b331dc86050bc0151e67d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e", size = 2160358, upload-time = "2026-10-08T14:26:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/35e314c08e721ec1f070408a4e41926b90347ecd2863c08c78daad5f8a16/pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f", size = 2231416, upload-time = "2026-10-08T14:26:52.733Z" }, + { url = "https://files.pythonhosted.org/packages/0f/3a/6f7c36afe35a9eca24e5f74f33ca641ac8cf308aae4ac7af4e585d464a40/pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c", size = 2242496, upload-time = "2026-10-08T14:26:54.485Z" }, + { url = "https://files.pythonhosted.org/packages/8d/af/77adf30285836c25f6a927e170df45f8dd4af713c89135b22abcbf3c6d67/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0", size = 2279176, upload-time = "2026-10-08T14:26:56.432Z" }, + { url = "https://files.pythonhosted.org/packages/08/37/4e2a05247f82b59337bf45e9bb14aed85e2d83aecd21e49ad6cedf91882b/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4", size = 2450488, upload-time = "2026-10-08T14:26:58.218Z" }, + { url = "https://files.pythonhosted.org/packages/85/e6/75f25906212ecb94d69a0c8ed16f5c4ac312dc9961c2e93ff2a7fdf9a07a/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa", size = 2476664, upload-time = "2026-10-08T14:26:59.98Z" }, + { url = "https://files.pythonhosted.org/packages/75/72/ebb97b3becd0c0f2c722f698d34dec951ab1aa6086edf535f8769b545e3e/pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe", size = 2102409, upload-time = "2026-10-08T14:27:01.846Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7b/5ebf3e62f5d0f6e3503ffdfffd7d0c2f8d45690afd37fa0cfdcb1786d7d6/pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1", size = 2171536, upload-time = "2026-10-08T14:27:03.797Z" }, + { url = "https://files.pythonhosted.org/packages/58/1c/879ee9d5b63c60e5a077bab74ad93a4a48476090ca9c97534ab4263d0bae/pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7", size = 2162946, upload-time = "2026-10-08T14:27:05.876Z" }, + { url = "https://files.pythonhosted.org/packages/81/25/f9a6958f73d92f66d620e3e1b091becf6b7a2ea89437118d397e2c6ca9ba/pydantic_core-2.50.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:049b0404792dcb942f1092bfdae5f819ef30445b0d174782e1909fbdd91bb48b", size = 2222904, upload-time = "2026-10-08T14:27:07.869Z" }, + { url = "https://files.pythonhosted.org/packages/c5/41/7f299b2ecf0ddbec8c2a68057ed53d29458ea5b0850615570849c454dd89/pydantic_core-2.50.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f16bc5bb12f4c581b0f40facc28dbf286db32d1bf4e7e4f4c4e0f7f4e34ecf9", size = 2024863, upload-time = "2026-10-08T14:27:09.78Z" }, + { url = "https://files.pythonhosted.org/packages/89/db/a9852fa8780acca5dd81a21bb66d4b2fb41c39dc5672cec66c32cc7f13e9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1d084e92d0f4a096a5155b23d1ac603db8073ef98a3d1384badf1455e5ae742", size = 2047340, upload-time = "2026-10-08T14:27:11.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/82/cd174e776e71ebcb1d289a57cb3e89565123a84c5708865e12efa82313f9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0af2d2f745ec00a6616c4dfba4477f9156ccfb45690f6ffa5fd34f42e871ed4", size = 2173461, upload-time = "2026-10-08T14:27:13.948Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5c/9c4b2aea09ec7b7af966e79631b1970f1adeb6a8ed001bc8bb64a2d1c23d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f28a5a299d4cefd1066a6883d22600b9ae3606f881c3015d255784634647580", size = 2362629, upload-time = "2026-10-08T14:27:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/93afd007b61c50425c3c8f0402425779341c8d498bd224e5788eaf419c4c/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd70c2d9e73bca09fad54605dfeaae2b4e770b2800c65f5f5342901ed567b9f", size = 2440134, upload-time = "2026-10-08T14:27:17.716Z" }, + { url = "https://files.pythonhosted.org/packages/c9/93/a4ef199535547aaa7c5d2cbf009a1d6a2b252885b14c6e70404e1c661716/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e58acd43ac8d3905659c1d5309318dd576243e723dd7c3b5dd4f555479b77d4b", size = 2158480, upload-time = "2026-10-08T14:27:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/f216e028d3bfbd9f8c67f7a2f0f8db04240926f630e2e66a5a8ac45fe00d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:5a07c644047f5abc268b4c39f8cb5a30e08a3c349228cb70142c7d3ed87587c0", size = 2231837, upload-time = "2026-10-08T14:27:21.742Z" }, + { url = "https://files.pythonhosted.org/packages/56/3f/57a6acf26e0acb82238ba4e25b35cbd6013b1d74eac99c4f358ff56bf6d6/pydantic_core-2.50.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8ad8dad549cd1645be591a50b4573995ee7e018f6619ddc2bc6ea44b2ad9f694", size = 2242240, upload-time = "2026-10-08T14:27:23.564Z" }, + { url = "https://files.pythonhosted.org/packages/94/41/f5f4014b40f91db4479e87dc038ac48a460a60baadea7ff9c942bc64dc62/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1773001198030e16f946a7ff7760fc3ebd45b12150f66fd0f433780043dc13d8", size = 2277852, upload-time = "2026-10-08T14:27:25.517Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fa/485e4db093e10f29676fc696abf9d88f3626ee520e99cc488fb091222db9/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8ab9e74878172948e0426e3d27fba333fd6a1c3f9456d8e75643e8e6868ac1a1", size = 2450132, upload-time = "2026-10-08T14:27:27.493Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/fd07063cfc65e498528cea59b27b979eb5c890a54dcf5bb19ab0a589516d/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e15bb1535f68a27f28e579ba3a8f74e7b05d350c45f5622b76a311d5a19ae48a", size = 2477011, upload-time = "2026-10-08T14:27:29.972Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/6f34c285dc87ed2fae5f1b53c51bb732eda21dc0c6c73d6a5d9dcc7033cd/pydantic_core-2.50.0-cp313-cp313-win32.whl", hash = "sha256:c21e6a6e4e6d32fb6acbc4f0fa69e8319cac0d65eeaa8298d757371cc2a9c687", size = 2102027, upload-time = "2026-10-08T14:27:32.497Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/dbdb3ba6699d494e59db2f145aeb97990e28d1503bc9f7bdd57eb4c15677/pydantic_core-2.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f96ccd9368ecbf6685d8f6981584ab72b4f0ffd20685e747737e7e340277b8d", size = 2169974, upload-time = "2026-10-08T14:27:34.692Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ae/cfe0e52a9b45b5ba12b3a839db928669543948760a059a72f13cf0346c29/pydantic_core-2.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2b88f9f9fa52e1c34938ff1a18ee7fbffe482df0bb43c087a9a60578d273d68", size = 2162504, upload-time = "2026-10-08T14:27:36.989Z" }, + { url = "https://files.pythonhosted.org/packages/59/d9/6dd838672e5ccddf01556bfd1b4a6767e0c75abf9aaa4092cb87a56dbc4a/pydantic_core-2.50.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:62a93a9d206a3580c975c3c1f65a869cd063844d016c004f8d786a9309b3c591", size = 2224496, upload-time = "2026-10-08T14:27:39.281Z" }, + { url = "https://files.pythonhosted.org/packages/b3/20/c57d2efcc63fb8fb6ae9ec2818baf30e2a68751e8d1dedefaf3dab2e45fd/pydantic_core-2.50.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a8403eef4a66743e339102fb3cdd8c8b9016b8bd67924685893d062c88896f9", size = 2023860, upload-time = "2026-10-08T14:27:41.332Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/f44ea3f1b00288f715e2320cbcd0f604104325370ef4bee42c5ed2ab076e/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8354fbbe2abf0fb724b9303bef43bfd9b7a2779332813fa6d6559983954e7d8", size = 2049144, upload-time = "2026-10-08T14:27:43.695Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/2f5cf84ceaf6d7c351737124aebc135f50ce48a9dd28d2b5283103face54/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00963fde61cf8880d9e7b5635a9830e0591edfa45168447fdc5b47635c0f6437", size = 2173456, upload-time = "2026-10-08T14:27:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/97/de/dc0bd815a328e62b72bb93b5f3c762939670d4119fd27405b454970833da/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bbd7da16d8b2912cc56c9d0b85c4998a6cbf39b220f81c0d8c397c64672ae0e", size = 2365204, upload-time = "2026-10-08T14:27:48.117Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a5/458c4a29f52fdecb16b192fb27f117359da9c9c58401ebfd956637822dca/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed557fa2744617eac3e34dd39b85037efbf23cd33f06851c00fdb8f18ad8f4c2", size = 2445007, upload-time = "2026-10-08T14:27:50.637Z" }, + { url = "https://files.pythonhosted.org/packages/98/14/0c0e72e0663be91a456167a2f7ed49c65195b388e8f1e8b2bb5083b27458/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f816dc04e99627a5a6352ae51dfb30e603f2cb0b7009c91dc640c533def014", size = 2159911, upload-time = "2026-10-08T14:27:52.881Z" }, + { url = "https://files.pythonhosted.org/packages/62/7f/64af6921e17ed04dda0e07ebbd394b29fb557d33618cb0f3f81ec59c0508/pydantic_core-2.50.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:779b6c74526596a86d38248dedaecfb7851bbaf319c234be042c57acddd2c8c4", size = 2235761, upload-time = "2026-10-08T14:27:55.194Z" }, + { url = "https://files.pythonhosted.org/packages/82/11/b6ff9f7207af629094f4deac947d04be3812d5c95054010ad8f710a052f0/pydantic_core-2.50.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:21b62d45f327eb0802f842c132fda6d01a8376a8077922dc4dda69011c64d34a", size = 2243726, upload-time = "2026-10-08T14:27:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/6f7101f3c746a1686987a32875ae2f7c19f518d783979cee1a8a32e0a9ef/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f31af62efd1fd0257735b72e6716b32d4f207654adeabfe524d44baf1bb6bed", size = 2277158, upload-time = "2026-10-08T14:27:59.714Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/67fe208f3ec7f5d8c47bf7cba70019a84b1cffad947ebc8caef14271b54f/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:933f2d639eb81a3e1f145aec415453cc00983236629933f028d9507222583a2e", size = 2450059, upload-time = "2026-10-08T14:28:02.17Z" }, + { url = "https://files.pythonhosted.org/packages/cd/10/4a9c56a69f5841bdd94ffff5876a12ad2733209d5b89cadb67638d69e7c7/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c05d035e72530f6b00297941b0162218601542b76870c3bf2756bd87f16fc538", size = 2479142, upload-time = "2026-10-08T14:28:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/30d36746051e42bb67df986ee874f787526edb99205f6b1583c33dd81202/pydantic_core-2.50.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab3f95f737fc1b258b8210308fc02ce1442cf5950cf6d30b09ad89ab9e8afebd", size = 1483929, upload-time = "2026-10-08T14:28:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/04/2c/c0e8949be4f99a74f02ae02d603bdd518c8a9eba72982a34d675134a792a/pydantic_core-2.50.0-cp314-cp314-win32.whl", hash = "sha256:f12d9690634414fc04b1a7072fdc35c34a9242232c1851fe4518383578bb09d4", size = 2104255, upload-time = "2026-10-08T14:28:08.729Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6f/a5a5baf99509c998e0f2335d66b815880c7d6c63231c8e4017b237d6837d/pydantic_core-2.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:63263d64884688554fb025a3906c7b00573980cfee75f9afeb86239d577384bc", size = 2170939, upload-time = "2026-10-08T14:28:11.08Z" }, + { url = "https://files.pythonhosted.org/packages/40/bc/c89b93b69d59cfe61c1e91b1cd3d87cc740e57327ecfef84ce34b7dcadac/pydantic_core-2.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:753863dd4317ec8cc9eb3e6d9d01a8ef1a1726a8003b4354658d68a1ae05f9db", size = 2157035, upload-time = "2026-10-08T14:28:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5e/d0ad47a406c42db95876900f4bfc520313c28d07a0dd5080da4972220c27/pydantic_core-2.50.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4abc425789f8540e86ca4cfdbc8dc650433cc2ac7c6136fee9bcb29d5665a02", size = 2234007, upload-time = "2026-10-08T14:28:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f4/69a7ec8400c1e34e5e11ab967f850ffd8ca71bb3aa0a8db93df5912bb6f2/pydantic_core-2.50.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf150693a51ca21e8288cd08a9de05e5ab331776dfcfd0537b14523338f0502a", size = 2000888, upload-time = "2026-10-08T14:28:17.983Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f1/860bb499f7cdf4bb453e8080af5bc182487f2f025d94baeee29c907bde6f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158749408ee19682b8a2a7e7135cced7f41d6f2f9de96088b1b1609858a6a158", size = 2022761, upload-time = "2026-10-08T14:28:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/89/fd/f571420436e79b9f7cc8f8ec534dee1759c240b4dc086737608f14d05c93/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcbd1fe5083315243447c13f7252ae9fe9d128b1ae2857e3a916c609235dd863", size = 2192910, upload-time = "2026-10-08T14:28:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/cc/94/b47c4a01ea978a7a3d02551e3625f0344ae1352d67ea78011c2eca5e39e4/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fdeda6272d60b1f6fb63f6a3dade55e274af62e1514d24a6549a12150c385cc", size = 2390178, upload-time = "2026-10-08T14:28:24.861Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/d109f26b3ee4443cca220eaaa4f3fbcf78ff07aeb577becc37d9224db63f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b16e164205a90b1050d2f5469f8a7829db7698ad198c69e6aab6cbfb5648b87", size = 2464453, upload-time = "2026-10-08T14:28:27.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/a4/7be2f608f6f8e7655155057a82a7e2f135ad9b37fcbadd34fdf11b1ade24/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17a5ca9c197788a6424749a09a3dce824cb2c17b72f835f8a5e330935b973609", size = 2150682, upload-time = "2026-10-08T14:28:29.65Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/a8d867f6d3981c232d0f3c2254458cdee8471de7bff3f7edd252cc00c30c/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:01340f4fbb4f854b1f36a6fe9dcd2b26c8936aead4e4ad1205624ac025c875fc", size = 2245995, upload-time = "2026-10-08T14:28:31.934Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a6/7297a39c8814beab877be5ba4f594c2e166108cdd20c1f1c51fb71d5389e/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99e203d5c2a814facef78dcd993b0fb7a933124a3475a38979fc09cceae210b7", size = 2264090, upload-time = "2026-10-08T14:28:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/c3/65/634fc407eaf61abec0d015d6fd4fd8456c18c71358e984f60b0a05092d49/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:2092ce156f92aff17e3345baba2b7c0c1701f32ba922c5de71bd6248fbe164e3", size = 2293708, upload-time = "2026-10-08T14:28:36.725Z" }, + { url = "https://files.pythonhosted.org/packages/3a/04/b87fcf8062b8907c77769385a4340a814fe359660593dbbe4d8f3f7e9e61/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:74cbb6cbd74445ca279668790e0c10eccac0428fd79fe061fce6c9e3982ad3fe", size = 2468464, upload-time = "2026-10-08T14:28:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/77/f4/d7aacec95f9e00dcbad84bee2fd0fc081ae292d380dc8a3268e4103de957/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:42fff617cb0b08505d8123d71e6e7a8e54210d9007498f564855bd843ce984b1", size = 2511897, upload-time = "2026-10-08T14:28:41.681Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d8/0a755b0069f9a0f4c57f98fd05b524473952556406244649d632d25f8646/pydantic_core-2.50.0-cp314-cp314t-win32.whl", hash = "sha256:36f9ed6ae1069913e4f6e86d8233e119e83e00a20c54f88faf9c81292f2fecc0", size = 2098906, upload-time = "2026-10-08T14:28:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/cd10a1fbd1ba1d730871e382ec2ac550487465c7c9be1a72b3f4f9181817/pydantic_core-2.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c81c2ec53ea9eb2227c14b3e6de35670b2de163b638f2a803e90a3bd5bbb0", size = 2148359, upload-time = "2026-10-08T14:28:46.47Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b1/a766eadfbc16b58d401f63dde3bf4e0943dcd2402011b6b8d9de2031ebe0/pydantic_core-2.50.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dec0dafc116ac29a84d143fdbc3b83fdb5d4ed339276be2251154537ab30e14d", size = 2175241, upload-time = "2026-10-08T14:28:48.934Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/43d0e765a80d6fc7d87b5f66a9023777aad6da7e7d64f486a4c2be8158b7/pydantic_core-2.50.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:af2b808a79bb04075e87c81a5b6179365b93f9a851f29dafd67abff72085d0c8", size = 2224750, upload-time = "2026-10-08T14:28:51.227Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/07e047c21ad90f184c7e8bfc6c9966b9ca51d4698e1a06460b65ca2ce105/pydantic_core-2.50.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2918547195ffb20118b829fdb8938e9dc92c9527fe6cbe58572594c96362880e", size = 2023936, upload-time = "2026-10-08T14:28:53.978Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a9/fb26fdd343ead65a699245c9d2150e84c47496a9b250859214a90cceecc9/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0393763d66f6f61715488d074a2cefac04aeb3ee281e36fb4925dd44deaf9e17", size = 2048872, upload-time = "2026-10-08T14:28:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4d/9fc4ea28a8ecc1b85fe77a616f8df9a51696476aaa93c5b256269d025044/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62ec6568896e0abf258cbcd22c406c1c8bf27d16224b21bec8f75c4ae88a8173", size = 2173752, upload-time = "2026-10-08T14:28:58.805Z" }, + { url = "https://files.pythonhosted.org/packages/70/7e/74b55196339413d787b283c2568cca4679b0645e48229421228acfa8839f/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec786cb9d597dd75d993f8c1273e31bb2114c9bc22f67fba611e654e8347701b", size = 2365284, upload-time = "2026-10-08T14:29:01.434Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0e/4ebc7a851f0ef63fcaba205739c171e50dd183f8d79dcf0099c3b46aa60b/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fce74add1099da1ea09473268950270071fd56773e2968604efb3ab1d240e02", size = 2445279, upload-time = "2026-10-08T14:29:04.034Z" }, + { url = "https://files.pythonhosted.org/packages/07/09/03d3524fc7d4960840e32fd88c8cf487b5516daa7b6845ffc74274e0247a/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:243088c95e23b12db9f2cd7661d584a3f00814e087489f40cde7f9feac56b694", size = 2160424, upload-time = "2026-10-08T14:29:06.628Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b9/7c530c84049089033dd746700bfbb51520ed9376bc90f3dbbcb319880acf/pydantic_core-2.50.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:42a56b0052ac11d9d0d87b1c94a1ce52e31914fd133f269585e0f63a4ed988f2", size = 2236109, upload-time = "2026-10-08T14:29:09.053Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/70e07379ebc2538c22f8c372a37c009b96a3a1bd7921aee1dad28ee41be0/pydantic_core-2.50.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:350001f5573451150d919722ee095aa28bec037d6e23b86d7f04581a910fe924", size = 2244011, upload-time = "2026-10-08T14:29:11.549Z" }, + { url = "https://files.pythonhosted.org/packages/66/49/294810baacae4b088bfac60edab06036951e5ae6e0c20e8f6cfef7d70f66/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:21e38a011783d8afc8b9d79928e273d06f349b52ae84edf63ec18ad07f077484", size = 2278392, upload-time = "2026-10-08T14:29:13.998Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/6990b812f124cf3564b36492799df7a66cd3cf5678004ffe65dda85b136b/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:588d309ce5c85448379556d72011b191c0414ee2e80d7c3f1ebe2ceb2d9027b1", size = 2450012, upload-time = "2026-10-08T14:29:16.524Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c4/f8f1a763550a077c51d2c69b7a32db56d1b54e61c613bc19e6c7fd2e7cb2/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:a8ee3965e01f10e4ff92ba1727626328ab7ba93bcd5674ff2b78ea1048ee0cea", size = 2479233, upload-time = "2026-10-08T14:29:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/9d/83/7a09baa1e0e4710be47b84aaf808e7b86247d7fa43251ac8934099113f8d/pydantic_core-2.50.0-cp315-cp315-win32.whl", hash = "sha256:c05b75ef3574c9ee4e05bbcf8513f7ccb155d426514be9efef5f6f53152d5f5c", size = 2104507, upload-time = "2026-10-08T14:29:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/f1cf61f747538834ea2c14c442264e4f29663c95294ad7933867633a1dea/pydantic_core-2.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:8447678b49412294801c9ea15ae31ea3bae7da65425268c92d40e032c38eac6f", size = 2171379, upload-time = "2026-10-08T14:29:24.613Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/9871261b760672cfa334498d94bc7dcbff2495345e639e806127f7087254/pydantic_core-2.50.0-cp315-cp315-win_arm64.whl", hash = "sha256:92016718bcf3e6f35a6bd986880191a8da7a35aa1f5b1e97544582ef938464cf", size = 2157182, upload-time = "2026-10-08T14:29:27.082Z" }, + { url = "https://files.pythonhosted.org/packages/de/3c/5107269aee5ee7855fa370ff837998792dbf124b8545244c370bec251a66/pydantic_core-2.50.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:71061800a3c225e730f7997f8a7fad6e0d0dcbe36609cdc7e60576a099a2832b", size = 2234440, upload-time = "2026-10-08T14:29:29.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ac/cb179b0c404337ef517515539f9899164a6d3d43d5c79c2556165da211c1/pydantic_core-2.50.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bc87bd34239835c8d73171acd039e591cea0a2ca8615e6188044ad170a40fca1", size = 2001074, upload-time = "2026-10-08T14:29:32.327Z" }, + { url = "https://files.pythonhosted.org/packages/b0/59/ef1714204f145e9497ab71e8bd82c5e40b5adf365d420be777d1e047cdaf/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c0e5b26d82bf31ab2b044781527b1b1a36e9ed400856b6aca5097eb1abb909", size = 2023016, upload-time = "2026-10-08T14:29:35.06Z" }, + { url = "https://files.pythonhosted.org/packages/87/e9/08a3ade34b4af09a83740b62a4442b7623127d388fc21d4938798dd85a26/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41b9f2821f0a105f88cd54ad03fe1392ec600618ffa8610f7c5e466ecd98c531", size = 2193165, upload-time = "2026-10-08T14:29:37.721Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/29aa3e540d3aca72139e5b7dc56395a940849d85da2b7a9ccfbff71d0928/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:364f62d8024997536e57865cb1c8b36effad3249bf392a29a2379ea69db28238", size = 2390198, upload-time = "2026-10-08T14:29:40.616Z" }, + { url = "https://files.pythonhosted.org/packages/76/63/a5cbdde0a2090c47002819d85317428e15b41d22e581cf38e9714b4903af/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:955d7878130dfc124d6a5343e1b87d2933244fe14a4a0a3e98787e8e660a8eb4", size = 2464777, upload-time = "2026-10-08T14:29:43.29Z" }, + { url = "https://files.pythonhosted.org/packages/c5/55/fff3a363b0dcf72fdd72311c676b1fa769f2c13342129c694b2eb9886580/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf81432281af66ba3285b26b09c2d478d84730dc50ff90926c1bdcef54048a33", size = 2150389, upload-time = "2026-10-08T14:29:46.271Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f3/0062361fd2377185d322dc84cd05ec4b96da352195c89939c7b509664721/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:34a4a0938eb30931baca56e55786c5a6871ac7aa891a38c8cabcdb7e49dab91b", size = 2246225, upload-time = "2026-10-08T14:29:49.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/662e3870143d5f1632a02ceb9ce2d5168e88caf3da80e9b42f495ceb1284/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f517a417cd02aa8fb05b603a0ac6d87b7b004c3ba4fcd279cad25cab7229043b", size = 2264404, upload-time = "2026-10-08T14:29:51.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/5e/8b14ebf111700362c6e519e40209d86f521da81ef171b2891af0bb5de311/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:d01029d54ff1f45c195b1f7e6fbf6e58fb7e7e12cda9a6e639570d9c581decb2", size = 2294152, upload-time = "2026-10-08T14:29:54.349Z" }, + { url = "https://files.pythonhosted.org/packages/55/29/85c486e0d25a8b523803032e97d7138edb7b7bc887fa8c6dd085e8824cdb/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d06dbbfe8da01a0574de27afc19915bb3e7dddfbd184bb97e958f94051d8531b", size = 2469160, upload-time = "2026-10-08T14:29:57.193Z" }, + { url = "https://files.pythonhosted.org/packages/12/bf/c451db7567e92d6601c4d2f6bdbb945cf60104182e2b241d8fe0b6f01414/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:5307a8bd49158b57a0ca4e10950d31085aa35d9007e934047043ccc6d28eea97", size = 2512204, upload-time = "2026-10-08T14:30:00.177Z" }, + { url = "https://files.pythonhosted.org/packages/43/1e/5b93a2513ced099acb0dc4dd6684742d6564ade60a59abcbabd161624e07/pydantic_core-2.50.0-cp315-cp315t-win32.whl", hash = "sha256:c2b246fa7cbdf9918488d1542a82bbb928cf71bcba66905c24131981e759ff0b", size = 2099026, upload-time = "2026-10-08T14:30:03.154Z" }, + { url = "https://files.pythonhosted.org/packages/37/f5/1b5967e31b025a23f2f627ffea1f5a874cff0adb72fa5f5af6bb36394ad9/pydantic_core-2.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:36c49d4e1769127461b609f110d91963790091ffcc2de401ac1d3b2f6a63bd54", size = 2148456, upload-time = "2026-10-08T14:30:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/213d6fe84816e8a5ed7b7539ecebb3f14c20739c2469f4e372f0f15bb042/pydantic_core-2.50.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f3abcabb04503023e1053add24472e78878de0bfd5c8a93686be01f4032c363c", size = 2175368, upload-time = "2026-10-08T14:30:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/11/ff/8d126ca04417cbc76a81648c135296ecc2f36d07adfb0b3376638efd1dd0/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:38b03c5439c6a7a952f56e3e6596dae44bc78c3fb6a69df46cabfb2f888e5f0c", size = 2162541, upload-time = "2026-10-08T14:30:12.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/e3f11c40950d0f37b3891c1ed015f04a4ef36c881d04ef1a5d1ac91d1c6f/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:4faa766450ef44d9eabed5b65a280f63f903d1e1ed6d2e278961eb22345ec49f", size = 2075402, upload-time = "2026-10-08T14:30:14.968Z" }, + { url = "https://files.pythonhosted.org/packages/42/f2/727d2b23ff6ca67c9c050c4ff4e5abd7b599d9ebcd819a74141d3b11c7c1/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50920d66aaab60dbc6023c3035d55fbabfa58b6d205d0b39d47d820c4a7a13a8", size = 2108552, upload-time = "2026-10-08T14:30:17.774Z" }, + { url = "https://files.pythonhosted.org/packages/4a/da/a4321058eea61f0504366668a09472a4c37514be30cce77967c35ab7ffaa/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:586699b43066ca6038f96bd71d0eff0b6c292929e3d66ff63d5dc79d0f06d589", size = 2267745, upload-time = "2026-10-08T14:30:20.619Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c8/45e2cbac6379b8cec8cffd7e6aed50754fce13d93f0cff28bbbfe18c1d1c/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b916ff828d604d4311a5639b7b3da51eaea3923833ec3e300a5ee35eade99691", size = 2217049, upload-time = "2026-10-08T14:30:23.532Z" }, + { url = "https://files.pythonhosted.org/packages/61/8c/b81c4139bff6305e7a98fa7845a99e05df1461d3c33d0fef417e02114ae7/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:732efbb50977ab7cf18f01d4c255834aeb14cb2425459f7bff681a1ba3a4ffa1", size = 2039121, upload-time = "2026-10-08T14:30:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/0f/49/679293741809cf835290bccf80216aebfcf5d94cb84a34709f18b21abfd5/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad9185366893714cd4514ae5e1a098227704fa395cb41a7855d75968ecedc826", size = 2109280, upload-time = "2026-10-08T14:30:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/f5/49/76f167aec9b9d77a82e38131539ca6c6fcf69d720965f76eda20b87b454d/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0def1dc09802a790e4a1b3cbc4401f0b53c58f273ce3671df9867f7bdf1fbe20", size = 2269313, upload-time = "2026-10-08T14:30:32.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/50/a6bd3c397b609aade207ffb78ce46f4ad4e29bb42a39007c87ee00e778f3/pydantic_core-2.50.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:68421ba548f6d86c7dededd70bfe530b4faf67eb811c53c70a1be69438d3ce99", size = 2154371, upload-time = "2026-10-08T14:30:34.965Z" }, + { url = "https://files.pythonhosted.org/packages/11/3c/2d32a1f945b9809b65701d2b72315bdcd700f13c093df5e60baaa784bc6a/pydantic_core-2.50.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:23e0940b7d73f405d92e98b2a05b93b16db163be13b7478bb19e1db2e486ade6", size = 2087028, upload-time = "2026-10-08T14:30:37.814Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/c2aea271192d8c4b81afeea9d61de8f5dc8686a1fcd1ace80e70a482fbce/pydantic_core-2.50.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee9913fa2b5bfa6111c11158cf481bb13544f8bd5d10382fb6ee2612b16fcf28", size = 2259611, upload-time = "2026-10-08T14:30:40.581Z" }, + { url = "https://files.pythonhosted.org/packages/35/88/32128d5498b95722fe914a21a25f59edd7dc8917474f47924005682532a8/pydantic_core-2.50.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ad3532291deedfdfcad3cf5d351d0076d646c68a0c63869a1bebf87c22159600", size = 2268636, upload-time = "2026-10-08T14:30:43.511Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/7dae31279e07aaa63dd4b17756f4ebd5647aed6bee5581ee3b3e85d82a0c/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:9279f4be22bc3765dd9f61f5b4d38d9cb219d167bbd6f2e0ffa3368b1b71b4d1", size = 2305416, upload-time = "2026-10-08T14:30:46.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/61e47defbd490618fcde2641f80d5c077f2961de62e88500edacb49297f9/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3b2f3e44af4c6512dd73557621385a71b18094c8be81d60e5a8e73a8ce96b7f4", size = 2466713, upload-time = "2026-10-08T14:30:49.214Z" }, + { url = "https://files.pythonhosted.org/packages/61/57/81327752c007834d684b0b7d20ce00087206f512c7347a6236fd89ef0223/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:593dd4f24abeb3ed90222f98bc6eefcac68cd5aa96fc7745f93862a802952bce", size = 2481971, upload-time = "2026-10-08T14:30:52.088Z" }, + { url = "https://files.pythonhosted.org/packages/48/c3/e321d3a4b2737372cf775ffdd1a02428c7d2791ee5afc9f228793a0ee245/pydantic_core-2.50.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b123f9d8702106f39dc3af63a031ca8b5279862a3747ec6c03ca49dbe78b71b9", size = 2337958, upload-time = "2026-10-08T14:30:55.045Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pyevio" +source = { editable = "." } +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "click", version = "8.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "lz4" }, + { name = "matplotlib", version = "3.9.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "matplotlib", version = "3.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "rich" }, +] + +[package.optional-dependencies] +gui = [ + { name = "textual" }, + { name = "textual-plotext" }, +] +test = [ + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +web = [ + { name = "fastapi", version = "0.128.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "fastapi", version = "0.143.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "uvicorn", version = "0.54.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "fastapi", version = "0.128.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "fastapi", version = "0.143.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "httpx" }, + { name = "playwright", version = "1.60.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "playwright", version = "1.63.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyarrow", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pyarrow", version = "25.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "pyarrow", version = "26.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-asyncio", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pyyaml" }, + { name = "textual" }, + { name = "textual-plotext" }, + { name = "uvicorn", version = "0.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "uvicorn", version = "0.54.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.0" }, + { name = "fastapi", marker = "extra == 'web'", specifier = ">=0.110" }, + { name = "lz4", specifier = ">=4.4.0" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "pytest", marker = "extra == 'test'" }, + { name = "rich" }, + { name = "textual", marker = "extra == 'gui'", specifier = ">=4.0" }, + { name = "textual-plotext", marker = "extra == 'gui'", specifier = ">=1.0" }, + { name = "uvicorn", marker = "extra == 'web'", specifier = ">=0.27" }, +] +provides-extras = ["test", "web", "gui"] + +[package.metadata.requires-dev] +dev = [ + { name = "fastapi", specifier = ">=0.128.8" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "playwright", specifier = ">=1.60.0" }, + { name = "pyarrow", specifier = ">=21.0.0" }, + { name = "pytest" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "textual", specifier = ">=4.0" }, + { name = "textual-plotext", specifier = ">=1.0" }, + { name = "uvicorn", specifier = ">=0.39.0" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.3.1", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "backports-asyncio-runner" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" }, + { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" }, + { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "starlette" +version = "0.49.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "anyio", version = "4.12.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "anyio", version = "4.15.1", source = { registry = "https://pypi.org/simple" } }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["linkify"], marker = "python_full_version < '3.10'" }, + { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, extra = ["linkify"], marker = "python_full_version >= '3.10'" }, + { name = "mdit-py-plugins", version = "0.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "mdit-py-plugins", version = "0.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "platformdirs", version = "4.12.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "platformdirs", version = "4.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "textual-plotext" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "plotext" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/b0/e4e0f38df057db778252db0dd2c08522d7222b8537b6a0181d797b9044bd/textual_plotext-1.0.1.tar.gz", hash = "sha256:836f53a3316756609e194129a35c2875638e7958c261f541e0a794f7c98011be", size = 16489, upload-time = "2024-11-30T19:25:56.625Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/53/fba7da208f9d3f59254413660fa0aa6599f2aca806f3ae356670455fd4ea/textual_plotext-1.0.1-py3-none-any.whl", hash = "sha256:6b6bfd00b29f121ddf216eaaf9bdac9d688ed72f40028484d279a10cbbb169ed", size = 16558, upload-time = "2024-11-30T19:25:32.208Z" }, +] + +[[package]] +name = "tomli" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" }, + { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" }, + { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, +] + +[[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 = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" } }, + { name = "h11" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/4f/f9fdac7cf6dd79790eb165639b5c452ceeabc7bbabbba4569155470a287d/uvicorn-0.39.0.tar.gz", hash = "sha256:610512b19baa93423d2892d7823741f6d27717b642c8964000d7194dded19302", size = 82001, upload-time = "2025-12-21T13:05:17.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/25/db2b1c6c35bf22e17fe5412d2ee5d3fd7a20d07ebc9dac8b58f7db2e23a0/uvicorn-0.39.0-py3-none-any.whl", hash = "sha256:7beec21bd2693562b386285b188a7963b06853c0d006302b3e4cfed950c9929a", size = 68491, upload-time = "2025-12-21T13:05:16.291Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "click", version = "8.5.0", source = { registry = "https://pypi.org/simple" } }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, +]