diff --git a/.asf.yaml b/.asf.yaml index 79102664da..e3ae0fa846 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -59,6 +59,9 @@ github: edit_comment_discussion: "Re: {title}" delete_comment_discussion: "Re: {title}" + collaborators: + - justinmclean + notifications: commits: commits@iggy.apache.org issues: commits@iggy.apache.org diff --git a/.claude/skills/connector-pr-review/SKILL.md b/.claude/skills/connector-pr-review/SKILL.md new file mode 100644 index 0000000000..01f2c38915 --- /dev/null +++ b/.claude/skills/connector-pr-review/SKILL.md @@ -0,0 +1,248 @@ +--- +name: connector-pr-review +description: Review checklist for Apache Iggy connector sink/source PRs. Load when reviewing a connectors plugin PR, when authoring a new sink/source and wanting to pre-flight against common review blockers, or when diagnosing why a connectors PR is stuck in review. Encodes recurring review patterns mined from real apache/iggy connector PRs. NOT for runtime/SDK internals (use connector-runtime / connector-sdk). +--- + +# Connector PR review checklist + +> Universal rules live in [connectors-overview](../connectors-overview/SKILL.md). +> Authoring skills: [connector-sink](../connector-sink/SKILL.md), +> [connector-source](../connector-source/SKILL.md), +> [connector-testing](../connector-testing/SKILL.md). +> Fill-in-the-blank kits: those skills' `TEMPLATE.md` files. + +Use this skill to **catch the issues that repeatedly burn review cycles** +before asking for a human re-review. Cite symbols/paths, not stale line numbers. + +## Contents + +- [How to use](#how-to-use) +- [Blockers (must fix before merge)](#blockers-must-fix-before-merge) +- [High-frequency convention nits](#high-frequency-convention-nits) +- [Delivery semantics (document honestly)](#delivery-semantics-document-honestly) +- [PR / CI hygiene](#pr--ci-hygiene) +- [Pre-flight author checklist](#pre-flight-author-checklist) +- [Evidence base](#evidence-base) + +## How to use + +1. Load this skill for any PR under `core/connectors/sinks/` or `core/connectors/sources/`. +2. Walk **Blockers** first. Any hit is CHANGES_REQUESTED. +3. Then **Convention nits** and **Delivery semantics**. +4. End with **PR / CI hygiene** (cheap passes that still delay first review). +5. Prefer "copy the closest exemplar" over inventing new knobs. + +## Blockers (must fix before merge) + +### B1. Secrets + +- [ ] Every credential field is `secrecy::SecretString` with + `#[serde(serialize_with = "iggy_common::serde_secret::serialize_secret")]`. +- [ ] No connection string / API key / token in `info!`/`debug!`/`error!` / + `format!` into SQL / file-state metadata. +- [ ] Source state must not persist URL userinfo or bearer tokens. + +Plain `String` for a credential is a review-blocker. Pattern: +`PostgresSinkConfig::connection_string` in `sinks/postgres_sink`. + +### B2. Swallowing `Err` while offsets advance (sinks) + +- [ ] `consume()` must **not** catch a batch failure and return `Ok(())`. +- [ ] Prefer `last_err` pattern: process all batches, return the last transient + error (see `connector-sink` Hard rules / TEMPLATE). +- [ ] README must not claim "no data loss" / strong idempotency unless the + backend + runtime path actually enforce it. + +Today the runtime can commit consumer offsets even when plugin errors are +poorly surfaced (#2927 / #2928 class issues). Authors must be honest about +loss windows instead of overselling. + +### B3. Source cursor / side-effects staged for `on_batch_result`, not committed in `poll()` + +Since #3855, this is an SDK-enforced contract, not just a convention: +`poll()` returns *candidate* state; the runtime sends the batch, saves +that state on success, then calls `on_batch_result(Ack | Nack)`. Only +`on_batch_result` may commit. + +- [ ] `poll()` stages cursor changes and destructive work (delete-after-read / + mark-processed / ACK upstream) - it does not mutate committed state or + touch upstream rows directly. Committing in `poll()` leaves nothing to + roll back on a Nack, defeating the point of the handshake. +- [ ] `on_batch_result(SourceBatchResult::Ack)` commits the staged work; + `on_batch_result(SourceBatchResult::Nack)` discards it so the batch is + redelivered against unchanged committed state. A source with no staged + work (e.g. a pure generator) may rely on the SDK's default no-op impl. +- [ ] Returning `Err` from `on_batch_result` is deliberate - it stops the SDK + from polling further rather than risk silently advancing past a failed + rollback. Don't swallow a rollback failure into `Ok(())`. +- [ ] Always return `ConnectorState` in every `ProducedMessages` that made + progress; return `state: None` for an empty poll that made none (avoids + an unnecessary write and can't persist state left over from a failed + batch). +- [ ] `poll()` sleeps **first**, then fetches (never sleep after holding a batch). + +### B4. Transient vs permanent errors + +- [ ] Infra/auth/schema-gone failures map to `Error::PermanentHttpError` / + `Error::InitError` / `Error::SchemaMismatch` — not `InvalidRecord`. +- [ ] Retryable network/5xx/SQLSTATE map to transient variants + (`HttpRequestFailed`, `Connection`, `CannotStoreData`). +- [ ] Do **not** classify retryability by substring-matching `err.to_string()`. +- [ ] `max_retries` means **total attempts** (default 3). README must match code. +- [ ] Cap retry budget so a dead backend cannot delay shutdown unboundedly. + +### B5. Idempotency claims must be real + +- [ ] Stable `ProducedMessage.id` / sink dedup key from natural IDs + (table+PK, document `_id`, `stream:topic:partition:message_id`) — + never random UUIDs per emit. +- [ ] If the backend PK / unique index is informational only (e.g. Redshift), + do not advertise idempotency in README. +- [ ] External workflow IDs (Airflow `dag_run_id`, etc.) must be deterministic + across retries. + +### B6. Secrets / license policy for new SDKs + +- [ ] New backend crates pass `scripts/ci/third-party-licenses.sh` (no BUSL / + incompatible licenses pulled into the tree). +- [ ] Prefer workspace deps; avoid vendoring a license-hostile SDK just to wrap HTTP. + +### B7. Tests that must exist + +#### Sources + +- [ ] Four canonical state tests (restore / no-state / invalid-state / + round-trip), plus two ACK/NACK tests (`given_ack_when_batch_is_staged_ + should_commit_candidate_state`, `given_nack_when_batch_is_staged_ + should_keep_committed_state`) if `on_batch_result` is overridden - six + total. Copy `sources/random_source/src/lib.rs::tests`. A source relying + on the SDK's default no-op `on_batch_result` (no staged work) may skip + the ACK/NACK pair. + +#### Any external backend plugin + +- [ ] At least one real-infra integration test under + `core/integration/tests/connectors//` with `#[iggy_harness]` + + `testcontainers-modules` (or `wiremock` for pure HTTP). +- [ ] No false-green mocks that diverge from real backend semantics + (Decimal, COPY, PK enforcement, etc.). + +### B8. Config validation timing + +- [ ] Structural validation + unknown enum rejection in `new()` / + `open()` — not on first `poll()`/`consume()` after sleep. +- [ ] Connectivity check in `open()`; fail with `Error::InitError`. +- [ ] Invalid restored state: start fresh + `warn!`, but do **not** silently + re-emit an entire index without calling that out in README. +- [ ] Config flag combos that no-op should `warn!` or `Err`, not silently ignore. + +## High-frequency convention nits + +These are "cheap" but burn full review rounds when missed. + +### C1. Copy the closest exemplar + +- [ ] File layout, log labels, error mapping, and test structure match the + nearest sibling (`postgres_*`, `http_sink`, `elasticsearch_*`, …). +- [ ] Do not invent new names for existing knobs. + +### C2. Config knob name canon + +| Concept | Canonical field | Notes | +| ------- | --------------- | ----- | +| Request timeout | `timeout` | Not `request_timeout` | +| Retry attempts | `max_retries` | Total attempts, default 3 | +| Base backoff | `retry_delay` | humantime `Option` | +| Backoff ceiling | `retry_max_delay` | Matches SDK's `ConnectivityConfig::open_retry_max_delay` | +| Poll cadence (sources) | `poll_interval` | humantime; sleep first | +| Plugin verbosity | `verbose_logging` | Mirror runtime `verbose` | +| Credentials | `connection_string` / `api_key` / … | Always `SecretString` | + +- [ ] Durations: `Option` + `humantime::Duration` in `new()`; fall back + with `warn!`, never panic. Workspace `humantime` — not a pinned + `humantime-serde`. +- [ ] New fields: `Option` + `#[serde(default)]` where needed. +- [ ] Prefer `#[serde(deny_unknown_fields)]` on plugin config so typo’d knobs + fail loud. + +### C3. Crate / path / docs checklist + +- [ ] `[lib] crate-type = ["cdylib", "lib"]`. +- [ ] Example TOML plugin path uses `../../target/release/lib…` like siblings. +- [ ] Row added to `sinks/README.md` or `sources/README.md`. +- [ ] Sample under `runtime/example_config/connectors/`. +- [ ] README defaults **byte-equal** to consts in code (diff them). +- [ ] No links to non-existent docs. + +### C4. Hot path + +- [ ] No `payload.clone().try_to_bytes()` — use `try_to_bytes(&self)`. +- [ ] `Vec::with_capacity(n)` for per-batch buffers. +- [ ] No `std::sync::Mutex` across `.await`; use `tokio::sync::Mutex`. +- [ ] `&self` on `consume` / `poll` (interior mutability only). +- [ ] No `tokio::spawn` inside plugin code. +- [ ] No `unwrap()`/`expect()` on external I/O outside tests. +- [ ] No eager `format!` around tracing args. + +### C5. Containers / fixtures + +- [ ] Testcontainers named `iggy-test-*` via `fixtures::unique_container_name` + (or fixed `iggy-test-` for reuse fixtures). +- [ ] Custom Docker networks cleaned up. + +## Delivery semantics (document honestly) + +Every new connector README must answer in one short paragraph: + +1. **What happens on transient failure?** (retry N times, then Err) +2. **What happens on permanent failure?** (drop/skip vs fail batch) +3. **What is the duplication window?** (at-least-once because state saves + after Iggy send; or at-most-once if upstream ACK precedes send — say so) +4. **What is the dedup key?** (or "none — duplicates possible") + +If the answer is hand-wavy, the PR is not ready. + +## PR / CI hygiene + +- [ ] Conventional commit: `feat(connectors): …` / `fix(connectors): …`. +- [ ] PR template filled (motivation, linked issue). +- [ ] `cargo fmt --all` + `cargo sort --no-format --workspace` + + `cargo clippy -p --all-targets -- -D warnings` + + `cargo test -p ` green locally. +- [ ] Minimal `Cargo.lock` delta — no unrelated dependency churn. +- [ ] Do not modify unrelated Java/Python/foreign SDK trees in a connectors PR. +- [ ] Mark ready for review only after the above; stale-bot closes waiting PRs. + +## Pre-flight author checklist + +Paste into the PR description (or run mentally before `/ready`): + +```text +[ ] SecretString on all credentials; no secret logs/state +[ ] consume/poll never returns Ok(()) after a failed batch that should retry +[ ] Transient vs permanent errors mapped (no Display substring matching) +[ ] Stable message / dedup IDs (no random UUID per emit) +[ ] README delivery semantics paragraph present and honest +[ ] README defaults match code consts +[ ] deny_unknown_fields on plugin config +[ ] Canonical knob names (timeout, max_retries, retry_delay, poll_interval) +[ ] Sources: 4 state tests (+2 ACK/NACK if on_batch_result overridden); sleep-first poll; state staged in poll, committed only in on_batch_result +[ ] External backend: real-infra integration test (not a lying mock) +[ ] example_config + sinks/sources README row +[ ] fmt / sort --no-format / clippy -D warnings / unit tests green +[ ] Cargo.lock churn limited to this crate's deps +``` + +## Evidence base + +Recurring comments mined from connector PRs including (non-exhaustive): +SurrealDB sink (#3453), Meilisearch sink/source (#3497/#3498), OpenSearch +source (#3515), Quickwit convention (#3523), MySQL source (#3568), JDBC +source (#3588), Doris retry (#3574), Redshift sink (#3654), Airflow trigger +(#3716), Fluss sink (#3782). Highest-density themes: delivery/offset +semantics, idempotency IDs, transient/permanent mapping, config-name drift, +secrets, README/code drift, false-green tests, CI/lockfile hygiene. + +--- + +Discussion / help: see [AGENTS.md](../../../AGENTS.md#discussion-and-support). diff --git a/.claude/skills/connector-runtime/SKILL.md b/.claude/skills/connector-runtime/SKILL.md index cee028180d..0a45a921f6 100644 --- a/.claude/skills/connector-runtime/SKILL.md +++ b/.claude/skills/connector-runtime/SKILL.md @@ -211,17 +211,18 @@ Fatal errors propagate to `main` and exit. Per-connector / per-message errors ar All families labeled by `connector_key` + `connector_type` (histogram adds `stage`): -- **Counters**: `iggy_connector_messages_{produced,sent,consumed,processed,filtered}_total` and `iggy_connector_errors_total`. These are the *rendered* names; each is registered without the `_total`, which the OpenMetrics encoder appends. +- **Counters**: `iggy_connector_messages_{produced,sent,consumed,processed,filtered,errors}_total`. - `messages_filtered_total` - intentional drops via transform `Ok(None)`. - - `errors_total` - unexpected drops (decode/encode/build failure, missing field, ...) + batch-level failures. -- **Histograms**: `iggy_connector_stage_duration_seconds{stage}` (snake_case stage labels - `prepare`, `ffi`, `decode`, `iggy_send`, `state_save`, `total`). Buckets `STAGE_BUCKETS_SECONDS`. Always populated regardless of any flag. Scraped at `/metrics` when `[http.metrics] enabled = true`. + - `errors_total` - unexpected drops (decode/encode/build failure, missing field, ...) + batch-level failures, one per failed run. + - `sink_runs_total` - FFI `consume()` calls for sink batches, one per contiguous payload-variant run. Divide by `stage_duration_seconds_count{stage="total"}` for runs per batch. +- **Histograms**: `iggy_connector_stage_duration_seconds{stage}` (snake_case stage labels - `prepare`, `ffi`, `decode`, `iggy_send`, `state_save`, `total`). Buckets `STAGE_BUCKETS_SECONDS`. Always populated regardless of any flag. One sample per batch for every stage; `ffi` is summed over the batch's runs. Scraped at `/metrics` when `[http.metrics] enabled = true`. - **Gauges**: `iggy_connectors_{sources,sinks}_{total,running}`. `/stats` JSON surface mirrors counters per-connector via `ConnectorStats` (`sdk/api.rs`): `messages_filtered`, `errors`, kind-specific counters. When adding a metric: -- Add family to `Metrics` struct + `init`, register with name + help text. Never end a `Counter` family's registered name in `_total`: the encoder appends it and the series renders `_total_total`. Gauges get no suffix, so a gauge name may end in `_total` literally. +- Add family to `Metrics` struct + `init`, register with name + help text. - New label sets define `EncodeLabelSet` struct + label enum (hand-impl `EncodeLabelValue` for snake_case values - the derive emits PascalCase). - Histograms: pass `fn() -> Histogram` to `Family::new_with_constructor`. - Add unit tests under `mod tests` with `given_*_when_*_should_*` BDD names. diff --git a/.claude/skills/connector-sdk/SKILL.md b/.claude/skills/connector-sdk/SKILL.md index d155b4b541..8ad3a27918 100644 --- a/.claude/skills/connector-sdk/SKILL.md +++ b/.claude/skills/connector-sdk/SKILL.md @@ -47,7 +47,7 @@ sdk/src/ ├── api.rs ConnectorStatus, ConnectorStats (feature = "api"). ├── convert.rs owned_value_to_serde_json (simd_json ⇄ serde_json bridge). ├── log.rs CallbackLayer for tracing across FFI. -├── retry.rs CircuitBreaker, HttpRetryMiddleware, exponential_backoff, jitter. +├── retry.rs retry_async + RetryPolicy, CircuitBreaker, HttpRetryMiddleware. ├── decoders/ One per schema: json, raw, text, proto, flatbuffer, avro. ├── encoders/ Mirror of decoders. └── transforms/ add_fields, delete_fields, update_fields, filter_fields, @@ -63,6 +63,7 @@ sdk/src/ 5. **`simd_json::OwnedValue`** for JSON payloads, not `serde_json::Value`. Use `convert::owned_value_to_serde_json` as a bridge when interop is required. 6. **`BTreeMap`** for headers - deterministic ordering. Never `HashMap` on the wire. 7. **No breaking changes** to `Sink`/`Source`/`StreamDecoder`/`StreamEncoder`/`Transform` trait signatures without coordinating with all in-tree plugins in the same PR. +8. **A `Schema` variant that changes meaning is as breaking as a new variant.** The discriminant crosses FFI unchanged, so a plugin built against the old SDK keeps decoding it the old way and misreads every batch with no error anywhere. Same process as a trait change: bump the SDK minor version, update every in-tree plugin in the same PR, and state in the PR description that plugins built against the previous SDK must be rebuilt. ## Adding a new `Schema` variant @@ -72,13 +73,15 @@ The `Schema` enum (in `lib.rs`) is `#[repr(C)]` - it crosses FFI. Touch points t 2. `Payload` enum: add a matching variant carrying the deserialized form. 3. `Payload::try_into_vec` - consuming bytes-out path. 4. `Payload::try_to_bytes` - **borrowing** bytes-out path. For non-trivial payloads (parsed trees), implement a no-clone serialization, not a `clone() + serialize`. See the `Payload::Json` arm for the canonical optimization. -5. `Payload::Display`. -6. `Schema::try_into_payload` - bytes → `Payload`. -7. `Schema::decoder()` - factory returning `Arc`. -8. `Schema::encoder()` - factory returning `Arc`. -9. New `decoders/.rs` and `encoders/.rs`. -10. Update `sdk/README.md` and `core/connectors/README.md` schema list. -11. Tests: round-trip encode/decode, error paths. +5. `Payload::schema()` - variant → `Schema`. The runtime tags every sink batch from this, so a missing arm will not compile but a wrong one silently mislabels the batch. +6. `Payload::Display`. +7. `Schema::try_into_payload` - bytes → `Payload`, for a tag naming a wire format (the source path). +8. `Payload::try_from_schema` - bytes → `Payload`, for a tag that came from `Payload::schema()` (the sink path). The exact inverse of item 5. +9. `Schema::decoder()` - factory returning `Arc`. +10. `Schema::encoder()` - factory returning `Arc`. +11. New `decoders/.rs` and `encoders/.rs`. +12. Update the schema list in `sdk/README.md`. +13. Tests: round-trip encode/decode, error paths. `Payload::schema()` must round-trip through `Payload::try_from_schema`, with no exceptions: every variant survives that trip and the test is table-driven over all of them. Miss any of these → silent failures at runtime (typically `Error::InvalidPayloadType` or surprising decoder behavior in plugin code). @@ -149,8 +152,10 @@ Plugin authors call this on every consumed message. The implementation in `lib.r ## Retry helpers (`retry.rs`) - `CircuitBreaker`: threshold + cooldown, `try_lock()` on the success path to avoid hot-path contention. +- `retry_async(policy, context, should_retry, op)`: the retry loop for anything failing as `Err`. Owns attempt counting, backoff and the per-retry log; returns `RetryFailure { error, attempts, exhausted }` so callers write their own terminal log. +- `retry_backoff(base, retry, max)`: backoff only, for a loop that computes its own delay. `retry` is 1-based. `exponential_backoff` is the 0-based primitive underneath and applies no jitter, so call it directly only when the delay must be exact (`source.rs::nack_retry_delay`, whose tests assert exact values). - `HttpRetryMiddleware`: integrates with `reqwest-middleware`. Retries 429 + 5xx + network errors. Honors `Retry-After`. -- `max_retries` = **total attempts** including the first try, not extra retries. Document if you change this convention. +- `max_retries` = **total attempts** including the first try, not extra retries. Document if you change this convention. `meilisearch_sink` is the standing exception: its `max_retries` / `max_open_retries` count retries *after* the first, as its README states. - New helpers must take `Duration` (not `u64 millis`) on the public API. Internal computation uses `humantime` parsing of `String`. ## `ConnectorState` diff --git a/.claude/skills/connector-sink/SKILL.md b/.claude/skills/connector-sink/SKILL.md index 80533036b6..ae03a13066 100644 --- a/.claude/skills/connector-sink/SKILL.md +++ b/.claude/skills/connector-sink/SKILL.md @@ -34,7 +34,8 @@ for getting them to the external system reliably and efficiently. ## Quick reference -- Skeleton: [TEMPLATE.md](TEMPLATE.md) (load on demand when authoring). +- Skeleton: [TEMPLATE.md](TEMPLATE.md) (fill-in-the-blank kit — implement only `TODO(ConnectorDeveloper)`). +- PR pre-flight: [connector-pr-review](../connector-pr-review/SKILL.md). - Exemplars: `stdout_sink` (minimal), `postgres_sink` (DB + transient detection), `http_sink` (validation, batch modes, retry middleware), `mongodb_sink` (atomic counters), `elasticsearch_sink` / `iceberg_sink` (backend-specific idioms). ## Hard rules @@ -54,7 +55,7 @@ for getting them to the external system reliably and efficiently. ### Payloads (efficiency-critical) -- Dispatch on `messages_metadata.schema` and the `Payload` variant. Handle `Json`, `Raw`, `Text` at minimum. Unsupported -> `Error::InvalidPayloadType` or fall back to base64 (`elasticsearch_sink`). +- Dispatch on the `Payload` variant. Handle `Json`, `Raw`, `Text` and `Proto` at minimum. `Proto` holds text that is a JSON document when `proto_convert` had no descriptor or could not encode, and arbitrary text otherwise: call `Payload::json_document()` first and fall back to the text handling when it returns `None`. Unsupported -> `Error::InvalidPayloadType` or fall back to base64 (`elasticsearch_sink`). - For `Payload::Json`, call `try_to_bytes(&self)` - single-pass serialization without cloning the `OwnedValue` tree (`sdk/src/lib.rs::Payload::try_to_bytes`). Never `payload.clone().try_into_vec()`. - To take a payload out of `ConsumedMessage` without cloning: `std::mem::replace(&mut message.payload, Payload::Raw(vec![]))`. Standard pattern across `http_sink::send_individual` / `send_ndjson` / `send_json_array`. - Pre-allocate per-batch buffers with `Vec::with_capacity(messages.len())` (or `* factor` for serialized bodies). Used across `postgres_sink`, `mongodb_sink`, `quickwit_sink`, `elasticsearch_sink`, `http_sink`. @@ -82,7 +83,8 @@ for getting them to the external system reliably and efficiently. - SDK helpers cover the simple case: `iggy_connector_sdk::retry::check_connectivity_with_retry(...)` for `open()`, `HttpRetryMiddleware` for default 429/5xx/network policy on reqwest clients. - Custom strategies: `reqwest-middleware` + `reqwest_retry::RetryTransientMiddleware::new_with_policy_and_strategy`. `http_sink` defines its own `HttpSinkRetryStrategy` (honors `success_status_codes`, per-status decisions). - Non-HTTP clients: write `is_transient_error(&e)` mapping driver-specific codes. `postgres_sink::is_transient_error` maps SQLSTATEs `40001`, `40P01`, `57P01-03`, `08000/03/06`. -- Backoff: `iggy_connector_sdk::retry::exponential_backoff(base, attempt, max)` + `jitter()`. +- Retry loop: new connectors use `iggy_connector_sdk::retry::retry_async(policy, context, should_retry, op)` for anything that fails as `Err`. It owns attempt counting, backoff and the per-retry log, and returns `RetryFailure { error, attempts, exhausted }`; the caller logs the terminal failure. +- Backoff only, for a loop that computes its own delay (it retries on an `Ok` response, carries a deadline, or reconnects between attempts): `retry_backoff(base, retry, max)`, where `retry` is 1-based. `exponential_backoff` is the 0-based primitive underneath, and it applies no jitter; call it directly only when the delay must be exact, as `sdk/src/source.rs::nack_retry_delay` needs for its tests. - Cap retries at 3 total attempts. ### Idempotency diff --git a/.claude/skills/connector-sink/TEMPLATE.md b/.claude/skills/connector-sink/TEMPLATE.md index 32a9f97270..05aa5f770d 100644 --- a/.claude/skills/connector-sink/TEMPLATE.md +++ b/.claude/skills/connector-sink/TEMPLATE.md @@ -1,100 +1,336 @@ -# Sink plugin skeleton +# Sink plugin fill-in-the-blank kit -Boilerplate for a new `core/connectors/sinks/_sink/`. Adapt the -`MySink` / `Client` types to the backend driver you're integrating. +Copy this kit into `core/connectors/sinks/_sink/`. The scaffolding +covers config, secrets, retry, error classification, batching, logging, +and unit-test shape. **You only implement the marked `TODO(ConnectorDeveloper)` +sections:** build a client from the connection string, and push one +batch. + +Also read [SKILL.md](SKILL.md) and pre-flight with +[connector-pr-review](../connector-pr-review/SKILL.md) before `/ready`. + +Prefer starting from a compiling crate over copying this prose kit: +`core/connectors/sinks/sink_template/` implements the same shape as real, +tested code you can `cargo build`/`cargo test` immediately, with the same +`TODO(ConnectorDeveloper)` markers. Use this kit instead only when copying +a whole crate is more scaffolding than you need. + +## Files to create + +```text +core/connectors/sinks/_sink/ +├── Cargo.toml +├── README.md +├── config.toml +└── src/lib.rs +``` + +Add a workspace member, a row in `sinks/README.md`, and a sample under +`runtime/example_config/connectors/`. + +--- +# Sink plugin fill-in-the-blank kit + +Copy this kit into `core/connectors/sinks/_sink/`. The scaffolding +covers config, secrets, retry, error classification, batching, logging, +and unit-test shape. **You only implement the marked `TODO(ConnectorDeveloper)` +sections:** build a client from the connection string, and push one +batch. + +Also read [SKILL.md](SKILL.md) and pre-flight with +[connector-pr-review](../connector-pr-review/SKILL.md) before `/ready`. + +Prefer starting from a compiling crate over copying this prose kit: +`core/connectors/sinks/sink_template/` implements the same shape as real, +tested code you can `cargo build`/`cargo test` immediately, with the same +`TODO(ConnectorDeveloper)` markers. Use this kit instead only when copying +a whole crate is more scaffolding than you need. + +## Files to create + +```text +core/connectors/sinks/_sink/ +├── Cargo.toml +├── README.md +├── config.toml +└── src/lib.rs +``` + +Add a workspace member, a row in `sinks/README.md`, and a sample under +`runtime/example_config/connectors/`. + +--- ## Cargo.toml ```toml -# Apache 2.0 header (copy verbatim from any existing sink Cargo.toml) +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + [package] name = "iggy_connector__sink" -version = "0.4.1-edge.1" # match the version most other sinks use +version = "0.5.0" # match the version most other sinks use edition = "2024" license = "Apache-2.0" publish = false -# ...keywords, description, repository, homepage all identical to existing sinks +description = "Apache Iggy sink connector" +repository = "https://github.com/apache/iggy" +homepage = "https://iggy.apache.org" +description = "Apache Iggy sink connector" +repository = "https://github.com/apache/iggy" +homepage = "https://iggy.apache.org" [package.metadata.cargo-machete] -ignored = ["dashmap", "once_cell"] # used by sink_connector! macro +ignored = ["dashmap", "once_cell"] +ignored = ["dashmap", "once_cell"] [lib] -crate-type = ["cdylib", "lib"] # cdylib = runtime-loadable; lib = unit tests +crate-type = ["cdylib", "lib"] +crate-type = ["cdylib", "lib"] [dependencies] -async-trait = { workspace = true } -dashmap = { workspace = true } +async-trait = { workspace = true } +dashmap = { workspace = true } +humantime = { workspace = true } +iggy_common = { workspace = true } +async-trait = { workspace = true } +dashmap = { workspace = true } +humantime = { workspace = true } +iggy_common = { workspace = true } iggy_connector_sdk = { workspace = true } -once_cell = { workspace = true } -serde = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -# + your client crate (reqwest, sqlx, mongodb, ...) +once_cell = { workspace = true } +secrecy = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +# TODO(ConnectorDeveloper): add your client crate as a workspace dependency +once_cell = { workspace = true } +secrecy = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +# TODO(ConnectorDeveloper): add your client crate as a workspace dependency ``` -Run `cargo sort --no-format --workspace` after edits. +Run `cargo sort --no-format --workspace` after edits. Keep `Cargo.lock` +churn limited to this crate's deps. + +--- + +## config.toml (example) + +```toml +# Plugin path matches sibling sinks (relative to connectors runtime cwd). +path = "../../target/release/libiggy_connector__sink" + +[[sinks]] +key = "" +enabled = true +# path is also set via IGGY_CONNECTORS_SINK__PATH in integration tests + +[sinks..plugin_config] +# Never commit real secrets. Use env overrides in tests/ops. +connection_string = "scheme://user:pass@host:port/db" +batch_size = 100 +max_retries = 3 +retry_delay = "500ms" +verbose_logging = false +``` + +--- +Run `cargo sort --no-format --workspace` after edits. Keep `Cargo.lock` +churn limited to this crate's deps. + +--- + +## config.toml (example) + +```toml +# Plugin path matches sibling sinks (relative to connectors runtime cwd). +path = "../../target/release/libiggy_connector__sink" + +[[sinks]] +key = "" +enabled = true +# path is also set via IGGY_CONNECTORS_SINK__PATH in integration tests + +[sinks..plugin_config] +# Never commit real secrets. Use env overrides in tests/ops. +connection_string = "scheme://user:pass@host:port/db" +batch_size = 100 +max_retries = 3 +retry_delay = "500ms" +verbose_logging = false +``` + +--- ## src/lib.rs -Code reads top to bottom. Public types first, then `impl`, then private -helpers. +Replace `` / `` and implement only the `TODO(ConnectorDeveloper)` blocks. +Replace `` / `` and implement only the `TODO(ConnectorDeveloper)` blocks. ```rust -/* Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements... */ // full Apache header +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. use async_trait::async_trait; +use humantime::Duration as HumanDuration; use iggy_connector_sdk::{ ConsumedMessage, Error, MessagesMetadata, Sink, TopicMetadata, sink_connector, }; +use secrecy::{ExposeSecret, SecretString}; +use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; use std::str::FromStr; use std::time::Duration; use tokio::sync::Mutex; use tracing::{debug, error, info, warn}; -sink_connector!(MySink); // generates FFI symbols + version export +sink_connector!(NameSink); +sink_connector!(NameSink); + +const CONNECTOR_NAME: &str = "Name sink"; +const DEFAULT_BATCH_SIZE: u32 = 100; +const DEFAULT_MAX_RETRIES: u32 = 3; // total attempts +const DEFAULT_RETRY_DELAY: &str = "500ms"; -const CONNECTOR_NAME: &str = "My sink"; +/// Backend client. Replace with the real driver type. +struct BackendClient { + // TODO(ConnectorDeveloper): fields +} +const CONNECTOR_NAME: &str = "Name sink"; +const DEFAULT_BATCH_SIZE: u32 = 100; +const DEFAULT_MAX_RETRIES: u32 = 3; // total attempts +const DEFAULT_RETRY_DELAY: &str = "500ms"; + +/// Backend client. Replace with the real driver type. +struct BackendClient { + // TODO(ConnectorDeveloper): fields +} #[derive(Debug, Serialize, Deserialize)] -pub struct MySinkConfig { - pub endpoint: String, +#[serde(deny_unknown_fields)] +pub struct NameSinkConfig { + #[serde(serialize_with = "iggy_common::serde_secret::serialize_secret")] + pub connection_string: SecretString, +#[serde(deny_unknown_fields)] +pub struct NameSinkConfig { + #[serde(serialize_with = "iggy_common::serde_secret::serialize_secret")] + pub connection_string: SecretString, pub batch_size: Option, pub max_retries: Option, - pub retry_delay: Option, // humantime, e.g. "500ms" - pub verbose_logging: Option, // mirror runtime's `verbose` flag - // Every runtime-tunable field is Option; defaults applied in new() + pub retry_delay: Option, + pub verbose_logging: Option, + // TODO(ConnectorDeveloper): optional non-secret knobs (table, index, batch_mode, ...) + pub retry_delay: Option, + pub verbose_logging: Option, + // TODO(ConnectorDeveloper): optional non-secret knobs (table, index, batch_mode, ...) } #[derive(Debug)] -pub struct MySink { +pub struct NameSink { +pub struct NameSink { id: u32, - config: MySinkConfig, + config: NameSinkConfig, + config: NameSinkConfig, batch_size: usize, max_retries: u32, retry_delay: Duration, verbose: bool, - client: Option, + client: Option, + client: Option, state: Mutex, } -#[derive(Debug)] +#[derive(Debug, Default)] +#[derive(Debug, Default)] struct State { messages_processed: u64, errors: u64, } -impl MySink { - pub fn new(id: u32, config: MySinkConfig) -> Self { - let batch_size = config.batch_size.unwrap_or(100) as usize; - let max_retries = config.max_retries.unwrap_or(3); +impl NameSink { + pub fn new(id: u32, config: NameSinkConfig) -> Self { + let batch_size = config.batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize; + let max_retries = config.max_retries.unwrap_or(DEFAULT_MAX_RETRIES); +impl NameSink { + pub fn new(id: u32, config: NameSinkConfig) -> Self { + let batch_size = config.batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize; + let max_retries = config.max_retries.unwrap_or(DEFAULT_MAX_RETRIES); let retry_delay = config .retry_delay .as_deref() - .and_then(|raw| humantime::Duration::from_str(raw).ok().map(|d| *d)) + .and_then(|raw| HumanDuration::from_str(raw).ok().map(|d| *d)) + .and_then(|raw| HumanDuration::from_str(raw).ok().map(|d| *d)) .unwrap_or_else(|| { - warn!("Invalid retry_delay for {CONNECTOR_NAME} ID: {id}, defaulting to 500ms"); + warn!( + "Invalid retry_delay for {CONNECTOR_NAME} ID: {id}, defaulting to {DEFAULT_RETRY_DELAY}" + ); + warn!( + "Invalid retry_delay for {CONNECTOR_NAME} ID: {id}, defaulting to {DEFAULT_RETRY_DELAY}" + ); Duration::from_millis(500) }); let verbose = config.verbose_logging.unwrap_or(false); @@ -106,24 +342,79 @@ impl MySink { retry_delay, verbose, client: None, - state: Mutex::new(State { messages_processed: 0, errors: 0 }), + state: Mutex::new(State::default()), + } + } + + async fn send_batch_with_retry( + &self, + client: &BackendClient, + topic_metadata: &TopicMetadata, + batch: &[ConsumedMessage], + ) -> Result<(), Error> { + let mut attempt = 0u32; + loop { + attempt += 1; + match push_batch(client, topic_metadata, batch).await { + Ok(()) => return Ok(()), + Err(error) if is_permanent(&error) => return Err(error), + Err(error) if attempt >= self.max_retries => return Err(error), + Err(error) => { + warn!( + "{CONNECTOR_NAME} ID: {} retry {attempt}/{}: {error}", + self.id, self.max_retries + ); + tokio::time::sleep(self.retry_delay.saturating_mul(attempt)).await; + } + } + state: Mutex::new(State::default()), + } + } + + async fn send_batch_with_retry( + &self, + client: &BackendClient, + topic_metadata: &TopicMetadata, + batch: &[ConsumedMessage], + ) -> Result<(), Error> { + let mut attempt = 0u32; + loop { + attempt += 1; + match push_batch(client, topic_metadata, batch).await { + Ok(()) => return Ok(()), + Err(error) if is_permanent(&error) => return Err(error), + Err(error) if attempt >= self.max_retries => return Err(error), + Err(error) => { + warn!( + "{CONNECTOR_NAME} ID: {} retry {attempt}/{}: {error}", + self.id, self.max_retries + ); + tokio::time::sleep(self.retry_delay.saturating_mul(attempt)).await; + } + } } } } #[async_trait] -impl Sink for MySink { +impl Sink for NameSink { +impl Sink for NameSink { async fn open(&mut self) -> Result<(), Error> { + // Structural validation belongs here / in new(), not in consume(). + // Structural validation belongs here / in new(), not in consume(). let client = build_client(&self.config) .await .map_err(|e| Error::InitError(format!("client build failed: {e}")))?; - client.ping() + ping(&client) + ping(&client) .await .map_err(|e| Error::InitError(format!("connectivity check failed: {e}")))?; self.client = Some(client); info!( - "Opened {CONNECTOR_NAME} connector ID: {}, endpoint: {}", - self.id, self.config.endpoint + "Opened {CONNECTOR_NAME} connector ID: {}, endpoint: ", + self.id + "Opened {CONNECTOR_NAME} connector ID: {}, endpoint: ", + self.id ); Ok(()) } @@ -140,28 +431,66 @@ impl Sink for MySink { if self.verbose { info!( - "{CONNECTOR_NAME} ID: {} consuming {} messages from stream: {}, topic: {}, offset: {}, current_offset: {}", - self.id, messages.len(), topic_metadata.stream, topic_metadata.topic, - messages_metadata.partition_id, messages_metadata.current_offset + "{CONNECTOR_NAME} ID: {} consuming {} messages from stream: {}, topic: {}, partition_id: {}, current_offset: {}", + self.id, + messages.len(), + topic_metadata.stream, + topic_metadata.topic, + messages_metadata.partition_id, + messages_metadata.current_offset + "{CONNECTOR_NAME} ID: {} consuming {} messages from stream: {}, topic: {}, partition_id: {}, current_offset: {}", + self.id, + messages.len(), + topic_metadata.stream, + topic_metadata.topic, + messages_metadata.partition_id, + messages_metadata.current_offset ); } else { debug!( "{CONNECTOR_NAME} ID: {} consuming {} messages", - self.id, messages.len() + self.id, + messages.len() + self.id, + messages.len() ); } + // Never swallow a failed batch as Ok(()) — offsets may still advance. + // Never swallow a failed batch as Ok(()) — offsets may still advance. let mut last_err: Option = None; for batch in messages.chunks(self.batch_size) { - match self.send_batch(client, batch).await { - Ok(()) => { /* counter */ } + match self + .send_batch_with_retry(client, topic_metadata, batch) + .await + { + Ok(()) => { + let mut state = self.state.lock().await; + state.messages_processed += batch.len() as u64; + } + match self + .send_batch_with_retry(client, topic_metadata, batch) + .await + { + Ok(()) => { + let mut state = self.state.lock().await; + state.messages_processed += batch.len() as u64; + } Err(Error::PermanentHttpError(message)) => { error!( "{CONNECTOR_NAME} ID: {} dropping batch (permanent): {message}", self.id ); + let mut state = self.state.lock().await; + state.errors += 1; + let mut state = self.state.lock().await; + state.errors += 1; } Err(error) => { + let mut state = self.state.lock().await; + state.errors += 1; + let mut state = self.state.lock().await; + state.errors += 1; last_err = Some(error); } } @@ -173,9 +502,9 @@ impl Sink for MySink { } async fn close(&mut self) -> Result<(), Error> { - // sqlx pools have `.close().await`; reqwest/mongodb/elasticsearch just drop. if let Some(client) = self.client.take() { - let _ = client; // or `client.close().await;` for sqlx + close_client(client).await; + close_client(client).await; } let state = self.state.lock().await; info!( @@ -186,5 +515,193 @@ impl Sink for MySink { } } -async fn build_client(config: &MySinkConfig) -> Result { /* ... */ } +// ─── Backend surface: implement these ─────────────────────────────────────── + +/// TODO(ConnectorDeveloper): parse `config.connection_string.expose_secret()` and build the client. +async fn build_client(config: &NameSinkConfig) -> Result { + let _secret = config.connection_string.expose_secret(); + Err("TODO(ConnectorDeveloper): build_client".into()) +} + +/// TODO(ConnectorDeveloper): cheap connectivity probe used from open(). +async fn ping(_client: &BackendClient) -> Result<(), String> { + Ok(()) +} + +/// TODO(ConnectorDeveloper): push one batch. Prefer a stable dedup key from +/// `stream:topic:partition:message_id` (or backend natural key). +/// Use `message.payload.try_to_bytes()` — do not clone Payload::Json. +async fn push_batch( + _client: &BackendClient, + _topic_metadata: &TopicMetadata, + _batch: &[ConsumedMessage], +) -> Result<(), Error> { + Err(Error::InitError("TODO(ConnectorDeveloper): push_batch".into())) +} + +/// Map driver errors. Never classify via `err.to_string()` substrings. +fn is_permanent(error: &Error) -> bool { + matches!( + error, + Error::PermanentHttpError(_) | Error::SchemaMismatch(_) | Error::InvalidRecordValue(_) + ) +} + +async fn close_client(_client: BackendClient) { + // sqlx: pool.close().await; most HTTP clients: drop +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> NameSinkConfig { + NameSinkConfig { + connection_string: SecretString::from("scheme://localhost/db"), + batch_size: Some(10), + max_retries: Some(2), + retry_delay: Some("10ms".into()), + verbose_logging: Some(false), + } + } + + #[test] + fn given_defaults_should_apply_consts() { + let sink = NameSink::new( + 1, + NameSinkConfig { + connection_string: SecretString::from("scheme://localhost/db"), + batch_size: None, + max_retries: None, + retry_delay: None, + verbose_logging: None, + }, + ); + assert_eq!(sink.batch_size, DEFAULT_BATCH_SIZE as usize); + assert_eq!(sink.max_retries, DEFAULT_MAX_RETRIES); + } + + #[test] + fn given_invalid_retry_delay_should_fall_back_to_default() { + let mut config = test_config(); + config.retry_delay = Some("not-a-duration".into()); + let sink = NameSink::new(1, config); + assert_eq!(sink.retry_delay, Duration::from_millis(500)); + } +} +``` + +--- + +## README.md (required paragraphs) + +Your README must include a **Delivery semantics** section answering: + +1. Transient failure behavior (retry N times, then `Err`) +2. Permanent failure behavior (drop/skip vs fail) +3. Duplication window (usually at-least-once) +4. Dedup key (or "none — duplicates possible") + +Diff README defaults against the `DEFAULT_*` consts before opening the PR. + +--- + +## Before `/ready` + +Run the pre-flight checklist in +[connector-pr-review](../connector-pr-review/SKILL.md#pre-flight-author-checklist). +// ─── Backend surface: implement these ─────────────────────────────────────── + +/// TODO(ConnectorDeveloper): parse `config.connection_string.expose_secret()` and build the client. +async fn build_client(config: &NameSinkConfig) -> Result { + let _secret = config.connection_string.expose_secret(); + Err("TODO(ConnectorDeveloper): build_client".into()) +} + +/// TODO(ConnectorDeveloper): cheap connectivity probe used from open(). +async fn ping(_client: &BackendClient) -> Result<(), String> { + Ok(()) +} + +/// TODO(ConnectorDeveloper): push one batch. Prefer a stable dedup key from +/// `stream:topic:partition:message_id` (or backend natural key). +/// Use `message.payload.try_to_bytes()` — do not clone Payload::Json. +async fn push_batch( + _client: &BackendClient, + _topic_metadata: &TopicMetadata, + _batch: &[ConsumedMessage], +) -> Result<(), Error> { + Err(Error::InitError("TODO(ConnectorDeveloper): push_batch".into())) +} + +/// Map driver errors. Never classify via `err.to_string()` substrings. +fn is_permanent(error: &Error) -> bool { + matches!( + error, + Error::PermanentHttpError(_) | Error::SchemaMismatch(_) | Error::InvalidRecordValue(_) + ) +} + +async fn close_client(_client: BackendClient) { + // sqlx: pool.close().await; most HTTP clients: drop +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> NameSinkConfig { + NameSinkConfig { + connection_string: SecretString::from("scheme://localhost/db"), + batch_size: Some(10), + max_retries: Some(2), + retry_delay: Some("10ms".into()), + verbose_logging: Some(false), + } + } + + #[test] + fn given_defaults_should_apply_consts() { + let sink = NameSink::new( + 1, + NameSinkConfig { + connection_string: SecretString::from("scheme://localhost/db"), + batch_size: None, + max_retries: None, + retry_delay: None, + verbose_logging: None, + }, + ); + assert_eq!(sink.batch_size, DEFAULT_BATCH_SIZE as usize); + assert_eq!(sink.max_retries, DEFAULT_MAX_RETRIES); + } + + #[test] + fn given_invalid_retry_delay_should_fall_back_to_default() { + let mut config = test_config(); + config.retry_delay = Some("not-a-duration".into()); + let sink = NameSink::new(1, config); + assert_eq!(sink.retry_delay, Duration::from_millis(500)); + } +} ``` + +--- + +## README.md (required paragraphs) + +Your README must include a **Delivery semantics** section answering: + +1. Transient failure behavior (retry N times, then `Err`) +2. Permanent failure behavior (drop/skip vs fail) +3. Duplication window (usually at-least-once) +4. Dedup key (or "none — duplicates possible") + +Diff README defaults against the `DEFAULT_*` consts before opening the PR. + +--- + +## Before `/ready` + +Run the pre-flight checklist in +[connector-pr-review](../connector-pr-review/SKILL.md#pre-flight-author-checklist). diff --git a/.claude/skills/connector-source/SKILL.md b/.claude/skills/connector-source/SKILL.md index 5627b1812d..fa2fb38981 100644 --- a/.claude/skills/connector-source/SKILL.md +++ b/.claude/skills/connector-source/SKILL.md @@ -9,8 +9,13 @@ A **source** is a Rust `cdylib` that implements `iggy_connector_sdk::Source` and exposes FFI symbols via the `source_connector!` macro. The runtime calls `poll()` in a loop, applies transforms, encodes via the configured `Schema`, sends to -Apache Iggy, and persists the returned `ConnectorState` after every -successful send. +Apache Iggy, and persists the state `poll()` returned - but only after +the send succeeds. Only one batch is ever in flight: the runtime does +not call `poll()` again until it has reported `Ack` or `Nack` for the +current one via `on_batch_result()` (source batch acknowledgment, see PR #3855 +for the full contract). See +[State persistence](#state-persistence-stage-in-poll-commit-in-on_batch_result) +below. > **Universal connector rules** (SecretString, benchmark, verbose flag, drop accounting, filter contract, exemplar patterns) live in > [connectors-overview](../connectors-overview/SKILL.md). This skill @@ -27,14 +32,15 @@ successful send. ## STOP and ask the user before -- Changing the SDK trait surface (`Source::open` / `poll` / `close`) - that's an SDK change. -- Adding a long-running side task in the plugin - the runtime owns lifecycle. orphans survive `close()`. +- Changing the SDK trait surface (`Source::open` / `poll` / `on_batch_result` / `on_batch_result` / `close`) - that's an SDK change, and `poll`/`on_batch_result` are also an FFI change (`iggy_source_handle_v2`, `iggy_source_batch_result` - breaks every pre-built plugin `.so`). +- Adding a long-running side task in the plugin - the runtime owns lifecycle, and orphans survive `close()`. Sanctioned only where the source is itself a server the runtime cannot drive, as in `http_source`'s listener, and then only with an explicit shutdown in the last `close()` that awaits its tasks before returning. - Persisting unbounded state - `State` is rewritten every batch. - Adding a source that requires authoritative offsets external to Apache Iggy without coordinating retention. ## Quick reference -- Skeleton: [TEMPLATE.md](TEMPLATE.md) (load on demand). +- Skeleton: [TEMPLATE.md](TEMPLATE.md) (fill-in-the-blank kit — implement only `TODO(ConnectorDeveloper)`). +- PR pre-flight: [connector-pr-review](../connector-pr-review/SKILL.md). - Exemplars: `random_source` (minimal + canonical state tests), `postgres_source` (cursor / delete-after-read / processed-column modes, restart-survives-state tests), `elasticsearch_source` (scroll cursor), `influxdb_source` (time-series scan). ## Hard rules @@ -45,24 +51,100 @@ The macro shares the source as `Arc` across the FFI callback and forwarding l ### Lock discipline -Never hold the state `Mutex` across upstream I/O. Canonical pattern (matches `sources/postgres_source/src/lib.rs::poll_tables`): +Never hold the state `Mutex` across upstream I/O. Build a candidate from committed +state, then stage it until the runtime reports the batch result: ```rust -let cursor = { self.state.lock().await.cursor.clone() }; // brief read -let rows = client.query(&sql, &[&cursor]).await?; // no lock held -let persisted = { // brief write - let mut state = self.state.lock().await; - state.cursor = Some(new_cursor); - ConnectorState::serialize(&*state, CONNECTOR_NAME, self.id) -}; +let mut candidate = self.state.lock().await.clone(); +let rows = client.query(&sql, &[&candidate.cursor]).await?; +candidate.cursor = Some(new_cursor); +let persisted = ConnectorState::serialize(&candidate, CONNECTOR_NAME, self.id) + .ok_or_else(|| Error::Serialization("failed to serialize source state".into()))?; +*self.pending.lock().await = Some(candidate); ``` -### State persistence +### Delivery acknowledgment + +`on_batch_result` (added by #3855) is how a source learns what happened to the batch it just +returned. The SDK keeps exactly one batch in flight: it will not call `poll()` again until this +returns, and it stops the source after `MAX_CONSECUTIVE_NACKS` (5) consecutive NACKs, roughly 1.5s +of backoff, without calling `close()`. + +- `Ack` means the runtime sent the batch **and** persisted its state. `Nack` means it could not + confirm both, which is **not** the same as neither happening: a batch that reached the topic but + whose state save failed is NACKed, and the SDK NACKs on its own result timeout while the send may + still have landed. A source that replays on `Nack` is at-least-once, not exactly-once. +- The trait has a **default no-op**, which suits only a source with no staged cursor and no + destructive work. If `poll()` advances a cursor, deletes rows, or drains an in-memory buffer, + omitting this loses data silently and nothing will tell you. `random_source` and `http_source` + implement it; the other shipped sources take the default and skip rows on a NACK. +- Stage in `poll()`, apply on `Ack`, discard or replay on `Nack`. A source whose input is pushed to + it, rather than re-readable upstream, has to hold the batch itself: see `http_source`'s staging. +- Returning `Err` from it stops the source immediately, so it is not a retry signal. + +### State persistence: stage in `poll()`, commit in `on_batch_result()` + +Source connectors use a one-in-flight-batch ACK/NACK contract (#3855) +between the plugin and the runtime: + +1. `poll()` returns messages and *candidate* state without committing + cursor changes or destructive operations (deletes, mark-processed). +2. The runtime sends the batch to Apache Iggy and waits for the + producer result. +3. After a successful send, the runtime persists the candidate state + to `{state_path}/source_{key}.state`. +4. The runtime calls `on_batch_result(SourceBatchResult::Ack)`. A send + or state-save failure calls `on_batch_result(SourceBatchResult::Nack)` + instead. +5. `on_batch_result()` commits or discards the plugin's staged work + before the next `poll()` starts. The SDK allows only one batch in + flight - it will not call `poll()` again until `on_batch_result()` + for the current batch has returned. + +Canonical pattern (`sources/random_source/src/lib.rs`): + +```rust +pending_state: Mutex>, // staged, not yet committed + +async fn poll(&self) -> Result { + // ... fetch ... + let candidate_state = State { cursor: next_cursor }; + *self.pending_state.lock().await = Some(candidate_state.clone()); + Ok(ProducedMessages { + schema: Schema::Json, + messages, + state: Some(ConnectorState::serialize(&candidate_state, NAME, self.id)?), + }) +} + +async fn on_batch_result(&self, result: SourceBatchResult) -> Result<(), Error> { + let candidate_state = self.pending_state.lock().await.take(); + if result == SourceBatchResult::Ack + && let Some(candidate_state) = candidate_state + { + *self.state.lock().await = candidate_state; + } + // Nack: drop candidate_state, committed self.state is untouched - + // the same range is polled again. + Ok(()) +} +``` - `ConnectorState` is `Vec` via MessagePack (`rmp_serde`). Use `ConnectorState::serialize(&state, NAME, id)` + `ConnectorState::deserialize::(NAME, id)`. Both return `Option` and log on failure (non-fatal). -- Runtime saves to `{state_path}/source_{key}.state` only after a successful Iggy send. Between `poll()` returning and the runtime persisting the save, a crash leaves the same cursor for the next poll - downstream must tolerate at-least-once. -- **Always return state in every `ProducedMessages`**, including empty polls. Empty results still need to advance watermarks (timestamp sources) or affirm "nothing new." +- **The default `on_batch_result` is a no-op.** Only override it - and only then does staging via `pending_state` matter - if `poll()` advances a cursor or performs destructive work (delete-after-read, mark-processed). A source with no staged work (e.g. a pure generator) can rely on the default. +- Returning `Err` from `on_batch_result` **stops the SDK from polling further** - a failed rollback must not be allowed to silently advance to the next batch. +- **Always return state in every `ProducedMessages`**, including empty polls that made progress. Return `state: None` for an empty poll that made *no* progress - this avoids an unnecessary state write and cannot persist state left over from a failed batch. - Keep `State` small - rewritten every batch. No unbounded vecs. +- NACK handling must discard staged cursor changes and staged delete/mark operations so polling redelivers the batch. The SDK retries NACKed batches with capped exponential backoff and stops the source after repeated consecutive NACKs. +- Crash recovery is at-least-once at every point except after the plugin has processed the ACK (see the SDK README's crash-point table, `core/connectors/sdk/README.md#source-delivery-acknowledgment`, for the full breakdown). + +The SDK allows one in-flight batch. Five consecutive NACKs stop the source and +require a manual restart. Returning `Err` from `on_batch_result` is fatal, so +retry transient backend failures inside the callback before returning an error. +The runtime must report ACK or NACK within the SDK's 30-second batch-result +window. Once the result is received, the SDK waits for `on_batch_result` to +finish, so the callback must bound its own connection acquisition and retry +budget rather than relying on the SDK deadline. ### Sleep first @@ -79,25 +161,28 @@ Match `ProducedMessages.schema` to the bytes in `messages[i].payload`: ### IDs and timestamps -- `ProducedMessage.id: Option` - set when a natural ID exists (DB PK, document id). Apache Iggy can dedupe on this. +- `ProducedMessage.id: Option` - set when a natural ID exists (DB PK, document id). It rides the wire as the message id, which is what a **consumer** can dedupe an at-least-once duplicate on. +- **Iggy itself does not dedupe on that id.** The server's only read of the field is `core/server/src/http/wire.rs`, which mints a fresh uuid when the incoming id is 0 and otherwise passes it through. Its dedup path is a per-client request-id watermark (`dedup_clients_max`) that never looks at this field. - `origin_timestamp: Option` - source-system event time in nanoseconds. Lets downstream sinks reason about lag. - `timestamp` and `checksum` are Iggy-side - leave `None`. ### Concurrency - Runtime spawns ONE `poll()` task per source. No concurrent `poll()`. -- Don't spawn your own long-running Tokio tasks - runtime owns lifecycle. +- Only one batch is ever in flight: the SDK does not call `poll()` again until `on_batch_result()` has returned for the previous batch (up to a 30s result timeout, after which the SDK treats it as a Nack). +- Don't spawn your own long-running Tokio tasks: the runtime owns lifecycle. The exception is a source that listens rather than polls, which has to own its listener; `http_source` is the worked example, and it shuts its tasks down in the last `close()` rather than leaving them to outlive the connector. ### Errors -| Scenario | Variant | -| ------------------------------------------- | ------------------------------------------------- | -| Bad config in `new()`/`open()` | `Error::InitError` | -| Cannot reach external system at startup | `Error::InitError` or `Error::Connection` | -| Transient fetch failure (retry-worthy) | `Error::Connection` or `Error::HttpRequestFailed` | -| Permanent fetch failure (auth, schema gone) | `Error::PermanentHttpError` | -| Row failed to serialize | `Error::Serialization(...)` | -| State serialization failed | log + skip (non-fatal) | +| Scenario | Variant | +| --------------------------------------------------- | ------------------------------------------------- | +| Bad config in `new()`/`open()` | `Error::InitError` | +| Cannot reach external system at startup | `Error::InitError` or `Error::Connection` | +| Transient fetch failure (retry-worthy) | `Error::Connection` or `Error::HttpRequestFailed` | +| Permanent fetch failure (auth, schema gone) | `Error::PermanentHttpError` | +| Row failed to serialize | `Error::Serialization(...)` | +| State serialization failed | log + skip (non-fatal) | +| `on_batch_result()` failed to roll back staged work | `Err` - stops the SDK from polling further | Returning `Err` from `poll()` is only logged by the SDK's FFI bridge (`sdk/src/source.rs::handle_messages`) - the loop continues, the next @@ -135,9 +220,13 @@ Iggy consumer-loop labels use literal API names (`offset=`, `current_offset=`). ## Tests -Mandatory four canonical source state tests (see [connector-testing](../connector-testing/SKILL.md) for the full pattern). Copy from `sources/random_source/src/lib.rs::tests`. Plus config defaults, payload building, schema selection. +Mandatory six canonical source tests (see [connector-testing](../connector-testing/SKILL.md) for the full pattern): the four +state tests (restore / no-state / invalid-state / round-trip) plus `given_ack_when_batch_is_staged_should_commit_candidate_state` +and `given_nack_when_batch_is_staged_should_keep_committed_state`. Copy from `sources/random_source/src/lib.rs::tests`. Plus +config defaults, payload building, schema selection. A source relying on the default no-op `on_batch_result` (no staged work) +may skip the ack/nack pair. -Integration tests under `core/integration/tests/connectors//` for any source backed by external infra. Use `#[iggy_harness]` + a `TestFixture` backed by `testcontainers-modules`. Reference: `core/integration/tests/connectors/postgres/postgres_source.rs` (multi-mode tests) + `restart.rs` (state survives restart). +Integration tests under `core/integration/tests/connectors//` for any source backed by external infra. Use `#[iggy_harness]` + a `TestFixture` backed by `testcontainers-modules`. Reference: `core/integration/tests/connectors/postgres/postgres_source.rs` (multi-mode tests) + `restart.rs` (state survives restart). Exercise both ACK and NACK paths when the source stages cursors or destructive work. ## Before declaring done diff --git a/.claude/skills/connector-source/TEMPLATE.md b/.claude/skills/connector-source/TEMPLATE.md index 7a27953654..4692d6046d 100644 --- a/.claude/skills/connector-source/TEMPLATE.md +++ b/.claude/skills/connector-source/TEMPLATE.md @@ -1,60 +1,133 @@ -# Source plugin skeleton +# Source plugin fill-in-the-blank kit -Boilerplate for a new `core/connectors/sources/_source/`. Adapt -the `MySource` / `Client` / row types to the backend driver. +Copy this kit into `core/connectors/sources/_source/`. The +scaffolding covers config, secrets, sleep-first poll, lock discipline, +staged/committed state ser·de (ACK/NACK batch acknowledgment, #3855), +retry classification, logging, and the six canonical state tests. +**You only implement the marked `TODO(ConnectorDeveloper)` sections:** build a +client from the connection string, and fetch the next batch (advancing +a cursor). -`Cargo.toml` is identical to a sink's (see -[connector-sink/TEMPLATE.md](../connector-sink/TEMPLATE.md)) - only -the crate name suffix changes (`iggy_connector__source`) and the -upstream client dep. +Also read [SKILL.md](SKILL.md) and pre-flight with +[connector-pr-review](../connector-pr-review/SKILL.md) before `/ready`. + +Prefer starting from a compiling crate over copying this prose kit: +`core/connectors/sources/source_template/` implements the same shape as +real, tested code you can `cargo build`/`cargo test` immediately, with the +same `TODO(ConnectorDeveloper)` markers. Use this kit instead only when +copying a whole crate is more scaffolding than you need. + +## Files to create + +```text +core/connectors/sources/_source/ +├── Cargo.toml +├── README.md +├── config.toml +└── src/lib.rs +``` + +Add a workspace member, a row in `sources/README.md`, and a sample under +`runtime/example_config/connectors/`. + +--- + +## Cargo.toml + +Same shape as the sink kit (`cdylib` + `lib`, workspace deps, Apache +header). Only the package name suffix changes: + +```toml +name = "iggy_connector__source" +# ... identical metadata / machete ignored / crate-type ... +# TODO(ConnectorDeveloper): add your client crate as a workspace dependency +``` + +--- + +## config.toml (example) + +```toml +path = "../../target/release/libiggy_connector__source" + +[[sources]] +key = "" +enabled = true + +[sources..plugin_config] +connection_string = "scheme://user:pass@host:port/db" +poll_interval = "5s" +batch_size = 100 +max_retries = 3 +retry_delay = "500ms" +verbose_logging = false +``` + +Defaults in this file must match `DEFAULT_*` consts in code. + +--- ## src/lib.rs -Code reads top to bottom. Public types first, `impl Source` after, -helpers below. +Replace `` / `` and implement only the `TODO(ConnectorDeveloper)` blocks. ```rust /* Apache 2.0 header */ use async_trait::async_trait; +use humantime::Duration as HumanDuration; use iggy_connector_sdk::{ - ConnectorState, Error, ProducedMessage, ProducedMessages, Schema, Source, source_connector, + ConnectorState, Error, ProducedMessage, ProducedMessages, Schema, Source, + source::SourceBatchResult, source_connector, }; +use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; -use std::str::FromStr; -use std::time::Duration; use tokio::sync::Mutex; use tokio::time::sleep; use tracing::{debug, error, info, warn}; -source_connector!(MySource); +source_connector!(NameSource); + +const CONNECTOR_NAME: &str = "Name source"; +const DEFAULT_POLL_INTERVAL: &str = "5s"; +const DEFAULT_BATCH_SIZE: u32 = 100; +const DEFAULT_MAX_RETRIES: u32 = 3; // total attempts +const DEFAULT_RETRY_DELAY: &str = "500ms"; -const CONNECTOR_NAME: &str = "My source"; +struct BackendClient { + // TODO(ConnectorDeveloper): fields +} #[derive(Debug, Serialize, Deserialize)] -pub struct MySourceConfig { - pub endpoint: String, - pub poll_interval: Option, // humantime, "10s" default +#[serde(deny_unknown_fields)] +pub struct NameSourceConfig { + #[serde(serialize_with = "iggy_common::serde_secret::serialize_secret")] + pub connection_string: SecretString, + pub poll_interval: Option, pub batch_size: Option, pub max_retries: Option, pub retry_delay: Option, pub verbose_logging: Option, + // TODO(ConnectorDeveloper): optional non-secret knobs (query, table, index, ...) } #[derive(Debug, Serialize, Deserialize)] struct State { - cursor: Option, // WAL LSN, scroll id, timestamp, ... - last_offset: u64, + /// Opaque backend cursor (LSN, scroll id, timestamp, PK, ...). Keep O(1). + cursor: Option, messages_produced: u64, } #[derive(Debug)] -pub struct MySource { +pub struct NameSource { id: u32, - config: MySourceConfig, + config: NameSourceConfig, poll_interval: Duration, + batch_size: usize, + max_retries: u32, + retry_delay: Duration, verbose: bool, - client: Option, + client: Option, state: Mutex, } @@ -64,28 +137,45 @@ impl MySource { let poll_interval = humantime::Duration::from_str(&raw_interval) .map(|d| *d) .unwrap_or_else(|_| { - warn!("Invalid poll_interval for {CONNECTOR_NAME} ID: {id}, defaulting to 10s"); - Duration::from_secs(10) + warn!( + "Invalid poll_interval for {CONNECTOR_NAME} ID: {id}, defaulting to {DEFAULT_POLL_INTERVAL}" + ); + Duration::from_secs(5) + }); + let batch_size = config.batch_size.unwrap_or(DEFAULT_BATCH_SIZE) as usize; + let max_retries = config.max_retries.unwrap_or(DEFAULT_MAX_RETRIES); + let retry_delay = config + .retry_delay + .as_deref() + .and_then(|raw| HumanDuration::from_str(raw).ok().map(|d| *d)) + .unwrap_or_else(|| { + warn!( + "Invalid retry_delay for {CONNECTOR_NAME} ID: {id}, defaulting to {DEFAULT_RETRY_DELAY}" + ); + Duration::from_millis(500) }); - let verbose = config.verbose_logging.unwrap_or(false); let restored = state .and_then(|s| s.deserialize::(CONNECTOR_NAME, id)) - .inspect(|s| info!( - "Restored state for {CONNECTOR_NAME} ID: {id}, last_offset: {}, cursor: {:?}", - s.last_offset, s.cursor - )); + .inspect(|s| { + info!( + "Restored state for {CONNECTOR_NAME} ID: {id}, cursor: {:?}, messages_produced: {}", + s.cursor, s.messages_produced + ); + }); Self { id, config, poll_interval, + batch_size, + max_retries, + retry_delay, verbose, client: None, state: Mutex::new(restored.unwrap_or(State { cursor: None, - last_offset: 0, messages_produced: 0, })), } @@ -93,15 +183,19 @@ impl MySource { } #[async_trait] -impl Source for MySource { +impl Source for NameSource { async fn open(&mut self) -> Result<(), Error> { + // Validate query/cursor shape here — not on first poll after sleep. let client = build_client(&self.config) .await .map_err(|e| Error::InitError(format!("client build failed: {e}")))?; + ping(&client) + .await + .map_err(|e| Error::InitError(format!("connectivity check failed: {e}")))?; self.client = Some(client); info!( - "Opened {CONNECTOR_NAME} connector ID: {}, endpoint: {}", - self.id, self.config.endpoint + "Opened {CONNECTOR_NAME} connector ID: {}, endpoint: ", + self.id ); Ok(()) } @@ -132,8 +226,10 @@ impl Source for MySource { if self.verbose { info!( - "{CONNECTOR_NAME} ID: {} produced {} messages, next cursor: {:?}", - self.id, messages.len(), next_cursor + "{CONNECTOR_NAME} ID: {} polled {} messages, next_cursor: {:?}", + self.id, + fetched.messages.len(), + fetched.next_cursor ); } @@ -155,7 +251,7 @@ impl Source for MySource { async fn close(&mut self) -> Result<(), Error> { if let Some(client) = self.client.take() { - let _ = client; // or `client.close().await;` for sqlx pools + close_client(client).await; } let state = self.state.lock().await; info!( @@ -166,5 +262,187 @@ impl Source for MySource { } } -async fn build_client(config: &MySourceConfig) -> Result { /* ... */ } +// ─── Backend surface: implement these ─────────────────────────────────────── + +/// TODO(ConnectorDeveloper): parse `config.connection_string.expose_secret()` and build the client. +async fn build_client(config: &NameSourceConfig) -> Result { + let _secret = config.connection_string.expose_secret(); + Err("TODO(ConnectorDeveloper): build_client".into()) +} + +/// TODO(ConnectorDeveloper): cheap connectivity probe used from open(). +async fn ping(_client: &BackendClient) -> Result<(), String> { + Ok(()) +} + +/// TODO(ConnectorDeveloper): fetch up to `limit` rows after `cursor`. +/// Set `ProducedMessage.id` from a stable natural key (never random UUID). +/// Set `origin_timestamp` when the backend has event time (nanoseconds). +async fn fetch_batch( + _client: &BackendClient, + _cursor: Option<&str>, + _limit: usize, +) -> Result { + Err(Error::InitError("TODO(ConnectorDeveloper): fetch_batch".into())) +} + +fn is_permanent(error: &Error) -> bool { + matches!( + error, + Error::PermanentHttpError(_) | Error::SchemaMismatch(_) | Error::InvalidConfigValue(_) + ) +} + +async fn close_client(_client: BackendClient) {} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> NameSourceConfig { + NameSourceConfig { + connection_string: SecretString::from("scheme://localhost/db"), + poll_interval: Some("100ms".into()), + batch_size: Some(10), + max_retries: Some(2), + retry_delay: Some("10ms".into()), + verbose_logging: Some(false), + } + } + + #[test] + fn given_persisted_state_should_restore_cursor() { + let state = State { + cursor: Some("cursor-1".into()), + messages_produced: 7, + }; + let bytes = rmp_serde::to_vec(&state).expect("serialize"); + let source = NameSource::new(1, test_config(), Some(ConnectorState(bytes))); + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let restored = source.state.lock().await; + assert_eq!(restored.cursor.as_deref(), Some("cursor-1")); + assert_eq!(restored.messages_produced, 7); + }); + } + + #[test] + fn given_no_state_should_start_fresh() { + let source = NameSource::new(1, test_config(), None); + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let restored = source.state.lock().await; + assert!(restored.cursor.is_none()); + assert_eq!(restored.messages_produced, 0); + }); + } + + #[test] + fn given_invalid_state_should_start_fresh() { + let invalid = ConnectorState(b"not valid msgpack".to_vec()); + let source = NameSource::new(1, test_config(), Some(invalid)); + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let restored = source.state.lock().await; + assert!(restored.cursor.is_none()); + assert_eq!(restored.messages_produced, 0); + }); + } + + #[test] + fn state_should_be_serializable_and_deserializable() { + let original = State { + cursor: Some("c".into()), + messages_produced: 3, + }; + let bytes = rmp_serde::to_vec(&original).unwrap(); + let restored: State = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(original, restored); + } + + #[test] + fn given_defaults_should_apply_consts() { + let source = NameSource::new( + 1, + NameSourceConfig { + connection_string: SecretString::from("scheme://localhost/db"), + poll_interval: None, + batch_size: None, + max_retries: None, + retry_delay: None, + verbose_logging: None, + }, + None, + ); + assert_eq!(source.batch_size, DEFAULT_BATCH_SIZE as usize); + assert_eq!(source.max_retries, DEFAULT_MAX_RETRIES); + assert_eq!(source.poll_interval, Duration::from_secs(5)); + } + + // Stages `pending_state` directly rather than going through poll() - poll()'s + // TODO(ConnectorDeveloper) fetch is unimplemented in this template, but on_batch_result's + // commit/discard contract is independently testable and must stay covered once + // the backend is filled in. + + #[test] + fn given_ack_when_batch_is_staged_should_commit_candidate_state() { + let source = NameSource::new(1, test_config(), None); + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let candidate = State { + cursor: Some("cursor-2".into()), + messages_produced: 5, + }; + *source.pending_state.lock().await = Some(candidate.clone()); + + source + .on_batch_result(SourceBatchResult::Ack) + .await + .expect("ack should be applied"); + + assert_eq!(*source.state.lock().await, candidate); + assert!(source.pending_state.lock().await.is_none()); + }); + } + + #[test] + fn given_nack_when_batch_is_staged_should_keep_committed_state() { + let source = NameSource::new(1, test_config(), None); + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let committed_before = source.state.lock().await.clone(); + *source.pending_state.lock().await = Some(State { + cursor: Some("cursor-2".into()), + messages_produced: 5, + }); + + source + .on_batch_result(SourceBatchResult::Nack) + .await + .expect("nack should be applied"); + + assert_eq!(*source.state.lock().await, committed_before); + assert!(source.pending_state.lock().await.is_none()); + }); + } +} ``` + +--- + +## README.md (required paragraphs) + +Include a **Delivery semantics** section: + +1. Transient fetch failure → retry N times, then `Err` (loop continues; see `connector-source`) +2. Cursor commits only on `on_batch_result(Ack)` - a Nack (send or state-save failure) discards the staged cursor and redelivers the batch +3. Whether destructive work (delete/mark-processed) is staged in `poll()` and applied only in `on_batch_result()` on Ack (standard - no loss window to document), or must happen earlier for some architectural reason (if so, document the loss window explicitly) +4. Dedup key for `ProducedMessage.id` (or "none") + +--- + +## Before `/ready` + +Run the pre-flight checklist in +[connector-pr-review](../connector-pr-review/SKILL.md#pre-flight-author-checklist). +Mandatory: the six canonical state tests above must stay green. diff --git a/.claude/skills/connector-testing/SKILL.md b/.claude/skills/connector-testing/SKILL.md index 5aa3379071..9e6669e057 100644 --- a/.claude/skills/connector-testing/SKILL.md +++ b/.claude/skills/connector-testing/SKILL.md @@ -54,6 +54,35 @@ fn given_payload_json_should_serialize_correctly() { ... } You may **omit one part** when the test is small (`given_X_should_Y` instead of full `given_X_when_Y_should_Z`), but **stay consistent within a file**. Imperative `test_foo` / `does_bar` names are not the convention here. +A test whose subject is a **type** rather than a scenario has no "given" to name, and drops the prefix: it reads `_should_`. The canonical state round trip below is `state_should_be_serializable_and_deserializable` in all three shipped sources (`random_source`, `postgres_source`, `http_source`). Leave those as they are rather than bending them into the scenario form. + +### Prove a test can fail, before trusting it + +A passing test says nothing until you have watched it fail. Break the behaviour it names, run the +suite, and put the code back: + +```bash +# commit first: this reverts with `git checkout --`, which discards +# every uncommitted change in the file, not only the mutation +git status --porcelain -- # must be empty + +cargo nextest run -p # expect a failure naming that test +git checkout -- +``` + +Two failures this catches, both seen in review of `http_source`: + +- **A fixture that makes the interesting case unreachable.** A guard test for endpoint resurrection + passed `&[]` as the static config, so there was nothing to resurrect and the assertion held + whatever `restore` did. +- **An assertion aimed at something self-healing.** A test for a teardown guard asserted on gauges, + which `Metrics::encode` rebuilds from the live instances on every scrape, so it passed with the + guard deleted. Delete such a test rather than keep it; a test that cannot fail is worse than none, + because it reads as coverage. + +Watch for an arm hidden behind a catch-all: `StatusClass::from` ends in `_ => ServerError`, so the +redirect arm could be removed with every existing assertion still green. + ### `test_config()` helper Every plugin's tests start with a small helper returning a tuned-down version of the production config. New tests extend it. don't construct from scratch each time. @@ -127,6 +156,28 @@ fn state_should_be_serializable_and_deserializable() { State is the only thing that survives a plugin restart. Silent corruption here means lost data on the next deploy. +**The third test has two correct shapes, and which one applies is a design decision rather than a style choice.** + +Starting fresh is right when the state is a cursor. The worst case is re-reading from the beginning, which the at-least-once contract already permits, so discarding an undecodable cursor costs duplicates and nothing else. + +It is wrong when the state carries something whose *absence* is a downgrade rather than a repeat. `http_source` keeps its revocation tombstones in state, so starting fresh after a decode failure would serve every revoked endpoint again, with its secret. Its `restore` returns `Err` instead and the connector refuses to open. A source in that position asserts the refusal: + +```rust +#[test] +fn given_invalid_state_should_refuse_to_open() { + let invalid = ConnectorState(b"not valid msgpack".to_vec()); + + let restored = MyState::restore(&config_entries(), Some(invalid), 1); + + assert!( + restored.is_err(), + "discarding this state silently would re-serve what it was revoking" + ); +} +``` + +Name the test for whichever it is and say why in a comment. What is never right is starting fresh silently when the state that was discarded was load-bearing. + ### Sink unit tests Cover pure per-message logic: diff --git a/.claude/skills/connectors-overview/SKILL.md b/.claude/skills/connectors-overview/SKILL.md index 230ad5354b..4f6f5e169e 100644 --- a/.claude/skills/connectors-overview/SKILL.md +++ b/.claude/skills/connectors-overview/SKILL.md @@ -25,7 +25,7 @@ Repo-wide rules (Apache headers, fmt/sort/clippy order, idiomatic Rust traits, i ## STOP and ask the user before -- Bumping `iggy_connector_sdk` MAJOR version or changing any FFI signature in `sdk/src/{sink,source}.rs` - breaks every pre-built plugin `.so`. +- Bumping `iggy_connector_sdk` MAJOR version or changing any FFI signature in `sdk/src/{sink,source}.rs` - breaks every pre-built plugin `.so`. Source FFI is `iggy_source_handle_v2` (batch ID carried to the runtime callback) + `iggy_source_batch_result` (plugin-exported ACK/NACK, #3855). - Changing the runtime's wire conventions (postcard FFI payload structs, default consumer group naming, plugin path resolution). - Modifying `runtime/src/state.rs` save protocol (atomic rename + fsync ordering) - corruption risk. - Renaming or repurposing a `Schema` variant - decoders/encoders pinned to wire bytes. @@ -42,27 +42,31 @@ Both compile as `cdylib` shared libraries (`.so`/`.dylib`/`.dll`) loaded by the ```text ┌─ optional transforms ─┐ External ──poll──▶ SOURCE ──FFI──▶ RUNTIME ──encode──▶ Apache Iggy stream - system plugin ▲ ▲ - │ │ - state save (msgpack) │ - │ - ┌─ optional transforms ─┐ │ + system plugin ▲ ▲ + │ │ + Ack/Nack (on_batch_result) │ + │ state save (msgpack) + └────────────────────┘ + ┌─ optional transforms ─┐ Apache Iggy stream ──decode──▶ RUNTIME ──FFI──▶ SINK ──write──▶ External plugin system ``` +Source is a request/response FFI, not fire-and-forget: after the runtime sends the batch and saves the state `poll()` staged, it reports `SourceBatchResult::Ack` or `::Nack` back to the plugin via `iggy_source_batch_result` (#3855) before calling `poll()` again. See [connector-source](../connector-source/SKILL.md#state-persistence-stage-in-poll-commit-in-on_batch_result) for the full handshake. + Headers set on the source side ride through transforms (which may modify, drop, or pass them) and arrive at the sink with `BTreeMap` preserved deterministically. ## Which skill to load -| Task | Skill | -| --------------------------------------------------- | --------------------- | -| Write a new sink plugin | `connector-sink` | -| Write a new source plugin | `connector-source` | -| Add schema / decoder / encoder / SDK trait surface | `connector-sdk` | -| Change runtime internals (FFI, manager, state, ...) | `connector-runtime` | -| Add a transform (field-level or format conversion) | `connector-transform` | -| Write unit / integration tests for any of the above | `connector-testing` | +| Task | Skill | +| ---------------------------------------------------- | --------------------- | +| Write a new sink plugin | `connector-sink` | +| Write a new source plugin | `connector-source` | +| Add schema / decoder / encoder / SDK trait surface | `connector-sdk` | +| Change runtime internals (FFI, manager, state, ...) | `connector-runtime` | +| Add a transform (field-level or format conversion) | `connector-transform` | +| Write unit / integration tests for any of the above | `connector-testing` | +| Review a sink/source PR / pre-flight before `/ready` | `connector-pr-review` | ## Stick to conventions @@ -96,24 +100,14 @@ The connectors codebase is intentionally repetitive across plugins. Cross-plugin ### Secrets -Any credential-bearing field (connection strings, API keys, bearer tokens, AWS keys) must be `SecretString` from the `secrecy` crate. Plain `String` for a credential is a review-blocker: `SecretString` redacts on `Debug`, so it is what keeps a credential out of a log line that formats the whole config. - -**`serde_secret::serialize_secret` EXPOSES the secret. It does not redact.** It calls `expose_secret()` and writes the plaintext. `SecretString` deliberately has no `Serialize` impl, and that absence is the protection - so adding `serialize_with` is what *unblocks* the derive and turns a compile-time guarantee into plaintext output. Use it only where the plaintext is the point: a wire payload, a persisted config, an API response that exposes credentials by design. - -So the default for a plugin config struct is **derive `Deserialize`, but not `Serialize`**. `Deserialize` is required: the SDK glue deserializes the config into the plugin's own struct (`sdk/src/{sink,source}.rs` call `serde_json::from_str::` under a `DeserializeOwned` bound). - -What never happens is the return trip. The runtime holds plugin configuration as a `serde_json::Value` - parsed from TOML, posted as JSON to the control API, or injected by env var - and hands that across the FFI, so nothing re-serializes the plugin's struct. Leaving `Serialize` off makes that compiler-enforced instead of convention-enforced (`sources/http_source/src/lib.rs::HttpSourceConfig` does this, and comments the omission so nobody adds it back). - -Pattern: +Any credential-bearing field (connection strings, API keys, bearer tokens, AWS keys) must be `SecretString` from the `secrecy` crate, with the workspace serde wrapper applied so `Debug` and serialization both redact. Runtime exposes plugin configs over the `/stats` HTTP surface via serialization - plain `String` leaks the secret to anyone who can hit the endpoint. Plain `String` for a credential is a review-blocker. Pattern (from `sinks/postgres_sink/src/lib.rs::PostgresSinkConfig`): ```rust use secrecy::{ExposeSecret, SecretString}; -// `Deserialize` only. Nothing re-serializes a plugin config, and leaving -// `Serialize` off is what makes the credential unserializable rather than -// merely un-serialized. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct MyConfig { + #[serde(serialize_with = "iggy_common::serde_secret::serialize_secret")] pub connection_string: SecretString, } @@ -123,13 +117,7 @@ let pool = PgPoolOptions::new() .await?; ``` -If a config struct genuinely needs `Serialize`, `serde_secret::serialize_redacted` (and `serialize_optional_redacted`) write `[REDACTED]` in place of the value. Reach for `serialize_secret` only when the caller must get the real thing back. The sinks and sources listed below predate that helper and use the exposing one; the annotation is inert today, but it is not the protection it looks like. - -Note that none of this protects the credential from the runtime's own control API, which returns plugin configuration verbatim - see #3802. Plugin-side annotations are inert there because the runtime never routes through them. - -Plugin-side uses of the exposing helpers: `sinks/{postgres,mongodb,elasticsearch,influxdb,s3,surrealdb}_sink`, `sources/{postgres,elasticsearch,influxdb}_source`. - -That list is plugin-side only, not an inventory of every caller in the tree, and the others are not all mistakes: `runtime/src/api/config.rs` puts `serialize_secret` on `HttpConfig::api_key` (inert for the same reason), and several `core/common` wire-payload types (login, create-user, change-password, PAT) use these helpers by design, because there the credential *is* the payload. +In-tree uses: `sinks/{postgres,mongodb,elasticsearch,influxdb,delta}_sink`, `sources/{postgres,elasticsearch,influxdb}_source`. ### Errors @@ -181,7 +169,7 @@ JSON log format, parser unit tests). | Real-infra sink + integration tests | `sinks/postgres_sink/` + `integration/tests/connectors/postgres/postgres_sink.rs` | | Feature-rich sink config (validation, batch modes, retry) | `sinks/http_sink/` | | Atomic counters on hot path | `sinks/mongodb_sink/` | -| Simplest source (4 canonical state tests) | `sources/random_source/` | +| Simplest source (6 canonical state + ACK/NACK tests) | `sources/random_source/` | | Real-infra source | `sources/postgres_source/` + `integration/tests/connectors/postgres/postgres_source.rs` | Read the relevant exemplar end-to-end before writing or modifying a connector. @@ -209,7 +197,7 @@ Each implemented in at least one in-tree plugin or runtime path. | `flume::unbounded()` channel | `runtime/src/source.rs::spawn_source_handler` / `source_forwarding_loop` | MPSC handoff from SDK async task to runtime loop | | `tokio::sync::watch::channel(())` | `sdk/src/{sink,source}.rs`, `runtime/src/sink.rs`, `runtime/src/manager/*` | One-shot shutdown broadcast | | `dashmap::DashMap` | `runtime/src/manager/sink.rs`, `source.rs::SOURCE_SENDERS`, SDK `INSTANCES` | Lock-free concurrent keyed access | -| `secrecy::SecretString` + `iggy_common::serde_secret::serialize_secret` | `sinks/postgres_sink::PostgresSinkConfig::connection_string` | `Debug` redacts; `serialize_secret` EXPOSES | +| `secrecy::SecretString` + `iggy_common::serde_secret::serialize_secret` | `sinks/postgres_sink::PostgresSinkConfig::connection_string` | Auto-redact on Debug/Display + serialization | ## Drop accounting @@ -222,13 +210,21 @@ Each implemented in at least one in-tree plugin or runtime path. - `&mut self` on `Sink::consume` or `Source::poll` impls - won't compile, flag any creative workaround. - `std::sync::Mutex` held across `.await` - swap for `tokio::sync::Mutex`. - Missing `[lib] crate-type = ["cdylib", "lib"]` in plugin `Cargo.toml`. -- Source plugin without the four canonical state tests (see `connector-testing`). +- Source plugin without the four canonical state tests, or without ACK/NACK tests when `on_batch_result` is overridden (see `connector-testing`). +- Source `poll()` committing a cursor or destructive work directly instead of staging it for `on_batch_result` to commit on `Ack` / discard on `Nack`. - New silent message drop without a metric increment. - Wrapping `format!()` around args passed to `error!`/`warn!`/`info!`/`debug!` - eager `format!` allocates even when level filters the line out. Pass args directly: `error!("foo: {x}")` or `error!(error = %x, "foo")`. - Logging a connection string, API key, or token. - Plain `String` for a credential field - use `SecretString`. - `tokio::spawn` inside plugin code - runtime owns lifecycle. - `std::time::SystemTime::now()` in transforms - non-deterministic, breaks tests. +- Returning `Ok(())` from `consume` after a failed batch (offsets can still advance). +- Random UUIDs as message IDs / dedup keys. +- Classifying retryability via `err.to_string()` substring matches. +- README defaults that disagree with code consts. +- Invented config knob names (e.g. `request_timeout` instead of `timeout`) instead of the canon in `connector-pr-review`. + +For the full PR review checklist (blockers, delivery-semantics paragraph, pre-flight paste), load [connector-pr-review](../connector-pr-review/SKILL.md). ## File map diff --git a/.claude/skills/team-review-slim/SKILL.md b/.claude/skills/team-review-slim/SKILL.md new file mode 100644 index 0000000000..d2a6ce0bc0 --- /dev/null +++ b/.claude/skills/team-review-slim/SKILL.md @@ -0,0 +1,230 @@ +--- +name: team-review-slim +description: | + Team-review without the verification chain. Reviews a PR, the current branch against master, or of a ref range. +argument-hint: "[PR number | branch | ref range]" +disable-model-invocation: true +--- + +# Apache Iggy Team Review (slim version) + +`` = `$ARGUMENTS`: a PR number, a branch, or a ref range. Empty means `origin/master..HEAD`. Mission critical +code. + +You = **moderator**. You never open the diff or a source file: you route paths, merge claims, synthesize. Every token +you load rides along every later turn. Reviewers are one-shot agents that deliver by writing a file, they do not chat. + +## Charter (paste VERBATIM into every expert prompt) + +> You think deep. You write plain. Keep the two apart. +> +> **Thinking, unchanged.** Read the diff, then every changed file in full from the local checkout, then whatever call +> sites you need. Trace call chains. Verify invariants. Prove findings, don't guess. Cite exact `file:line`. Running +> tests or builds needs a stated justification: reading and tracing settles most claims, and parallel cargo runs block +> on one target-dir lock. +> +> **Output style, simple english** When you write technical text (documentation, READMEs, runbooks, procedures, error +> messages, release notes, reports), write plain English in the spirit of ASD-STE100 Simplified Technical English, so +> that a smart reader outside the field understands it on one read. Obey these rules: +> +> CLASSIFY FIRST. Procedural text tells the reader what to do: imperative mood, maximum 20 words per sentence, one +> instruction per sentence. Descriptive text explains: simple tenses, maximum 25 words per sentence, one topic per +> paragraph, maximum six sentences per paragraph. Never mix the two in one passage. +> +> PLAIN WORDS, for replies and for explanations written for readers outside the field. Use the common word when one +> exists ("use", not "utilize"). Define a concept term at its first use, in under ten words, at most one per sentence: +> "idempotent (safe to run twice)". Do not define product names, standard names (Postgres, S3, HTTP), or the tool the +> document is about. Address the reader as "you". Lead with the point. Procedures and reference documents follow the +> rules above alone. +> +> VERBS. Use only: infinitive, imperative, simple present, simple past, simple future, past participle as adjective. No +> present perfect ("has completed" -> "completed"). No "-ing" verb forms ("making it easy" -> new sentence). Active +> voice; passive only in descriptions when the agent is unknown. Approved modals: can, will, must. Banned: should, +> would, may, might, could. For "should": write "must" if required, delete if optional. +> +> SENTENCES. Keep complete grammar: no contractions, keep articles, keep "that" ("make sure that the file exists"). Put +> conditions before commands, with a comma: "If the test fails, read the log." No semicolons: write two sentences. No +> em-dashes: an em-dash hides the logic between two statements. Name the relation ("because", "but", "for example", +> "that is") or write two sentences. Use a vertical list for more than two items or steps. +> +> WORDS. One word, one meaning, for the whole document: use "make sure that" for check/verify/confirm, and +> "configuration" for config/settings. Noun chains of maximum three words. Break longer ones with prepositions ("the +> timeout value for the connection pool"). Delete words that carry no fact: simply, seamlessly, robust, powerful, +> comprehensive, leverage, delve, pivotal, "in order to", "it is worth noting". Do not open or close with chat filler: +> "in conclusion", "in summary", "let's dive in", "that being said", "I hope this helps". +> +> AVOID THE AI DRIFTS. Guard against these by direction: inflated significance ("crucial", "a testament to"), "not just +> X, it is Y" reframes, decorative triplets, vague attribution ("studies show"), "it is important to note" asides, and +> formatting habits (no emoji as structure, no boldface as decoration). State the fact. The fact carries itself. +> Replace: utilize -> use, prior to -> before, in the event that -> if, e.g. -> for example. American spelling. +> +> WARNINGS. Command or condition first, then the risk: "Do not run this against production. The command deletes rows." +> +> NEVER TOUCH. Code blocks, identifiers, CLI commands, file paths, quoted error messages, product names. Each counts as +> one word toward sentence limits. Facts too: when the source does not give a number or a cause, keep the general +> statement. Do not invent specifics. +> +> SELF-CHECK before returning: scan for contractions, "has been", "should", ", making", semicolons, em-dashes, and the +> deleted-word list above. Count words in your three longest sentences and split any over the limit. Collapse synonym +> rotation. +> +> REPLIES TO THE USER. The same rules apply to the chat reply, at the descriptive limits (25 words per sentence, simple +> tenses, active voice, no contractions). Start with the answer or the result. If a concept term is necessary, define it +> in a few words. Do not restate the request. Keep the whole reply to 5 sentences or fewer, code and lists excluded. Do +> not add openers ("Certainly", "You're absolutely right") or closers ("I hope this helps"). Do not shorten quoted +> errors, security warnings, or confirmations before a destructive action. +> +> Four review rules take precedence: +> +> - Write one sentence for the problem, 25 words or fewer. Write one sentence for the fix, imperative, 20 words or +> fewer. +> - Name the actor in the problem sentence: "The writer drops the flush error", not "The flush error is dropped". +> - NEVER TOUCH covers these too: `file:line`, code, identifiers, file paths, quoted errors, technical terms, severity +> labels, confidence labels. Keep them EXACT. +> - These writing rules govern your own output only. Never review the code, the comments, or the commit messages against +> them. +> +> **Finding format.** One entry per finding: +> +> ```text +> [sev] file:line - problem. Fix: action. (origin, conf:H|M|L) +> Evidence: the traced path or the line that proves the finding. +> ``` +> +> The `Evidence:` line is required for every `critical` only. Not for `warning`, `nit` and `simplify`. +> +> - `sev`: `critical` = correctness, safety, data loss, or security, and it blocks the merge. `warning` = a real defect, +> a performance hit, or an API problem. `nit` = style or naming. `simplify` = less complexity or dead code, in the +> format +> `[simplify] file:line - the current shape. Simpler: alternative. Saves: about N lines, or removes indirection. (origin, conf)`. +> - `origin`: `intro` (the change introduced it), `pre-surfaced` (it existed, and the change exposed it), +> `pre-untouched` (it existed, and the change did not touch it). +> - `conf`: `H` = a traced call path proves it. `M` = a strong reading, but one gap remains. `L` = a suspicion, and the +> reader must check it. +> - Never flag em dashes or other punctuation style in the reviewed code as a finding. +> - Simplification mandate: less code beats more code. For each changed file, ask whether a 30% smaller file keeps the +> same behavior. Look for dead fields, dead parameters, dead branches, dead imports, duplication of an existing helper +> (cite the helper), single-implementation traits, premature generics, and checks for impossible states. Do not +> propose a simplification that changes the semantics or that breaks the public API. If nothing qualifies, write +> `Simplifications: none`. +> +> **Self-verification, before you write the file.** Run this pass: +> +> 1. Re-open every cited `file:line`. Make sure that the anchor still names the code that you describe. +> 2. Trace one reachable call path for each `critical` and `warning`. Record that path for `critical` in the `Evidence:` +> line. +> 3. Delete every finding that you cannot prove from the code that you read. An unreachable concern is not a finding. +> 4. Set the confidence label from the evidence that you hold, not from how much the defect worries you. +> 5. Ask once whether the severity is calibrated. A cold-path clone is never `critical`. +> +> Deep analysis, plain words. Dig deep. Write short and clear. + +## Step 1: Identify the target (no reading) + +- Classify ``: matches `^#?(pr)?[0-9]+$` case-insensitively -> PR, the digits are ``. Anything else -> ref + range or bare branch. Empty -> review `origin/master..HEAD`. +- ``: `` lowercased, chars outside `[a-z0-9-]` replaced by `-`, repeats collapsed, trimmed, max 40 chars + (`PR3123` -> `pr3123`, `origin/master..HEAD` -> `origin-master-head`). Empty -> `date +%s`. +- `` = `/review-`. `mkdir -p` it. +- PR: `gh pr view --json title,body,headRefOid > /pr.json`, `gh pr diff > /diff.patch`, + `gh pr diff --name-only > /files.txt`. `` = first 8 of `headRefOid`. +- Ref range or bare branch: `git diff $(git merge-base origin/master HEAD)..HEAD > /diff.patch`, same with + `--name-only`, `` = `git rev-parse --short=8 HEAD`. No `pr.json` on this path. +- Guard: `git rev-parse HEAD` must equal the reviewed head. Experts read the local checkout; if it differs, stop and ask + the user to check out the reviewed head. +- ``: 1-3 word `snake_case` summary, `[a-z0-9_]`, \<= 24 chars. From the PR title; no PR -> from + `git log -1 --format=%s`. +- Report path: `/report.md`. + +Do not `cat` any of the files you just wrote. `wc -l /diff.patch` is the only look you take. + +## Step 2: Four one-shot experts (one message, parallel) + +Spawn 4 `Agent` calls in a single message: `subagent_type: general-purpose`, `name: -` (bare role names +collide with concurrent sessions: one shared agent namespace), no `model` (inherits). Prompt = role block + Charter + +this brief, with ``, ``, `` filled in: + +> Target: `` at ``. Diff: `/diff.patch`. Changed files: `/files.txt`. PR title and body: +> `/pr.json` (drop this sentence when there is no PR). Classify each finding's origin; check existing codebase +> conventions before calling a deviation `intro`. Deliverable = the file `/.md`, written with the Write tool +> BEFORE you end your turn: findings in Charter format, then `Simplifications: ...`, then +> `Verdict: APPROVE | REQUEST CHANGES - reason`. A previous worker finished reading and then idled without delivering; +> the Write call IS the delivery, your final message is just the path. Budget 3/4 reading, 1/4 writing; partial beats +> unshipped. Run the self-verification pass of the Charter before you write. No validator follows you, and an unproven +> `critical` finding costs the user. You work alone: no teammates, no SendMessage, no questions back. + +Role blocks: + +- **storage**: Senior storage/DB engineer, 15 years of WAL, B-trees, LSM, crash recovery, fsync semantics. Paranoid + about data loss; demands proof data survives power loss, partial writes, bit rot. Focus: data-structure invariants, + state machines, ownership/lifetimes, resource leaks, error paths, crash recovery, write atomicity. Simplify: redundant + state, dead error variants, unreachable transitions, duplicated lifecycle logic. +- **perf**: Performance engineer / kernel dev. Flamegraphs, cache lines, io_uring, allocators. Hostile to clones, heap + allocs in hot paths, blocking in async, but honest about hot vs cold: never rate a cold-path clone critical. Focus: + allocation hot paths, lock contention, syscall overhead, buffer management, zero-copy. Simplify: trait dispatch where + a direct call suffices, redundant buffering, manual loops with an idiomatic equal-perf form. +- **distsys**: Distributed-systems architect, formal methods. TLA+, linearizability, "message arrives twice / out of + order / never". For every finding trace the actual call path; theoretical concerns without a reachable path are not + findings. Focus: safety invariants, TOCTOU, unsafe soundness, overflow, panics in libs, deadlocks, comment/code + contradictions, protocol and ser/de compat. Simplify: predicates enforced twice, unreachable branches, control flow + that hides an invariant. +- **ecosystem**: SDK and API ecosystem lead across the client languages. Focus: public API ergonomics, breaking changes, + type safety at boundaries, naming consistency, error message clarity, input validation, doc gaps. Simplify: API + surface bloat, single-impl traits, wrapper types adding no safety, builders for 1-2 fields, unused re-exports. + +Collect: wait for the completion notifications, then `ls /*.md`. A role with no file gets one `SendMessage` nudge +to `-` ("Write `/.md` now, then stop."); still missing after that, respawn the role once with +the same prompt. Never open a subagent transcript via `TaskOutput` (it is the whole JSONL). + +## Step 3: Merge the role files (moderator, no agents) + +Read the 4 role files. Merge them straight into the report sections of Step 4, and write no intermediate file. + +- The same anchor and the same defect from several roles = one entry, at the highest severity of the group. Record the + roles that raised it, and keep the strongest `Evidence:` line of the group. +- When two roles disagree on the severity of one anchor, keep the higher severity and add + `(disputed: rates it )`. You do not adjudicate, and the user decides at the cited line. +- Keep the confidence label of every entry. It is the reader's map for the manual verification. +- Simplify items are entries too, and they go into their own section. +- Rewrite nothing. Keep the plain English of the experts, and fix a sentence only when it breaks a Charter rule. + +No findings at all: go to Step 4 with empty sections and `Verdict: APPROVE`. The report file still gets written. + +## Step 4: Write the report, then stop + +Output: + +```text +## Review: + +### Findings +- [sev] file:line - problem. Fix: action. (raised: role[, role]; conf:H|M|L) + Evidence: the traced path or the line that proves the finding. + +### Unconfirmed (conf:L, check these first) +- [sev] file:line - problem. Open question: what the reader must check. + +### Pre-existing (origin pre-*, does not block the merge) +- file:line - problem. The code follows the pattern in . + +### Simplification opportunities (does not block the merge) +- file:line - the current shape. Simpler: alternative. Saves: about N lines, or removes indirection. + +### Verdict: APPROVE | REQUEST CHANGES +Reason: one sentence. Only `critical` and `warning` entries with conf:H or conf:M decide the verdict. +Simplifications are informational. + +Counts: critical N, warning N, nit N, simplify N +Verification: none ran. Open each cited line and confirm the finding before you change the code. +``` + +Then write `/report.md` with: + +1. H1 `# Iggy Team Review (small) - ()`. +2. Metadata, one line each: target ``, reviewed commit, ISO timestamp, roles, expert count, + `validation: none (manual)`. +3. The report above, verbatim. +4. Appendix `## Raw findings per expert`: each role file verbatim, in a fenced block. + +Last user-facing line: `Findings written: /report.md`. Then name the highest-severity entry in one sentence, and +stop. There is no cleanup: the one-shot agents end themselves, and `` stays in the scratchpad. diff --git a/.claude/skills/team-review/SKILL.md b/.claude/skills/team-review/SKILL.md new file mode 100644 index 0000000000..4d4605ace7 --- /dev/null +++ b/.claude/skills/team-review/SKILL.md @@ -0,0 +1,233 @@ +--- +name: team-review +description: | + Adversarial 4-expert review (storage, perf, distsys, ecosystem) of a PR, branch, or ref range, with clean-room + validation of every finding. Experts work alone, no peer debate. Expensive, one run spawns ~10 subagents. +argument-hint: "[PR number | branch | ref range]" +disable-model-invocation: true +--- + +# Apache Iggy Team Review + +`` = `$ARGUMENTS`: a PR number, a branch, or a ref range. Empty means `origin/master..HEAD`. Mission critical +code. + +You = **moderator**. You never open the diff or a source file: you route paths, merge claims, synthesize. Every token +you load rides along every later turn. Reviewers and validators are one-shot agents that deliver by writing a file; +nobody chats. + +## Charter (paste VERBATIM into every expert, validator, and tiebreak prompt) + +> You think deep. You write plain. Keep the two apart. +> +> **Thinking, unchanged.** Read the diff, then every changed file in full from the local checkout, then whatever call +> sites you need. Trace call chains. Verify invariants. Prove findings, do not guess. Cite exact `file:line`. Running +> tests or builds needs a stated justification: reading and tracing settles most claims, and parallel cargo runs block +> on one target-dir lock. +> +> **Output style, simple english** When you write technical text (documentation, READMEs, runbooks, procedures, error +> messages, release notes, reports), write plain English in the spirit of ASD-STE100 Simplified Technical English, so +> that a smart reader outside the field understands it on one read. Obey these rules: +> +> CLASSIFY FIRST. Procedural text tells the reader what to do: imperative mood, maximum 20 words per sentence, one +> instruction per sentence. Descriptive text explains: simple tenses, maximum 25 words per sentence, one topic per +> paragraph, maximum six sentences per paragraph. Never mix the two in one passage. +> +> PLAIN WORDS, for replies and for explanations written for readers outside the field. Use the common word when one +> exists ("use", not "utilize"). Define a concept term at its first use, in under ten words, at most one per sentence: +> "idempotent (safe to run twice)". Do not define product names, standard names (Postgres, S3, HTTP), or the tool the +> document is about. Address the reader as "you". Lead with the point. Procedures and reference documents follow the +> rules above alone. +> +> VERBS. Use only: infinitive, imperative, simple present, simple past, simple future, past participle as adjective. No +> present perfect ("has completed" -> "completed"). No "-ing" verb forms ("making it easy" -> new sentence). Active +> voice; passive only in descriptions when the agent is unknown. Approved modals: can, will, must. Banned: should, +> would, may, might, could. For "should": write "must" if required, delete if optional. +> +> SENTENCES. Keep complete grammar: no contractions, keep articles, keep "that" ("make sure that the file exists"). Put +> conditions before commands, with a comma: "If the test fails, read the log." No semicolons: write two sentences. No +> em-dashes: an em-dash hides the logic between two statements. Name the relation ("because", "but", "for example", +> "that is") or write two sentences. Use a vertical list for more than two items or steps. +> +> WORDS. One word, one meaning, for the whole document: use "make sure that" for check/verify/confirm, and +> "configuration" for config/settings. Noun chains of maximum three words. Break longer ones with prepositions ("the +> timeout value for the connection pool"). Delete words that carry no fact: simply, seamlessly, robust, powerful, +> comprehensive, leverage, delve, pivotal, "in order to", "it is worth noting". Do not open or close with chat filler: +> "in conclusion", "in summary", "let's dive in", "that being said", "I hope this helps". +> +> AVOID THE AI DRIFTS. Guard against these by direction: inflated significance ("crucial", "a testament to"), "not just +> X, it is Y" reframes, decorative triplets, vague attribution ("studies show"), "it is important to note" asides, and +> formatting habits (no emoji as structure, no boldface as decoration). State the fact. The fact carries itself. +> Replace: utilize -> use, prior to -> before, in the event that -> if, e.g. -> for example. American spelling. +> +> WARNINGS. Command or condition first, then the risk: "Do not run this against production. The command deletes rows." +> +> NEVER TOUCH. Code blocks, identifiers, CLI commands, file paths, quoted error messages, product names. Each counts as +> one word toward sentence limits. Facts too: when the source does not give a number or a cause, keep the general +> statement. Do not invent specifics. +> +> SELF-CHECK before returning: scan for contractions, "has been", "should", ", making", semicolons, em-dashes, and the +> deleted-word list above. Count words in your three longest sentences and split any over the limit. Collapse synonym +> rotation. +> +> REPLIES TO THE USER. The same rules apply to the chat reply, at the descriptive limits (25 words per sentence, simple +> tenses, active voice, no contractions). Start with the answer or the result. If a concept term is necessary, define it +> in a few words. Do not restate the request. Keep the whole reply to 5 sentences or fewer, code and lists excluded. Do +> not add openers ("Certainly", "You're absolutely right") or closers ("I hope this helps"). Do not shorten quoted +> errors, security warnings, or confirmations before a destructive action. +> +> Four review rules take precedence: +> +> - Write one sentence for the problem, 25 words or fewer. Write one sentence for the fix, imperative, 20 words or +> fewer. +> - Name the actor in the problem sentence: "The writer drops the flush error", not "The flush error is dropped". +> - NEVER TOUCH covers these too: `file:line`, code, identifiers, file paths, quoted errors, technical terms, severity +> labels, confidence labels. Keep them EXACT. +> - These writing rules govern your own output only. Never review the code, the comments, or the commit messages against +> them. +> +> **Finding format.** +> +> - Finding, one line each: `[sev] file:line - problem. Fix: action. (origin, conf:H|M|L)` +> - `sev`: `critical` = correctness/safety/data-loss/security, blocks merge; `warning` = real defect, perf hit, API +> issue; `nit` = style/naming; `simplify` = complexity/dead-code reduction, format +> `[simplify] file:line - what's complex. Simpler: alternative. Saves: ~N lines / removes indirection. (origin, conf)`. +> - `origin`: `intro` (PR introduced), `pre-surfaced` (existed, exposed by PR), `pre-untouched` (existed, not touched). +> - Never flag em dashes or other punctuation style as a finding. +> - Simplification mandate: less code > more code. Per changed file ask whether ~30% smaller keeps correctness: dead +> fields/params/branches/imports, duplication of an existing helper (cite it), single-impl traits, premature generics, +> checks for impossible states. Do not propose simplifications that change semantics or break public API. If nothing +> qualifies, write `Simplifications: none`. +> +> Deep analysis, plain words. Dig deep. Write short and clear. + +## Step 1: Identify the target (no reading) + +- Classify ``: matches `^#?(pr)?[0-9]+$` case-insensitively -> PR, the digits are ``. Anything else -> ref + range or bare branch. Empty -> review `origin/master..HEAD`. +- ``: `` lowercased, chars outside `[a-z0-9-]` replaced by `-`, repeats collapsed, trimmed, max 40 chars + (`PR3123` -> `pr3123`, `origin/master..HEAD` -> `origin-master-head`). Empty -> `date +%s`. +- `` = `/review-`. `mkdir -p` it. +- PR: `gh pr view --json title,body,headRefOid > /pr.json`, `gh pr diff > /diff.patch`, + `gh pr diff --name-only > /files.txt`. `` = first 8 of `headRefOid`. +- Ref range or bare branch: `git diff $(git merge-base origin/master HEAD)..HEAD > /diff.patch`, same with + `--name-only`, `` = `git rev-parse --short=8 HEAD`. No `pr.json` on this path. +- Guard: `git rev-parse HEAD` must equal the reviewed head. Experts read the local checkout; if it differs, stop and ask + the user to check out the reviewed head. +- ``: 1-3 word `snake_case` summary, `[a-z0-9_]`, \<= 24 chars. From the PR title; no PR -> from + `git log -1 --format=%s`. +- Report path: `/report.md`. + +Do not `cat` any of the files you just wrote. `wc -l /diff.patch` is the only look you take. + +## Step 2: Round 1, four one-shot experts (one message, parallel) + +Spawn 4 `Agent` calls in a single message: `subagent_type: general-purpose`, `name: -` (bare role names +collide with concurrent sessions: one shared agent namespace), no `model` (inherits). Prompt = role block + Charter + +this brief, with ``, ``, `` filled in: + +> Target: `` at ``. Diff: `/diff.patch`. Changed files: `/files.txt`. PR title and body: +> `/pr.json` (drop this sentence when there is no PR). Classify each finding's origin; check existing codebase +> conventions before calling a deviation `intro`. Deliverable = the file `/.md`, written with the Write tool +> BEFORE you end your turn: findings in Charter format, then `Simplifications: ...`, then +> `Verdict: APPROVE | REQUEST CHANGES - reason`. A previous worker finished reading and then idled without delivering; +> the Write call IS the delivery, your final message is just the path. Budget 3/4 reading, 1/4 writing; partial beats +> unshipped. You work alone: no teammates, no SendMessage, no questions back. + +Role blocks: + +- **storage**: Senior storage/DB engineer, 15 years of WAL, B-trees, LSM, crash recovery, fsync semantics. Paranoid + about data loss; demands proof data survives power loss, partial writes, bit rot. Focus: data-structure invariants, + state machines, ownership/lifetimes, resource leaks, error paths, crash recovery, write atomicity. Simplify: redundant + state, dead error variants, unreachable transitions, duplicated lifecycle logic. +- **perf**: Performance engineer / kernel dev. Flamegraphs, cache lines, io_uring, allocators. Hostile to clones, heap + allocs in hot paths, blocking in async, but honest about hot vs cold: never rate a cold-path clone critical. Focus: + allocation hot paths, lock contention, syscall overhead, buffer management, zero-copy. Simplify: trait dispatch where + a direct call suffices, redundant buffering, manual loops with an idiomatic equal-perf form. +- **distsys**: Distributed-systems architect, formal methods. TLA+, linearizability, "message arrives twice / out of + order / never". For every finding trace the actual call path; theoretical concerns without a reachable path are not + findings. Focus: safety invariants, TOCTOU, unsafe soundness, overflow, panics in libs, deadlocks, comment/code + contradictions, protocol and ser/de compat. Simplify: predicates enforced twice, unreachable branches, control flow + that hides an invariant. +- **ecosystem**: SDK and API ecosystem lead across the client languages. Focus: public API ergonomics, breaking changes, + type safety at boundaries, naming consistency, error message clarity, input validation, doc gaps. Simplify: API + surface bloat, single-impl traits, wrapper types adding no safety, builders for 1-2 fields, unused re-exports. + +Collect: wait for the completion notifications, then `ls /*.md`. A role with no file gets one `SendMessage` nudge +to `-` ("Write `/.md` now, then stop."); still missing after that, respawn the role once with +the same prompt. Never open a subagent transcript via `TaskOutput` (it is the whole JSONL). + +## Step 3: Merge into neutral claims (moderator) + +Read the 4 role files. Write `/claims.md`, one line per claim: +`C [sev] file:line - claim. Fix: action. (origin)`. Strip role names, confidence, and argument. Same anchor + same +defect from several roles = one claim at the highest severity; keep a private raised-by map for the report. Simplify +items are claims too. + +No claims at all: skip Steps 4 and 5, go to Step 6 with empty sections and `Verdict: APPROVE`. The report file still +gets written. + +## Step 4: Clean-room validation (one message, parallel) + +Shard claims ~5 per validator. Spawn one `Agent` per shard plus one sweep validator, all in one message: +`subagent_type: general-purpose`, `model: opus`, `name: validator--` / `sweep-`. Each gets ONLY: its +claims verbatim, `/files.txt`, `/diff.patch`, the target identity, the Charter. Not the role files, not +raised-by, not your reasoning; the missing context is what removes the anchoring bias. + +Validator mandate (adversarial): for each claim open the cited `file:line`, trace call sites, then rate +`C: PASS | FIX: | REMOVE: `; judge whether +the severity is calibrated; re-check the anchor. Deliverable `/validate-.md` via Write, same idle rule as Step +2\. + +Sweep mandate: all claims + the diff. Two questions only: which real defects in the diff are missing from the list, and +which listed items wrongly clear a bug. Deliverable `/sweep.md`, additions in Charter format tagged `(sweep)`. + +Apply: drop REMOVE, apply FIX (wording, line, severity), fold sweep additions in as `(sweep, unvalidated)`. A `critical` +sweep addition gets one extra validator before it may block the verdict. + +## Step 5: Contested items (only when triggered) + +Contested = a validator REMOVEs or downgrades a `critical` or `warning`, or a sweep addition contradicts a PASS. Per +item spawn one `Agent` (`model: opus`) with the claim, the validator's verdict text, the expert's original line, and the +paths; it writes `UPHELD | OVERTURNED - reason (cite path)` to `/contested-.md`. Cap 5 per run; past the cap you +adjudicate and mark `(moderator call)`. + +## Step 6: Synthesize, write, done + +Output in the simple English of the Charter. The Charter binds you too: + +```text +## Review: [change desc] + +### Confirmed (expert + clean-room validator) +- [sev] file:line - problem. Fix: action. (raised: role[, role]; validated: PASS|FIX) + +### Contested +- file:line - problem. + Expert: position. Validator: counter. **Tiebreak**: UPHELD|OVERTURNED - why. + +### Retracted (validator REMOVE) +- finding - why. + +### Pre-existing (origin pre-*, not blocking) +- file:line - follows pattern in [ref]. + +### Simplification opportunities (non-blocking) +- file:line - current shape. Simpler: alternative. Saves: ~N lines / removes indirection. + +### Verdict: APPROVE | REQUEST CHANGES +Confirmed critical + warning only. Simplifications informational. Reason: one line. + +Counts: critical N, warning N, nit N, simplify N (Confirmed + Simplification sections) +``` + +Then write `/report.md` with: + +1. H1 `# Iggy Team Review - ()`. +2. Metadata, one line each: target ``, reviewed commit, ISO timestamp, roles, validator count, contested count. +3. The report above, verbatim. +4. Appendix `## Raw findings per expert`: each role file verbatim in a fenced block. +5. `## Validation record`: counts of PASS / FIX / REMOVE, sweep additions, contested outcomes. + +Last user-facing line: `Findings written: /report.md`. No cleanup: one-shot agents end themselves, `` stays in +the scratchpad. diff --git a/.config/nextest.toml b/.config/nextest.toml index 3070df8bdc..271619d91a 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -15,27 +15,18 @@ # specific language governing permissions and limitations # under the License. +# Floor for the flags CI and scripts/ci/storage-compat.sh pass: --no-tests +# (0.9.75), --run-ignored only (0.9.76) and --ignore-default-filter (0.9.77). +# nextest refuses to run below it, and setup-rust-with-cache replaces a +# cache-restored binary that fails `cargo nextest show-config version`. +nextest-version = { required = "0.9.77" } + [[profile.default.overrides]] # This is a solution (or actually a workaround) for the problem that nextest does not support # #[serial] macro which shall enforce sequential execution of the test case. filter = 'package(integration) and test(cli::system::test_cli_session_scenario::should_be_successful)' threads-required = "num-cpus" -# Doris tests are serialized among themselves, but no longer monopolize the -# whole runner. The all-in-one image's BE advertises 127.0.0.1:8040 for the -# FE→BE 307 redirect, so only one Doris container per process can bind -# host:8040. Within a nextest binary the fixture caches one shared container in -# `SHARED_DORIS` so the first doris test pays the ~40s boot and the rest reuse -# it; `max-threads = 1` keeps a second nextest binary (or a re-run) from -# racing the same host port, while still letting unrelated light tests fill -# the rest of the runner. -[test-groups.doris] -max-threads = 1 - -[[profile.default.overrides]] -filter = 'package(integration) and test(/connectors::doris::/)' -test-group = "doris" - # Elasticsearch tests share one reusable container (fixed name # `iggy-test-elasticsearch`, ReuseDirective::Always). Serializing the group # lets the first test create it and the rest attach by name, instead of racing @@ -48,6 +39,26 @@ max-threads = 1 filter = 'package(integration) and test(/connectors::elasticsearch::/)' test-group = "elasticsearch" +# Each test in this binary spawns its own real iggy-server subprocess (isolated data dir, port +# from PortGuard's own flock, so no two ever share a port). #[serial] on them is a dead letter +# under nextest - nextest runs every test as its own process, and #[serial]'s mutex only excludes +# within one process (nextest does not support #[serial] macro, same gap the workaround above +# documents). Without this override, nextest's default scheduler runs as many of these processes +# at once as it has threads. +# +# The harness (tests/common/iggy_server.rs) sets IGGY_SHARDING_PIN_CORES=false and +# IGGY_SHARDING_CPU_ALLOCATION=0..4 on every spawned server, the same pairing core/integration's +# harness (harness/handle/server.rs) uses, so a server no longer sizes its shard pool to the whole +# box. core/integration runs a server per test under no cap on that strength. This cap is kept at +# the width of one server's core allocation, so these tests bound their own shard-thread +# footprint. +[test-groups.kafka_bridge] +max-threads = 4 + +[[profile.default.overrides]] +filter = 'binary_id(iggy-gateway-kafka::bridge_iggy_integration_tests)' +test-group = "kafka_bridge" + [profile.default] slow-timeout = { period = "60s", terminate-after = 5 } diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 450aad7972..3110ad371b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -33,7 +33,8 @@ "IGGY_HTTP_ADDRESS": "0.0.0.0:3000", "IGGY_QUIC_ADDRESS": "0.0.0.0:8080", "IGGY_TCP_ADDRESS": "0.0.0.0:8090", - "IGGY_WEBSOCKET_ADDRESS": "0.0.0.0:8092" + "IGGY_WEBSOCKET_ADDRESS": "0.0.0.0:8092", + "IGGY_NODE_ADVERTISED_ADDRESS": "localhost" }, "forwardPorts": [3000, 3050, 8080, 8090, 8092], "portsAttributes": { diff --git a/.github/actions/csharp-dotnet/post-merge/action.yml b/.github/actions/csharp-dotnet/post-merge/action.yml index 1718799844..99f6c7c2bd 100644 --- a/.github/actions/csharp-dotnet/post-merge/action.yml +++ b/.github/actions/csharp-dotnet/post-merge/action.yml @@ -122,7 +122,7 @@ runs: echo "📦 Publishing packages to NuGet..." # Push to NuGet for package in ./nupkgs/*.nupkg; do - echo "Publishing: $(basename $package)" + echo "Publishing: $(basename "$package")" dotnet nuget push "$package" \ --api-key "$NUGET_API_KEY" \ --source https://api.nuget.org/v3/index.json \ diff --git a/.github/actions/go/pre-merge/action.yml b/.github/actions/go/pre-merge/action.yml index 56e0162b70..1d06e1da92 100644 --- a/.github/actions/go/pre-merge/action.yml +++ b/.github/actions/go/pre-merge/action.yml @@ -134,13 +134,15 @@ runs: log-file: ${{ runner.temp }}/iggy-go-e2e.log wait-timeout-seconds: "90" env: - IGGY_SYSTEM_PATH: ${{ runner.temp }}/iggy-go-e2e-data + IGGY_PATH: ${{ runner.temp }}/iggy-go-e2e-data - name: Run e2e tests shell: bash if: inputs.task == 'e2e' env: IGGY_TCP_ADDRESS: 127.0.0.1:8090 + IGGY_ROOT_USERNAME: iggy + IGGY_ROOT_PASSWORD: iggy run: | echo "🧪 Running Go e2e tests..." @@ -188,7 +190,7 @@ runs: log-file: ${{ runner.temp }}/iggy-go-e2e-tls.log wait-timeout-seconds: "90" env: - IGGY_SYSTEM_PATH: ${{ runner.temp }}/iggy-go-e2e-tls-data + IGGY_PATH: ${{ runner.temp }}/iggy-go-e2e-tls-data IGGY_TCP_TLS_ENABLED: "true" IGGY_TCP_TLS_CERT_FILE: core/certs/iggy_cert.pem IGGY_TCP_TLS_KEY_FILE: core/certs/iggy_key.pem @@ -227,7 +229,7 @@ runs: wait-timeout-seconds: "90" env: IGGY_CLUSTER_ENABLED: "true" - IGGY_SYSTEM_PATH: ${{ runner.temp }}/iggy-go-cluster-0-data + IGGY_PATH: ${{ runner.temp }}/iggy-go-cluster-0-data - name: Start Iggy VSR cluster node 1 id: iggy-cluster-1 @@ -243,7 +245,7 @@ runs: wait-timeout-seconds: "90" env: IGGY_CLUSTER_ENABLED: "true" - IGGY_SYSTEM_PATH: ${{ runner.temp }}/iggy-go-cluster-1-data + IGGY_PATH: ${{ runner.temp }}/iggy-go-cluster-1-data - name: Run cluster e2e tests shell: bash diff --git a/.github/actions/java-gradle/post-merge/action.yml b/.github/actions/java-gradle/post-merge/action.yml index b09dbba368..e4e1927ea8 100644 --- a/.github/actions/java-gradle/post-merge/action.yml +++ b/.github/actions/java-gradle/post-merge/action.yml @@ -86,7 +86,7 @@ runs: echo "" echo "Artifacts that would be published:" find . -path "*/build/libs/*.jar" -type f 2>/dev/null | while read jar; do - echo " - $(basename $jar)" + echo " - $(basename "$jar")" done else if [ -z "$NEXUS_USER" ] || [ -z "$NEXUS_PW" ]; then diff --git a/.github/actions/java-gradle/pre-merge/action.yml b/.github/actions/java-gradle/pre-merge/action.yml index 565e33c30f..db377bd43d 100644 --- a/.github/actions/java-gradle/pre-merge/action.yml +++ b/.github/actions/java-gradle/pre-merge/action.yml @@ -88,7 +88,7 @@ runs: cargo-bin: iggy-server wait-timeout-seconds: "90" env: - IGGY_SYSTEM_PATH: ${{ runner.temp }}/iggy-java-data + IGGY_PATH: ${{ runner.temp }}/iggy-java-data - name: Test if: inputs.task == 'test' @@ -143,6 +143,11 @@ runs: mkdir -p reports/java-tests cp -r foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/build/test-results reports/java-tests/flink fi + if [ -d "foreign/java/external-processors/iggy-connector-pinot/build/test-results" ]; then + echo "Found test reports in pinot" + mkdir -p reports/java-tests + cp -r foreign/java/external-processors/iggy-connector-pinot/build/test-results reports/java-tests/pinot + fi - name: Stop Iggy server if: always() && inputs.task == 'test' @@ -161,7 +166,7 @@ runs: pid-file: ${{ runner.temp }}/iggy-server-tls.pid log-file: ${{ runner.temp }}/iggy-server-tls.log env: - IGGY_SYSTEM_PATH: ${{ runner.temp }}/iggy-java-tls-data + IGGY_PATH: ${{ runner.temp }}/iggy-java-tls-data IGGY_TCP_TLS_ENABLED: "true" IGGY_TCP_TLS_CERT_FILE: core/certs/iggy_cert.pem IGGY_TCP_TLS_KEY_FILE: core/certs/iggy_key.pem @@ -188,4 +193,5 @@ runs: paths: | foreign/java/java-sdk/build/test-results/**/TEST-*.xml foreign/java/external-processors/iggy-connector-flink/iggy-connector-library/build/test-results/**/TEST-*.xml + foreign/java/external-processors/iggy-connector-pinot/build/test-results/**/TEST-*.xml if: ${{ !cancelled() && inputs.task == 'test' }} diff --git a/.github/actions/php/pre-merge/action.yml b/.github/actions/php/pre-merge/action.yml index 789e9db400..4205d465cf 100644 --- a/.github/actions/php/pre-merge/action.yml +++ b/.github/actions/php/pre-merge/action.yml @@ -161,10 +161,9 @@ runs: shell: bash working-directory: foreign/php env: - IGGY_HOST: 127.0.0.1 - IGGY_PORT: 8090 - IGGY_USERNAME: iggy - IGGY_PASSWORD: iggy + IGGY_TCP_ADDRESS: 127.0.0.1:8090 + IGGY_ROOT_USERNAME: iggy + IGGY_ROOT_PASSWORD: iggy run: | mkdir -p ../../reports ./scripts/test.sh --log-junit ../../reports/php-junit.xml @@ -194,8 +193,7 @@ runs: shell: bash working-directory: foreign/php env: - IGGY_HOST: 127.0.0.1 - IGGY_PORT: 8090 + IGGY_TCP_ADDRESS: 127.0.0.1:8090 IGGY_TLS_CONNECTION_STRING: iggy+tcp://iggy:iggy@127.0.0.1:8090?tls=true&tls_domain=localhost&tls_ca_file=${{ github.workspace }}/core/certs/iggy_ca_cert.pem IGGY_TLS_PLAINTEXT_ADDRESS: 127.0.0.1:8090 run: ./scripts/test.sh --log-junit ../../reports/php-tls-junit.xml tests/TlsTest.php diff --git a/.github/actions/python-maturin/pre-merge/action.yml b/.github/actions/python-maturin/pre-merge/action.yml index f97462f34d..f765516348 100644 --- a/.github/actions/python-maturin/pre-merge/action.yml +++ b/.github/actions/python-maturin/pre-merge/action.yml @@ -20,7 +20,7 @@ description: Python pre-merge testing with maturin github iggy actions inputs: task: - description: "Task to run (lint, test, build)" + description: "Task to run (lint, test)" required: true runs: @@ -90,23 +90,17 @@ runs: echo "pyrefly version: $(uv run pyrefly --version)" shell: bash - - name: Build Python wheel with coverage instrumentation - if: inputs.task == 'test' + # The crate is outside the root workspace, so the Rust test jobs never + # reach it; without this its unit tests would only ever be compiled. + - name: Rust unit tests for Python extension + if: inputs.task == 'lint' run: | cd foreign/python - source <(cargo llvm-cov show-env --sh) - export CARGO_TARGET_DIR=$CARGO_LLVM_COV_TARGET_DIR - - # Propagate LLVM_PROFILE_FILE so the test step can write profraw data - echo "LLVM_PROFILE_FILE=${LLVM_PROFILE_FILE}" >> $GITHUB_ENV - - cargo llvm-cov clean --workspace - echo "Building Python wheel with coverage instrumentation..." - uv run --no-sync maturin develop + cargo test --manifest-path Cargo.toml shell: bash - name: Build Python wheel - if: inputs.task == 'build' + if: inputs.task == 'test' run: | cd foreign/python @@ -120,6 +114,39 @@ runs: ls -la dist/ shell: bash + - name: Check apache_iggy.pyi is up to date + if: inputs.task == 'test' + run: | + # stub_gen must run from foreign/python; a subdirectory corrupts the stub. + cd foreign/python + cargo run --bin stub_gen + + # The tracked stub is post-ruff. Format first: the raw output leaves + # whitespace on blank lines, which `check` reports but only `format` fixes. + uv run --no-sync ruff format apache_iggy.pyi + uv run --no-sync ruff check --fix apache_iggy.pyi + + if ! git diff --exit-code -- apache_iggy.pyi; then + echo "::error::apache_iggy.pyi is out of date. Run 'cargo run --bin stub_gen' from foreign/python, let the ruff hooks format it, and commit the result." + exit 1 + fi + shell: bash + + - name: Build Python wheel with coverage instrumentation + if: inputs.task == 'test' + run: | + cd foreign/python + source <(cargo llvm-cov show-env --sh) + export CARGO_TARGET_DIR=$CARGO_LLVM_COV_TARGET_DIR + + # Propagate LLVM_PROFILE_FILE so the test step can write profraw data + echo "LLVM_PROFILE_FILE=${LLVM_PROFILE_FILE}" >> "$GITHUB_ENV" + + cargo llvm-cov clean --workspace + echo "Building Python wheel with coverage instrumentation..." + uv run --no-sync maturin develop + shell: bash + - name: Build server Docker image for TLS tests if: inputs.task == 'test' run: | @@ -146,13 +173,25 @@ runs: run: | cd foreign/python - echo "Running integration tests with Iggy server at ${{ steps.iggy.outputs.address }}..." + # TCP and HTTP ports come from server-start's own outputs rather than + # duplicated literals, so a future default change here can't silently + # desync from what the server actually started with. QUIC and WebSocket + # stay literals below because server-start exposes no output for them. + tcp_port="${{ steps.iggy.outputs.tcp_address }}" + http_port="${{ steps.iggy.outputs.http_address }}" + tcp_port="${tcp_port##*:}" + http_port="${http_port##*:}" + + echo "Running integration tests with Iggy server at ${{ steps.iggy.outputs.tcp_address }}..." # Run all tests with server connection # --no-sync prevents uv from re-syncing the venv which would # overwrite the coverage-instrumented .so with a non-instrumented one IGGY_SERVER_HOST=127.0.0.1 \ - IGGY_SERVER_TCP_PORT=8090 \ + IGGY_SERVER_TCP_PORT="$tcp_port" \ + IGGY_SERVER_QUIC_PORT=8080 \ + IGGY_SERVER_HTTP_PORT="$http_port" \ + IGGY_SERVER_WS_PORT=8092 \ IGGY_SERVER_DOCKER_IMAGE=iggy-server:local \ uv run --no-sync pytest tests/ -v \ --junitxml=../../reports/python-junit.xml \ @@ -160,7 +199,7 @@ runs: --capture=no || TEST_EXIT_CODE=$? # Exit with test result - exit ${TEST_EXIT_CODE:-0} + exit "${TEST_EXIT_CODE:-0}" shell: bash - name: Stop Iggy server diff --git a/.github/actions/rust/pre-merge/action.yml b/.github/actions/rust/pre-merge/action.yml index 97efa4d3af..b9dee0eedc 100644 --- a/.github/actions/rust/pre-merge/action.yml +++ b/.github/actions/rust/pre-merge/action.yml @@ -20,7 +20,7 @@ description: Rust pre-merge testing and linting github iggy actions inputs: task: - description: "Task to run (check, check-msrv, fmt, clippy, sort, machete, doctest, verify-publish, test-1, test-2, test-3, miri)" + description: "Task to run (check, check-msrv, fmt, clippy, clippy-macos-aarch64, sort, machete, doctest, verify-publish, test-1, test-2, test-3, test-storage-compat, miri)" required: true component: description: "Component name (for context)" @@ -84,19 +84,25 @@ runs: # a subset of crates changed. # Safety: cargo check/clippy run on the full workspace separately, catching all # compilation errors. This only scopes test BUILD and EXECUTION. + # test-storage-compat is excluded from the DAG and coverage setup below: + # storage-compat.sh builds two explicit binaries and drives its own nextest + # run, and reads none of the /tmp plan files or the llvm-cov environment. - name: Fetch base branch for DAG analysis - if: startsWith(inputs.task, 'test-') + if: startsWith(inputs.task, 'test-') && inputs.task != 'test-storage-compat' run: git fetch origin master --depth=1 2>/dev/null || true shell: bash + # Pinned: 0.24 dropped `-f json` and 0.25 retired `[change-detection]` in + # rail.toml. Either failure falls back to the full test suite on every PR, + # so bump on purpose together with `cargo rail config migrate`. - name: Install cargo-rail - if: startsWith(inputs.task, 'test-') + if: startsWith(inputs.task, 'test-') && inputs.task != 'test-storage-compat' uses: taiki-e/install-action@v2 with: - tool: cargo-rail + tool: cargo-rail@0.23.0 - name: Compute affected crates (cargo-rail) - if: startsWith(inputs.task, 'test-') + if: startsWith(inputs.task, 'test-') && inputs.task != 'test-storage-compat' run: | METADATA_JSON=$(cargo metadata --format-version 1 --no-deps 2>/dev/null || echo "{}") TOTAL_CRATES=$(echo "$METADATA_JSON" | jq '.workspace_members | length' 2>/dev/null || echo "?") @@ -106,6 +112,10 @@ runs: # plugins (cdylib) are loaded at runtime via dlopen by iggy-connectors. # Both must always be compiled alongside affected crates. echo "$METADATA_JSON" | jq -r '.packages[] | select(.targets[] | .kind[] | (. == "bin" or . == "cdylib")) | .name' 2>/dev/null > /tmp/bin-packages.txt || true + # Connector plugins on their own. The same dlopen edge that keeps them + # out of the DAG also hides the integration suites that exercise them, + # which the filter below puts back. + echo "$METADATA_JSON" | jq -r '.packages[] | select(.targets[] | .kind[] | . == "cdylib") | .name' 2>/dev/null > /tmp/plugin-packages.txt || true PLAN_JSON=$(cargo rail plan --since origin/master -f json 2>/tmp/affected-stderr.txt || echo "") @@ -113,9 +123,49 @@ runs: MODE=$(echo "$PLAN_JSON" | jq -r '.scope.mode') if [[ "$MODE" == "crates" ]]; then CRATES=$(echo "$PLAN_JSON" | jq -r '.scope.crates[]') + # `integration` launches the binaries of these packages through + # `Command::cargo_bin`, an edge cargo cannot record. The DAG selects + # them when their code or a crate they link (e.g. `partitions`) + # changes, but it does not select `integration`, so add it here. + # Keep this list in sync with the `cargo_bin` calls in core/integration. + SPAWNED_BINARY_PACKAGES=(server iggy-connectors iggy-mcp iggy-bench iggy-cli) + if ! grep -qx integration <<< "$CRATES"; then + for package in "${SPAWNED_BINARY_PACKAGES[@]}"; do + if grep -qx "$package" <<< "$CRATES"; then + echo "::notice::Testing integration because it launches the binary of affected package ${package}" + CRATES=$(printf '%s\nintegration\n' "$CRATES") + break + fi + done + fi + # `iggy_connector__{sink,source}` is exercised by + # `integration::connectors::`, but the plugin reaches the + # runtime through dlopen, so cargo records no edge and the DAG drops + # those tests. Name the suite of every affected plugin that has one. + PLUGIN_TESTS="" + PLUGIN_SUITES=0 + while read -r plugin; do + if [[ ! -d "core/integration/tests/connectors/$plugin" ]]; then + echo "::warning::No integration suite at core/integration/tests/connectors/${plugin}, leaving it out of the test scope" + continue + fi + PLUGIN_TESTS+=" | (package(integration) & test(/^connectors::${plugin}::/))" + PLUGIN_SUITES=$(( PLUGIN_SUITES + 1 )) + done < <(comm -12 <(sort -u /tmp/plugin-packages.txt) <(echo "$CRATES" | sort -u) \ + | sed -nE 's/^iggy_connector_(.*)_(sink|source)$/\1/p' | sort -u) + echo "$PLUGIN_SUITES" > /tmp/plugin-suites.txt + # Those suites live in the integration test binary, so it has to be + # built even when the DAG left it out of scope. Only the build set + # grows: the `cargo test` fallback below has no filter, and adding + # the package there would run the whole integration suite. + BUILD_CRATES="$CRATES" + if (( PLUGIN_SUITES > 0 )); then + BUILD_CRATES=$(printf '%s\nintegration\n' "$CRATES" | sort -u) + fi CRATE_COUNT=$(echo "$CRATES" | wc -l) # Build nextest filter expression for cargo nextest run (affected crates only) - echo "$CRATES" | sed 's/^/package(/; s/$/)/' | paste -sd '|' | sed 's/|/ | /g' > /tmp/nextest-filter.txt + FILTER=$(echo "$CRATES" | sed 's/^/package(/; s/$/)/' | paste -sd '|' - | sed 's/|/ | /g') + echo "${FILTER}${PLUGIN_TESTS}" > /tmp/nextest-filter.txt # Save affected-only -p flags for cargo test fallback (no nextest filter) echo "$CRATES" | sed 's/^/-p /' | tr '\n' ' ' > /tmp/test-packages.txt # Build -p flags: affected crates + packages with bin/cdylib targets. @@ -123,17 +173,17 @@ runs: # artifacts; cdylib packages: connector plugins loaded via dlopen. # Both must be in the build even if not directly in the DAG scope. BIN_PKGS=$(cat /tmp/bin-packages.txt 2>/dev/null || echo "") - ALL_BUILD_PKGS=$(printf '%s\n%s\n' "$CRATES" "$BIN_PKGS" | sort -u | grep -v '^$') + ALL_BUILD_PKGS=$(printf '%s\n%s\n' "$BUILD_CRATES" "$BIN_PKGS" | sort -u | grep -v '^$') echo "$ALL_BUILD_PKGS" | sed 's/^/-p /' | tr '\n' ' ' > /tmp/packages.txt BUILD_COUNT=$(echo "$ALL_BUILD_PKGS" | wc -l) - echo "::notice::DAG analysis: testing ${CRATE_COUNT} crates, building ${BUILD_COUNT} (of ${TOTAL_CRATES} total, +$(( BUILD_COUNT - CRATE_COUNT )) binary pkgs)" + echo "::notice::DAG analysis: testing ${CRATE_COUNT} crates + ${PLUGIN_SUITES} connector suites, building ${BUILD_COUNT} (of ${TOTAL_CRATES} total, +$(( BUILD_COUNT - CRATE_COUNT )) extra pkgs)" else echo "::notice::Full workspace affected (${TOTAL_CRATES} crates)" fi else STDERR=$(cat /tmp/affected-stderr.txt 2>/dev/null || echo "") echo "::warning::Could not compute affected crates, running full test suite. ${STDERR}" - rm -f /tmp/nextest-filter.txt /tmp/packages.txt + rm -f /tmp/nextest-filter.txt /tmp/packages.txt /tmp/plugin-suites.txt fi shell: bash @@ -163,8 +213,11 @@ runs: run: cargo fmt --all -- --check shell: bash + # clippy-macos-aarch64 runs the same lint on macos-14. Code behind + # cfg(target_os) splits is dead to the Linux leg, so its warnings would + # otherwise reach master unseen. - name: Cargo clippy - if: inputs.task == 'clippy' + if: inputs.task == 'clippy' || inputs.task == 'clippy-macos-aarch64' run: cargo clippy --all-targets --all-features -- -D warnings shell: bash @@ -198,22 +251,29 @@ runs: if: startsWith(inputs.task, 'test-') && runner.os == 'Linux' run: | ./scripts/ci/apt-install.sh musl-tools gnome-keyring keyutils dbus-x11 libsecret-tools - rm -f $HOME/.local/share/keyrings/* + rm -f "$HOME"/.local/share/keyrings/* shell: bash - name: Install cargo-llvm-cov - if: startsWith(inputs.task, 'test-') + if: startsWith(inputs.task, 'test-') && inputs.task != 'test-storage-compat' uses: taiki-e/install-action@v2 with: tool: cargo-llvm-cov - name: Build and test with coverage - if: startsWith(inputs.task, 'test-') + # test-storage-compat drives its own nextest invocation from + # scripts/ci/storage-compat.sh. Without this exclusion it would also run + # here with an empty PARTITION_FLAG, i.e. a fourth *unpartitioned* copy + # of the whole suite on top of test-1/2/3. + if: startsWith(inputs.task, 'test-') && inputs.task != 'test-storage-compat' run: | # Parse partition index from task name (test-1 -> hash:1/3, test-2 -> hash:2/3, ...). # TEST_PARTITIONS must match the number of test-N tasks in # .github/config/components.yml. Cluster bootstrap makes each test # CPU-heavy, so partitions stay small. + # The regex is numeric-only on purpose: non-numeric test-* tasks + # (test-storage-compat) opt out of partitioning rather than producing + # an out-of-range hash:N/3. TEST_PARTITIONS=3 TASK="${{ inputs.task }}" PARTITION_FLAG="" @@ -228,6 +288,7 @@ runs: PACKAGE_FLAGS="" TEST_PACKAGE_FLAGS="" TOTAL_CRATES="?" + PLUGIN_SUITES=0 if [[ -f /tmp/nextest-filter.txt ]]; then NEXTEST_FILTER=$(cat /tmp/nextest-filter.txt) fi @@ -240,11 +301,16 @@ runs: if [[ -f /tmp/total-crates.txt ]]; then TOTAL_CRATES=$(cat /tmp/total-crates.txt) fi + if [[ -f /tmp/plugin-suites.txt ]]; then + PLUGIN_SUITES=$(cat /tmp/plugin-suites.txt) + fi if [[ -n "$PACKAGE_FLAGS" ]]; then - TEST_CRATE_COUNT=$(echo "$NEXTEST_FILTER" | grep -o 'package(' | wc -l) + # Each connector suite contributes its own `package(integration)`, so + # subtract them to keep the crate count a crate count. + TEST_CRATE_COUNT=$(( $(echo "$NEXTEST_FILTER" | grep -o 'package(' | wc -l) - PLUGIN_SUITES )) BUILD_CRATE_COUNT=$(echo "$PACKAGE_FLAGS" | grep -o '\-p ' | wc -l) - echo "::notice::DAG-scoped: testing ${TEST_CRATE_COUNT} crates, building ${BUILD_CRATE_COUNT} (cargo check/clippy cover full workspace separately)" + echo "::notice::DAG-scoped: testing ${TEST_CRATE_COUNT} crates + ${PLUGIN_SUITES} connector suites, building ${BUILD_CRATE_COUNT} (cargo check/clippy cover full workspace separately)" else echo "::notice::Full workspace build (no DAG filter available)" fi @@ -283,32 +349,45 @@ runs: # api_handler_tests, version_firewall_tests, and server_e2e_tests need gitignored # wire fixtures. Generate when iggy-gateway-kafka is in the DAG test scope, or (on - # a full-workspace run) when gateways/** changed vs origin/master — avoid building + # a full-workspace run) when gateways/** changed vs the PR base — avoid building # kafka-message-gen / kafka-protocol when the gateway was not touched. NEEDS_KAFKA_FIXTURES=false if grep -q 'package(iggy-gateway-kafka)' <<< "$NEXTEST_FILTER"; then NEEDS_KAFKA_FIXTURES=true elif [[ -z "$NEXTEST_FILTER" ]]; then + # HEAD on a pull_request run is the synthetic merge commit; its first parent is + # the base tip GitHub merged against. The depth-1 checkout holds that parent only + # as a shallow boundary: `HEAD^1` does not resolve and `origin/master...HEAD` has + # no merge base (so it failed on every full-workspace lane). Read the parent from + # the raw object and fetch that one commit before diffing. A HEAD with one parent + # (workflow_dispatch on a branch tip) has no PR base, so it takes the fail-safe. + # # `2>&1` into a variable, not `2>/dev/null` piped straight to grep: the old form - # silenced *any* git failure (e.g. a shallow checkout with no merge-base history for - # origin/master) into empty output, which `grep -q` then reads identically to "diff - # succeeded, no gateways/ changes" - so a broken diff and a clean diff produced the - # same "skip fixtures" outcome. Confirmed in CI run 32256598833: gateways/-only PR, - # full-workspace lane, fixtures silently skipped, KAFKA_FIXTURES_REQUIRED never set - - # every fixture-backed test quietly passed by skipping all its assertions instead of - # running them (0.005s for a test meant to decode 9 real fixtures). - if DIFF_OUTPUT=$(git diff --name-only origin/master...HEAD 2>&1); then + # silenced *any* git failure into empty output, which `grep -q` then reads + # identically to "diff succeeded, no gateways/ changes" - so a broken diff and a + # clean diff produced the same "skip fixtures" outcome. Confirmed in CI run + # 32256598833: gateways/-only PR, full-workspace lane, fixtures silently skipped, + # KAFKA_FIXTURES_REQUIRED never set - every fixture-backed test quietly passed by + # skipping all its assertions instead of running them (0.005s for a test meant to + # decode 9 real fixtures). + BASE_SHA="" + if [[ "$(git cat-file -p HEAD | grep -c '^parent ')" -eq 2 ]]; then + BASE_SHA=$(git cat-file -p HEAD | awk '/^parent /{print $2; exit}') + fi + if [[ -n "$BASE_SHA" ]] \ + && DIFF_OUTPUT=$(git fetch -q --no-tags --depth=1 origin "$BASE_SHA" 2>&1 \ + && git diff --name-only "$BASE_SHA" HEAD 2>&1); then if grep -qE '^gateways/' <<< "$DIFF_OUTPUT"; then NEEDS_KAFKA_FIXTURES=true else echo "::notice::Skipping Kafka wire fixtures (full suite, gateways/** unchanged)" fi else - # Diff itself failed - NOT the same as "no gateways/ changes". Generate - # unconditionally as the fail-safe default; this is the case the bug above collapsed - # into a silent skip. + # No PR base, or fetch/diff failed - NOT the same as "no gateways/ changes". + # Generate unconditionally as the fail-safe default; this is the case the bug + # above collapsed into a silent skip. NEEDS_KAFKA_FIXTURES=true - echo "::warning::git diff origin/master...HEAD failed (${DIFF_OUTPUT}); generating Kafka fixtures unconditionally as a fail-safe" + echo "::warning::git diff against the PR base failed (base: ${BASE_SHA:-none, HEAD is not a merge commit}; ${DIFF_OUTPUT:-}); generating Kafka fixtures unconditionally as a fail-safe" fi fi if [[ "$NEEDS_KAFKA_FIXTURES" == true ]]; then @@ -363,9 +442,9 @@ runs: echo "" echo "=========================================" if [[ -n "$PACKAGE_FLAGS" ]]; then - TEST_CRATE_COUNT=$(echo "$NEXTEST_FILTER" | grep -o 'package(' | wc -l) + TEST_CRATE_COUNT=$(( $(echo "$NEXTEST_FILTER" | grep -o 'package(' | wc -l) - PLUGIN_SUITES )) BUILD_CRATE_COUNT=$(echo "$PACKAGE_FLAGS" | grep -o '\-p ' | wc -l) - echo "DAG scope (test): ${TEST_CRATE_COUNT}/${TOTAL_CRATES} crates" + echo "DAG scope (test): ${TEST_CRATE_COUNT}/${TOTAL_CRATES} crates + ${PLUGIN_SUITES} connector suites" echo "DAG scope (build): ${BUILD_CRATE_COUNT}/${TOTAL_CRATES} crates" else echo "DAG scope: full workspace (${TOTAL_CRATES} crates)" @@ -383,6 +462,17 @@ runs: ls -la codecov.json shell: bash + # On-disk format backwards compatibility. The script builds an iggy-server + # from the master tip under the checked-out merge ref (origin/master off a + # pull_request run) and one from HEAD, seeds a data directory with the + # former and reads it back with the latter. It does its own shallow fetch + # of the baseline commit and exits non-zero if it cannot resolve one, so + # the default depth-1 checkout is sufficient here. + - name: Storage format backwards compatibility + if: inputs.task == 'test-storage-compat' + run: ./scripts/ci/storage-compat.sh + shell: bash + # Miri (UB detector) on the unsafe-heavy crates that don't pull tokio / # compio. Pinned nightly so MIRIFLAGS behavior is stable across CI runs; # bump the date quarterly. Tree-borrows is the future-default aliasing diff --git a/.github/actions/utils/setup-node-with-cache/action.yml b/.github/actions/utils/setup-node-with-cache/action.yml index f9ba44f6ae..a46b199e4e 100644 --- a/.github/actions/utils/setup-node-with-cache/action.yml +++ b/.github/actions/utils/setup-node-with-cache/action.yml @@ -67,7 +67,7 @@ runs: echo "📦 Installing Node.js dependencies..." if [ "${{ inputs.workspace }}" != "." ]; then echo "Changing to workspace directory: ${{ inputs.workspace }}" - cd ${{ inputs.workspace }} + cd "${{ inputs.workspace }}" pwd # Print working directory to verify fi diff --git a/.github/actions/utils/setup-rust-with-cache/action.yml b/.github/actions/utils/setup-rust-with-cache/action.yml index 82867a968e..023483300a 100644 --- a/.github/actions/utils/setup-rust-with-cache/action.yml +++ b/.github/actions/utils/setup-rust-with-cache/action.yml @@ -73,7 +73,7 @@ runs: # Pin version of hwloc to 2.12.2_1 # brew extract doesn't have this version, so we fetch the formula directly brew tap-new iggy/local-hwloc - curl -fsSL https://raw.githubusercontent.com/Homebrew/homebrew-core/bb1e23f8e5eacf4d31acd489f6079c8a53ebd690/Formula/h/hwloc.rb \ + curl -fsSL --retry 5 --retry-all-errors https://raw.githubusercontent.com/Homebrew/homebrew-core/bb1e23f8e5eacf4d31acd489f6079c8a53ebd690/Formula/h/hwloc.rb \ -o "$(brew --repository iggy/local-hwloc)/Formula/hwloc.rb" brew install iggy/local-hwloc/hwloc shell: bash @@ -125,9 +125,12 @@ runs: - name: Install cargo-nextest if: runner.os == 'Linux' && inputs.install-nextest == 'true' run: | - if command -v cargo-nextest &> /dev/null; then + # A binary restored from the cargo cache can predate the floor in + # .config/nextest.toml. `show-config version` exits non-zero below it, + # and on a nextest too old to know the subcommand at all, so both fall + # through to a fresh install instead of aborting the lane later. + if command -v cargo-nextest &> /dev/null && cargo nextest show-config version; then echo "cargo-nextest already installed" - cargo nextest --version exit 0 fi @@ -141,7 +144,12 @@ runs: ;; esac - curl -LsSf "https://get.nexte.st/latest/${NEXTEST_PLATFORM}" | tar xzf - -C ${CARGO_HOME:-~/.cargo}/bin + CARGO_BIN="${CARGO_HOME:-$HOME/.cargo}/bin" + tarball="$(mktemp)" + # Staged to a file because curl --retry cannot rewind a pipe. + curl -LsSf --retry 5 --retry-all-errors -o "$tarball" "https://get.nexte.st/latest/${NEXTEST_PLATFORM}" + tar xzf "$tarball" -C "$CARGO_BIN" + rm -f "$tarball" cargo nextest --version shell: bash continue-on-error: true @@ -149,8 +157,8 @@ runs: - name: Configure Cargo for CI if: inputs.read-cache == 'true' run: | - echo "CARGO_INCREMENTAL=0" >> $GITHUB_ENV - echo "CARGO_PROFILE_DEV_DEBUG=0" >> $GITHUB_ENV - echo "CARGO_PROFILE_TEST_DEBUG=0" >> $GITHUB_ENV - echo "CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse" >> $GITHUB_ENV + echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV" + echo "CARGO_PROFILE_DEV_DEBUG=0" >> "$GITHUB_ENV" + echo "CARGO_PROFILE_TEST_DEBUG=0" >> "$GITHUB_ENV" + echo "CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse" >> "$GITHUB_ENV" shell: bash diff --git a/.github/actions/utils/validate-third-party-licenses/action.yml b/.github/actions/utils/validate-third-party-licenses/action.yml index 711bbf2fbf..772da695d3 100644 --- a/.github/actions/utils/validate-third-party-licenses/action.yml +++ b/.github/actions/utils/validate-third-party-licenses/action.yml @@ -47,9 +47,13 @@ runs: shell: bash run: | TARGET="$(uname -m)-unknown-linux-musl" - curl -sSfL "https://github.com/EmbarkStudios/cargo-about/releases/download/0.9.0/cargo-about-0.9.0-${TARGET}.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" --strip-components=1 \ - "cargo-about-0.9.0-${TARGET}/cargo-about" + tarball="$(mktemp)" + # Staged to a file because curl --retry cannot rewind a pipe. + curl -sSfL --retry 5 --retry-all-errors -o "$tarball" \ + "https://github.com/EmbarkStudios/cargo-about/releases/download/0.9.0/cargo-about-0.9.0-${TARGET}.tar.gz" + tar -xzf "$tarball" -C "$HOME/.cargo/bin" --strip-components=1 \ + "cargo-about-0.9.0-${TARGET}/cargo-about" + rm -f "$tarball" - name: Validate apache/iggy bundle (server + CLI + embedded web) shell: bash diff --git a/.github/config/components.yml b/.github/config/components.yml index 5fef0e88a0..9ed475e6ce 100644 --- a/.github/config/components.yml +++ b/.github/config/components.yml @@ -23,6 +23,7 @@ components: - "Cargo.lock" - "rust-toolchain.toml" - ".cargo/**" + - ".config/**" # nextest + cargo-rail config gate every Rust test job # crates.io publish chain verification. Runs scripts/verify-crates-publish.sh, # which spins up cargo-http-registry as a local alt-registry and chain-publishes @@ -135,6 +136,43 @@ components: - "core/message_bus/**" - "core/partitions/**" + # On-disk format backwards compatibility. Boots a server built from the + # master tip the PR merges onto, seeds a data directory, then swaps in a + # HEAD-built binary and asserts it reads everything back. Paths are the + # crates that own the on-disk representation plus the boot path that reads + # it back; a change to any of them can silently break rollforward for + # existing deployments, which no other check covers. Matching is literal + # glob with no crate-graph expansion, so every crate has to be spelled out: + # `core/server/**` does not cover `core/server_common/**`. + # `breaking:storage` on the PR skips it (see .github/workflows/_test.yml). + rust-storage-compat: + depends_on: + - "rust-workspace" + - "rust-configs" # partition/metadata on-disk schema knobs and their defaults + - "ci-infrastructure" # action.yml/workflow edits should re-run the check + paths: + - "core/server/**" + - "core/server_common/**" # per-message segment record + segment readers/writers + - "core/binary_protocol/**" + - "core/common/**" + - "core/consensus/**" # VsrState is the superblock payload + - "core/journal/**" + - "core/metadata/**" + - "core/partitions/**" + - "core/shard/**" # drives the shutdown flush and boot-time partition recovery + - "scripts/ci/storage-compat.sh" + # The check itself, so weakening it re-runs it. Scoped to the test, the + # module wiring that keeps it compiled in, the crate manifest and the + # harness it drives rather than `core/integration/**`, which would fire + # this lane on every unrelated integration test change. + - "core/integration/Cargo.toml" + - "core/integration/tests/mod.rs" + - "core/integration/tests/data_integrity/mod.rs" + - "core/integration/tests/data_integrity/storage_compat.rs" + - "core/integration/src/harness/**" + tasks: + - "test-storage-compat" + # Standalone simulation tool, does NOT affect server binary or foreign SDKs. # Split from rust-cluster to avoid triggering SDK tests on simulator-only changes. rust-simulator: @@ -180,6 +218,7 @@ components: - "build-aarch64-gnu" - "build-aarch64-musl" - "build-macos-aarch64" + - "clippy-macos-aarch64" - "build-windows-sdk" # CLI component @@ -232,7 +271,7 @@ components: - "ci-infrastructure" # CI changes trigger full regression paths: - "foreign/python/**" - tasks: ["lint", "test", "build"] + tasks: ["lint", "test"] sdk-php: depends_on: @@ -491,7 +530,7 @@ components: web-ui: paths: - "web/**" - tasks: ["lint", "build"] + tasks: ["lint", "test", "build"] helm: paths: diff --git a/.github/config/hawkeye.version b/.github/config/hawkeye.version index 66ce77b7ea..9fe9ff9d99 100644 --- a/.github/config/hawkeye.version +++ b/.github/config/hawkeye.version @@ -1 +1 @@ -7.0.0 +7.0.1 diff --git a/.github/workflows/_build_python_wheels.yml b/.github/workflows/_build_python_wheels.yml index ae066bb246..04a3ba9ce7 100644 --- a/.github/workflows/_build_python_wheels.yml +++ b/.github/workflows/_build_python_wheels.yml @@ -68,7 +68,7 @@ jobs: - name: Download latest copy script from master if: inputs.use_latest_ci run: | - curl -sSL "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ + curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ -o /tmp/copy-latest-from-master.sh chmod +x /tmp/copy-latest-from-master.sh @@ -93,8 +93,12 @@ jobs: - name: Generate third-party license manifest run: | TARGET="$(uname -m)-unknown-linux-musl" - curl -sSfL "https://github.com/EmbarkStudios/cargo-about/releases/download/0.9.0/cargo-about-0.9.0-${TARGET}.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" --strip-components=1 "cargo-about-0.9.0-${TARGET}/cargo-about" + tarball="$(mktemp)" + # Staged to a file because curl --retry cannot rewind a pipe. + curl -sSfL --retry 5 --retry-all-errors -o "$tarball" \ + "https://github.com/EmbarkStudios/cargo-about/releases/download/0.9.0/cargo-about-0.9.0-${TARGET}.tar.gz" + tar -xzf "$tarball" -C "$HOME/.cargo/bin" --strip-components=1 "cargo-about-0.9.0-${TARGET}/cargo-about" + rm -f "$tarball" ./scripts/ci/third-party-licenses.sh --generate --manifest foreign/python/Cargo.toml --output foreign/python/LICENSE-binary - name: Build wheels @@ -130,7 +134,7 @@ jobs: - name: Download latest copy script from master if: inputs.use_latest_ci run: | - curl -sSL "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ + curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ -o /tmp/copy-latest-from-master.sh chmod +x /tmp/copy-latest-from-master.sh @@ -160,8 +164,11 @@ jobs: ARCH="$(uname -m | sed 's/arm64/aarch64/')" if [ "$ARCH" = "aarch64" ]; then TARGET="aarch64-apple-darwin" - curl -sSfL "https://github.com/EmbarkStudios/cargo-about/releases/download/0.9.0/cargo-about-0.9.0-${TARGET}.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" --strip-components=1 "cargo-about-0.9.0-${TARGET}/cargo-about" + tarball="$(mktemp)" + curl -sSfL --retry 5 --retry-all-errors -o "$tarball" \ + "https://github.com/EmbarkStudios/cargo-about/releases/download/0.9.0/cargo-about-0.9.0-${TARGET}.tar.gz" + tar -xzf "$tarball" -C "$HOME/.cargo/bin" --strip-components=1 "cargo-about-0.9.0-${TARGET}/cargo-about" + rm -f "$tarball" else cargo install cargo-about --locked --version 0.9.0 --features cli fi @@ -184,14 +191,23 @@ jobs: retention-days: 7 windows: - if: false # TODO(hubcio): temporarily disabled runs-on: windows-latest + strategy: + fail-fast: false + # Windows exposes no `python3.X` on PATH and maturin's py-launcher + # fallback does not see setup-python tool-cache installs, so one + # job cannot target several interpreters. One job per version + # instead, each falling through to maturin's default of the + # interpreter on PATH. 3.10 is Windows-only excluded, see + # WHEEL_MATRIX_SKIP in scripts/ci/sync-python-interpreter-version.sh. + matrix: + python-version: ["3.11", "3.12", "3.13"] steps: - name: Download latest copy script from master if: inputs.use_latest_ci shell: bash run: | - curl -sSL "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ + curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ -o /tmp/copy-latest-from-master.sh chmod +x /tmp/copy-latest-from-master.sh @@ -209,22 +225,54 @@ jobs: - name: Setup Python uses: actions/setup-python@v7.0.0 with: - python-version: "3.10" + python-version: ${{ matrix.python-version }} architecture: x64 + - name: Setup Rust with cache + uses: ./.github/actions/utils/setup-rust-with-cache + with: + shared-key: python-wheels-windows + + # aws-lc-sys, reached through quinn's default rustls provider, + # assembles its x86_64 crypto with NASM. The runner image ships + # CMake but not NASM, and Chocolatey leaves it off PATH. + - name: Install NASM + shell: pwsh + run: | + $nasmDir = "C:\Program Files\NASM" + choco install nasm --no-progress -y + Add-Content -Path $env:GITHUB_PATH -Value $nasmDir + # GITHUB_PATH only reaches later steps, so prove the install + # landed where expected here rather than in a linker error. + $env:PATH = "$nasmDir;$env:PATH" + nasm -v + + - name: Generate third-party license manifest + shell: bash + run: | + TARGET="x86_64-pc-windows-msvc" + # HOME is Windows-style here, which MSYS tar mishandles as -C. + CARGO_BIN="$(cygpath -u "$HOME")/.cargo/bin" + tarball="$(cygpath -u "$(mktemp)")" + curl -sSfL --retry 5 --retry-all-errors -o "$tarball" \ + "https://github.com/EmbarkStudios/cargo-about/releases/download/0.9.0/cargo-about-0.9.0-${TARGET}.tar.gz" + tar -xzf "$tarball" -C "$CARGO_BIN" --strip-components=1 "cargo-about-0.9.0-${TARGET}/cargo-about.exe" + rm -f "$tarball" + ./scripts/ci/third-party-licenses.sh --generate --manifest foreign/python/Cargo.toml --output foreign/python/LICENSE-binary + - name: Build wheels uses: PyO3/maturin-action@v1 with: target: x86_64 working-directory: foreign/python - args: --release --out dist --interpreter python3.10 python3.11 python3.12 python3.13 + args: --release --out dist sccache: "true" - name: Upload wheels if: inputs.upload_artifacts uses: actions/upload-artifact@v7 with: - name: wheels-windows-x64 + name: wheels-windows-x64-py${{ matrix.python-version }} path: foreign/python/dist retention-days: 7 @@ -234,7 +282,7 @@ jobs: - name: Download latest copy script from master if: inputs.use_latest_ci run: | - curl -sSL "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ + curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ -o /tmp/copy-latest-from-master.sh chmod +x /tmp/copy-latest-from-master.sh @@ -265,7 +313,7 @@ jobs: collect: name: Collect all wheels - needs: [linux, macos, sdist] # TODO(hubcio): add windows back when re-enabled + needs: [linux, macos, windows, sdist] if: ${{ !cancelled() }} runs-on: ubuntu-latest outputs: @@ -280,10 +328,10 @@ jobs: - name: List wheels run: | - echo "## 📦 Built Python Wheels" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Platform | Architecture | File |" >> $GITHUB_STEP_SUMMARY - echo "|----------|-------------|------|" >> $GITHUB_STEP_SUMMARY + echo "## 📦 Built Python Wheels" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Platform | Architecture | File |" >> "$GITHUB_STEP_SUMMARY" + echo "|----------|-------------|------|" >> "$GITHUB_STEP_SUMMARY" for wheel in dist/*.whl; do filename=$(basename "$wheel") @@ -297,11 +345,11 @@ jobs: elif [[ "$filename" == *"i686"* ]]; then arch="x86" else arch="any"; fi - echo "| $platform | $arch | \`$filename\` |" >> $GITHUB_STEP_SUMMARY + echo "| $platform | $arch | \`$filename\` |" >> "$GITHUB_STEP_SUMMARY" done - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Total wheels built:** $(ls -1 dist/*.whl | wc -l)" >> $GITHUB_STEP_SUMMARY + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Total wheels built:** $(ls -1 dist/*.whl | wc -l)" >> "$GITHUB_STEP_SUMMARY" - name: Upload combined artifact uses: actions/upload-artifact@v7 @@ -311,4 +359,34 @@ jobs: retention-days: 30 - id: output - run: echo "artifact_name=python-wheels-all" >> $GITHUB_OUTPUT + run: echo "artifact_name=python-wheels-all" >> "$GITHUB_OUTPUT" + + # Runs last so the artifact and the summary survive for inspection + # when a leg is missing. A failed build job uploads nothing, so a + # short count means one did not contribute. These counts are not + # derived from the matrices above - update them together. + - name: Verify wheel coverage + run: | + EXPECTED_LINUX=16 # 4 build variants x 4 interpreters + EXPECTED_MACOS=8 # 2 targets x 4 interpreters + EXPECTED_WINDOWS=3 # 1 interpreter per job, 3.10 excluded + EXPECTED_SDIST=1 + + missing=0 + check() { + local label="$1" pattern="$2" expected="$3" actual + actual=$(find dist -maxdepth 1 -name "$pattern" | wc -l) + if [ "$actual" -ne "$expected" ]; then + echo "::error::${label}: expected ${expected}, found ${actual}" + missing=1 + else + echo "${label}: ${actual}" + fi + } + + check Linux '*linux*.whl' "$EXPECTED_LINUX" + check macOS '*macosx*.whl' "$EXPECTED_MACOS" + check Windows '*win*.whl' "$EXPECTED_WINDOWS" + check sdist '*.tar.gz' "$EXPECTED_SDIST" + + exit "$missing" diff --git a/.github/workflows/_build_rust_artifacts.yml b/.github/workflows/_build_rust_artifacts.yml index bd56b10716..6b4d568f8b 100644 --- a/.github/workflows/_build_rust_artifacts.yml +++ b/.github/workflows/_build_rust_artifacts.yml @@ -46,7 +46,7 @@ on: connector_plugins: type: string required: false - default: "iggy_connector_elasticsearch_sink,iggy_connector_elasticsearch_source,iggy_connector_iceberg_sink,iggy_connector_postgres_sink,iggy_connector_postgres_source,iggy_connector_quickwit_sink,iggy_connector_random_source,iggy_connector_s3_sink,iggy_connector_stdout_sink,iggy_connector_surrealdb_sink" + default: "iggy_connector_elasticsearch_sink,iggy_connector_elasticsearch_source,iggy_connector_iceberg_sink,iggy_connector_postgres_sink,iggy_connector_postgres_source,iggy_connector_quickwit_sink,iggy_connector_random_source,iggy_connector_s3_sink,iggy_connector_stdout_sink,iggy_connector_surrealdb_sink, iggy_connector_rabbitmq_sink" description: "Comma-separated list of connector plugin crates to build as shared libraries" outputs: artifact_name: @@ -84,7 +84,7 @@ jobs: - name: Download latest copy script from master if: inputs.use_latest_ci run: | - curl -sSL "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ + curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ -o /tmp/copy-latest-from-master.sh chmod +x /tmp/copy-latest-from-master.sh @@ -109,7 +109,7 @@ jobs: run: rustup target add ${{ matrix.target }} - name: Setup Node.js - uses: actions/setup-node@v7.0.0 + uses: actions/setup-node@v7 with: node-version: "22" @@ -196,7 +196,7 @@ jobs: - name: Download latest copy script from master if: inputs.use_latest_ci run: | - curl -sSL "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ + curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ -o /tmp/copy-latest-from-master.sh chmod +x /tmp/copy-latest-from-master.sh diff --git a/.github/workflows/_common.yml b/.github/workflows/_common.yml index f9e75dab39..32af63ed5e 100644 --- a/.github/workflows/_common.yml +++ b/.github/workflows/_common.yml @@ -74,7 +74,7 @@ jobs: if ! command -v yq >/dev/null 2>&1; then YQ_VERSION="v4.47.1" YQ_CHECKSUM="0fb28c6680193c41b364193d0c0fc4a03177aecde51cfc04d506b1517158c2fb" - curl -sSL -o /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64 + curl -fsSL --retry 5 --retry-all-errors -o /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64 echo "${YQ_CHECKSUM} /usr/local/bin/yq" | sha256sum -c - || exit 1 chmod +x /usr/local/bin/yq fi @@ -93,7 +93,7 @@ jobs: run: echo "version=$(cat .github/config/hawkeye.version)" >> "$GITHUB_OUTPUT" - name: Install HawkEye - uses: taiki-e/install-action@v2.86.3 + uses: taiki-e/install-action@v2.87.8 with: tool: hawkeye@${{ steps.hawkeye-version.outputs.version }} @@ -114,7 +114,7 @@ jobs: - uses: actions/checkout@v7.0.1 - name: Setup Node.js - uses: actions/setup-node@v7.0.0 + uses: actions/setup-node@v7 with: node-version: "23" @@ -175,7 +175,12 @@ jobs: - name: Install taplo run: | - curl -fsSL https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-linux-x86_64.gz | gunzip > taplo + tarball="$(mktemp)" + # Staged to a file because curl --retry cannot rewind a pipe. + curl -fsSL --retry 5 --retry-all-errors -o "$tarball" \ + https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-linux-x86_64.gz + gunzip -c "$tarball" > taplo + rm -f "$tarball" chmod +x taplo sudo mv taplo /usr/local/bin/ @@ -206,7 +211,7 @@ jobs: with: fetch-depth: 0 - name: Check typos - uses: crate-ci/typos@v1.49.0 + uses: crate-ci/typos@v1.50.1 summary: name: Common checks summary diff --git a/.github/workflows/_detect.yml b/.github/workflows/_detect.yml index 66f7b48229..a3add24f8b 100644 --- a/.github/workflows/_detect.yml +++ b/.github/workflows/_detect.yml @@ -97,7 +97,7 @@ jobs: chmod +x /tmp/yq sudo mv /tmp/yq /usr/local/bin/yq fi - echo "components=$(yq -o=json -I=0 '.' .github/config/components.yml | jq -c)" >> $GITHUB_OUTPUT + echo "components=$(yq -o=json -I=0 '.' .github/config/components.yml | jq -c)" >> "$GITHUB_OUTPUT" - name: Build matrices id: mk diff --git a/.github/workflows/_publish_rust_crates.yml b/.github/workflows/_publish_rust_crates.yml index 8531143f3b..19696e077e 100644 --- a/.github/workflows/_publish_rust_crates.yml +++ b/.github/workflows/_publish_rust_crates.yml @@ -81,7 +81,7 @@ jobs: - name: Download latest copy script from master if: inputs.use_latest_ci run: | - curl -sSL "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ + curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ -o /tmp/copy-latest-from-master.sh chmod +x /tmp/copy-latest-from-master.sh echo "✅ Downloaded latest copy script from master" diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index eb0483efb7..8b1353afec 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -33,6 +33,7 @@ on: permissions: contents: read + issues: read # listLabelsOnIssue, for the breaking:storage escape hatch security-events: write pull-requests: write @@ -41,7 +42,7 @@ jobs: # Select runner based on build target runs-on: ${{ (inputs.task == 'build-aarch64-gnu' || inputs.task == 'build-aarch64-musl') && 'ubuntu-24.04-arm' || - inputs.task == 'build-macos-aarch64' && 'macos-14' || + (inputs.task == 'build-macos-aarch64' || inputs.task == 'clippy-macos-aarch64') && 'macos-14' || inputs.task == 'build-windows-sdk' && 'windows-latest' || 'ubuntu-latest' }} @@ -55,15 +56,62 @@ jobs: run: echo "No changes detected, skipping tests" # Rust + # `breaking:storage` is the documented escape hatch for a deliberate + # on-disk format change, which the compat check cannot pass by + # construction. The label has to be read live: pre-merge.yml does not + # subscribe to `labeled` (pr-triage-apply.yml writes S-* labels, so every + # triage write would re-run the whole gate), and a re-run replays the + # webhook-frozen `pull_request.labels` from the last push. The operator + # flow is "check fails, apply label, re-run", which the frozen payload + # cannot see. Paginated so a PR with more than 100 labels still resolves. + - name: Resolve breaking:storage escape hatch + id: storage_hatch + if: >- + startsWith(inputs.component, 'rust') && + inputs.task == 'test-storage-compat' && + github.event.pull_request.number + uses: actions/github-script@v9 + with: + script: | + let skip = false; + try { + const labels = await github.paginate( + github.rest.issues.listLabelsOnIssue, + { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + per_page: 100, + }, + ); + skip = labels.some(l => l.name === 'breaking:storage'); + } catch (e) { + // Fail toward running. A rate limit or a narrowed token must not + // wave an on-disk format break through as a green check. + core.warning(`listLabelsOnIssue failed (${e.status ?? e.code ?? 'network'}), running the check`); + } + core.info(`breaking:storage escape hatch: skip=${skip}`); + core.setOutput('skip', String(skip)); + - name: Run Rust task - if: startsWith(inputs.component, 'rust') + # Skipping the step (not the job) keeps the leg green so the pre-merge + # status roll-up stays reportable instead of pending. + if: >- + startsWith(inputs.component, 'rust') && + steps.storage_hatch.outputs.skip != 'true' uses: ./.github/actions/rust/pre-merge with: task: ${{ inputs.task }} component: ${{ inputs.component }} - name: Upload coverage to Codecov - if: startsWith(inputs.component, 'rust') && startsWith(inputs.task, 'test-') + # test-storage-compat runs its own nextest invocation without llvm-cov, + # so it emits no codecov.json. Uploading an empty report under the + # `rust` flag would read as a coverage drop on the PR. + if: >- + startsWith(inputs.component, 'rust') && + startsWith(inputs.task, 'test-') && + inputs.task != 'test-storage-compat' uses: codecov/codecov-action@v7.0.0 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -208,6 +256,14 @@ jobs: override_pr: ${{ github.event.pull_request.number }} # Web UI + - name: Setup Node.js for Web UI + if: inputs.component == 'web-ui' + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: npm + cache-dependency-path: web/package-lock.json + - name: Run Web UI task if: inputs.component == 'web-ui' run: | @@ -215,6 +271,8 @@ jobs: npm ci if [ "${{ inputs.task }}" = "lint" ]; then npm run lint + elif [ "${{ inputs.task }}" = "test" ]; then + npm test elif [ "${{ inputs.task }}" = "build" ]; then npm run build fi diff --git a/.github/workflows/_test_examples.yml b/.github/workflows/_test_examples.yml index 0efd28ba7b..c5c48c8c11 100644 --- a/.github/workflows/_test_examples.yml +++ b/.github/workflows/_test_examples.yml @@ -106,6 +106,16 @@ jobs: with: download-deps: "false" + - name: Test Go examples + if: startsWith(inputs.component, 'examples-') && inputs.task == 'examples-go' + working-directory: examples/go + run: go test -race ./... + + - name: Test Java examples + if: startsWith(inputs.component, 'examples-') && inputs.task == 'examples-java' + working-directory: examples/java + run: ./gradlew test + - name: Build common binaries for all examples if: startsWith(inputs.component, 'examples-') run: | diff --git a/.github/workflows/coverage-baseline.yml b/.github/workflows/coverage-baseline.yml index baa1ae160c..b7d0ddb7a2 100644 --- a/.github/workflows/coverage-baseline.yml +++ b/.github/workflows/coverage-baseline.yml @@ -90,7 +90,7 @@ jobs: - name: Install system dependencies run: | ./scripts/ci/apt-install.sh gnome-keyring keyutils dbus-x11 libsecret-tools - rm -f $HOME/.local/share/keyrings/* + rm -f "$HOME"/.local/share/keyrings/* - name: Setup Rust with cache uses: ./.github/actions/utils/setup-rust-with-cache @@ -104,7 +104,7 @@ jobs: free-disk-space-aggressive: "true" - name: Install cargo-llvm-cov - uses: taiki-e/install-action@v2.86.3 + uses: taiki-e/install-action@v2.87.8 with: tool: cargo-llvm-cov @@ -299,7 +299,7 @@ jobs: save-cache: "false" - name: Install cargo-llvm-cov - uses: taiki-e/install-action@v2.86.3 + uses: taiki-e/install-action@v2.87.8 with: tool: cargo-llvm-cov @@ -318,7 +318,7 @@ jobs: export CARGO_TARGET_DIR=$CARGO_LLVM_COV_TARGET_DIR # Propagate LLVM_PROFILE_FILE so the test step can write profraw data - echo "LLVM_PROFILE_FILE=${LLVM_PROFILE_FILE}" >> $GITHUB_ENV + echo "LLVM_PROFILE_FILE=${LLVM_PROFILE_FILE}" >> "$GITHUB_ENV" cargo llvm-cov clean --workspace echo "Building Python wheel with coverage instrumentation..." @@ -347,8 +347,21 @@ jobs: - name: Run tests run: | cd foreign/python + + # TCP and HTTP ports come from server-start's own outputs rather than + # duplicated literals, so a future default change here can't silently + # desync from what the server actually started with. QUIC and WebSocket + # stay literals below because server-start exposes no output for them. + tcp_port="${{ steps.iggy.outputs.tcp_address }}" + http_port="${{ steps.iggy.outputs.http_address }}" + tcp_port="${tcp_port##*:}" + http_port="${http_port##*:}" + IGGY_SERVER_HOST=127.0.0.1 \ - IGGY_SERVER_TCP_PORT=8090 \ + IGGY_SERVER_TCP_PORT="$tcp_port" \ + IGGY_SERVER_QUIC_PORT=8080 \ + IGGY_SERVER_HTTP_PORT="$http_port" \ + IGGY_SERVER_WS_PORT=8092 \ IGGY_SERVER_DOCKER_IMAGE=iggy-server:local \ uv run --no-sync pytest tests/ -v \ --junitxml=../../reports/python-junit.xml \ @@ -423,7 +436,7 @@ jobs: - uses: actions/checkout@v7.0.1 - name: Setup Node.js - uses: actions/setup-node@v7.0.0 + uses: actions/setup-node@v7 with: node-version: "23" cache: "npm" @@ -516,6 +529,10 @@ jobs: go test -v -race -coverprofile=../../reports/go-coverage-unit.out ./... - name: Run BDD tests with coverage + env: + IGGY_TCP_ADDRESS: 127.0.0.1:8090 + IGGY_ROOT_USERNAME: iggy + IGGY_ROOT_PASSWORD: iggy run: | cd bdd/go mkdir -p ../../reports diff --git a/.github/workflows/edge-release.yml b/.github/workflows/edge-release.yml index 2514c3227a..d68b36fcda 100644 --- a/.github/workflows/edge-release.yml +++ b/.github/workflows/edge-release.yml @@ -75,7 +75,7 @@ jobs: fi - name: Create edge pre-release - uses: softprops/action-gh-release@v3.0.2 + uses: softprops/action-gh-release@v3.0.3 with: tag_name: edge name: edge @@ -105,6 +105,7 @@ jobs: - `iggy_connector_s3_sink` - `iggy_connector_stdout_sink` - `iggy_connector_surrealdb_sink` + - `iggy_connector_rabbitmq_sink` ## Downloads diff --git a/.github/workflows/post-merge.yml b/.github/workflows/post-merge.yml index f45a4b64d5..feaaad1a38 100644 --- a/.github/workflows/post-merge.yml +++ b/.github/workflows/post-merge.yml @@ -55,7 +55,7 @@ jobs: if ! command -v yq >/dev/null 2>&1; then YQ_VERSION="v4.47.1" YQ_CHECKSUM="0fb28c6680193c41b364193d0c0fc4a03177aecde51cfc04d506b1517158c2fb" - curl -sSL -o /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64 + curl -fsSL --retry 5 --retry-all-errors -o /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64 echo "${YQ_CHECKSUM} /usr/local/bin/yq" | sha256sum -c - || exit 1 chmod +x /usr/local/bin/yq fi @@ -64,10 +64,12 @@ jobs: # Metadata-only `cargo rail plan` (no compile), so it runs on the runner's # preinstalled cargo (version pinned by rust-toolchain.toml) and skips the # heavyweight build-cache restore. + # Pinned for the same reason as .github/actions/rust/pre-merge/action.yml: + # 0.24 dropped the `-f json` edge-affected-images.sh relies on. - name: Install cargo-rail - uses: taiki-e/install-action@v2.86.3 + uses: taiki-e/install-action@v2.87.8 with: - tool: cargo-rail + tool: cargo-rail@0.23.0 - name: Check all components id: check diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 69b4fdcdc3..f53a37a861 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -85,6 +85,7 @@ jobs: security server shard + swift test web consensus diff --git a/.github/workflows/pr-triage-apply.yml b/.github/workflows/pr-triage-apply.yml index 3c5e7e6b15..d5840f4f30 100644 --- a/.github/workflows/pr-triage-apply.yml +++ b/.github/workflows/pr-triage-apply.yml @@ -189,6 +189,10 @@ jobs: script: | const LABEL_REVIEW = 'S-waiting-on-review'; const LABEL_AUTHOR = 'S-waiting-on-author'; + // Orthogonal to the S-* pair rather than a third state: `pinned` + // is the only label stale-prs.yml exempts, so it is added and + // removed on its own and never goes through replaceStateLabel. + const LABEL_PINNED = 'pinned'; const COMMITTER_ASSOCS = new Set(['MEMBER', 'COLLABORATOR', 'OWNER']); // Wider gate for the implicit changes_requested -> author // flip: a formal "Request changes" review is a deliberate, @@ -228,6 +232,7 @@ jobs: '- `/ready` - back to `S-waiting-on-review` after addressing feedback', '- `/author` - flip to `S-waiting-on-author` while you finish changes', '- `/request-review @user-or-team` - request a reviewer', + '- `/pin` - exempt the PR from the stale bot, `/unpin` to undo', '', 'See [CONTRIBUTING.md](https://github.com/apache/iggy/blob/master/CONTRIBUTING.md#pr-triage-commands) for details.', ].join('\n'); @@ -300,6 +305,72 @@ jobs: }), `setLabels ${add ?? '(clear S-*)'}`); }; + // Create the label when absent rather than assume it exists. + // stale-prs.yml names `pinned` as its only exemption, but no + // repo config guarantees the label itself was ever created, and + // an exemption naming a label nothing can carry is inert. + // Whether the labels API materializes an unknown name is + // undocumented, so do it explicitly. Needs `issues: write`. + const ensurePinnedLabel = async () => { + try { + await withRetry(() => github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: LABEL_PINNED, + }), `getLabel ${LABEL_PINNED}`); + return true; + } catch (e) { + if (e.status !== 404) { + core.warning(`${LABEL_PINNED}: lookup failed: ${e.message}`); + return false; + } + } + try { + await withRetry(() => github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: LABEL_PINNED, + color: 'd4c5f9', + description: 'Exempt from the stale bot', + }), `createLabel ${LABEL_PINNED}`); + core.info(`created label ${LABEL_PINNED}`); + return true; + } catch (e) { + // 422 means a concurrent run created it between our get and create. + if (e.status === 422) return true; + core.warning(`${LABEL_PINNED}: create failed: ${e.message}`); + return false; + } + }; + + // Add-only / remove-only, so unlike replaceStateLabel there is no + // list-then-PUT window an unrelated label change can be lost in. + const setPinned = async (on) => { + if (on) { + if (!await ensurePinnedLabel()) return false; + await withRetry(() => github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: [LABEL_PINNED], + }), `addLabels ${LABEL_PINNED}`); + return true; + } + try { + await withRetry(() => github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: LABEL_PINNED, + }), `removeLabel ${LABEL_PINNED}`); + } catch (e) { + // 404 is the PR not carrying the label, or the label not existing + // at all. Both mean it is already in the state /unpin asks for. + if (e.status !== 404) throw e; + } + return true; + }; + // Best-effort commenter feedback. A failed reaction or reply // must never fail the run, so each swallows its own errors. // Reactions exist only for issue comments — review-triggered @@ -504,7 +575,7 @@ jobs: // The trailing `(?:\s|$)` rejects suffixed prose like // `/ready-to-merge` — `\b` fires at hyphen/slash and would // silently flip state. - const COMMAND_RE = /^[ \t]*\/(request-review|ready|author)(?:\s|$)/m; + const COMMAND_RE = /^[ \t]*\/(request-review|ready|author|unpin|pin)(?:\s|$)/m; if (!COMMAND_RE.test(body) && !reviewWantsAuthor) { core.info('no command in body and no actionable review state, skipping'); return; @@ -569,6 +640,8 @@ jobs: let sawReassign = false; let sawReady = false; let sawAuthor = false; + let sawPin = false; + let sawUnpin = false; // Outcome tracking for commenter feedback (see react() above). // applied: at least one command took effect. denied: at least // one recognized command was rejected for lack of permission. @@ -650,6 +723,34 @@ jobs: } continue; } + // Same gate as /ready and /request-review: the PR author or a + // committer. Exempting a PR from the stale bot is reversible and + // visible as a label, so it does not need the wider /author gate. + if (!sawPin && /^\/pin(?:\s|$)/.test(line)) { + sawPin = true; + if (!(isCommitter || isPrAuthor)) { + core.info(`/pin: ignored, ${commentAuthor} lacks permission`); + denied = true; + } else if (await setPinned(true)) { + core.info(`/pin: added ${LABEL_PINNED}`); + applied = true; + } else { + await reply('`/pin`: could not apply the `pinned` label.'); + } + continue; + } + if (!sawUnpin && /^\/unpin(?:\s|$)/.test(line)) { + sawUnpin = true; + if (!(isCommitter || isPrAuthor)) { + core.info(`/unpin: ignored, ${commentAuthor} lacks permission`); + denied = true; + } else { + await setPinned(false); + core.info(`/unpin: removed ${LABEL_PINNED}`); + applied = true; + } + continue; + } } if (reviewers.size > 0 || teamReviewers.size > 0) { @@ -699,7 +800,8 @@ jobs: } } - if (!sawReassign && !sawReady && !sawAuthor && !reviewWantsAuthor) { + if (!sawReassign && !sawReady && !sawAuthor && !sawPin && !sawUnpin + && !reviewWantsAuthor) { core.info('no command matched'); } diff --git a/.github/workflows/pre-merge.yml b/.github/workflows/pre-merge.yml index edc9452f58..1967559347 100644 --- a/.github/workflows/pre-merge.yml +++ b/.github/workflows/pre-merge.yml @@ -31,6 +31,8 @@ concurrency: permissions: contents: read + # listLabelsOnIssue in _test.yml, for the breaking:storage escape hatch + issues: read security-events: write pull-requests: write @@ -350,8 +352,8 @@ jobs: - name: Check status run: | set -euxo pipefail - echo "## CI Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + echo "## CI Summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" # Helper function to format status with appropriate emoji format_status() { @@ -382,16 +384,16 @@ jobs: } # Language test results with timing - echo "### Test Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Component | Status | Duration |" >> $GITHUB_STEP_SUMMARY - echo "|-----------|--------|----------|" >> $GITHUB_STEP_SUMMARY + echo "### Test Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Component | Status | Duration |" >> "$GITHUB_STEP_SUMMARY" + echo "|-----------|--------|----------|" >> "$GITHUB_STEP_SUMMARY" # Detection and Common checks always run detect_status=$(format_status "${{ needs.detect.result }}" "${{ steps.times.outputs.detect_time }}") common_status=$(format_status "${{ needs.common.result }}" "${{ steps.times.outputs.common_time }}") - echo "| 🔍 Detection | $detect_status | ${{ steps.times.outputs.detect_time }} |" >> $GITHUB_STEP_SUMMARY - echo "| 📋 Common Checks | $common_status | ${{ steps.times.outputs.common_time }} |" >> $GITHUB_STEP_SUMMARY + echo "| 🔍 Detection | $detect_status | ${{ steps.times.outputs.detect_time }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| 📋 Common Checks | $common_status | ${{ steps.times.outputs.common_time }} |" >> "$GITHUB_STEP_SUMMARY" # Language/component tests rust_status=$(format_status "${{ needs.test-rust.result }}" "${{ steps.times.outputs.rust_time }}") @@ -406,19 +408,19 @@ jobs: examples_status=$(format_status "${{ needs.test-examples.result }}" "${{ steps.times.outputs.examples_time }}") other_status=$(format_status "${{ needs.test-other.result }}" "${{ steps.times.outputs.other_time }}") - echo "| 🦀 Rust | $rust_status | ${{ steps.times.outputs.rust_time }} |" >> $GITHUB_STEP_SUMMARY - echo "| 🐍 Python | $python_status | ${{ steps.times.outputs.python_time }} |" >> $GITHUB_STEP_SUMMARY - echo "| 🐘 PHP | $php_status | ${{ steps.times.outputs.php_time }} |" >> $GITHUB_STEP_SUMMARY - echo "| 🟢 Node | $node_status | ${{ steps.times.outputs.node_time }} |" >> $GITHUB_STEP_SUMMARY - echo "| 🐹 Go | $go_status | ${{ steps.times.outputs.go_time }} |" >> $GITHUB_STEP_SUMMARY - echo "| ☕ Java | $java_status | ${{ steps.times.outputs.java_time }} |" >> $GITHUB_STEP_SUMMARY - echo "| 🔷 C# | $csharp_status | ${{ steps.times.outputs.csharp_time }} |" >> $GITHUB_STEP_SUMMARY - echo "| 🔶 C++ | $cpp_status | ${{ steps.times.outputs.cpp_time }} |" >> $GITHUB_STEP_SUMMARY - echo "| 🧪 BDD | $bdd_status | ${{ steps.times.outputs.bdd_time }} |" >> $GITHUB_STEP_SUMMARY - echo "| 📚 Examples | $examples_status | ${{ steps.times.outputs.examples_time }} |" >> $GITHUB_STEP_SUMMARY - echo "| 📦 Other | $other_status | ${{ steps.times.outputs.other_time }} |" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Total workflow time:** ${{ steps.times.outputs.total_time }}" >> $GITHUB_STEP_SUMMARY + echo "| 🦀 Rust | $rust_status | ${{ steps.times.outputs.rust_time }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| 🐍 Python | $python_status | ${{ steps.times.outputs.python_time }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| 🐘 PHP | $php_status | ${{ steps.times.outputs.php_time }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| 🟢 Node | $node_status | ${{ steps.times.outputs.node_time }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| 🐹 Go | $go_status | ${{ steps.times.outputs.go_time }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| ☕ Java | $java_status | ${{ steps.times.outputs.java_time }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| 🔷 C# | $csharp_status | ${{ steps.times.outputs.csharp_time }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| 🔶 C++ | $cpp_status | ${{ steps.times.outputs.cpp_time }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| 🧪 BDD | $bdd_status | ${{ steps.times.outputs.bdd_time }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| 📚 Examples | $examples_status | ${{ steps.times.outputs.examples_time }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| 📦 Other | $other_status | ${{ steps.times.outputs.other_time }} |" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Total workflow time:** ${{ steps.times.outputs.total_time }}" >> "$GITHUB_STEP_SUMMARY" # Check for failures if [[ "${{ needs.common.result }}" == "failure" ]] || \ @@ -434,8 +436,8 @@ jobs: [[ "${{ needs.test-bdd.result }}" == "failure" ]] || \ [[ "${{ needs.test-examples.result }}" == "failure" ]] || \ [[ "${{ needs.test-other.result }}" == "failure" ]]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "❌ **CI Failed** - Please check the logs for details." >> $GITHUB_STEP_SUMMARY + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "❌ **CI Failed** - Please check the logs for details." >> "$GITHUB_STEP_SUMMARY" exit 1 elif [[ "${{ needs.common.result }}" == "cancelled" ]] || \ [[ "${{ needs.detect.result }}" == "cancelled" ]] || \ @@ -450,10 +452,10 @@ jobs: [[ "${{ needs.test-bdd.result }}" == "cancelled" ]] || \ [[ "${{ needs.test-examples.result }}" == "cancelled" ]] || \ [[ "${{ needs.test-other.result }}" == "cancelled" ]]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "⚠️ **CI Cancelled** - The workflow was cancelled." >> $GITHUB_STEP_SUMMARY + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "⚠️ **CI Cancelled** - The workflow was cancelled." >> "$GITHUB_STEP_SUMMARY" exit 1 else - echo "" >> $GITHUB_STEP_SUMMARY - echo "✅ **CI Passed** - All checks completed successfully!" >> $GITHUB_STEP_SUMMARY + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "✅ **CI Passed** - All checks completed successfully!" >> "$GITHUB_STEP_SUMMARY" fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d016d27539..3cfc78d9b4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -191,7 +191,7 @@ jobs: if: inputs.use_latest_ci run: | # Download the copy script from master branch - curl -sSL "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ + curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ -o /tmp/copy-latest-from-master.sh chmod +x /tmp/copy-latest-from-master.sh echo "✅ Downloaded latest copy script from master" @@ -357,7 +357,7 @@ jobs: - name: Download latest copy script from master if: inputs.use_latest_ci run: | - curl -sSL "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ + curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ -o /tmp/copy-latest-from-master.sh chmod +x /tmp/copy-latest-from-master.sh echo "✅ Downloaded latest copy script from master" @@ -396,15 +396,15 @@ jobs: run: | set -euo pipefail - echo "## 🏷️ Tag Existence Check" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + echo "## 🏷️ Tag Existence Check" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" if [ "${{ inputs.skip_tag_creation }}" = "true" ]; then - echo "### ℹ️ Tag Creation Disabled" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Tag creation has been explicitly disabled for this run." >> $GITHUB_STEP_SUMMARY - echo "Components will be published without creating git tags." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + echo "### ℹ️ Tag Creation Disabled" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Tag creation has been explicitly disabled for this run." >> "$GITHUB_STEP_SUMMARY" + echo "Components will be published without creating git tags." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" fi TARGETS_JSON='${{ needs.plan.outputs.targets }}' @@ -413,8 +413,22 @@ jobs: NEW_TAGS=() WRONG_TARGET_TAGS=() # populated when a tag exists at a commit != this run's target - echo "| Component | Version | Tag | Status |" >> $GITHUB_STEP_SUMMARY - echo "|-----------|---------|-----|--------|" >> $GITHUB_STEP_SUMMARY + # Every tag step is gated on dry_run == false at job or step level, and + # skip_tag_creation suppresses the tag push, so neither mode may + # render the "Will create" cell that promises a real tag write. + if [ "${{ inputs.dry_run }}" = "true" ]; then + NEW_TAG_CELL="🔍 Would create (dry run)" + NEW_TAG_LOG="🔍 Tag would be created (dry run):" + elif [ "${{ inputs.skip_tag_creation }}" = "true" ]; then + NEW_TAG_CELL="ℹ️ Would create (tag creation disabled)" + NEW_TAG_LOG="ℹ️ Tag would be created (tag creation disabled):" + else + NEW_TAG_CELL="✅ Will create" + NEW_TAG_LOG="✅ Tag will be created:" + fi + + echo "| Component | Version | Tag | Status |" >> "$GITHUB_STEP_SUMMARY" + echo "|-----------|---------|-----|--------|" >> "$GITHUB_STEP_SUMMARY" # Process substitution (not pipe) so EXISTING_TAGS / NEW_TAGS # mutations survive the loop: `jq | while ...` puts the loop @@ -449,7 +463,7 @@ jobs: SHOULD_TAG=$(scripts/extract-version.sh "$KEY" --should-tag 2>/dev/null || echo "false") if [ "$SHOULD_TAG" = "false" ]; then echo "ℹ️ $NAME: no tag will be created (SNAPSHOT or no tag_pattern)" - echo "| $NAME | $VERSION | _(none)_ | ℹ️ No tag (SNAPSHOT / no pattern) |" >> $GITHUB_STEP_SUMMARY + echo "| $NAME | $VERSION | _(none)_ | ℹ️ No tag (SNAPSHOT / no pattern) |" >> "$GITHUB_STEP_SUMMARY" continue fi @@ -459,14 +473,14 @@ jobs: if [ "${{ inputs.create_edge_docker_tag }}" = "true" ] && [ "$REGISTRY" = "dockerhub" ]; then if [[ ! "$VERSION" =~ -(edge|rc) ]]; then echo "⏭️ $NAME: Stable Docker version in auto-publish mode, tag will be skipped" - echo "| $NAME | $VERSION | $TAG | ⏭️ Stable (manual publish only) |" >> $GITHUB_STEP_SUMMARY + echo "| $NAME | $VERSION | $TAG | ⏭️ Stable (manual publish only) |" >> "$GITHUB_STEP_SUMMARY" continue fi fi if [ "$VERSION" = "ERROR" ] || [ "$TAG" = "ERROR" ]; then echo "❌ Failed to extract version/tag for $NAME" - echo "| $NAME | ERROR | ERROR | ❌ Failed to extract |" >> $GITHUB_STEP_SUMMARY + echo "| $NAME | ERROR | ERROR | ❌ Failed to extract |" >> "$GITHUB_STEP_SUMMARY" exit 1 fi @@ -497,7 +511,7 @@ jobs: # exit 1 below cannot trigger. if [ "${{ inputs.create_edge_docker_tag }}" = "true" ]; then echo "ℹ️ $NAME: $TAG already exists at $SHORT_SHA - already released, run will refresh :edge only" - echo "| $NAME | $VERSION | $TAG | ℹ️ Already released ($SHORT_SHA) - :edge refresh only |" >> $GITHUB_STEP_SUMMARY + echo "| $NAME | $VERSION | $TAG | ℹ️ Already released ($SHORT_SHA) - :edge refresh only |" >> "$GITHUB_STEP_SUMMARY" # Fail-fast on wrong-target for MANUAL publish. A wrong-target tag # means create-git-tag would hard-fail 20-40 minutes later after # publishing artifacts to crates.io / PyPI / npm / Maven / @@ -517,69 +531,74 @@ jobs: WRONG_TARGET_TAGS+=("$TAG|$SHORT_SHA") if [ "${{ inputs.skip_tag_creation }}" = "true" ]; then echo "⚠️ Tag exists on remote at WRONG target: $TAG (points to $SHORT_SHA, not enforced: skip_tag_creation=true)" - echo "| $NAME | $VERSION | $TAG | ⚠️ Wrong target at $SHORT_SHA (NOT enforced) |" >> $GITHUB_STEP_SUMMARY + echo "| $NAME | $VERSION | $TAG | ⚠️ Wrong target at $SHORT_SHA (NOT enforced) |" >> "$GITHUB_STEP_SUMMARY" else echo "❌ Tag exists on remote at wrong target: $TAG (points to $SHORT_SHA)" - echo "| $NAME | $VERSION | $TAG | ❌ Wrong target at $SHORT_SHA |" >> $GITHUB_STEP_SUMMARY + echo "| $NAME | $VERSION | $TAG | ❌ Wrong target at $SHORT_SHA |" >> "$GITHUB_STEP_SUMMARY" fi else echo "⚠️ Tag exists on remote at same target: $TAG (points to $SHORT_SHA)" - echo "| $NAME | $VERSION | $TAG | ⚠️ Exists at $SHORT_SHA (benign) |" >> $GITHUB_STEP_SUMMARY + echo "| $NAME | $VERSION | $TAG | ⚠️ Exists at $SHORT_SHA (benign) |" >> "$GITHUB_STEP_SUMMARY" fi else NEW_TAGS+=("$TAG") - echo "✅ Tag will be created: $TAG" - echo "| $NAME | $VERSION | $TAG | ✅ Will create |" >> $GITHUB_STEP_SUMMARY + echo "$NEW_TAG_LOG $TAG" + echo "| $NAME | $VERSION | $TAG | $NEW_TAG_CELL |" >> "$GITHUB_STEP_SUMMARY" fi done < <(echo "$TARGETS_JSON" | jq -r '.include[] | select(.key!="noop") | @base64') - echo "" >> $GITHUB_STEP_SUMMARY + echo "" >> "$GITHUB_STEP_SUMMARY" # Summary if [ ${#EXISTING_TAGS[@]} -gt 0 ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ⚠️ Warning: Existing Tags Detected" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The following tags already exist on the remote:" >> $GITHUB_STEP_SUMMARY + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "### ⚠️ Warning: Existing Tags Detected" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "The following tags already exist on the remote:" >> "$GITHUB_STEP_SUMMARY" for tag in "${EXISTING_TAGS[@]}"; do - echo "- $tag" >> $GITHUB_STEP_SUMMARY + echo "- $tag" >> "$GITHUB_STEP_SUMMARY" done - echo "" >> $GITHUB_STEP_SUMMARY + echo "" >> "$GITHUB_STEP_SUMMARY" if [ "${{ inputs.dry_run }}" = "false" ]; then if [ "${{ inputs.skip_tag_creation }}" = "true" ]; then - echo "**Note:** Tag creation is disabled for this run." >> $GITHUB_STEP_SUMMARY - echo "Components will be (re)published, but no git tags will be pushed." >> $GITHUB_STEP_SUMMARY + echo "**Note:** Tag creation is disabled for this run." >> "$GITHUB_STEP_SUMMARY" + echo "Components will be (re)published, but no git tags will be pushed." >> "$GITHUB_STEP_SUMMARY" else - echo "**Tag behavior with \`create-git-tag\` (SHA-match invariant):**" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- If a pre-existing tag points at the SAME commit this run is publishing, the tag step is a no-op (benign skip). The artifact is (re)published; the tag stays." >> $GITHUB_STEP_SUMMARY - echo "- If a pre-existing tag points at a DIFFERENT commit, the tag step hard-fails with recovery instructions. The artifact publish still runs (registries are idempotent) but no tag is pushed." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "To recover from a wrong-target tag:" >> $GITHUB_STEP_SUMMARY - echo "1. Verify the intended release commit." >> $GITHUB_STEP_SUMMARY - echo "2. Delete the existing tag on origin: \`git push --delete origin \`" >> $GITHUB_STEP_SUMMARY - echo "3. Rerun this workflow." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "To republish without touching tags, set \`skip_tag_creation: true\` on the workflow dispatch." >> $GITHUB_STEP_SUMMARY + echo "**Tag behavior with \`create-git-tag\` (SHA-match invariant):**" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- If a pre-existing tag points at the SAME commit this run is publishing, the tag step is a no-op (benign skip). The artifact is (re)published; the tag stays." >> "$GITHUB_STEP_SUMMARY" + echo "- If a pre-existing tag points at a DIFFERENT commit, the tag step hard-fails with recovery instructions. The artifact publish still runs (registries are idempotent) but no tag is pushed." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "To recover from a wrong-target tag:" >> "$GITHUB_STEP_SUMMARY" + echo "1. Verify the intended release commit." >> "$GITHUB_STEP_SUMMARY" + echo "2. Delete the existing tag on origin: \`git push --delete origin \`" >> "$GITHUB_STEP_SUMMARY" + echo "3. Rerun this workflow." >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "To republish without touching tags, set \`skip_tag_creation: true\` on the workflow dispatch." >> "$GITHUB_STEP_SUMMARY" fi fi fi if [ ${#NEW_TAGS[@]} -eq 0 ] && [ ${#EXISTING_TAGS[@]} -gt 0 ]; then - echo "### ℹ️ No New Tags to Create" >> $GITHUB_STEP_SUMMARY - echo "All specified components are already tagged at some commit. If tags match this run's commit, the rerun converges cleanly. If not, see the wrong-target recovery above or bump versions." >> $GITHUB_STEP_SUMMARY + echo "### ℹ️ No New Tags to Create" >> "$GITHUB_STEP_SUMMARY" + echo "All specified components are already tagged at some commit. If tags match this run's commit, the rerun converges cleanly. If not, see the wrong-target recovery above or bump versions." >> "$GITHUB_STEP_SUMMARY" elif [ ${#NEW_TAGS[@]} -gt 0 ]; then - if [ "${{ inputs.skip_tag_creation }}" = "true" ]; then - echo "### ℹ️ Tags That Would Be Created (Skipped)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The following tags would be created if tag creation wasn't disabled:" >> $GITHUB_STEP_SUMMARY + if [ "${{ inputs.dry_run }}" = "true" ]; then + echo "### 🔍 Tags That Would Be Created (Dry Run)" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "The following tags would be created by a real run:" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + elif [ "${{ inputs.skip_tag_creation }}" = "true" ]; then + echo "### ℹ️ Tags That Would Be Created (Skipped)" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "The following tags would be created if tag creation wasn't disabled:" >> "$GITHUB_STEP_SUMMARY" else - echo "### ✅ Tags to be Created" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + echo "### ✅ Tags to be Created" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" fi for tag in "${NEW_TAGS[@]}"; do - echo "- $tag" >> $GITHUB_STEP_SUMMARY + echo "- $tag" >> "$GITHUB_STEP_SUMMARY" done fi @@ -610,7 +629,7 @@ jobs: echo "1. Delete the wrong tag(s) on origin: \`git push --delete origin \`" echo "2. Or bump the version(s) and rerun the workflow" echo "3. Or rerun with \`skip_tag_creation: true\` to republish artifacts only" - } >> $GITHUB_STEP_SUMMARY + } >> "$GITHUB_STEP_SUMMARY" echo "❌ Wrong-target tags detected: ${WRONG_TARGET_TAGS[*]}" echo "See the Wrong-target tags block in the step summary for recovery steps." if [ "${{ inputs.skip_tag_creation }}" != "true" ]; then @@ -672,7 +691,7 @@ jobs: - name: Download latest copy script from master if: inputs.use_latest_ci run: | - curl -sSL "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ + curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ -o /tmp/copy-latest-from-master.sh chmod +x /tmp/copy-latest-from-master.sh echo "✅ Downloaded latest copy script from master" @@ -841,7 +860,7 @@ jobs: if ! command -v yq >/dev/null 2>&1; then YQ_VERSION="v4.47.1" YQ_CHECKSUM="0fb28c6680193c41b364193d0c0fc4a03177aecde51cfc04d506b1517158c2fb" - curl -sSL -o /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64 + curl -fsSL --retry 5 --retry-all-errors -o /usr/local/bin/yq https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64 echo "${YQ_CHECKSUM} /usr/local/bin/yq" | sha256sum -c - || exit 1 chmod +x /usr/local/bin/yq fi @@ -983,7 +1002,7 @@ jobs: - name: Download latest copy script from master if: inputs.use_latest_ci run: | - curl -sSL "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ + curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ -o /tmp/copy-latest-from-master.sh chmod +x /tmp/copy-latest-from-master.sh echo "✅ Downloaded latest copy script from master" @@ -1285,7 +1304,7 @@ jobs: - name: Download latest copy script from master if: inputs.use_latest_ci run: | - curl -sSL "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ + curl -fsSL --retry 5 --retry-all-errors "https://raw.githubusercontent.com/${{ github.repository }}/master/scripts/copy-latest-from-master.sh" \ -o /tmp/copy-latest-from-master.sh chmod +x /tmp/copy-latest-from-master.sh echo "✅ Downloaded latest copy script from master" diff --git a/.github/workflows/stale-prs.yml b/.github/workflows/stale-prs.yml index 0038d49a12..54e013dc0a 100644 --- a/.github/workflows/stale-prs.yml +++ b/.github/workflows/stale-prs.yml @@ -51,7 +51,9 @@ jobs: This pull request was automatically closed because it has been inactive for 14 days. Feel free to reopen it if you'd like to continue working on it. - exempt-pr-labels: "pinned" + # S-waiting-on-review means the next move is ours, not the author's, + # so the inactivity clock should not run against them. + exempt-pr-labels: "pinned,S-waiting-on-review" exempt-draft-pr: true days-before-issue-stale: -1 days-before-issue-close: -1 diff --git a/AGENTS.md b/AGENTS.md index 5e74d5a6ff..f3f3ce4231 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,11 +6,10 @@ Transports: QUIC, WebSocket, TCP (custom binary), HTTP (REST). SDKs: Rust, .NET, Java, Python, Go, C++, Node.js. A connectors subsystem ingests from / egresses to external systems via dlopened plugins. -> Skills under `.claude/skills/` are currently scoped to the -> **connectors** subsystem (`core/connectors/`). Load +> Skills live under `.claude/skills/`. Load > [connectors-overview](.claude/skills/connectors-overview/SKILL.md) -> first for any change there. Other subsystems follow the repo-wide -> principles in this file. +> first for any change under `core/connectors/`. Other subsystems +> follow the repo-wide principles in this file. ## Contents @@ -121,8 +120,8 @@ iggy/ ## Skills -Connectors-scoped. Each `SKILL.md` has YAML frontmatter (name, -description). Load `connectors-overview` first as router. +Each `SKILL.md` has YAML frontmatter (name, description). For +connectors work, load `connectors-overview` first as router. - [connectors-overview](.claude/skills/connectors-overview/SKILL.md) - router + universal connector rules - [connector-runtime](.claude/skills/connector-runtime/SKILL.md) - FFI host, lifecycle, state, metrics @@ -132,6 +131,12 @@ description). Load `connectors-overview` first as router. - [connector-transform](.claude/skills/connector-transform/SKILL.md) - transform authoring - [connector-testing](.claude/skills/connector-testing/SKILL.md) - unit + integration test patterns +Repo-wide, user-invoked only. `disable-model-invocation: true` keeps it +out of the agent's context; do not replicate its steps. When a +non-trivial change passes verification, suggest `/team-review `. + +- [team-review](.claude/skills/team-review/SKILL.md) - adversarial 4-expert PR/branch review, ~10 subagents per run + ## Repo-wide principles 1. **Apache 2.0 header on every new source file:** Follow the comment style configured in `licenserc.toml`; common examples: `.rs` uses `// ...`, `Cargo.toml` and shell use `# ...`. @@ -183,9 +188,11 @@ description). Load `connectors-overview` first as router. BDD test naming: `given_X_when_Y_should_Z` (3-part). Promote to 4-part `given_X_when_Y_then_Z_should_W` only with a distinct "then" intermediate. Be consistent inside a file. -Filter a single integration test by path: +Filter a single integration test by path. Build the binaries the harness launches first: it runs +whatever is already in `target/`, so a stale one fails as though the change under test broke it. ```bash +cargo build --bin iggy-server --bin iggy-connectors cargo test -p integration -- connectors::runtime::benchmark::given_logging_format_json ``` @@ -197,6 +204,7 @@ cargo test -p integration -- connectors::runtime::benchmark::given_logging_forma - **`test_logs/` grows quickly.** Wipe between major refactors. - **Miri only covers `binary_protocol` + `consensus`.** Cannot emulate `io_uring` syscalls. do not try to expand Miri to crates that pull `compio`. - **Integration crate has no `--test` target.** Filter by test path inside the single `mod.rs` binary. +- **The integration harness runs prebuilt binaries and never builds them.** `Command::cargo_bin` only resolves a path, so a stale `iggy-server`, `iggy-connectors` or `iggy-mcp` is used silently. CI builds first (`cargo build --locked --bin iggy-server --bin iggy`); a local run does not, and a stale binary fails in ways that look like the change under test. Rebuild after anything that touches a launched binary or its config. ## Local state directories diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8fac6a2f8..0df2c89eb6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Every new PR that introduces new functionality must link to an approved issue. PRs without one may be closed at maintainer's discretion. 1. Create an issue or comment under existing -2. Wait for maintainer approval (`good-first-issue` label or comment) +2. Wait for the issue to be assigned to you - Maintainer may request for more details or a different approach 3. Then code @@ -28,6 +28,23 @@ These require design discussion in the issue before coding: Authors of PRs must run the code locally. "Relying on CI" is not acceptable. +### AI Assistance + +You are responsible for the code you submit, even if a tool wrote it. + +Using an AI assistant to help you code is fine. Submitting code you don't understand is +not. Before you open a PR you must be able to explain what every part of the change does +and why, answer review questions about it yourself, and defend the design without going +back to the tool for an answer. If you can't, the PR isn't ready. + +While you're new to the project, please keep to **one open PR at a time**. Review takes +longer than writing, so a queue of changes from one contributor holds up everyone else's. + +Maintainers may close a PR at first review if it reads as a relay between the reviewer and +a model, rather than a change the author understands and takes responsibility for. That is +a judgment about the submission, not about you, and it does not bar you from contributing +again if you come back with a change you can take responsibility for. + ### Green CI Maintainers will not start reviewing a PR while its CI is failing. Get the @@ -146,6 +163,8 @@ line in a regular PR comment (not an inline review reply): | `/ready` | author or maintainer | mark `S-waiting-on-review` | | `/author` | maintainer or returning contributor | mark `S-waiting-on-author` | | `/request-review @user-or-team ...` | author or maintainer | request review from the listed `@user` / `@org/team` handles | +| `/pin` | author or maintainer | add `pinned`, exempting the PR from the stale bot | +| `/unpin` | author or maintainer | remove `pinned` | Some labels move on their own: opening or marking a non-draft PR ready sets `S-waiting-on-review`; a "Request changes" review sets `S-waiting-on-author`; @@ -164,8 +183,22 @@ PRs may be closed if: - Code not ran and tested locally - Mixed purposes or purposes not clear - Can't answer questions about the change -- Inactivity for longer than 7 days +- Inactivity, see [Stale PRs](#stale-prs) below + +Whoever closes leaves a comment saying why. If the thread holds a finding +that outlives the change, open an issue for it and link it from that +comment. The closed thread is the first place someone looks to find out +whether anything fell through. + +### Stale PRs + +A bot labels a PR `S-stale` after 7 days without activity and closes it 7 +days after that. A push, comment, review, reopen, or ready-for-review +clears the label. Drafts and PRs labeled `pinned` are exempt. Issues are +never labeled or closed by it. A closed PR can be reopened. ## Questions? -[Discussions](https://github.com/apache/iggy/discussions) or [Discord](https://discord.gg/apache-iggy) +[Discussions](https://github.com/apache/iggy/discussions), [Discord](https://discord.gg/apache-iggy) or the [dev@iggy.apache.org](https://iggy.apache.org/community/mailing-lists/) mailing list (subscribe by emailing ) + +GitHub Discussions are mirrored to dev@. The mirror only runs one way: replies sent to the list do not appear in Discussions. diff --git a/Cargo.lock b/Cargo.lock index c6aa065741..30c41241eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "actix-codec" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +checksum = "31404e1443b7b7bcaa311c1af456775cc3f668e34573f1503d7ce4844327629e" dependencies = [ "bitflags 2.13.1", "bytes", @@ -21,9 +21,9 @@ dependencies = [ [[package]] name = "actix-cors" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" +checksum = "2aff07ada3254fc02618cb7850da91dceb23b5dbda53c6676ccbb28ba504f150" dependencies = [ "actix-utils", "actix-web", @@ -36,9 +36,9 @@ dependencies = [ [[package]] name = "actix-files" -version = "0.6.10" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" +checksum = "ed87c765e9e5be096cc29b43d04b932209892ec871d05fe255f0ae1ccfed9a06" dependencies = [ "actix-http", "actix-service", @@ -59,15 +59,15 @@ dependencies = [ [[package]] name = "actix-http" -version = "3.13.1" +version = "3.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" +checksum = "11004b0e9b44b4eb3d15e0c3132b96fb178c7e50a74758b2f17bb9cc9a7fb4f6" dependencies = [ "actix-codec", "actix-rt", "actix-service", "actix-utils", - "base64", + "base64 0.22.1", "bitflags 2.13.1", "brotli", "bytes", @@ -123,9 +123,9 @@ dependencies = [ [[package]] name = "actix-rt" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +checksum = "6a16bf2f19c2ad84842bdfe6f3665620e93197d5c607889bfa3aaac45e762fd1" dependencies = [ "futures-core", "tokio", @@ -133,9 +133,9 @@ dependencies = [ [[package]] name = "actix-server" -version = "2.6.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +checksum = "5d44ae8a6516f4ac7bfc7b61aabcd286104e96b4b24c747ce220832a016056d9" dependencies = [ "actix-rt", "actix-service", @@ -143,7 +143,7 @@ dependencies = [ "futures-core", "futures-util", "mio", - "socket2 0.5.10", + "socket2 0.6.5", "tokio", "tracing", ] @@ -170,9 +170,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.14.0" +version = "4.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" +checksum = "bbacab3593b6b4f7be815076fc52d60a83c873426824675417e2abdd229e2e36" dependencies = [ "actix-codec", "actix-http", @@ -229,6 +229,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] + [[package]] name = "aead" version = "0.6.1" @@ -252,29 +262,43 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.1" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher 0.5.2", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] name = "aes-gcm" -version = "0.11.0" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes 0.9.1", - "cipher 0.5.2", - "ctr", - "ghash", + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", +] + [[package]] name = "ahash" version = "0.7.8" @@ -303,13 +327,19 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + [[package]] name = "aligned" version = "0.4.3" @@ -349,11 +379,59 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "amq-protocol" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "587d313f3a8b4a40f866cc84b6059fe83133bf172165ac3b583129dd211d8e1c" +dependencies = [ + "amq-protocol-tcp", + "amq-protocol-types", + "amq-protocol-uri", + "cookie-factory", + "nom 7.1.3", + "serde", +] + +[[package]] +name = "amq-protocol-tcp" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc707ab9aa964a85d9fc25908a3fdc486d2e619406883b3105b48bf304a8d606" +dependencies = [ + "amq-protocol-uri", + "tcp-stream", + "tracing", +] + +[[package]] +name = "amq-protocol-types" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf99351d92a161c61ec6ecb213bc7057f5b837dd4e64ba6cb6491358efd770c4" +dependencies = [ + "cookie-factory", + "nom 7.1.3", + "serde", + "serde_json", +] + +[[package]] +name = "amq-protocol-uri" +version = "7.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89f8273826a676282208e5af38461a07fe939def57396af6ad5997fcf56577d" +dependencies = [ + "amq-protocol-types", + "percent-encoding", + "url", +] + [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -422,9 +500,10 @@ checksum = "36fa98bc79671c7981272d91a8753a928ff6a1cd8e4f20a44c45bd5d313840bf" dependencies = [ "bigdecimal", "bon", + "crc32fast", "digest 0.10.7", "log", - "miniz_oxide", + "miniz_oxide 0.8.9", "num-bigint", "quad-rand", "rand 0.9.5", @@ -432,29 +511,54 @@ dependencies = [ "serde", "serde_bytes", "serde_json", + "snap", "strum 0.27.2", "strum_macros 0.27.2", - "thiserror 2.0.19", + "thiserror 2.0.20", "uuid", "zstd", ] +[[package]] +name = "apache-avro" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312c1ea69e5fe9966e0029fb95aca8790100b85aff4f0d3b00a9337c74069a9c" +dependencies = [ + "bigdecimal", + "bon", + "digest 0.11.3", + "log", + "miniz_oxide 0.9.1", + "num-bigint", + "ouroboros", + "quad-rand", + "rand 0.10.2", + "regex-lite", + "serde", + "serde_bytes", + "serde_json", + "strum 0.28.0", + "thiserror 2.0.20", + "uuid", +] + [[package]] name = "apple-native-keyring-store" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" dependencies = [ "keyring-core", "log", - "security-framework", + "security-framework 3.7.0", ] [[package]] name = "ar_archive_writer" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" dependencies = [ "object", ] @@ -487,13 +591,13 @@ dependencies = [ [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.1", "password-hash", ] @@ -517,102 +621,49 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arrow" -version = "57.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bd47f2a6ddc39244bd722a27ee5da66c03369d087b9e024eafdb03e98b98ea7" -dependencies = [ - "arrow-arith 57.3.1", - "arrow-array 57.3.1", - "arrow-buffer 57.3.1", - "arrow-cast 57.3.1", - "arrow-csv 57.3.1", - "arrow-data 57.3.1", - "arrow-ipc 57.3.1", - "arrow-json 57.3.1", - "arrow-ord 57.3.1", - "arrow-row 57.3.1", - "arrow-schema 57.3.1", - "arrow-select 57.3.1", - "arrow-string 57.3.1", -] - -[[package]] -name = "arrow" -version = "58.3.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378530e55cd479eda3c14eb345310799717e6f76d0c332041e8487022166b471" +checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3" dependencies = [ - "arrow-arith 58.3.0", - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-cast 58.3.0", - "arrow-csv 58.3.0", - "arrow-data 58.3.0", - "arrow-ipc 58.3.0", - "arrow-json 58.3.0", - "arrow-ord 58.3.0", - "arrow-row 58.3.0", - "arrow-schema 58.3.0", - "arrow-select 58.3.0", - "arrow-string 58.3.0", + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-csv", + "arrow-data", + "arrow-ipc", + "arrow-json", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", ] [[package]] name = "arrow-arith" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c7bbd679c5418b8639b92be01f361d60013c4906574b578b77b63c78356594c" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" dependencies = [ - "arrow-array 57.3.1", - "arrow-buffer 57.3.1", - "arrow-data 57.3.1", - "arrow-schema 57.3.1", - "chrono", - "num-traits", -] - -[[package]] -name = "arrow-arith" -version = "58.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ab212d2c1886e802f51c5212d78ebbcbb0bec980fff9dadc1eb8d45cd0b738" -dependencies = [ - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-data 58.3.0", - "arrow-schema 58.3.0", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", "chrono", "num-traits", ] [[package]] name = "arrow-array" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8a4ab47b3f3eac60f7fd31b81e9028fda018607bcc63451aca4f2b755269862" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" dependencies = [ "ahash 0.8.12", - "arrow-buffer 57.3.1", - "arrow-data 57.3.1", - "arrow-schema 57.3.1", - "chrono", - "half", - "hashbrown 0.16.1", - "num-complex", - "num-integer", - "num-traits", -] - -[[package]] -name = "arrow-array" -version = "58.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd33d3e92f207444098c75b42de99d329562be0cf686b307b097cc52b4e999e" -dependencies = [ - "ahash 0.8.12", - "arrow-buffer 58.3.0", - "arrow-data 58.3.0", - "arrow-schema 58.3.0", + "arrow-buffer", + "arrow-data", + "arrow-schema", "chrono", "chrono-tz", "half", @@ -624,21 +675,9 @@ dependencies = [ [[package]] name = "arrow-buffer" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d18b89b4c4f4811d0858175e79541fe98e33e18db3b011708bc287b1240593f" -dependencies = [ - "bytes", - "half", - "num-bigint", - "num-traits", -] - -[[package]] -name = "arrow-buffer" -version = "58.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6cd424c2693bcdbc150d843dc9d4d137dd2de4782ce6df491ad11a3a0416c0" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" dependencies = [ "bytes", "half", @@ -648,18 +687,18 @@ dependencies = [ [[package]] name = "arrow-cast" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "722b5c41dd1d14d0a879a1bce92c6fe33f546101bb2acce57a209825edd075b3" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" dependencies = [ - "arrow-array 57.3.1", - "arrow-buffer 57.3.1", - "arrow-data 57.3.1", - "arrow-ord 57.3.1", - "arrow-schema 57.3.1", - "arrow-select 57.3.1", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ord", + "arrow-schema", + "arrow-select", "atoi", - "base64", + "base64 0.22.1", "chrono", "half", "lexical-core", @@ -667,51 +706,15 @@ dependencies = [ "ryu", ] -[[package]] -name = "arrow-cast" -version = "58.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c5aefb56a2c02e9e2b30746241058b85f8983f0fcff2ba0c6d09006e1cded7f" -dependencies = [ - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-data 58.3.0", - "arrow-ord 58.3.0", - "arrow-schema 58.3.0", - "arrow-select 58.3.0", - "atoi", - "base64", - "chrono", - "half", - "lexical-core", - "num-traits", - "ryu", -] - -[[package]] -name = "arrow-csv" -version = "57.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ddb80a4848e03b1655af496d5ac2563a779e5742fcb48f2ca2e089c9cd2197" -dependencies = [ - "arrow-array 57.3.1", - "arrow-cast 57.3.1", - "arrow-schema 57.3.1", - "chrono", - "csv", - "csv-core", - "regex", -] - [[package]] name = "arrow-csv" -version = "58.3.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94e8cf7e517657a52b91ea1263acf38c4ca62a84655d72458a3359b12ab97de" +checksum = "af0dd6d90d1955e9f9a014c1e563ee8aeffc21909085d25623e1da44d96eca26" dependencies = [ - "arrow-array 58.3.0", - "arrow-cast 58.3.0", - "arrow-schema 58.3.0", + "arrow-array", + "arrow-cast", + "arrow-schema", "chrono", "csv", "csv-core", @@ -720,97 +723,46 @@ dependencies = [ [[package]] name = "arrow-data" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1683705c63dcf0d18972759eda48489028cbbff67af7d6bef2c6b7b74ab778a" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" dependencies = [ - "arrow-buffer 57.3.1", - "arrow-schema 57.3.1", + "arrow-buffer", + "arrow-schema", "half", "num-integer", "num-traits", ] -[[package]] -name = "arrow-data" -version = "58.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c88210023a2bfee1896af366309a3028fc3bcbd6515fa29a7990ee1baa08ee0" -dependencies = [ - "arrow-buffer 58.3.0", - "arrow-schema 58.3.0", - "half", - "num-integer", - "num-traits", -] - -[[package]] -name = "arrow-ipc" -version = "57.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf72d04c07229fbf4dbebe7145cac37d7cf7ec582fe705c6b92cb314af096ab" -dependencies = [ - "arrow-array 57.3.1", - "arrow-buffer 57.3.1", - "arrow-data 57.3.1", - "arrow-schema 57.3.1", - "arrow-select 57.3.1", - "flatbuffers", -] - [[package]] name = "arrow-ipc" -version = "58.3.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "238438f0834483703d88896db6fe5a7138b2230debc31b34c0336c2996e3c64f" +checksum = "29a908a11fcfb3fb2f6730f4ac15e367bc644e419155e96238f68cf3adde572b" dependencies = [ - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-data 58.3.0", - "arrow-schema 58.3.0", - "arrow-select 58.3.0", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", "flatbuffers", ] [[package]] name = "arrow-json" -version = "57.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a84a905f41fedfcd7679813c89a61dc369c0f932b27aa8dcc6aa051cc781a97d" -dependencies = [ - "arrow-array 57.3.1", - "arrow-buffer 57.3.1", - "arrow-cast 57.3.1", - "arrow-data 57.3.1", - "arrow-schema 57.3.1", - "chrono", - "half", - "indexmap 2.14.0", - "itoa", - "lexical-core", - "memchr", - "num-traits", - "ryu", - "serde_core", - "serde_json", - "simdutf8", -] - -[[package]] -name = "arrow-json" -version = "58.3.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "205ca2119e6d679d5c133c6f30e68f027738d95ed948cf77677ea69c7800036b" +checksum = "b8a96aed3931c076adee39ec2a40d8219fc7f09e79bcdaca1df16272993e1e14" dependencies = [ - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-cast 58.3.0", - "arrow-ord 58.3.0", - "arrow-schema 58.3.0", - "arrow-select 58.3.0", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-ord", + "arrow-schema", + "arrow-select", "chrono", "half", - "indexmap 2.14.0", + "indexmap 2.14.2", "itoa", "lexical-core", "memchr", @@ -823,67 +775,35 @@ dependencies = [ [[package]] name = "arrow-ord" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "082342947d4e5a2bcccf029a0a0397e21cb3bb8421edd9571d34fb5dd2670256" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" dependencies = [ - "arrow-array 57.3.1", - "arrow-buffer 57.3.1", - "arrow-data 57.3.1", - "arrow-schema 57.3.1", - "arrow-select 57.3.1", -] - -[[package]] -name = "arrow-ord" -version = "58.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bffd8fd2579286a5d63bac898159873e5094a79009940bcb42bbfce4f19f1d0" -dependencies = [ - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-data 58.3.0", - "arrow-schema 58.3.0", - "arrow-select 58.3.0", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", ] [[package]] name = "arrow-row" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a931b520a2a5e22033e01a6f2486b4cdc26f9106b759abeebc320f125e94d7" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" dependencies = [ - "arrow-array 57.3.1", - "arrow-buffer 57.3.1", - "arrow-data 57.3.1", - "arrow-schema 57.3.1", - "half", -] - -[[package]] -name = "arrow-row" -version = "58.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab5994731204603c73ba69267616c50f80780774c6bb0476f1f830625115e0c" -dependencies = [ - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-data 58.3.0", - "arrow-schema 58.3.0", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", "half", ] [[package]] name = "arrow-schema" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4cf0d4a6609679e03002167a61074a21d7b1ad9ea65e462b2c0a97f8a3b2bc6" - -[[package]] -name = "arrow-schema" -version = "58.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f633dbfdf39c039ada1bf9e34c694816eb71fbb7dc78f613993b7245e078a1ed" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" dependencies = [ "bitflags 2.13.1", "serde", @@ -892,60 +812,29 @@ dependencies = [ [[package]] name = "arrow-select" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b320d86a9806923663bb0fd9baa65ecaba81cb0cd77ff8c1768b9716b4ef891" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" dependencies = [ "ahash 0.8.12", - "arrow-array 57.3.1", - "arrow-buffer 57.3.1", - "arrow-data 57.3.1", - "arrow-schema 57.3.1", - "num-traits", -] - -[[package]] -name = "arrow-select" -version = "58.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd065c54172ac787cf3f2f8d4107e0d3fdc26edba76fdf4f4cc170258942222" -dependencies = [ - "ahash 0.8.12", - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-data 58.3.0", - "arrow-schema 58.3.0", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", "num-traits", ] [[package]] name = "arrow-string" -version = "57.3.1" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b493e99162e5764077e7823e50ba284858d365922631c7aaefe9487b1abd02c2" +checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c" dependencies = [ - "arrow-array 57.3.1", - "arrow-buffer 57.3.1", - "arrow-data 57.3.1", - "arrow-schema 57.3.1", - "arrow-select 57.3.1", - "memchr", - "num-traits", - "regex", - "regex-syntax", -] - -[[package]] -name = "arrow-string" -version = "58.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29dd7cda3ab9692f43a2e4acc444d760cc17b12bb6d8232ddf64e9bab7c06b42" -dependencies = [ - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-data 58.3.0", - "arrow-schema 58.3.0", - "arrow-select 58.3.0", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", "memchr", "num-traits", "regex", @@ -979,7 +868,7 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -1053,7 +942,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" dependencies = [ - "event-listener", + "event-listener 5.4.2", "event-listener-strategy", "futures-core", "pin-project-lite", @@ -1073,9 +962,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -1131,12 +1020,57 @@ checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", - "fastrand", - "futures-lite", + "fastrand 2.5.0", + "futures-lite 2.6.1", "pin-project-lite", "slab", ] +[[package]] +name = "async-global-executor" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f937e26114b93193065fd44f507aa2e9169ad0cdabbb996920b1fe1ddea7ba" +dependencies = [ + "async-channel", + "async-executor", + "async-io 2.6.0", + "async-lock 3.4.2", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "async-global-executor-trait" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af57045d58eeb1f7060e7025a1631cbc6399e0a1d10ad6735b3d0ea7f8346ce" +dependencies = [ + "async-global-executor", + "async-trait", + "executor-trait", +] + +[[package]] +name = "async-io" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af" +dependencies = [ + "async-lock 2.8.0", + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-lite 1.13.0", + "log", + "parking", + "polling 2.8.0", + "rustix 0.37.28", + "slab", + "socket2 0.4.10", + "waker-fn", +] + [[package]] name = "async-io" version = "2.6.0" @@ -1147,21 +1081,30 @@ dependencies = [ "cfg-if", "concurrent-queue", "futures-io", - "futures-lite", + "futures-lite 2.6.1", "parking", - "polling", + "polling 3.11.0", "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] +[[package]] +name = "async-lock" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287272293e9d8c41773cec55e365490fe034813a2f172f502d6ddcf75b2f582b" +dependencies = [ + "event-listener 2.5.3", +] + [[package]] name = "async-lock" version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener", + "event-listener 5.4.2", "event-listener-strategy", "pin-project-lite", ] @@ -1173,17 +1116,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" dependencies = [ "async-channel", - "async-io", - "async-lock", + "async-io 2.6.0", + "async-lock 3.4.2", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener", - "futures-lite", + "event-listener 5.4.2", + "futures-lite 2.6.1", "rustix 1.1.4", ] +[[package]] +name = "async-reactor-trait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6012d170ad00de56c9ee354aef2e358359deb1ec504254e0e5a3774771de0e" +dependencies = [ + "async-io 1.13.0", + "async-trait", + "futures-core", + "reactor-trait", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -1212,8 +1167,8 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ - "async-io", - "async-lock", + "async-io 2.6.0", + "async-lock 3.4.2", "atomic-waker", "cfg-if", "futures-core", @@ -1254,13 +1209,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.4", ] [[package]] @@ -1281,15 +1236,16 @@ dependencies = [ [[package]] name = "async_zip" -version = "0.0.18" +version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" +checksum = "fb7f5f40e1eb30949a266fc900d37fd3267c7baf50c3705ac09c5d8ced5def63" dependencies = [ "async-compression", + "binrw", "crc32fast", - "futures-lite", + "futures-lite 2.6.1", "pin-project", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", ] @@ -1333,8 +1289,8 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64", - "http 1.4.2", + "base64 0.22.1", + "http 1.5.0", "log", "rustls", "serde", @@ -1373,7 +1329,7 @@ dependencies = [ "num-traits", "pastey 0.1.1", "rayon", - "thiserror 2.0.19", + "thiserror 2.0.20", "v_frame", "y4m", ] @@ -1403,9 +1359,9 @@ dependencies = [ [[package]] name = "aws-config" -version = "1.9.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47712fde1909402600ccfbb26e47d482d2e58bb9e9e603d9f17e67cc435a6319" +checksum = "a767267da9e2c2e189b2f9df8b5657e850ecf5352644734ba130d4a57095cf1b" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1421,9 +1377,9 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand", + "fastrand 2.5.0", "hex", - "http 1.4.2", + "http 1.5.0", "sha1 0.10.7", "time", "tokio", @@ -1456,16 +1412,16 @@ dependencies = [ "quick-xml 0.38.4", "rust-ini", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "url", ] [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -1474,9 +1430,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -1491,14 +1447,14 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "838b36c8dc927b6db1b6c6b8f5d05865f2213550b9e83bf92fa99ed6525472c0" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "aws-runtime" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7816e98ee912159f45d307e5ee6bfea4a335a55aee15f7f3e32f81a6f3000f1d" +checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -1510,8 +1466,8 @@ dependencies = [ "aws-types", "bytes", "bytes-utils", - "fastrand", - "http 1.4.2", + "fastrand 2.5.0", + "http 1.5.0", "http-body 1.1.0", "percent-encoding", "pin-project-lite", @@ -1521,9 +1477,9 @@ dependencies = [ [[package]] name = "aws-sdk-dynamodb" -version = "1.117.0" +version = "1.123.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f2b4adcf6592e06ebb2c78235ae09cee178263a5b7fc20ae5ea07ca67067bb6" +checksum = "dcd10d92058d5da989a7838fd09d70e9457390a81ba672abf5685099f3562bce" dependencies = [ "arc-swap", "aws-credential-types", @@ -1538,18 +1494,19 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand", + "fastrand 2.5.0", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "regex-lite", "tracing", + "url", ] [[package]] name = "aws-sdk-sso" -version = "1.103.0" +version = "1.108.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0469f435f645ad2162cfb463b15bde37115966ee3acf2d87fb4871ee309b8401" +checksum = "c15301b04372832947916607983b114b3374b9db0be058a00fb7513800de1f05" dependencies = [ "arc-swap", "aws-credential-types", @@ -1564,18 +1521,18 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand", + "fastrand 2.5.0", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.105.0" +version = "1.110.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "085faefb253f770655e162b9304321e62a1e71adf7f019ee1f4454228a377b3a" +checksum = "72cc2c205cb27108183cf1856333f7d584c2ba0f505421b4209ca5828f9ea899" dependencies = [ "arc-swap", "aws-credential-types", @@ -1590,18 +1547,18 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand", + "fastrand 2.5.0", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.108.0" +version = "1.113.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c72b08911d8128dd360fe1b22a9fec0fa8b552dde8ec828dcf20ef5ec974e9f" +checksum = "68182ecb449f7537db0f4d5d25917789cf41e32074a9fe47b6a0b847fe1d2032" dependencies = [ "arc-swap", "aws-credential-types", @@ -1617,9 +1574,9 @@ dependencies = [ "aws-smithy-types", "aws-smithy-xml", "aws-types", - "fastrand", + "fastrand 2.5.0", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "regex-lite", "tracing", ] @@ -1639,7 +1596,7 @@ dependencies = [ "hex", "hmac 0.13.0", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "percent-encoding", "sha2 0.11.0", "time", @@ -1669,7 +1626,7 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "percent-encoding", @@ -1680,21 +1637,21 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.2.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e" +checksum = "ebfd138fac0337cee7516c352757ea73b9f2266e57d0bcb5bc70e9547e45aef1" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.4.15", - "http 1.4.2", + "h2 0.4.19", + "http 1.5.0", "hyper", "hyper-rustls", "hyper-util", "pin-project-lite", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", "tokio-rustls", @@ -1724,19 +1681,22 @@ dependencies = [ [[package]] name = "aws-smithy-query" -version = "0.61.1" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd22a6ba36e3f113cb8d5b3d1fe0ed31c76ee608ef63322d753bb8d2c9479e77" +checksum = "512346c7212ab7436df2d77a16d976a468ae44a418835511d2a69269810aaf62" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", + "aws-smithy-xml", "urlencoding", ] [[package]] name = "aws-smithy-runtime" -version = "1.12.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea94a9ff8464016338c851e24b472d7131c388c88898a502e781815b2ee6045" +checksum = "b82e438d30e02a825d363bd639a9efaed68a8089d86101054b0081e7e0d3e606" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -1746,9 +1706,9 @@ dependencies = [ "aws-smithy-schema", "aws-smithy-types", "bytes", - "fastrand", + "fastrand 2.5.0", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "http-body 0.4.6", "http-body 1.1.0", "http-body-util", @@ -1760,16 +1720,16 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ed1ebe6e0a95ea84570225f5a8208dec4b8f77e61a9b0d6f51773fcb4612f0" +checksum = "954c563ce84507722d2679f07a35d21b9c6466b3872d513020d0281fc8112ac9" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "pin-project-lite", "tokio", "tracing", @@ -1795,21 +1755,21 @@ checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", - "http 1.4.2", + "http 1.5.0", ] [[package]] name = "aws-smithy-types" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" +checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647" dependencies = [ "base64-simd", "bytes", "bytes-utils", "futures-core", "http 0.2.12", - "http 1.4.2", + "http 1.5.0", "http-body 0.4.6", "http-body 1.1.0", "http-body-util", @@ -1826,9 +1786,9 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.61.1" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea3f68eec3607f02acd24067969ce2abc6ba16aa7d5ce59ca450ed2fb5f78957" +checksum = "ce84f71c72fee2cbbadde6e7d082f5fb466e3a84733855295fa7aafd1b31b7d8" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -1838,9 +1798,9 @@ dependencies = [ [[package]] name = "aws-types" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e957a6c6dbce82b7a91f44231c09273159703769f447cbe85e854dfe9cf67f86" +checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -1862,7 +1822,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "hyper", @@ -1893,7 +1853,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "mime", @@ -1925,7 +1885,7 @@ dependencies = [ "bytes", "either", "fs-err", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "hyper", "hyper-util", @@ -1949,7 +1909,7 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ - "fastrand", + "fastrand 2.5.0", "gloo-timers 0.3.0", "tokio", ] @@ -1966,6 +1926,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.8.0" @@ -2010,7 +1976,7 @@ checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" [[package]] name = "bench-dashboard-frontend" -version = "0.8.0-edge.3" +version = "0.8.0" dependencies = [ "bench-dashboard-shared", "bench-report", @@ -2020,7 +1986,7 @@ dependencies = [ "gloo 0.12.0", "js-sys", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "uuid", "wasm-bindgen", "web-sys", @@ -2040,7 +2006,7 @@ dependencies = [ [[package]] name = "bench-report" -version = "0.4.0-edge.3" +version = "0.4.0" dependencies = [ "charming", "colored", @@ -2111,7 +2077,7 @@ dependencies = [ "clang-sys", "itertools 0.13.0", "log", - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "quote", "regex", @@ -2120,6 +2086,30 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "binrw" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ad120d555272286c1017d25165ab8bd74806f13fc85b258484ec7e4ce75458f" +dependencies = [ + "array-init", + "binrw_derive", + "bytemuck", +] + +[[package]] +name = "binrw_derive" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92e0e9baae4dc82c7bad7715ca40c0a5c71539057bf2ea04a5c29c980410b" +dependencies = [ + "either", + "owo-colors", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -2207,25 +2197,24 @@ dependencies = [ [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] name = "blake3" -version = "1.8.5" +version = "1.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" dependencies = [ - "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -2255,6 +2244,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -2266,14 +2264,14 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", "futures-io", - "futures-lite", + "futures-lite 2.6.1", "piper", ] @@ -2294,7 +2292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" dependencies = [ "async-stream", - "base64", + "base64 0.22.1", "bitflags 2.13.1", "bollard-buildkit-proto", "bollard-stubs", @@ -2303,7 +2301,7 @@ dependencies = [ "futures-util", "hex", "home", - "http 1.4.2", + "http 1.5.0", "http-body-util", "hyper", "hyper-named-pipe", @@ -2315,13 +2313,13 @@ dependencies = [ "pin-project-lite", "rand 0.9.5", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "rustls-pki-types", "serde", "serde_derive", "serde_json", "serde_urlencoded", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tokio-stream", @@ -2351,7 +2349,7 @@ version = "1.52.1-rc.29.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" dependencies = [ - "base64", + "base64 0.22.1", "bollard-buildkit-proto", "bytes", "prost", @@ -2363,34 +2361,32 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.3" +version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +checksum = "60eafe0d77c3a2fc292c1d1346c3041b33c0a108085a2afabf672b70f69dbbc9" dependencies = [ "bon-macros", - "rustversion", ] [[package]] name = "bon-macros" -version = "3.9.3" +version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +checksum = "bd0f9631d8aaaee112c41985d675ef269e02acbd4f33122836af4f0c5f699ff6" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "ident_case", - "prettyplease", + "prettyplease 0.3.0", "proc-macro2", "quote", - "rustversion", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] name = "borsh" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ "borsh-derive", "bytes", @@ -2399,15 +2395,15 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" dependencies = [ "once_cell", "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -2447,12 +2443,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969a9ba84b0ff843813e7249eed1678d9b6607ce5a3b8f0a47af3fcf7978e6e" dependencies = [ "ahash 0.8.12", - "base64", + "base64 0.22.1", "bitvec", "getrandom 0.2.17", "getrandom 0.3.4", "hex", - "indexmap 2.14.0", + "indexmap 2.14.2", "js-sys", "once_cell", "rand 0.9.5", @@ -2465,9 +2461,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -2492,25 +2488,25 @@ version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2235eb320cd7178862a32dd111bd0c0f71a368e393add4914c50129add478eab" dependencies = [ - "arrow 58.3.0", + "arrow", "buoyant_kernel_derive", "bytes", "chrono", "crc", "futures", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools 0.14.0", "object_store", - "parquet 58.3.0", + "parquet", "percent-encoding", "rand 0.9.5", - "reqwest 0.13.4", + "reqwest 0.13.5", "roaring", "rustc_version", "serde", "serde_json", "strum 0.27.2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "tracing-subscriber", @@ -2532,12 +2528,12 @@ dependencies = [ [[package]] name = "byte-unit" -version = "5.2.5" +version = "5.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a813de7f2bbedb7dce265b64f1cf5908ebe4d56281ece8d847e98113788b9b0" +checksum = "c719d56f7e96194cfc53460976d3ba51c85719747c9c62ed99981847b551152b" dependencies = [ "rust_decimal", - "schemars 1.2.1", + "schemars 1.2.2", "serde", "utf8-width", ] @@ -2581,13 +2577,13 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -2638,9 +2634,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.4" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] @@ -2686,7 +2682,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2700,18 +2696,27 @@ dependencies = [ [[package]] name = "cbc" -version = "0.1.2" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ - "cipher 0.4.4", + "cipher 0.5.2", ] [[package]] name = "cc" -version = "1.3.0" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -2747,7 +2752,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -2813,7 +2818,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", + "crypto-common 0.1.6", "inout 0.1.4", ] @@ -2830,9 +2835,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -2841,9 +2846,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.3" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -2851,9 +2856,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -2864,23 +2869,23 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.7" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8b397918185f0161ff3d6fcaa9e4bfc09b8367caf6e1d4a2848e5477ed027b" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.3" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f2392eae7f16557a3d727ef3a12e57b2b2ca6f98566a5f4fb41ffe305df077" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -2911,13 +2916,25 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "x509-cert", +] + [[package]] name = "cobs" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2943,9 +2960,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -2953,9 +2970,9 @@ dependencies = [ [[package]] name = "comfy-table" -version = "7.2.2" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +checksum = "136c8c4c3823846e8ba6d4bda011b4e5d5827b8bb0ac26c31f9ab31abe9f54f2" dependencies = [ "unicode-segmentation", "unicode-width 0.2.2", @@ -2976,9 +2993,9 @@ dependencies = [ [[package]] name = "compio" -version = "0.19.1" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5cf9e29d3c2d2a37078198795631b61d2b5c9cab68191e31526fe342d742135" +checksum = "c19d81c636b0f47aa50041d25b02d9d8289a3319727a8051209f71bea10e89d6" dependencies = [ "compio-buf", "compio-driver", @@ -3006,9 +3023,9 @@ dependencies = [ [[package]] name = "compio-driver" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1f07e864382cbb51d601416dc41cb1dc99ad3a01c6745cece55e431ca547fe" +checksum = "293e8086a35f52b5002402937cf4e69b5e414917e511c29c5e7ba2cebe6ef7b3" dependencies = [ "bitflags 2.13.1", "cfg_aliases", @@ -3016,7 +3033,7 @@ dependencies = [ "compio-log", "compio-send-wrapper", "crossbeam-queue", - "flume", + "flume 0.12.0", "futures-util", "io-uring", "libc", @@ -3024,7 +3041,7 @@ dependencies = [ "mod_use", "once_cell", "pastey 0.2.3", - "polling", + "polling 3.11.0", "rustix 1.1.4", "smallvec", "socket2 0.6.5", @@ -3035,9 +3052,9 @@ dependencies = [ [[package]] name = "compio-executor" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2402a55af5e31454cd77d4b6044e2e6b8c30aa037655585ebd6505b97680468c" +checksum = "94961c5908b02bbb082e046968b8ee3d18995845a5c261376f0b0b5c95c87c2b" dependencies = [ "compio-log", "compio-send-wrapper", @@ -3048,9 +3065,9 @@ dependencies = [ [[package]] name = "compio-fs" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "934c384c7c1dca1d68540bd0ef16186a8e7f33fab330e54652b45ee80f051ee4" +checksum = "8a374c5a03bdf7ecb42894a49c3a40c22f743f8d7dc39cc21a5dd3258d8219c7" dependencies = [ "cfg_aliases", "compio-buf", @@ -3084,31 +3101,31 @@ dependencies = [ [[package]] name = "compio-log" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef39fff6341af7ab6c27fae9a3887e1ec618320d43f3b9c924c7a644f693a859" +checksum = "9dd867f29de59e4eff577dfeee3744cb0a264249f3faaf11945e8be79a3c3c62" dependencies = [ "tracing", ] [[package]] name = "compio-macros" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9ac573b3bb60fffabf47c2ce01c61536fb5eb2168d66e0375e85603e7d51614" +checksum = "dc53390a911fe6b317abe66c0583723539477f81a940e124b40d462fb976674a" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] name = "compio-net" -version = "0.12.2" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e940998522d9f78342b7ab5c663aefe888a504b74a7f933dcd058cdde26df392" +checksum = "31737ec57865d2b46284def673cd21fe478c592ca05c1e040e10d457aac6c2a8" dependencies = [ "compio-buf", "compio-driver", @@ -3127,9 +3144,9 @@ dependencies = [ [[package]] name = "compio-quic" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd50cb1b2c2c8276728871bc3bf3c12618c551cdd550123d564aa49b059aea5" +checksum = "65b1501ba168bfd2cf63ffc120d81a015a1d78cc853bbd9b4f2a7cb85cd2ab9a" dependencies = [ "cfg_aliases", "compio-buf", @@ -3137,7 +3154,7 @@ dependencies = [ "compio-log", "compio-net", "compio-runtime", - "flume", + "flume 0.12.0", "futures-util", "libc", "quinn-proto", @@ -3145,15 +3162,15 @@ dependencies = [ "rustls", "rustls-platform-verifier", "synchrony", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows-sys 0.61.2", ] [[package]] name = "compio-runtime" -version = "0.12.3" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3919cf83e9e531fdceda551085cdf05d570de98436401c89f121d3fae827521" +checksum = "20406f0a4e69cdd0c0a58c4a7cf51bcc965a543fb5da2fe931e970354e291ded" dependencies = [ "compio-buf", "compio-driver", @@ -3250,10 +3267,11 @@ dependencies = [ "figment", "iggy_common", "ipnet", - "jsonwebtoken", + "jsonwebtoken 11.0.0", "serde", "serde_json", "serde_with", + "serial_test", "server_common", "static-toml", "tracing", @@ -3263,10 +3281,10 @@ dependencies = [ name = "configs_derive" version = "0.1.0" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -3404,6 +3422,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + [[package]] name = "core-foundation" version = "0.9.4" @@ -3465,9 +3489,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -3498,9 +3522,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -3513,9 +3537,9 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +checksum = "e71406cd8807725f7ac2f999a4cdd32e98f829fdf65f528343cebf945e41df1e" dependencies = [ "crossbeam-channel", "crossbeam-deque", @@ -3569,9 +3593,9 @@ checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crossfire" -version = "3.1.19" +version = "3.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "111ce8f7abfbac38b4bc4f32a3a2dda1a8034b873c90c7899f302cc9dbbc05ec" +checksum = "adb5e5c51e448ac2309883593482db91a5549c528d08a36aaa87bba13e5a40ae" dependencies = [ "crossbeam-utils", "futures-core", @@ -3600,11 +3624,12 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -3653,11 +3678,11 @@ dependencies = [ [[package]] name = "ctor" -version = "1.0.9" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a394189d59f9befacce833f337f7b1eca5e9a91221bcdd4d28e0114d96e597b3" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" dependencies = [ - "link-section 0.19.0", + "link-section 0.19.3", "linktime-proc-macro", ] @@ -3667,6 +3692,15 @@ version = "0.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a949c44fcacbbbb7ada007dc7acb34603dd97cd47de5d054f2b6493ecebb483" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctr" version = "0.10.1" @@ -3787,14 +3821,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f8c8abaf78cfe3cb7838d7cfcf75e4e3bf02fe3e54571e0a1f10bb485cf8fbf" dependencies = [ "async-stream", - "base64", + "base64 0.22.1", "cfg-if", "cfg_aliases", "compio", "cyper-core", "encoding_rs", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body-util", "hyper", "hyper-util", @@ -3804,7 +3838,7 @@ dependencies = [ "serde", "serde_urlencoded", "synchrony", - "thiserror 2.0.19", + "thiserror 2.0.20", "tower-service", "url", ] @@ -3864,12 +3898,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -3901,15 +3935,15 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -3936,13 +3970,13 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -3961,9 +3995,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "data-url" @@ -4027,7 +4061,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -4063,7 +4097,7 @@ dependencies = [ "futures", "object_store", "regex", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "typed-builder 0.23.2", @@ -4080,7 +4114,7 @@ dependencies = [ "bytes", "deltalake-core", "object_store", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "url", ] @@ -4091,17 +4125,17 @@ version = "0.32.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4588e95ff3b2ccdba56d9ec262bd3467c0593000f729402528706f62be8be1ca" dependencies = [ - "arrow 58.3.0", - "arrow-arith 58.3.0", - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-cast 58.3.0", - "arrow-ipc 58.3.0", - "arrow-json 58.3.0", - "arrow-ord 58.3.0", - "arrow-row 58.3.0", - "arrow-schema 58.3.0", - "arrow-select 58.3.0", + "arrow", + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-ipc", + "arrow-json", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", "async-trait", "buoyant_kernel", "bytes", @@ -4109,16 +4143,16 @@ dependencies = [ "chrono", "dashmap", "deltalake-derive", - "dirs", + "dirs 6.0.0", "either", "futures", "humantime", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools 0.14.0", "num_cpus", "object_store", "parking_lot", - "parquet 58.3.0", + "parquet", "percent-encoding", "percent-encoding-rfc3986", "pin-project-lite", @@ -4128,7 +4162,7 @@ dependencies = [ "serde_json", "sqlparser 0.61.0", "strum 0.27.2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "url", @@ -4160,7 +4194,7 @@ dependencies = [ "deltalake-core", "futures", "object_store", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "url", @@ -4186,7 +4220,7 @@ dependencies = [ "deno_path_util", "deno_unsync", "futures", - "indexmap 2.14.0", + "indexmap 2.14.2", "libc", "parking_lot", "percent-encoding", @@ -4197,7 +4231,7 @@ dependencies = [ "smallvec", "sourcemap", "static_assertions", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "url", "v8", @@ -4241,7 +4275,7 @@ version = "0.227.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bab1eaf578a8cc0ae6fb933e91dc3388b41df22e5974d5891c17ba66b3a0bbb" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "proc-macro-rules", "proc-macro2", "quote", @@ -4249,7 +4283,7 @@ dependencies = [ "strum 0.27.2", "strum_macros 0.27.2", "syn 2.0.119", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -4261,7 +4295,7 @@ dependencies = [ "deno_error", "percent-encoding", "sys_traits", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", ] @@ -4283,6 +4317,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] @@ -4301,6 +4337,17 @@ dependencies = [ "rusticata-macros", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -4397,6 +4444,15 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "difflib" version = "0.4.0" @@ -4411,7 +4467,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "const-oid 0.9.6", - "crypto-common 0.1.7", + "crypto-common 0.1.6", "subtle", ] @@ -4447,6 +4503,15 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -4473,13 +4538,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -4490,25 +4555,24 @@ checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" [[package]] name = "dlopen2" -version = "0.8.2" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +checksum = "60768c353d76ba6dd1b6332a5dbbdd3bc2dddadc2935f4cc6a9d0f17ca8ecb6a" dependencies = [ "dlopen2_derive", "libc", - "once_cell", - "winapi", + "windows-sys 0.61.2", ] [[package]] name = "dlopen2_derive" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +checksum = "1aacdd87ba97c2f83ff479e881e547f776b0a7c42921be22d4391f32b6284050" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -4520,13 +4584,19 @@ dependencies = [ "const-random", ] +[[package]] +name = "doc-comment" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" + [[package]] name = "docker_credential" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" dependencies = [ - "base64", + "base64 0.22.1", "serde", "serde_json", ] @@ -4560,9 +4630,9 @@ dependencies = [ [[package]] name = "dtor" -version = "1.0.5" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d738e43aa64edab57c983d56de890d65fea7dc05605490c74451ce721dfd84b" +checksum = "cd3f6c362b2bbd7063090886afd78978f5dbf1f11da7efcc40f81d32c943fac1" dependencies = [ "linktime-proc-macro", ] @@ -4625,9 +4695,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" dependencies = [ "serde", ] @@ -4638,7 +4708,7 @@ version = "9.1.0-alpha.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12bb303aa6e1d28c0c86b6fbfe484fd0fd3f512629aeed1ac4f6b85f81d9834a" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "dyn-clone", "flate2", @@ -4790,9 +4860,9 @@ dependencies = [ [[package]] name = "err_trail" -version = "0.11.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b044506df3479abb857a3e1514fea858db128ea6fd95cc26088d9cf748f2a" +checksum = "2b27a06060b17402c741a32ffbcbf41873de113c24f87da431079ae03ed3430b" dependencies = [ "err_trail_macros", "tracing", @@ -4800,9 +4870,9 @@ dependencies = [ [[package]] name = "err_trail_macros" -version = "0.11.1" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d9ab0367afaae0ba1538512a0360b2b9fc737089a48612334200d815c7982f9" +checksum = "95ac02e2533e6552b990bb4a8fdb1dfb17bf43eb54e1b009dd25ed35c849aa6b" dependencies = [ "proc-macro2", "quote", @@ -4861,11 +4931,16 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -4876,10 +4951,19 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener", + "event-listener 5.4.2", "pin-project-lite", ] +[[package]] +name = "executor-trait" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c39dff9342e4e0e16ce96be751eb21a94e94a87bb2f6e63ad1961c2ce109bf" +dependencies = [ + "async-trait", +] + [[package]] name = "expect-test" version = "1.5.1" @@ -4899,7 +4983,7 @@ dependencies = [ "bit_field", "half", "lebe", - "miniz_oxide", + "miniz_oxide 0.8.9", "num-complex", "pulp", "rayon-core", @@ -4937,6 +5021,15 @@ dependencies = [ "serde", ] +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -5013,9 +5106,9 @@ dependencies = [ [[package]] name = "file-operation" -version = "0.8.28" +version = "0.8.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069e9a5a2347a89fcf2d63241ecaee5aef21063df00ae22d7bde905950b7d962" +checksum = "f33d2bf3c4a174097353ba4c9a2374c308dbe4102622caeb705fa2f21308a73f" dependencies = [ "tokio", ] @@ -5032,9 +5125,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flagset" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" [[package]] name = "flatbuffers" @@ -5048,12 +5147,12 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -5072,13 +5171,24 @@ dependencies = [ "num-traits", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "flume" version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ - "fastrand", + "fastrand 2.5.0", "futures-core", "futures-sink", "spin", @@ -5190,9 +5300,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -5205,9 +5315,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -5215,15 +5325,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -5243,9 +5353,24 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] [[package]] name = "futures-lite" @@ -5253,7 +5378,7 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ - "fastrand", + "fastrand 2.5.0", "futures-core", "futures-io", "parking", @@ -5262,13 +5387,13 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -5284,15 +5409,15 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-timer" @@ -5302,9 +5427,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -5334,9 +5459,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -5384,13 +5509,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + [[package]] name = "ghash" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "polyval", + "polyval 0.7.3", ] [[package]] @@ -5399,14 +5534,14 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e2c0d8c632f8a251ce9a8198079b1022adc586ff4e3d33e18debd40eb463b31" dependencies = [ - "heck", + "heck 0.5.0", "peg", "quote", "serde", "serde_json", "syn 2.0.119", "textwrap", - "thiserror 2.0.19", + "thiserror 2.0.20", "typed-builder 0.23.2", ] @@ -5444,15 +5579,15 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", @@ -5630,7 +5765,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_urlencoded", - "thiserror 2.0.19", + "thiserror 2.0.20", "wasm-bindgen", "web-sys", ] @@ -5666,12 +5801,12 @@ dependencies = [ "futures-core", "futures-sink", "gloo-utils 0.3.0", - "http 1.4.2", + "http 1.5.0", "js-sys", "pin-project", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -5722,7 +5857,7 @@ dependencies = [ "js-sys", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "wasm-bindgen", "web-sys", ] @@ -5807,7 +5942,7 @@ dependencies = [ "js-sys", "pinned", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -5892,7 +6027,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.14.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -5901,17 +6036,17 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.2", - "indexmap 2.14.0", + "http 1.5.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -5942,9 +6077,9 @@ dependencies = [ [[package]] name = "handlebars" -version = "6.4.3" +version = "6.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4633d16a2350341713c379d6d06a4b9e1845329386026a49ce4fd09c2f3b16f6" +checksum = "75c54236f9045c8004a77942bebc52145b4844639db934a5c70fe08617fbe61a" dependencies = [ "derive_builder", "log", @@ -5953,7 +6088,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -5962,7 +6097,7 @@ version = "0.1.0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -6035,6 +6170,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -6043,9 +6184,15 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hermit-abi" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -6070,7 +6217,7 @@ dependencies = [ "ipnet", "jni", "rand 0.10.2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tokio", "tracing", @@ -6091,7 +6238,7 @@ dependencies = [ "prefix-trie", "rand 0.10.2", "ring", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "url", @@ -6118,7 +6265,7 @@ dependencies = [ "resolv-conf", "smallvec", "system-configuration", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -6192,9 +6339,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -6218,18 +6365,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.4.2", + "http 1.5.0", ] [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "pin-project-lite", ] @@ -6246,7 +6393,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" dependencies = [ - "http 1.4.2", + "http 1.5.0", "serde", ] @@ -6287,7 +6434,7 @@ dependencies = [ "hwlocality-sys", "libc", "strum 0.28.0", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -6310,25 +6457,25 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2 0.4.15", - "http 1.4.2", + "h2 0.4.19", + "http 1.5.0", "http-body 1.1.0", "httparse", "httpdate", @@ -6359,12 +6506,12 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.2", + "http 1.5.0", "hyper", "hyper-util", "log", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "tokio", "tokio-rustls", "tower-service", @@ -6390,11 +6537,11 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "hyper", "ipnet", @@ -6450,25 +6597,26 @@ dependencies = [ [[package]] name = "iceberg" -version = "0.9.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d9c3fc1f55c84ff64645c0d2ee35159f5574d33f729fc0860783bc247c8b8c5" +checksum = "6e50fb53c7480f414911ab76b6e14c0042d54ad5c30f6ac2bf2f52a487a25716" dependencies = [ + "aes-gcm 0.10.3", "anyhow", - "apache-avro", + "apache-avro 0.21.0", "array-init", - "arrow-arith 57.3.1", - "arrow-array 57.3.1", - "arrow-buffer 57.3.1", - "arrow-cast 57.3.1", - "arrow-ord 57.3.1", - "arrow-schema 57.3.1", - "arrow-select 57.3.1", - "arrow-string 57.3.1", + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-ord", + "arrow-schema", + "arrow-select", + "arrow-string", "as-any", "async-trait", "backon", - "base64", + "base64 0.22.1", "bimap", "bytes", "chrono", @@ -6483,7 +6631,7 @@ dependencies = [ "murmur3", "once_cell", "ordered-float 4.6.0", - "parquet 57.3.1", + "parquet", "rand 0.9.5", "reqwest 0.12.28", "roaring", @@ -6495,22 +6643,24 @@ dependencies = [ "serde_with", "strum 0.27.2", "tokio", + "tracing", "typed-builder 0.20.1", "typetag", "url", "uuid", + "zeroize", "zstd", ] [[package]] name = "iceberg-catalog-rest" -version = "0.9.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a49dfef578060c3a2a3f619522dcb25382a7ce85336dcb500495d4b8d72ae714" +checksum = "d1500a26a9b18f286a319e914ce7290dddb7e2eef810edf535afc4ce0c8a6f45" dependencies = [ "async-trait", "chrono", - "http 1.4.2", + "http 1.5.0", "iceberg", "itertools 0.13.0", "reqwest 0.12.28", @@ -6518,25 +6668,25 @@ dependencies = [ "serde_derive", "serde_json", "tokio", - "tracing", "typed-builder 0.20.1", "uuid", ] [[package]] name = "iceberg-storage-opendal" -version = "0.9.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30acae4698949eea6cfbd3e64ddf1c3ec39e6b7191acb0b0da2dcd1a25dfa842" +checksum = "983efbae430ed40c074af69a8b0aff99bee73e54686cf26ecde97f84127812a2" dependencies = [ "anyhow", "async-trait", "bytes", "cfg-if", + "futures", "iceberg", "opendal", - "reqsign", - "reqwest 0.12.28", + "reqsign-aws-v4", + "reqsign-core", "serde", "typetag", "url", @@ -6544,9 +6694,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -6558,9 +6708,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -6571,9 +6721,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -6585,16 +6735,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -6605,15 +6756,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -6659,7 +6810,7 @@ checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" [[package]] name = "iggy" -version = "0.11.0-edge.5" +version = "0.11.0" dependencies = [ "async-broadcast", "async-dropper", @@ -6668,14 +6819,14 @@ dependencies = [ "bytemuck", "bytes", "dashmap", - "flume", + "flume 0.12.0", "futures", "futures-util", "iggy_binary_protocol", "iggy_common", "mockall", "quinn", - "reqwest 0.13.4", + "reqwest 0.13.5", "reqwest-middleware", "reqwest-retry", "reqwest-tracing", @@ -6693,7 +6844,7 @@ dependencies = [ [[package]] name = "iggy-bench" -version = "0.6.0-edge.5" +version = "0.6.0" dependencies = [ "async-trait", "bench-report", @@ -6702,6 +6853,7 @@ dependencies = [ "chrono", "clap", "comfy-table", + "configs", "figlet-rs", "futures-util", "governor", @@ -6722,7 +6874,7 @@ dependencies = [ [[package]] name = "iggy-bench-dashboard-server" -version = "0.8.0-edge.3" +version = "0.8.0" dependencies = [ "actix-cors", "actix-files", @@ -6739,7 +6891,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "tracing-subscriber", @@ -6750,7 +6902,7 @@ dependencies = [ [[package]] name = "iggy-cli" -version = "0.14.0-edge.5" +version = "0.14.0" dependencies = [ "anyhow", "apple-native-keyring-store", @@ -6761,7 +6913,7 @@ dependencies = [ "clap", "clap_complete", "comfy-table", - "dirs", + "dirs 7.0.0", "figlet-rs", "iggy", "iggy_binary_protocol", @@ -6772,9 +6924,9 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "tracing", "tracing-appender", "tracing-subscriber", @@ -6784,8 +6936,9 @@ dependencies = [ [[package]] name = "iggy-connectors" -version = "0.5.0-edge.5" +version = "0.5.0" dependencies = [ + "apache-avro 0.22.0", "async-trait", "axum", "axum-server", @@ -6795,12 +6948,12 @@ dependencies = [ "configs_derive", "dashmap", "derive_more", - "dirs", + "dirs 7.0.0", "dlopen2", "dotenvy", "figlet-rs", "figment", - "flume", + "flume 0.12.0", "futures", "iggy", "iggy_common", @@ -6813,7 +6966,10 @@ dependencies = [ "opentelemetry_sdk", "postcard", "prometheus-client", - "reqwest 0.13.4", + "prost", + "prost-types", + "protox-parse", + "reqwest 0.13.5", "reqwest-middleware", "reqwest-retry", "reqwest-tracing", @@ -6827,10 +6983,10 @@ dependencies = [ "sysinfo 0.39.6", "system_stats", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", - "toml 1.1.3+spec-1.1.0", - "tower-http 0.7.0", + "toml 1.1.6+spec-1.1.0", + "tower-http 0.7.1", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -6843,12 +6999,18 @@ name = "iggy-gateway-kafka" version = "0.1.0" dependencies = [ "bytes", + "iggy", "kafka-protocol", "libc", + "secrecy", + "serde", + "serial_test", "socket2 0.6.5", - "thiserror 2.0.19", + "tempfile", + "thiserror 2.0.20", "tokio", "tokio-util", + "toml 1.1.6+spec-1.1.0", "tracing", "tracing-appender", "tracing-subscriber", @@ -6856,13 +7018,13 @@ dependencies = [ [[package]] name = "iggy-mcp" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "axum", "axum-server", "configs", "configs_derive", - "dirs", + "dirs 7.0.0", "dotenvy", "figlet-rs", "figment", @@ -6879,10 +7041,10 @@ dependencies = [ "socket2 0.6.5", "strum 0.28.0", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-util", - "tower-http 0.7.0", + "tower-http 0.7.1", "tracing", "tracing-opentelemetry", "tracing-subscriber", @@ -6890,25 +7052,25 @@ dependencies = [ [[package]] name = "iggy_binary_protocol" -version = "0.11.0-edge.5" +version = "0.11.0" dependencies = [ "aligned-vec", "bytemuck", "bytes", "enumset", "secrecy", - "thiserror 2.0.19", + "thiserror 2.0.20", "twox-hash", ] [[package]] name = "iggy_common" -version = "0.11.0-edge.5" +version = "0.11.0" dependencies = [ - "aes-gcm", + "aes-gcm 0.11.1", "async-broadcast", "async-trait", - "base64", + "base64 0.23.1", "blake3", "bon", "byte-unit", @@ -6926,7 +7088,7 @@ dependencies = [ "serde_json", "serde_with", "strum 0.28.0", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "tungstenite 0.30.0", @@ -6938,25 +7100,25 @@ dependencies = [ [[package]] name = "iggy_connector_clickhouse_sink" -version = "0.2.0-edge.4" +version = "0.2.0" dependencies = [ "async-trait", "bytes", "iggy_connector_sdk", "rand 0.10.2", - "reqwest 0.13.4", + "reqwest 0.13.5", "secrecy", "serde", "serde_json", "simd-json", "tokio", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "tracing", ] [[package]] name = "iggy_connector_delta_sink" -version = "0.2.0-edge.4" +version = "0.2.0" dependencies = [ "async-trait", "chrono", @@ -6966,6 +7128,7 @@ dependencies = [ "secrecy", "serde", "serde_json", + "simd-json", "tokio", "tracing", "url", @@ -6973,15 +7136,15 @@ dependencies = [ [[package]] name = "iggy_connector_doris_sink" -version = "0.2.0-edge.4" +version = "0.2.0" dependencies = [ "async-trait", - "base64", + "base64 0.23.1", "blake3", "bytes", "humantime", "iggy_connector_sdk", - "reqwest 0.13.4", + "reqwest 0.13.5", "secrecy", "serde", "serde_json", @@ -6993,10 +7156,10 @@ dependencies = [ [[package]] name = "iggy_connector_elasticsearch_sink" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", - "base64", + "base64 0.23.1", "dashmap", "elasticsearch", "iggy_common", @@ -7007,11 +7170,12 @@ dependencies = [ "simd-json", "tokio", "tracing", + "wiremock", ] [[package]] name = "iggy_connector_elasticsearch_source" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", "dashmap", @@ -7023,104 +7187,134 @@ dependencies = [ "serde", "serde_json", "simd-json", + "tempfile", "tokio", "tracing", + "wiremock", ] [[package]] name = "iggy_connector_http_sink" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", - "base64", + "base64 0.23.1", "bytes", "humantime", "iggy_connector_sdk", - "reqwest 0.13.4", + "reqwest 0.13.5", "reqwest-middleware", "reqwest-retry", "reqwest-tracing", "serde", "serde_json", - "simd-json", - "strum_macros 0.28.0", + "simd-json", + "strum_macros 0.28.0", + "tokio", + "toml 1.1.6+spec-1.1.0", + "tracing", +] + +[[package]] +name = "iggy_connector_http_source" +version = "0.5.0" +dependencies = [ + "arc-swap", + "async-trait", + "axum", + "crossfire", + "dashmap", + "hex", + "iggy_common", + "iggy_connector_sdk", + "prometheus-client", + "rand 0.10.2", + "reqwest 0.13.5", + "ring", + "rmp-serde", + "secrecy", + "serde", + "serde_json", + "subtle", "tokio", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "tracing", ] [[package]] name = "iggy_connector_iceberg_sink" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ - "arrow-json 57.3.1", + "arrow-array", + "arrow-json", "async-trait", "dashmap", "iceberg", "iceberg-catalog-rest", "iceberg-storage-opendal", "iggy_connector_sdk", - "parquet 57.3.1", + "parquet", "serde", "simd-json", "strum 0.28.0", + "tokio", "tracing", "uuid", ] [[package]] name = "iggy_connector_influxdb_sink" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.23.1", "bytes", "iggy_common", "iggy_connector_sdk", - "reqwest 0.13.4", + "reqwest 0.13.5", "reqwest-middleware", "secrecy", "serde", "serde_json", "simd-json", "tokio", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "tracing", ] [[package]] name = "iggy_connector_influxdb_source" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "ahash 0.8.12", "async-trait", "axum", - "base64", + "base64 0.23.1", "chrono", "csv", "dashmap", "iggy_common", "iggy_connector_sdk", "regex", - "reqwest 0.13.4", + "reqwest 0.13.5", "reqwest-middleware", "secrecy", "serde", "serde_json", "simd-json", "tokio", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "tracing", "uuid", ] [[package]] name = "iggy_connector_meilisearch_sink" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", - "base64", + "base64 0.23.1", "iggy_common", "iggy_connector_sdk", "meilisearch-sdk", @@ -7135,7 +7329,7 @@ dependencies = [ [[package]] name = "iggy_connector_mongodb_sink" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", "humantime", @@ -7151,7 +7345,7 @@ dependencies = [ [[package]] name = "iggy_connector_postgres_sink" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", "dashmap", @@ -7170,16 +7364,17 @@ dependencies = [ [[package]] name = "iggy_connector_postgres_source" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", - "base64", + "base64 0.23.1", "chrono", "dashmap", "futures", "humantime", "iggy_common", "iggy_connector_sdk", + "rmp-serde", "secrecy", "serde", "serde_json", @@ -7192,21 +7387,41 @@ dependencies = [ [[package]] name = "iggy_connector_quickwit_sink" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", - "dashmap", + "base64 0.23.1", + "humantime", "iggy_connector_sdk", - "reqwest 0.13.4", + "reqwest 0.13.5", + "reqwest-middleware", "serde", "serde_yaml_ng", "simd-json", + "tokio", + "tracing", +] + +[[package]] +name = "iggy_connector_rabbitmq_sink" +version = "0.4.1" +dependencies = [ + "async-trait", + "dashmap", + "iggy", + "iggy_common", + "iggy_connector_sdk", + "lapin", + "secrecy", + "serde", + "serde_json", + "tokio", "tracing", ] [[package]] name = "iggy_connector_random_source" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", "dashmap", @@ -7223,15 +7438,15 @@ dependencies = [ [[package]] name = "iggy_connector_redshift_sink" -version = "0.4.1-edge.1" +version = "0.4.1" dependencies = [ - "arrow 57.3.1", + "arrow", "async-trait", "chrono", "humantime", "iggy_common", "iggy_connector_sdk", - "parquet 57.3.1", + "parquet", "rust-s3", "secrecy", "serde", @@ -7245,10 +7460,10 @@ dependencies = [ [[package]] name = "iggy_connector_s3_sink" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", - "base64", + "base64 0.23.1", "byte-unit", "chrono", "dashmap", @@ -7262,19 +7477,20 @@ dependencies = [ "simd-json", "tokio", "tracing", + "wiremock", ] [[package]] name = "iggy_connector_sdk" -version = "0.4.0-edge.3" +version = "0.5.0" dependencies = [ "anyhow", - "apache-avro", + "apache-avro 0.22.0", "async-trait", - "base64", + "base64 0.23.1", "dashmap", "flatbuffers", - "http 1.4.2", + "http 1.5.0", "humantime", "iggy", "iggy_common", @@ -7285,7 +7501,7 @@ dependencies = [ "protox-parse", "rand 0.10.2", "regex", - "reqwest 0.13.4", + "reqwest 0.13.5", "reqwest-middleware", "rmp-serde", "serde", @@ -7293,16 +7509,17 @@ dependencies = [ "simd-json", "strum 0.28.0", "strum_macros 0.28.0", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "tracing-subscriber", "uuid", + "wiremock", ] [[package]] name = "iggy_connector_stdout_sink" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", "dashmap", @@ -7314,14 +7531,14 @@ dependencies = [ [[package]] name = "iggy_connector_surrealdb_sink" -version = "0.5.0-edge.4" +version = "0.5.0" dependencies = [ "async-trait", - "base64", + "base64 0.23.1", "bytes", "iggy_common", "iggy_connector_sdk", - "reqwest 0.13.4", + "reqwest 0.13.5", "secrecy", "serde", "serde_json", @@ -7330,6 +7547,41 @@ dependencies = [ "tracing", ] +[[package]] +name = "iggy_connector_template_sink" +version = "0.1.0" +dependencies = [ + "async-trait", + "dashmap", + "iggy_common", + "iggy_connector_sdk", + "reqwest 0.13.5", + "reqwest-middleware", + "secrecy", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "iggy_connector_template_source" +version = "0.1.0" +dependencies = [ + "async-trait", + "dashmap", + "humantime", + "iggy_common", + "iggy_connector_sdk", + "reqwest 0.13.5", + "reqwest-middleware", + "rmp-serde", + "secrecy", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iggy_examples" version = "0.0.6" @@ -7345,7 +7597,7 @@ dependencies = [ "rand 0.10.2", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "tracing-subscriber", @@ -7353,9 +7605,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.31" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" dependencies = [ "crossbeam-deque", "globset", @@ -7387,7 +7639,7 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.1", + "zune-core 0.5.3", "zune-jpeg 0.5.15", ] @@ -7409,15 +7661,15 @@ checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" [[package]] name = "imgref" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" [[package]] name = "impl-more" -version = "0.3.1" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870" +checksum = "277ff51754a3f68f12f58446c5d006aa8baa4914ea273cce24a599cfaff33d4f" [[package]] name = "implicit-clone" @@ -7426,7 +7678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1689b939ee35e3a075b0834b5672efd43aec8a6e81a1c6002b76a5ca2f211ae0" dependencies = [ "implicit-clone-derive", - "indexmap 2.14.0", + "indexmap 2.14.2", ] [[package]] @@ -7452,9 +7704,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -7476,9 +7728,9 @@ checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" [[package]] name = "inotify" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ "bitflags 2.13.1", "inotify-sys", @@ -7500,7 +7752,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", + "block-padding 0.3.3", "generic-array", ] @@ -7510,9 +7762,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ + "block-padding 0.4.2", "hybrid-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "integer-encoding" version = "3.0.4" @@ -7523,10 +7785,11 @@ checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" name = "integration" version = "0.0.1" dependencies = [ - "arrow 57.3.1", + "apache-avro 0.22.0", + "arrow", "assert_cmd", "async-trait", - "base64", + "base64 0.23.1", "bon", "bytemuck", "bytes", @@ -7535,9 +7798,9 @@ dependencies = [ "configs", "configs_derive", "consensus", - "ctor 1.0.9", + "ctor 1.0.13", "deltalake", - "dtor 1.0.5", + "dtor 1.0.6", "figment", "futures", "harness_derive", @@ -7550,26 +7813,28 @@ dependencies = [ "iggy_connector_doris_sink", "iggy_connector_sdk", "journal", - "jsonwebtoken", + "jsonwebtoken 11.0.0", "keyring-core", + "lapin", "lazy_static", "libc", "mongodb", - "parquet 57.3.1", + "parquet", "pgwire", "predicates", "rand 0.10.2", "rcgen", - "reqwest 0.13.4", + "reqwest 0.13.5", "reqwest-middleware", "reqwest-retry", + "ring", "rmcp", "rust-s3", "secrecy", "serde", "serde_json", "serial_test", - "sqlparser 0.62.0", + "sqlparser 0.63.0", "sqlx", "sysinfo 0.39.6", "tempfile", @@ -7578,7 +7843,7 @@ dependencies = [ "testcontainers-modules", "tokio", "tokio-postgres", - "toml 1.1.3+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "tracing", "tracing-subscriber", "twox-hash", @@ -7609,11 +7874,22 @@ dependencies = [ "rustversion", ] +[[package]] +name = "io-lifetimes" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" +dependencies = [ + "hermit-abi 0.3.9", + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "io-uring" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9080b15e63775b9a2ac7dca720f7050a8b955e092ea0f6020a4a80f69998cdc0" +checksum = "d64d8ca234d152948ceaede1f419b6a83983a5ecccaac05fb337a809c96d3aa6" dependencies = [ "bitflags 2.13.1", "cfg-if", @@ -7635,9 +7911,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" dependencies = [ "serde", ] @@ -7683,18 +7959,20 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.34" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e184d09547b80eb7e20d141ba2fb1fbac843ca53f4cf1b31210adc4c1adc6e16" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", "jiff-core", "jiff-static", "jiff-tzdb-platform", + "js-sys", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -7709,9 +7987,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.34" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323da076b7a6faf914dc677cb05a4b907742ff7375c8322c9e7f5061e5e0e9de" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ "jiff-core", "proc-macro2", @@ -7746,7 +8024,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -7801,17 +8079,20 @@ dependencies = [ "compio", "futures", "iggy_binary_protocol", + "iggy_common", + "nix", "server_common", "tempfile", + "tokio", "tracing", "twox-hash", ] [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -7824,15 +8105,39 @@ version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ - "base64", + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac 0.12.1", + "js-sys", + "p256", + "p384", + "pem 3.0.6", + "rand 0.8.8", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "jsonwebtoken" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" +dependencies = [ + "base64 0.22.1", "ed25519-dalek", "getrandom 0.2.17", "hmac 0.12.1", "js-sys", "p256", "p384", - "pem", - "rand 0.8.7", + "pem 3.0.6", + "rand 0.8.8", "rsa", "serde", "serde_json", @@ -7861,7 +8166,7 @@ dependencies = [ "clap", "hex", "iggy-gateway-kafka", - "indexmap 2.14.0", + "indexmap 2.14.2", "kafka-protocol", "tokio", "tracing", @@ -7870,31 +8175,26 @@ dependencies = [ [[package]] name = "kafka-protocol" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66292444a1cd4d430d450d472c30cba839d0724229aba2d79affffcf901516e2" +checksum = "099d5c2f1b40cd830cbf18ca4d2a0805f2875b811ca18f0372b2433e68fe2dda" dependencies = [ "anyhow", "bytes", "crc", "crc32c", - "flate2", - "indexmap 2.14.0", - "lz4", - "paste", - "snap", + "indexmap 2.14.2", "uuid", - "zstd", ] [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] @@ -7908,9 +8208,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -7943,11 +8243,33 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" +[[package]] +name = "lapin" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d2aa4725b9607915fa1a73e940710a3be6af508ce700e56897cbe8847fbb07" +dependencies = [ + "amq-protocol", + "async-global-executor-trait", + "async-reactor-trait", + "async-trait", + "executor-trait", + "flume 0.11.1", + "futures-core", + "futures-io", + "parking_lot", + "pinky-swear", + "reactor-trait", + "serde", + "tracing", + "waker-fn", +] + [[package]] name = "lazy-regex" -version = "3.6.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" +checksum = "4994ba703f78b083e2f7946dac9251abd83fd43a0365f030e99b69be5b4b9ef9" dependencies = [ "lazy-regex-proc_macros", "once_cell", @@ -7956,9 +8278,9 @@ dependencies = [ [[package]] name = "lazy-regex-proc_macros" -version = "3.6.0" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" +checksum = "fd97232314824e6dbef1918a871bb93f51070455e3715bf26e19a6d01aa977a0" dependencies = [ "proc-macro2", "quote", @@ -8057,9 +8379,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.188" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libfuzzer-sys" @@ -8073,9 +8395,9 @@ dependencies = [ [[package]] name = "libgit2-sys" -version = "0.18.5+1.9.4" +version = "0.18.8+1.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005d6ae6eac1912906073e069f7db60b1fa98e052a68227824afe3e3a1c59ca2" +checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" dependencies = [ "cc", "libc", @@ -8095,18 +8417,18 @@ dependencies = [ [[package]] name = "liblzma" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45aec2360b3933207e27908049d8e4df4e476b58180afb1e56b2a4fb72efe4ba" +checksum = "2fe0a34ca854fd4f20c07f696fc8675aec78f87d88d29f5e10257a7490a1b2e1" dependencies = [ "liblzma-sys", ] [[package]] name = "liblzma-sys" -version = "0.4.7" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a046c7f353ba30f810545151e04f63545833803f5b86ee3ddf1517247fe560a5" +checksum = "a0dad045e4b1b7b170be4b60b54b780cafb4490165461bac7d1cf7b703f61d5f" dependencies = [ "cc", "libc", @@ -8130,9 +8452,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "libc", ] @@ -8167,9 +8489,9 @@ checksum = "b685d66585d646efe09fec763d796c291049c8b6bf84e04954bffc8748341f0d" [[package]] name = "link-section" -version = "0.19.0" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e333fe507b738576d6da5bb3f1a7d7a1c80307ed9ef31624c057d844c19c93e9" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" [[package]] name = "linked-hash-map" @@ -8179,9 +8501,15 @@ checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" [[package]] name = "linktime-proc-macro" -version = "0.2.0" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + +[[package]] +name = "linux-raw-sys" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" +checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" [[package]] name = "linux-raw-sys" @@ -8197,9 +8525,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "local-channel" @@ -8214,9 +8542,9 @@ dependencies = [ [[package]] name = "local-event" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76dda8459b10a8960dfae91c1c77316fc15e7caf94f9f18794126512a8dc77e5" +checksum = "23ab4b951e96ffb2da6e25cec3d038d0e2930f4c406d1ec194b5120ae41ec6d3" [[package]] name = "local-waker" @@ -8235,9 +8563,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "logos" @@ -8336,34 +8664,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lz4" -version = "1.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" -dependencies = [ - "lz4-sys", -] - -[[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "lz4_flex" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90071f8077f8e40adfc4b7fe9cd495ce316263f19e75c2211eeff3fdf475a3d9" -dependencies = [ - "twox-hash", -] - [[package]] name = "lz4_flex" version = "0.13.1" @@ -8492,6 +8792,16 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" +[[package]] +name = "mea" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c709842c4ce65cb91e2666ad5319dfc1efc3af0d34f02075eddca9000d9f8afb" +dependencies = [ + "hashbrown 0.17.1", + "slab", +] + [[package]] name = "meilisearch-index-setting-macro" version = "0.33.0" @@ -8519,14 +8829,14 @@ dependencies = [ "futures-io", "futures-util", "iso8601", - "jsonwebtoken", + "jsonwebtoken 10.4.0", "log", "meilisearch-index-setting-macro", "pin-project-lite", "reqwest 0.12.28", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "uuid", @@ -8586,7 +8896,7 @@ dependencies = [ "server_common", "socket2 0.6.5", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "zeroize", ] @@ -8687,6 +8997,16 @@ dependencies = [ "simd-adler32", ] +[[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.2" @@ -8733,16 +9053,16 @@ checksum = "21909324aa58f5c284d91cac514c6d081210901dc372d7c8ea6a9d7e0406097a" [[package]] name = "moka" -version = "0.12.15" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" dependencies = [ - "async-lock", + "async-lock 3.4.2", "crossbeam-channel", "crossbeam-epoch", "crossbeam-utils", "equivalent", - "event-listener", + "event-listener 5.4.2", "futures-util", "parking_lot", "portable-atomic", @@ -8771,11 +9091,11 @@ checksum = "851fac73f7fe22f6a3ab87f720ce509cae7c9fd08e7dd27866cc232dee07ccf4" [[package]] name = "mongodb" -version = "3.8.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b814038f367d212f55de0a630cb35102a9b8ca23785a86955d62c0087c93846d" +checksum = "af84443ae9878f59a6818686f339064479f42cdf293c4c637d8fc9c0a8c5eed6" dependencies = [ - "base64", + "base64 0.22.1", "bitflags 2.13.1", "bson", "derive-where", @@ -8792,7 +9112,7 @@ dependencies = [ "md-5 0.11.0", "mongocrypt", "mongodb-internal-macros", - "pbkdf2", + "pbkdf2 0.13.0", "percent-encoding", "rand 0.9.5", "rustc_version_runtime", @@ -8806,7 +9126,7 @@ dependencies = [ "stringprep", "strsim", "take_mut", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-util", @@ -8817,9 +9137,9 @@ dependencies = [ [[package]] name = "mongodb-internal-macros" -version = "3.8.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f736d2fbc56e0011a341fbb9172bd822fda75c5f93b82fae1c7aab1e2613c810" +checksum = "504024bbb83ab1bf1512007f7288b54d3a6343043f3f619c26a153346773598d" dependencies = [ "macro_magic", "proc-macro2", @@ -8997,7 +9317,7 @@ checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", - "rand 0.8.7", + "rand 0.8.8", "serde", ] @@ -9012,7 +9332,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.7", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -9046,9 +9366,9 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -9065,9 +9385,9 @@ dependencies = [ [[package]] name = "num-modular" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" +checksum = "bd8e500409e6cd603b03e477c26a6caecdc27ac58979a53e881c75eafc079f44" [[package]] name = "num-order" @@ -9105,7 +9425,7 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi", + "hermit-abi 0.5.3", "libc", ] @@ -9186,9 +9506,9 @@ dependencies = [ [[package]] name = "object" -version = "0.37.3" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" dependencies = [ "memchr", ] @@ -9200,14 +9520,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "form_urlencoded", "futures-channel", "futures-core", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body-util", "httparse", "humantime", @@ -9224,7 +9544,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "url", @@ -9235,13 +9555,12 @@ dependencies = [ [[package]] name = "octocrab" -version = "0.54.0" +version = "0.54.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27be39870c558e1fbc5a5f8d4a29aa916268c1dbcb9d467f2a9bbab35598060e" +checksum = "8486ecb1ba585c76dd91472320b5334e95d0d2e8b451a3f0a3fced4415ec3993" dependencies = [ "arc-swap", - "async-trait", - "base64", + "base64 0.22.1", "bytes", "cargo_metadata", "cfg-if", @@ -9249,7 +9568,7 @@ dependencies = [ "futures", "futures-util", "getrandom 0.2.17", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "http-serde", @@ -9257,9 +9576,10 @@ dependencies = [ "hyper-rustls", "hyper-timeout", "hyper-util", - "jsonwebtoken", + "jsonwebtoken 10.4.0", "percent-encoding", "pin-project", + "rustls", "secrecy", "serde", "serde_json", @@ -9294,40 +9614,145 @@ dependencies = [ ] [[package]] -name = "once_cell_polyfill" -version = "1.70.2" +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "opendal" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c9c85ce253ff87225e7669979d877a20c98a06604ec9d6dd5f4473e08f1ae1" +dependencies = [ + "ctor 1.0.13", + "opendal-core", + "opendal-layer-concurrent-limit", + "opendal-layer-logging", + "opendal-layer-retry", + "opendal-layer-timeout", + "opendal-service-fs", + "opendal-service-s3", +] + +[[package]] +name = "opendal-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4f8607c90e2c963a91467f50fb49fbc7fb3d573f88cea219ca59ccd3740b309" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bytes", + "futures", + "http 1.5.0", + "http-body 1.1.0", + "jiff", + "log", + "md-5 0.11.0", + "mea", + "percent-encoding", + "quick-xml 0.39.4", + "reqsign-core", + "reqwest 0.13.5", + "serde", + "serde_json", + "tokio", + "url", + "uuid", + "web-time", +] + +[[package]] +name = "opendal-layer-concurrent-limit" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d6f81ba6960e3fae1882f253b114b21d7e444e1534f209c7737a79f6243eb6f" +dependencies = [ + "futures", + "http 1.5.0", + "mea", + "opendal-core", +] + +[[package]] +name = "opendal-layer-logging" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ada45c6d81d1aa4c9305d0c7d4bc317c59c85866a0908a2d75a7a978aa5ee2" +dependencies = [ + "log", + "opendal-core", +] + +[[package]] +name = "opendal-layer-retry" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2a25a718afb81fad81cb9a0580a1cb989221fa2317f888c6a37f8dad408eb7" +dependencies = [ + "backon", + "log", + "opendal-core", +] + +[[package]] +name = "opendal-layer-timeout" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e91f731724c213af81e9d03517859c8fc47b4578e64ad61ae4f099f10fe36e3" +dependencies = [ + "opendal-core", + "tokio", +] + +[[package]] +name = "opendal-service-fs" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +checksum = "22e89a665fef0e6bd249cf5ea47fc174b7ba892159bee4b9382528b1ca873a2c" +dependencies = [ + "bytes", + "log", + "opendal-core", + "serde", + "tokio", + "xattr", +] [[package]] -name = "opendal" -version = "0.55.0" +name = "opendal-service-s3" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d075ab8a203a6ab4bc1bce0a4b9fe486a72bf8b939037f4b78d95386384bc80a" +checksum = "313d46c9f5ae70bca26b7c3e3fbb9b639292625f28af73aa016f47e788af9deb" dependencies = [ - "anyhow", - "backon", - "base64", + "base64 0.22.1", "bytes", "crc32c", - "futures", - "getrandom 0.2.17", - "http 1.4.2", - "http-body 1.1.0", - "jiff", + "http 1.5.0", "log", - "md-5 0.10.6", - "percent-encoding", - "quick-xml 0.38.4", - "reqsign", - "reqwest 0.12.28", + "md-5 0.11.0", + "opendal-core", + "quick-xml 0.39.4", + "reqsign-aws-v4", + "reqsign-core", + "reqsign-file-read-tokio", "serde", - "serde_json", - "tokio", "url", - "uuid", ] +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -9344,7 +9769,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -9369,9 +9794,9 @@ checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" dependencies = [ "async-trait", "bytes", - "http 1.4.2", + "http 1.5.0", "opentelemetry", - "reqwest 0.13.4", + "reqwest 0.13.5", ] [[package]] @@ -9380,14 +9805,14 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ - "http 1.4.2", + "http 1.5.0", "opentelemetry", "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", "prost", - "reqwest 0.13.4", - "thiserror 2.0.19", + "reqwest 0.13.5", + "thiserror 2.0.20", "tokio", "tonic", "tonic-types", @@ -9425,7 +9850,7 @@ dependencies = [ "percent-encoding", "portable-atomic", "rand 0.9.5", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", ] @@ -9474,12 +9899,64 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + [[package]] name = "outref" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owo-colors" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + +[[package]] +name = "p12-keystore" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cae83056e7cb770211494a0ecf66d9fa7eba7d00977e5bb91f0e925b40b937f" +dependencies = [ + "cbc 0.1.2", + "cms", + "der", + "des", + "hex", + "hmac 0.12.1", + "pkcs12", + "pkcs5", + "rand 0.9.5", + "rc2", + "sha1 0.10.7", + "sha2 0.10.9", + "thiserror 2.0.20", + "x509-parser 0.17.0", +] + [[package]] name = "p256" version = "0.13.2" @@ -9506,9 +9983,9 @@ dependencies = [ [[package]] name = "papaya" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +checksum = "da2442474a9404698c42509b8967f437249dbc7b50493e83020333d3943ec0ae" dependencies = [ "equivalent", "seize", @@ -9545,54 +10022,18 @@ dependencies = [ [[package]] name = "parquet" -version = "57.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e832c6aa20310fc6de7ea5a3f4e20d34fd83e3b43229d32b81ffe5c14d74692" -dependencies = [ - "ahash 0.8.12", - "arrow-array 57.3.1", - "arrow-buffer 57.3.1", - "arrow-cast 57.3.1", - "arrow-data 57.3.1", - "arrow-ipc 57.3.1", - "arrow-schema 57.3.1", - "arrow-select 57.3.1", - "base64", - "brotli", - "bytes", - "chrono", - "flate2", - "futures", - "half", - "hashbrown 0.16.1", - "lz4_flex 0.12.2", - "num-bigint", - "num-integer", - "num-traits", - "paste", - "seq-macro", - "simdutf8", - "snap", - "thrift", - "tokio", - "twox-hash", - "zstd", -] - -[[package]] -name = "parquet" -version = "58.3.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dafa7d01085b62a47dd0c1829550a0a36710ea9c4fe358a05a85477cec8a908" +checksum = "d298093b2dec60289dce0684c986d0f7679e9dd15771c2c65406e1aaf604a704" dependencies = [ "ahash 0.8.12", - "arrow-array 58.3.0", - "arrow-buffer 58.3.0", - "arrow-data 58.3.0", - "arrow-ipc 58.3.0", - "arrow-schema 58.3.0", - "arrow-select 58.3.0", - "base64", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-ipc", + "arrow-schema", + "arrow-select", + "base64 0.22.1", "brotli", "bytes", "chrono", @@ -9606,6 +10047,7 @@ dependencies = [ "num-traits", "object_store", "paste", + "ring", "seq-macro", "simdutf8", "snap", @@ -9676,13 +10118,12 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "getrandom 0.4.3", + "phc", ] [[package]] @@ -9703,6 +10144,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "pbkdf2" version = "0.13.0" @@ -9768,7 +10219,17 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" +dependencies = [ + "base64 0.23.1", "serde_core", ] @@ -9795,9 +10256,9 @@ checksum = "3637c05577168127568a64e9dc5a6887da720efef07b3d9472d45f63ab191166" [[package]] name = "pest" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -9805,9 +10266,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" dependencies = [ "pest", "pest_generator", @@ -9815,9 +10276,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" dependencies = [ "pest", "pest_meta", @@ -9828,9 +10289,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.7" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" dependencies = [ "pest", ] @@ -9848,13 +10309,13 @@ dependencies = [ [[package]] name = "pgwire" -version = "0.40.4" +version = "0.40.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7981cfde34009be689a05a30c497ad5fbb552531d3d54230b3627264ff1bc384" +checksum = "8265901ede50d0879fe401c6fe282e7e4ff83ae10a48c1f8e4f89b92f7d5f604" dependencies = [ "async-trait", "aws-lc-rs", - "base64", + "base64 0.23.1", "bytes", "chrono", "derive-new", @@ -9872,13 +10333,24 @@ dependencies = [ "serde_json", "smol_str", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-util", "x509-certificate", ] +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.12.1" @@ -9954,6 +10426,18 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pinky-swear" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1ea6e230dd3a64d61bcb8b79e597d3ab6b4c94ec7a234ce687dd718b4f2e657" +dependencies = [ + "doc-comment", + "flume 0.11.1", + "parking_lot", + "tracing", +] + [[package]] name = "pinned" version = "0.1.0" @@ -9972,7 +10456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", - "fastrand", + "fastrand 2.5.0", "futures-io", ] @@ -9987,6 +10471,36 @@ dependencies = [ "spki", ] +[[package]] +name = "pkcs12" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2" +dependencies = [ + "cms", + "const-oid 0.9.6", + "der", + "digest 0.10.7", + "spki", + "x509-cert", + "zeroize", +] + +[[package]] +name = "pkcs5" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" +dependencies = [ + "aes 0.8.4", + "cbc 0.1.2", + "der", + "pbkdf2 0.12.2", + "scrypt", + "sha2 0.10.9", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -9999,9 +10513,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "png" @@ -10013,7 +10527,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -10026,7 +10540,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -10035,6 +10549,22 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dcdc93847ad24990939cce6e1804361e903efcb5f99daa5abd87943a9d6d7ba" +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + [[package]] name = "polling" version = "3.11.0" @@ -10043,12 +10573,24 @@ checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ "cfg-if", "concurrent-queue", - "hermit-abi", + "hermit-abi 0.5.3", "pin-project-lite", "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + [[package]] name = "polyval" version = "0.7.3" @@ -10056,15 +10598,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures 0.3.0", - "universal-hash", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", ] [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -10094,7 +10636,7 @@ version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ - "base64", + "base64 0.22.1", "byteorder", "bytes", "fallible-iterator", @@ -10123,9 +10665,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -10196,6 +10738,16 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.4", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -10336,9 +10888,9 @@ dependencies = [ [[package]] name = "prometheus-client" -version = "0.25.0" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba70bf887030e45213b4a95c9b08d5a450b157f87c1d63661ed0847a12fa2aad" +checksum = "1784dc11a05f5a8f57c4a62915686be140f24f70301290b997e317241fa9ffc2" dependencies = [ "dtoa", "itoa", @@ -10348,13 +10900,13 @@ dependencies = [ [[package]] name = "prometheus-client-derive-encode" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9adf1691c04c0a5ff46ff8f262b58beb07b0dbb61f96f9f54f6cbd82106ed87f" +checksum = "01e34894696ff94f64a20c2c373a6440903e9c2789a303d68ec6e6f953f890e4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -10413,7 +10965,7 @@ dependencies = [ "prost-reflect", "prost-types", "protox-parse", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -10425,14 +10977,14 @@ dependencies = [ "logos 0.15.1", "miette", "prost-types", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "psm" -version = "0.1.31" +version = "0.1.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" dependencies = [ "ar_archive_writer", "cc", @@ -10525,9 +11077,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", "serde", @@ -10535,9 +11087,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", "serde", @@ -10545,9 +11097,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", "serde", @@ -10567,7 +11119,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2 0.6.5", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -10575,9 +11127,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ "aws-lc-rs", "bytes", @@ -10592,7 +11144,7 @@ dependencies = [ "rustls-pki-types", "rustls-platform-verifier", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -10641,9 +11193,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -10763,7 +11315,7 @@ dependencies = [ "rand 0.9.5", "rand_chacha 0.9.0", "simd_helpers", - "thiserror 2.0.19", + "thiserror 2.0.20", "v_frame", "wasm-bindgen", ] @@ -10812,20 +11364,40 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rc2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "rcgen" -version = "0.14.8" +version = "0.14.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" dependencies = [ - "pem", + "pem 4.0.0", "ring", "rustls-pki-types", "time", - "x509-parser", + "x509-parser 0.18.1", "yasna", ] +[[package]] +name = "reactor-trait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "438a4293e4d097556730f4711998189416232f009c137389e0f961d2bc0ddc58" +dependencies = [ + "async-trait", + "futures-core", + "futures-io", +] + [[package]] name = "reborrow" version = "0.5.5" @@ -10869,27 +11441,27 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.4", ] [[package]] @@ -10906,9 +11478,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -10937,31 +11509,71 @@ dependencies = [ ] [[package]] -name = "reqsign" -version = "0.16.5" +name = "reqsign-aws-core" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43451dbf3590a7590684c25fb8d12ecdcc90ed3ac123433e500447c7d77ed701" +checksum = "bac4749b7dfa7bfaccd01eb03e9dc795ed37e3f20d6f0f38e2c67ee85ad6bc86" dependencies = [ - "anyhow", - "async-trait", - "base64", - "chrono", + "bytes", "form_urlencoded", - "getrandom 0.2.17", "hex", - "hmac 0.12.1", - "home", - "http 1.4.2", + "http 1.5.0", "log", "percent-encoding", - "quick-xml 0.37.5", - "rand 0.8.7", - "reqwest 0.12.28", + "quick-xml 0.41.0", + "reqsign-core", "rust-ini", "serde", "serde_json", - "sha1 0.10.7", - "sha2 0.10.9", + "serde_urlencoded", + "sha1 0.11.0", +] + +[[package]] +name = "reqsign-aws-v4" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff250f0fd0b913fbd565e405acc553da0f13bde30bfb5403178c9d0313cdc15f" +dependencies = [ + "bytes", + "http 1.5.0", + "log", + "quick-xml 0.41.0", + "reqsign-aws-core", + "reqsign-core", + "serde", +] + +[[package]] +name = "reqsign-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff052daffb0599681c50f85c59e7236438976efe991ab864edd9f3b235501a0f" +dependencies = [ + "anyhow", + "base64 0.23.1", + "bytes", + "futures", + "hex", + "hmac 0.13.0", + "http 1.5.0", + "jiff", + "log", + "mea", + "percent-encoding", + "sha1 0.11.0", + "sha2 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "reqsign-file-read-tokio" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3235df90a6bca681aa47dd86f2393d122a6d77042aa8a7c81e218cd45c5bfc0" +dependencies = [ + "anyhow", + "reqsign-core", "tokio", ] @@ -10971,12 +11583,12 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-core", "futures-util", - "h2 0.4.15", - "http 1.4.2", + "h2 0.4.19", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "hyper", @@ -10988,7 +11600,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "rustls-pki-types", "serde", "serde_json", @@ -11010,18 +11622,18 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "encoding_rs", "futures-channel", "futures-core", "futures-util", - "h2 0.4.15", - "http 1.4.2", + "h2 0.4.19", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "hyper", @@ -11061,10 +11673,10 @@ checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", - "http 1.4.2", - "reqwest 0.13.4", + "http 1.5.0", + "reqwest 0.13.5", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", "tower-service", ] @@ -11078,12 +11690,12 @@ dependencies = [ "async-trait", "futures", "getrandom 0.2.17", - "http 1.4.2", + "http 1.5.0", "hyper", - "reqwest 0.13.4", + "reqwest 0.13.5", "reqwest-middleware", "retry-policies", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "wasmtimer", @@ -11098,9 +11710,9 @@ dependencies = [ "anyhow", "async-trait", "getrandom 0.2.17", - "http 1.4.2", + "http 1.5.0", "matchit", - "reqwest 0.13.4", + "reqwest 0.13.5", "reqwest-middleware", "tracing", ] @@ -11207,28 +11819,29 @@ dependencies = [ [[package]] name = "rmcp" -version = "2.2.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +checksum = "b23c62fe489ac1d401ab32688cfacac3737a8978dc3343e5361464c7724fd3cb" dependencies = [ "async-trait", - "base64", + "base64 0.23.1", "bytes", "chrono", "futures", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", + "indexmap 2.14.2", "pastey 0.2.3", "pin-project-lite", "rand 0.10.2", - "reqwest 0.13.4", + "reqwest 0.13.5", "rmcp-macros", - "schemars 1.2.1", + "schemars 1.2.2", "serde", "serde_json", "sse-stream", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", @@ -11239,15 +11852,15 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "2.2.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +checksum = "cd740c45d66ceb87e5579082abc27bd771665e464e9660a17a048c721b2a6025" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", "serde_json", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -11271,9 +11884,9 @@ dependencies = [ [[package]] name = "roaring" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" +checksum = "18bd8a37d17a58532776dcdf6041ce64929adca78e8489d5cacbafe99229d3e1" dependencies = [ "bytemuck", "byteorder", @@ -11374,13 +11987,13 @@ dependencies = [ "async-trait", "aws-creds", "aws-region", - "base64", + "base64 0.22.1", "bytes", "cfg-if", "futures-util", "hex", "hmac 0.12.1", - "http 1.4.2", + "http 1.5.0", "log", "maybe-async", "md5", @@ -11393,7 +12006,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sysinfo 0.37.2", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tokio-stream", @@ -11411,7 +12024,7 @@ dependencies = [ "bytes", "num-traits", "postgres-types", - "rand 0.8.7", + "rand 0.8.8", "rkyv", "serde", "serde_json", @@ -11452,6 +12065,20 @@ dependencies = [ "nom 7.1.3", ] +[[package]] +name = "rustix" +version = "0.37.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519165d378b97752ca44bbe15047d5d3409e875f39327546b42ac81d7e18c1b6" +dependencies = [ + "bitflags 1.3.2", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys 0.3.8", + "windows-sys 0.48.0", +] + [[package]] name = "rustix" version = "0.38.44" @@ -11480,9 +12107,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -11494,16 +12121,42 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-connector" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70cc376c6ba1823ae229bacf8ad93c136d93524eab0e4e5e0e4f96b9c4e5b212" +dependencies = [ + "log", + "rustls", + "rustls-native-certs 0.7.3", + "rustls-pki-types", + "rustls-webpki", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + [[package]] name = "rustls-native-certs" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -11517,9 +12170,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -11537,10 +12190,10 @@ dependencies = [ "log", "once_cell", "rustls", - "rustls-native-certs", + "rustls-native-certs 0.8.4", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -11554,9 +12207,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -11613,6 +12266,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "same-file" version = "1.0.6" @@ -11645,9 +12307,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "chrono", "dyn-clone", @@ -11659,14 +12321,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -11681,6 +12343,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2 0.12.2", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "sd-notify" version = "0.5.0" @@ -11733,23 +12406,36 @@ dependencies = [ [[package]] name = "secret-service" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" dependencies = [ - "aes 0.8.4", - "cbc", + "aes 0.9.3", + "cbc 0.2.1", "futures-util", - "generic-array", - "getrandom 0.2.17", - "hkdf 0.12.4", + "getrandom 0.4.3", + "hkdf 0.13.0", + "hybrid-array", "num", "once_cell", "serde", - "sha2 0.10.9", + "sha2 0.11.0", "zbus", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -11865,18 +12551,18 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.4", ] [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -11885,7 +12571,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "itoa", "memchr", "serde", @@ -11912,7 +12598,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.4", ] [[package]] @@ -11955,24 +12641,25 @@ dependencies = [ "num-bigint", "serde", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "v8", ] [[package]] name = "serde_with" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4" dependencies = [ - "base64", + "base64 0.23.1", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -11981,14 +12668,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -11997,7 +12684,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "itoa", "ryu", "serde", @@ -12006,9 +12693,9 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.5.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +checksum = "a6df5ed973ad8d834e09f824f9e9f449af6b9a3745f78dec7cc752770bd3bf11" dependencies = [ "futures-executor", "futures-util", @@ -12020,18 +12707,18 @@ dependencies = [ [[package]] name = "serial_test_derive" -version = "3.5.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] name = "server" -version = "0.9.0-edge.6" +version = "0.9.0" dependencies = [ "ahash 0.8.12", "argon2", @@ -12048,6 +12735,7 @@ dependencies = [ "compio", "configs", "consensus", + "cpu_allocation", "crossfire", "ctrlc", "cyper", @@ -12068,7 +12756,7 @@ dependencies = [ "iggy_binary_protocol", "iggy_common", "journal", - "jsonwebtoken", + "jsonwebtoken 11.0.0", "left-right", "message_bus", "metadata", @@ -12082,7 +12770,9 @@ dependencies = [ "opentelemetry_sdk", "papaya", "partitions", + "proc-macro2", "prometheus-client", + "quote", "rand 0.10.2", "ringbuffer", "rmp-serde", @@ -12097,16 +12787,16 @@ dependencies = [ "serde_json", "server_common", "shard", - "shard_allocator", "socket2 0.6.5", "strum 0.28.0", + "syn 3.0.4", "sysinfo 0.39.6", "system_stats", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", - "toml 1.1.3+spec-1.1.0", - "tower-http 0.7.0", + "toml 1.1.6+spec-1.1.0", + "tower-http 0.7.1", "tracing", "tracing-appender", "tracing-opentelemetry", @@ -12148,7 +12838,7 @@ dependencies = [ "serial_test", "smallvec", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "tracing-appender", "tracing-opentelemetry", @@ -12174,7 +12864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -12196,7 +12886,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -12229,18 +12919,7 @@ dependencies = [ "partitions", "prometheus-client", "server_common", - "thiserror 2.0.19", - "tracing", -] - -[[package]] -name = "shard_allocator" -version = "0.1.0" -dependencies = [ - "cpu_allocation", - "hwlocality", - "nix", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", ] @@ -12293,9 +12972,9 @@ checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd-json" -version = "0.17.3" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e32d7ab2678282d21e53374fbead7119b7eacbede73685dcaac472870a29a11c" +checksum = "6ebe141eb4a23ecc4d5de93fa5b139ac65dc07c38037a2fe9a8fb7c9d36bd832" dependencies = [ "halfbrown", "ref-cast", @@ -12338,7 +13017,7 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -12365,7 +13044,7 @@ dependencies = [ "futures", "iggy_binary_protocol", "iggy_common", - "indexmap 2.14.0", + "indexmap 2.14.2", "journal", "message_bus", "metadata", @@ -12380,6 +13059,7 @@ dependencies = [ "tempfile", "tracing", "tracing-subscriber", + "twox-hash", ] [[package]] @@ -12405,9 +13085,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" dependencies = [ "serde", ] @@ -12454,7 +13134,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -12468,12 +13148,12 @@ checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" [[package]] name = "socket2" -version = "0.5.10" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" dependencies = [ "libc", - "windows-sys 0.52.0", + "winapi", ] [[package]] @@ -12550,9 +13230,9 @@ dependencies = [ [[package]] name = "sqlparser" -version = "0.62.0" +version = "0.63.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c6d1b651dc4edf07eead2a0c6c78016ce971bc2c10da5266861b13f25e7cec" +checksum = "3679862809bd1f92e563cf6fd820e28f39135303668001c10331d5027183e8b4" dependencies = [ "log", "recursive", @@ -12561,13 +13241,13 @@ dependencies = [ [[package]] name = "sqlparser_derive" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6dd45d8fc1c79299bfbb7190e42ccbbdf6a5f52e4a6ad98d92357ea965bd289" +checksum = "2905ae8e8148d8f7f5748ea95d1e949fa4fc1d7f5161547b758ad118b2e4b0aa" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -12589,21 +13269,22 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", + "bigdecimal", "bytes", "cfg-if", "chrono", "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 5.4.2", "futures-core", "futures-intrusive", "futures-io", "futures-util", "hashbrown 0.16.1", "hashlink", - "indexmap 2.14.0", + "indexmap 2.14.2", "log", "memchr", "percent-encoding", @@ -12612,7 +13293,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -12643,7 +13324,7 @@ dependencies = [ "cfg-if", "dotenvy", "either", - "heck", + "heck 0.5.0", "hex", "proc-macro2", "quote", @@ -12655,7 +13336,7 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.119", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "url", ] @@ -12666,6 +13347,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ + "bigdecimal", "bitflags 2.13.1", "byteorder", "bytes", @@ -12683,7 +13365,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "uuid", ] @@ -12695,7 +13377,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", + "bigdecimal", "bitflags 2.13.1", "byteorder", "chrono", @@ -12712,6 +13395,7 @@ dependencies = [ "log", "md-5 0.11.0", "memchr", + "num-bigint", "rand 0.10.2", "serde", "serde_json", @@ -12719,7 +13403,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "uuid", "whoami", @@ -12733,7 +13417,7 @@ checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "chrono", - "flume", + "flume 0.12.0", "form_urlencoded", "futures-channel", "futures-core", @@ -12745,7 +13429,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "url", "uuid", @@ -12753,9 +13437,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" dependencies = [ "bytes", "futures-util", @@ -12772,9 +13456,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stacker" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" dependencies = [ "cc", "cfg-if", @@ -12882,7 +13566,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -12894,7 +13578,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -12946,9 +13630,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.2" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -12970,7 +13654,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d6d5fbc4583cf3e5eee953506f13853a42ee6b4a21a983dffffb10c765cb80" dependencies = [ - "event-listener", + "event-listener 5.4.2", "futures-util", "local-event", "loom", @@ -13128,13 +13812,25 @@ dependencies = [ "xattr", ] +[[package]] +name = "tcp-stream" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495b0abdce3dc1f8fd27240651c9e68890c14e9d9c61527b1ce44d8a5a7bd3d5" +dependencies = [ + "cfg-if", + "p12-keystore", + "rustls-connector", + "rustls-pemfile", +] + [[package]] name = "tempfile" version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand", + "fastrand 2.5.0", "getrandom 0.4.3", "once_cell", "rustix 1.1.4", @@ -13205,17 +13901,17 @@ dependencies = [ "etcetera", "ferroid", "futures", - "http 1.4.2", + "http 1.5.0", "itertools 0.14.0", "log", "memchr", "parse-display", "pin-project-lite", - "reqwest 0.13.4", + "reqwest 0.13.5", "serde", "serde_json", "serde_with", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", @@ -13262,11 +13958,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -13282,13 +13978,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.4", ] [[package]] @@ -13327,9 +14023,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "libc", @@ -13394,9 +14090,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -13436,13 +14132,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -13473,9 +14169,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" dependencies = [ "rustls", "tokio", @@ -13483,9 +14179,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -13510,15 +14206,16 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] @@ -13554,11 +14251,11 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.3+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -13591,7 +14288,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -13602,7 +14299,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -13612,9 +14309,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ "winnow 1.0.4", ] @@ -13639,10 +14336,10 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", - "h2 0.4.15", - "http 1.4.2", + "h2 0.4.19", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "hyper", @@ -13703,7 +14400,7 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.14.0", + "indexmap 2.14.2", "pin-project-lite", "slab", "sync_wrapper", @@ -13725,7 +14422,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "http-body-util", "pin-project-lite", @@ -13740,13 +14437,13 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ "bitflags 2.13.1", "bytes", - "http 1.4.2", + "http 1.5.0", "http-body 1.1.0", "percent-encoding", "pin-project-lite", @@ -13787,7 +14484,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -13873,9 +14570,9 @@ dependencies = [ [[package]] name = "trait-variant" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3" dependencies = [ "proc-macro2", "quote", @@ -13905,14 +14602,14 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.4.2", + "http 1.5.0", "httparse", "log", "rand 0.9.5", "rustls", "rustls-pki-types", "sha1 0.10.7", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -13923,21 +14620,21 @@ checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ "bytes", "data-encoding", - "http 1.4.2", + "http 1.5.0", "httparse", "log", "rand 0.10.2", "rustls", "rustls-pki-types", "sha1 0.11.0", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] name = "twox-hash" -version = "2.1.3" +version = "2.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" +checksum = "5283634e518fe9e82c7b20520bb4bc209009fd16c82077c802f8111ecbb0117a" dependencies = [ "rand 0.10.2", ] @@ -14041,7 +14738,7 @@ checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.4", ] [[package]] @@ -14173,6 +14870,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + [[package]] name = "universal-hash" version = "0.6.1" @@ -14203,11 +14910,11 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d" dependencies = [ - "base64", + "base64 0.23.1", "flate2", "log", "percent-encoding", @@ -14220,12 +14927,12 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613" dependencies = [ - "base64", - "http 1.4.2", + "base64 0.23.1", + "http 1.5.0", "httparse", "log", ] @@ -14255,7 +14962,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -14302,9 +15009,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -14325,11 +15032,17 @@ dependencies = [ "fslock", "gzip-header", "home", - "miniz_oxide", + "miniz_oxide 0.8.9", "paste", "which", ] +[[package]] +name = "v_escape-base" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1212fce830b75af194b578e55b3db9049f2c8c45f58d397fb25602fdb50fb3d" + [[package]] name = "v_frame" version = "0.3.9" @@ -14343,9 +15056,12 @@ dependencies = [ [[package]] name = "v_htmlescape" -version = "0.15.8" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" +checksum = "befb3d53c9e3ec641417685896cbc8cc5bd264d6a2e190c56aaef1af24740d99" +dependencies = [ + "v_escape-base", +] [[package]] name = "validator" @@ -14469,6 +15185,12 @@ dependencies = [ "libc", ] +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + [[package]] name = "walkdir" version = "2.5.0" @@ -14523,9 +15245,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -14537,9 +15259,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -14547,9 +15269,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -14557,9 +15279,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -14570,9 +15292,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -14610,7 +15332,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51cf5f08b357e64cd7642ab4bbeb11aecab9e15520692129624fb9908b8df2c" dependencies = [ "deno_error", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -14629,9 +15351,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -14695,9 +15417,9 @@ dependencies = [ [[package]] name = "whoami" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" dependencies = [ "libc", "libredox", @@ -14948,6 +15670,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -14984,6 +15715,21 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -15035,6 +15781,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -15047,6 +15799,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -15059,6 +15817,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -15083,6 +15847,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -15095,6 +15865,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -15107,6 +15883,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -15119,6 +15901,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -15171,10 +15959,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", - "base64", + "base64 0.22.1", "deadpool", "futures", - "http 1.4.2", + "http 1.5.0", "http-body-util", "hyper", "hyper-util", @@ -15195,9 +15983,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -15208,6 +15996,17 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", +] + [[package]] name = "x509-certificate" version = "0.25.0" @@ -15219,14 +16018,31 @@ dependencies = [ "chrono", "der", "hex", - "pem", + "pem 3.0.6", "ring", "signature", "spki", - "thiserror 2.0.19", + "thiserror 2.0.20", "zeroize", ] +[[package]] +name = "x509-parser" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4569f339c0c402346d4a75a9e39cf8dad310e287eef1ff56d4c68e5067f53460" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + [[package]] name = "x509-parser" version = "0.18.1" @@ -15241,7 +16057,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", ] @@ -15310,12 +16126,12 @@ dependencies = [ "futures", "gloo 0.11.0", "implicit-clone", - "indexmap 2.14.0", + "indexmap 2.14.2", "js-sys", "rustversion", "serde", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokise", "tracing", @@ -15331,7 +16147,7 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08b883d84a035f57519d057f65a2a07ae25f1884ad485e1a9a523c9d880c1ad" dependencies = [ - "prettyplease", + "prettyplease 0.2.37", "proc-macro-error", "proc-macro2", "quote", @@ -15400,23 +16216,23 @@ checksum = "c6e61e59a957b7ccee15d2049f86e8bfd6f66968fcd88f018950662d9b86e675" [[package]] name = "zbus" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", - "async-io", - "async-lock", + "async-io 2.6.0", + "async-lock 3.4.2", "async-process", "async-recursion", "async-task", "async-trait", "blocking", "enumflags2", - "event-listener", + "event-listener 5.4.2", "futures-core", - "futures-lite", + "futures-lite 2.6.1", "hex", "libc", "ordered-stream", @@ -15435,9 +16251,9 @@ dependencies = [ [[package]] name = "zbus-secret-service-keyring-store" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" dependencies = [ "keyring-core", "secret-service", @@ -15446,14 +16262,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.18.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", "zbus_names", "zvariant", "zvariant_utils", @@ -15470,20 +16286,29 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", @@ -15533,9 +16358,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -15544,9 +16369,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -15555,13 +16380,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", ] [[package]] @@ -15584,7 +16409,7 @@ checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "crc32fast", "flate2", - "indexmap 2.14.0", + "indexmap 2.14.2", "memchr", "typed-path", "zopfli", @@ -15592,9 +16417,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" @@ -15650,9 +16475,9 @@ checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-inflate" @@ -15678,45 +16503,46 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core 0.5.1", + "zune-core 0.5.3", ] [[package]] name = "zvariant" -version = "5.13.1" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", "serde", "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.13.1" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate 3.3.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.4", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.5.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.119", + "syn 3.0.4", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index ea2d476acb..f4fa3cb7cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,14 +44,18 @@ members = [ "core/connectors/sinks/mongodb_sink", "core/connectors/sinks/postgres_sink", "core/connectors/sinks/quickwit_sink", + "core/connectors/sinks/rabbitmq_sink", "core/connectors/sinks/redshift_sink", "core/connectors/sinks/s3_sink", + "core/connectors/sinks/sink_template", "core/connectors/sinks/stdout_sink", "core/connectors/sinks/surrealdb_sink", "core/connectors/sources/elasticsearch_source", + "core/connectors/sources/http_source", "core/connectors/sources/influxdb_source", "core/connectors/sources/postgres_source", "core/connectors/sources/random_source", + "core/connectors/sources/source_template", "core/consensus", "core/cpu_allocation", "core/harness_derive", @@ -64,7 +68,6 @@ members = [ "core/server", "core/server_common", "core/shard", - "core/shard_allocator", "core/simulator", "core/system_stats", "core/tools", @@ -82,52 +85,50 @@ rust-version = "1.95" warnings = "deny" [workspace.dependencies] -actix-cors = "0.7.1" -actix-files = "0.6.10" -actix-web = "4.14.0" -aes-gcm = "0.11.0" +actix-cors = "0.7.2" +actix-files = "0.7.0" +actix-web = "4.15.0" +aes-gcm = "0.11.1" ahash = { version = "0.8.12", features = ["serde"] } aligned-vec = "0.6.4" anyhow = "1.0.104" -apache-avro = "0.21.0" -apple-native-keyring-store = { version = "1.0.1", features = ["keychain"] } -# "std" is load-bearing: it cascades to rand_core/getrandom, which the -# crypto module's `OsRng` import needs even when no sibling crate in the -# build graph happens to enable it via feature unification. -argon2 = { version = "0.5.3", features = ["std"] } -# Pinned to 57 because iceberg 0.9.1 still requires arrow/parquet 57. -arrow = "57.3.1" -arrow-array = "57.3.1" -arrow-json = "57.3.1" +apache-avro = "0.22.0" +apple-native-keyring-store = { version = "1.0.2", features = ["keychain"] } +arc-swap = "1.9.2" +argon2 = "0.6.0" +# arrow 58: iceberg 0.10.1 and deltalake 0.32.4 still require it. +arrow = "58.4.0" +arrow-array = "58.4.0" +arrow-json = "58.4.0" assert_cmd = "2.2.2" async-broadcast = "0.7.2" async-channel = "2.5.0" async-dropper = { version = "0.3.1", features = ["tokio", "simple"] } -async-trait = "0.1.91" -async_zip = { version = "0.0.18", features = ["tokio", "lzma", "bzip2", "xz", "deflate", "zstd"] } +async-trait = "0.1.92" +async_zip = { version = "0.0.19", features = ["tokio", "lzma", "bzip2", "xz", "deflate", "zstd"] } axum = { version = "0.8.9", features = ["macros"] } axum-server = { version = "0.8.0", features = ["tls-rustls"] } -base64 = "0.22.1" +base64 = "0.23.1" bench-dashboard-frontend = { path = "core/bench/dashboard/frontend" } bench-dashboard-server = { path = "core/bench/dashboard/server" } bench-dashboard-shared = { path = "core/bench/dashboard/shared" } bench-report = { path = "core/bench/report" } bench-runner = { path = "core/bench/runner" } bit-set = "0.11.1" -blake3 = "1.8.5" -bon = "3.9.3" -byte-unit = { version = "5.2.5", default-features = false, features = ["serde", "byte", "std"] } +blake3 = "1.8.7" +bon = "3.10.1" +byte-unit = { version = "5.2.6", default-features = false, features = ["serde", "byte", "std"] } bytemuck = { version = "1.25", features = ["derive", "min_const_generics"] } bytes = "1.12.1" cfg_aliases = "0.2.2" charming = "0.6.0" chrono = { version = "0.4.45", features = ["serde"] } -clap = { version = "4.6.3", features = ["derive", "wrap_help"] } -clap_complete = "4.6.7" +clap = { version = "4.6.6", features = ["derive", "wrap_help"] } +clap_complete = "4.6.9" clock = { path = "core/clock" } colored = "3.1.1" -comfy-table = { version = "7.2.2", default-features = false } -compio = { version = "=0.19.1", features = [ +comfy-table = { version = "8.0.0", default-features = false } +compio = { version = "0.19.2", features = [ "runtime", "macros", "io-uring", @@ -141,54 +142,50 @@ compio = { version = "=0.19.1", features = [ "fs", ] } compio-buf = "0.8.3" -compio-driver = "0.12.4" -compio-quic = "=0.8.0" -compio-ws = "=0.4.0" +compio-driver = "0.12.5" +compio-quic = "0.8.2" +compio-ws = "0.4.0" configs = { path = "core/configs", version = "0.1.0" } configs_derive = { path = "core/configs_derive", version = "0.1.0" } consensus = { path = "core/consensus" } console-subscriber = "0.5.0" cpu_allocation = { path = "core/cpu_allocation" } -crossbeam = "0.8.4" -crossfire = "3.1.19" +crossbeam = "0.8.5" +crossfire = "3.1.20" csv = "1.4.0" -ctor = "1.0.9" +ctor = "1.0.13" ctrlc = { version = "3.5", features = ["termination"] } cucumber = "0.23" cyper = { version = "0.9.0", features = ["rustls", "stream"], default-features = false } cyper-axum = { version = "0.9.0" } cyper-core = { version = "0.9.0", default-features = false } -darling = "0.23" +darling = "0.24" dashmap = "6.2.1" deltalake = { version = "0.32.4", features = ["azure", "gcs", "s3"] } derive-new = "0.7.0" derive_builder = "0.20.2" derive_more = { version = "2.1.1", features = ["full"] } dircpy = "0.3.20" -dirs = "6.0.0" -dlopen2 = "0.8.2" +dirs = "7.0.0" +dlopen2 = "0.9.0" dotenvy = "0.15.7" -dtor = "1.0.5" +dtor = "1.0.6" elasticsearch = { version = "9.1.0-alpha.1", features = ["rustls-tls"], default-features = false } enumset = "1.1" env_logger = "0.11.11" -err_trail = { version = "0.11.1", features = ["tracing"] } +err_trail = { version = "0.11.2", features = ["tracing"] } error_set = "0.9.2" figlet-rs = "1.0.0" figment = { version = "0.10.19", features = ["toml", "env"] } -file-operation = "0.8.28" +file-operation = "0.8.32" flatbuffers = "25.12.19" flume = "0.12.0" fs2 = "0.4.3" -futures = "0.3.33" -futures-core = { version = "0.3.33", default-features = false } -# Pinned to the minor compio-tls 0.10.0 resolves to: the replica plane -# handshakes with futures-rustls directly (TLS exporter access) and hands -# the stream to compio-tls via its From impls, so both crates must agree -# on the futures-rustls types. Cargo unifies within 0.26.x; drift to 0.27 -# fails compilation loudly (see the tripwire test in message_bus). +futures = "0.3.34" +futures-core = { version = "0.3.34", default-features = false } +# Keep on the minor compio-tls 0.10 uses; the message_bus tripwire test catches drift. futures-rustls = "0.26.0" -futures-util = "0.3.33" +futures-util = "0.3.34" getrandom = { version = "0.4", features = ["wasm_js"] } git2 = { version = "0.21.0", default-features = false, features = ["vendored-libgit2"] } gloo = "0.12" @@ -197,31 +194,39 @@ harness_derive = { path = "core/harness_derive" } hash32 = "1.0.0" hex = "0.4.3" hostname = "0.4.2" -http = "1.4.2" +http = "1.5.0" human-repr = "1.1.0" humantime = "2.4.0" hwlocality = "1.0.0-alpha.12" -hyper = "1.11.0" +hyper = "1.11.1" hyper-util = { version = "0.1.20", features = ["server-auto", "service"] } -iceberg = "0.9.1" -iceberg-catalog-rest = "0.9.1" -iceberg-storage-opendal = "0.9.1" -iggy = { path = "core/sdk", version = "0.11.0-edge.5" } -iggy-cli = { path = "core/cli", version = "0.14.0-edge.5" } -iggy_binary_protocol = { path = "core/binary_protocol", version = "0.11.0-edge.5" } -iggy_common = { path = "core/common", version = "0.11.0-edge.5" } -iggy_connector_sdk = { path = "core/connectors/sdk", version = "0.4.0-edge.3" } -indexmap = "2.14.0" +iceberg = "0.10.1" +iceberg-catalog-rest = "0.10.1" +iceberg-storage-opendal = "0.10.1" +iggy = { path = "core/sdk", version = "0.11.0" } +iggy-cli = { path = "core/cli", version = "0.14.0" } +iggy-gateway-kafka = { path = "gateways/kafka" } +iggy_binary_protocol = { path = "core/binary_protocol", version = "0.11.0" } +iggy_common = { path = "core/common", version = "0.11.0" } +iggy_connector_doris_sink = { path = "core/connectors/sinks/doris_sink" } +iggy_connector_sdk = { path = "core/connectors/sdk", version = "0.5.0" } +indexmap = "2.14.2" integration = { path = "core/integration" } -ipnet = "2.12.0" +ipnet = "2.12.2" journal = { path = "core/journal" } js-sys = "0.3" -jsonwebtoken = { version = "10.4.0", features = ["rust_crypto"] } +jsonwebtoken = { version = "11.0.0", features = ["rust_crypto"] } +kafka-protocol = { version = "0.18.0", default-features = false, features = ["broker"] } keyring-core = "1.0.0" +lapin = "2.5.1" lazy_static = "1.5.0" -left-right = "0.11" -libc = "0.2.188" -log = "0.4.33" +# Pinned exactly: `metadata::stm::stream`'s keyed stats evictions lean on +# left-right draining the previous batch's `absorb_second` before the current +# batch's `absorb_first`, which is an implementation detail of 0.11.8's +# `write.rs`, not a documented contract. +left-right = "=0.11.8" +libc = "0.2.189" +log = "0.4.34" lz4_flex = "0.14.0" meilisearch-sdk = { version = "0.33.0", default-features = false, features = [ "reqwest", @@ -233,11 +238,11 @@ metadata = { path = "core/metadata" } mimalloc = "0.1" mime_guess = "2.0" mockall = "0.15.0" -mongodb = { version = "3.8.0", features = ["rustls-tls"] } +mongodb = { version = "3.9.1", features = ["rustls-tls"] } nix = { version = "0.31.3", features = ["feature", "fs", "resource", "sched"] } nonzero_lit = "0.1.2" notify = "8.2.0" -octocrab = "0.54.0" +octocrab = "0.54.2" opentelemetry = { version = "0.32.0", features = ["trace", "logs"] } opentelemetry-appender-tracing = { version = "0.32.0", features = ["log"] } opentelemetry-otlp = { version = "0.32.0", features = [ @@ -256,16 +261,16 @@ opentelemetry_sdk = { version = "0.32.1", features = [ "experimental_logs_batch_log_processor_with_async_runtime", "experimental_trace_batch_span_processor_with_async_runtime", ] } -papaya = "0.2.4" -parquet = "57.3.1" +papaya = "0.2.5" +parquet = "58.4.0" partitions = { path = "core/partitions" } passterm = "2.0.6" paste = "1.0" -pgwire = "0.40.4" +pgwire = "0.40.7" postcard = { version = "1.1.3", features = ["alloc"] } predicates = "3.1.4" proc-macro2 = "1" -prometheus-client = "0.25.0" +prometheus-client = "0.25.1" prost = "0.14.4" prost-types = "0.14.4" protox = "0.9.1" @@ -275,21 +280,21 @@ quote = "1" rand = "0.10.2" rand_xoshiro = "0.8.1" rayon = "1.12.0" -rcgen = "0.14.8" +rcgen = "0.14.10" regex = "1.13.1" -reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls"] } +reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] } reqwest-middleware = { version = "0.5.2", features = ["json", "query"] } reqwest-retry = "0.9.1" reqwest-tracing = "0.7.1" ring = "0.17.14" ringbuffer = "0.16.0" -rmcp = "2.2.0" +rmcp = "3.3.0" rmp = "0.8.15" rmp-serde = "1.3.1" rolling-file = "0.2.0" rust-embed = "8.12.0" rust-s3 = { version = "0.37.2", default-features = false, features = ["tokio-rustls-tls", "tags"] } -rustls = { version = "0.23.42", features = ["ring"] } +rustls = { version = "0.23.45", features = ["ring"] } rustls-pemfile = "2.2.0" scopeguard = "1.2.0" sd-notify = "0.5" @@ -297,21 +302,21 @@ secrecy = { version = "0.10", features = ["serde"] } send_wrapper = "0.6.0" serde = { version = "1.0.229", features = ["derive", "rc"] } serde_json = "1.0.151" -serde_with = { version = "3.21.0", features = ["base64", "macros"] } +serde_with = { version = "3.23.0", features = ["base64", "macros"] } serde_yaml_ng = "0.10.0" -serial_test = "3.5.0" -server = { path = "core/server" } +serial_test = "4.0.1" +server = { path = "core/server", default-features = false } server_common = { path = "core/server_common" } shard = { path = "core/shard" } -shard_allocator = { path = "core/shard_allocator" } -simd-json = { version = "0.17.3", features = ["serde_impl"] } -smallvec = "1.15" +simd-json = { version = "0.18.1", features = ["serde_impl"] } +smallvec = "1.16" socket2 = "0.6.5" -sqlparser = { version = "0.62.0", features = ["visitor"] } +sqlparser = { version = "0.63.0", features = ["visitor"] } sqlx = { version = "0.9.0", features = [ "runtime-tokio", "tls-rustls", "postgres", + "bigdecimal", # "mysql": Doris exposes a MySQL-wire frontend; the Doris sink's # integration-test fixture talks to it over this driver. Cargo unifies # features across the workspace, so it is declared once here. @@ -324,23 +329,23 @@ static-toml = "1.3.0" strum = { version = "0.28.0", features = ["derive"] } strum_macros = "0.28.0" subtle = "2.6.1" -# Pinned to 2 because darling 0.23 still emits syn 2 types. -syn = { version = "2", features = ["full", "extra-traits"] } +syn = { version = "3", features = ["full", "extra-traits"] } sysinfo = "0.39.6" system_stats = { path = "core/system_stats" } tempfile = "3.27.0" terminal_size = { version = "0.4.4" } test-case = "3.3.1" +# Pinned to 0.27 because testcontainers-modules 0.15.0 still requires it. testcontainers = { version = "0.27.3", features = ["reusable-containers"] } testcontainers-modules = { version = "0.15.0", features = ["postgres", "http_wait"] } -thiserror = "2.0.19" +thiserror = "2.0.20" tokio = { version = "1.53.1", features = ["full"] } tokio-postgres = "0.7.18" -tokio-rustls = "0.26.4" +tokio-rustls = "0.26.5" tokio-tungstenite = { version = "0.30", features = ["rustls-tls-webpki-roots"] } -tokio-util = { version = "0.7.18", features = ["compat"] } -toml = "1.1.3" -tower-http = { version = "0.7.0", features = ["add-extension", "cors", "trace"] } +tokio-util = { version = "0.7.19", features = ["compat"] } +toml = "1.1.6" +tower-http = { version = "0.7.1", features = ["add-extension", "cors", "trace"] } tracing = "0.1.44" tracing-appender = "0.2.5" tracing-opentelemetry = "0.33.0" @@ -349,13 +354,13 @@ tracing-subscriber = { version = "0.3.23", default-features = false, features = "env-filter", "ansi", ] } -trait-variant = "0.1.2" +trait-variant = "0.1.3" tungstenite = "0.30.0" -twox-hash = { version = "2.1.3", features = ["xxhash32"] } +twox-hash = { version = "2.1.4", features = ["xxhash32"] } ulid = "3.0.0" -ureq = "3.3" +ureq = "3.4" url = "2.5.8" -uuid = { version = "1.24.0", features = ["v4", "v7", "fast-rng", "serde", "zerocopy"] } +uuid = { version = "1.26.1", features = ["v4", "v7", "fast-rng", "serde", "zerocopy"] } vergen-git2 = { version = "10.0.1", features = ["build", "cargo", "rustc", "si"] } walkdir = "2.5.0" wasm-bindgen = "0.2" @@ -373,7 +378,7 @@ windows-native-keyring-store = "1.1.0" wiremock = "0.6" yew = { version = "0.23", features = ["csr"] } yew-router = "0.20" -zbus-secret-service-keyring-store = { version = "1.0.0", features = ["rt-async-io-crypto-rust"] } +zbus-secret-service-keyring-store = { version = "1.0.1", features = ["rt-async-io-crypto-rust"] } zeroize = "1.9.0" zip = { version = "8.6.0", default-features = false, features = ["deflate"] } @@ -381,19 +386,15 @@ zip = { version = "8.6.0", default-features = false, features = ["deflate"] } lto = true codegen-units = 1 -# Selectively optimize CPU-intensive dependencies in dev profile: -# - argon2: password hashing -# - twox-hash: checksum calculation -# - rand_chacha: random number generation -# - iggy_common: wrapper functions for above -# This is faster than global opt-level=1 which adds significant compile time. +# CPU-heavy crates that dominate dev-profile test time: password hashing, +# checksums, RNG. Cheaper than raising opt-level for the whole graph. [profile.dev.package.argon2] opt-level = 3 [profile.dev.package.twox-hash] opt-level = 3 -[profile.dev.package.rand_chacha] +[profile.dev.package.chacha20] opt-level = 3 [profile.dev.package.iggy_common] diff --git a/README.md b/README.md index fd61c9a57d..a447f0387e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@
-[Website](https://iggy.apache.org) | [Getting started](https://iggy.apache.org/docs/introduction/getting-started/) | [Documentation](https://iggy.apache.org/docs/) | [Blog](https://iggy.apache.org/blogs/) | [Discord](https://discord.gg/apache-iggy) | [Crates](https://crates.io/crates/iggy) +[Website](https://iggy.apache.org) | [Getting started](https://iggy.apache.org/docs/introduction/quickstart/) | [Documentation](https://iggy.apache.org/docs/) | [Blog](https://iggy.apache.org/blogs/) | [Discord](https://discord.gg/apache-iggy) | [Crates](https://crates.io/crates/iggy) | [Mailing list](https://iggy.apache.org/community/mailing-lists/)

@@ -39,6 +39,27 @@ Chat with Apache Iggy community on Discord

+**Contents:** [What is Apache Iggy?](#what-is-apache-iggy) · +[Features](#features) · +[Architecture](#architecture) · +[Clustering](#clustering) · +[Version](#version) · +[Supported languages SDK](#supported-languages-sdk) · +[CLI](#cli) · +[Web UI](#web-ui) · +[Connectors](#connectors) · +[Kafka gateway](#kafka-gateway) · +[Model Context Protocol](#model-context-protocol) · +[Docker](#docker) · +[Kubernetes](#kubernetes) · +[Configuration](#configuration) · +[Run the server](#run-the-server) · +[Try the CLI](#try-the-cli) · +[Examples](#examples) · +[SDK](#sdk) · +[Benchmarks](#benchmarks) · +[Contributing](#contributing) + --- ## What is Apache Iggy? @@ -68,14 +89,14 @@ The name is an abbreviation for the Italian Greyhound - small yet extremely fast - **Thread per core shared nothing design** together with `io_uring` guarantee the best possible performance on modern `Linux` systems. - **Works directly with binary data**, avoiding enforced schema and serialization/deserialization overhead - Custom **zero-copy (de)serialization**, which greatly improves the performance and reduces memory usage. -- Configurable server features (e.g. caching, segment size, data flush interval, transport protocols etc.) +- Configurable server features (e.g. caching and transport protocols), plus per-topic segment size, durability and flush thresholds - Server-side storage of **consumer offsets** - Multiple ways of polling the messages: - By offset (using the indexes) - By timestamp (using the time indexes) - First/Last N messages - Next N messages for the specific consumer -- Possibility of **auto committing the offset** (e.g. to achieve *at-most-once* delivery) +- Optional **poll auto-commit**; processing guarantees depend on application processing and offset-commit ordering - **Consumer groups** providing the message ordering and horizontal scaling across the connected clients - **Message expiry** with auto deletion based on the configurable **retention policy** - Additional features such as **server side message deduplication** @@ -89,10 +110,10 @@ The name is an abbreviation for the Italian Greyhound - small yet extremely fast are reserved for future disk/network compression support; use message headers for manual compression today (see `examples/rust/src/message-headers/message-compression`). - Optional **data backups and archiving** to disk or **S3** compatible cloud storage (e.g. AWS S3) -- Support for **OpenTelemetry** logs & traces + Prometheus metrics +- Prometheus metrics for the server and connectors runtime, plus **OpenTelemetry** logs & traces in the connectors runtime. Server OTLP export is unavailable pending runtime integration. - Built-in **CLI** to manage the streaming server installable via `cargo install iggy-cli` - Built-in **benchmarking app** to test the performance -- **Single binary deployment** (no external dependencies) +- **Single binary deployment** without an external broker or database; dynamically linked builds still require operating-system libraries - Running as a single node or as a **cluster**, with data replication based on **[Viewstamped Replication (VSR)](https://github.com/apache/iggy/blob/master/assets/vsr.pdf)** ![server](assets/server.png) @@ -109,11 +130,21 @@ This is the high-level architecture of the Iggy message streaming server, where --- +## Clustering + +Clustering is built into the standard `iggy-server` binary, with no separate build or feature flag. The same server runs as a single node or as a cluster, and `cluster.enabled` in the configuration chooses which. Clustering is disabled by default. + +Replicas stay consistent using [Viewstamped Replication (VSR)](https://github.com/apache/iggy/blob/master/assets/vsr.pdf), which commits on a quorum and elects a new primary when the current one fails. See the [clustering documentation](https://iggy.apache.org/docs/clustering/vsr) for configuration and deployment. + +--- + ## Version -The official releases follow the regular semver (`0.7.0`) or have `latest` tag applied (`apache/iggy:latest`). +The official releases follow semver (for example `server-0.9.0`), and the Docker images also carry the `latest` tag (`apache/iggy:latest`). + +The server, SDKs and CLI are versioned independently. For example, server 0.9.0 ships with Rust SDK 0.11.0 and CLI 0.14.0. The [server compatibility table](https://iggy.apache.org/docs/sdk/introduction#server-compatibility) lists the SDK version for each server release. -We do also publish edge/dev/nightly releases (e.g. `0.7.0-edge.1` or `apache/iggy:edge`), for both, SDKs and the Docker images, which are typically compatible with the latest changes, but are not guaranteed to be stable, and as the name states, are not recommended for production use. +We also publish edge releases (versions with an `-edge.N` suffix, and `apache/iggy:edge`) for the SDKs and the Docker images. They track the latest changes, but are not guaranteed to be stable and are not recommended for production use. --- @@ -126,13 +157,13 @@ We do also publish edge/dev/nightly releases (e.g. `0.7.0-edge.1` or `apache/igg - [Node.js (TypeScript)](https://www.npmjs.com/package/apache-iggy) - [Go](https://pkg.go.dev/github.com/apache/iggy/foreign/go) -[C++](https://github.com/apache/iggy/tree/master/foreign/cpp) is work in progress. +[C++](https://github.com/apache/iggy/tree/master/foreign/cpp) and [PHP](https://github.com/apache/iggy/tree/master/foreign/php) are work in progress. --- ## CLI -The interactive CLI is implemented under the `cli` project, to provide the best developer experience. This is a great addition to the Web UI, especially for all the developers who prefer using the console tools. +The interactive CLI is implemented under `core/cli`, to provide the best developer experience. This is a great addition to the Web UI, especially for all the developers who prefer using the console tools. Iggy CLI can be installed with `cargo install iggy-cli` and then simply accessed by typing `iggy` in your terminal. @@ -186,6 +217,12 @@ fields = ["email", "created_at"] --- +## Kafka gateway + +The [Kafka gateway](https://github.com/apache/iggy/tree/master/gateways/kafka) is a Kafka wire protocol listener in front of Iggy, intended to let existing Kafka clients use Iggy. It is in development and not yet part of a release. See its [README](gateways/kafka/README.md) for the current status and how to run it. + +--- + ## Model Context Protocol The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is an open protocol that standardizes how applications provide context to LLMs. The **[Iggy MCP Server](https://github.com/apache/iggy/tree/master/core/ai/mcp)** is an implementation of the MCP protocol for the message streaming infrastructure. It can be used to provide context to LLMs in real-time, allowing for more accurate and relevant responses. @@ -196,31 +233,45 @@ The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is an open p ## Docker -The official Apache Iggy images can be found in [Docker Hub](https://hub.docker.com/r/apache/iggy), simply type `docker pull apache/iggy` to pull the image. +The official images are on [Docker Hub](https://hub.docker.com/r/apache/iggy). Images tagged `latest` are built from stable releases, and `edge` images are built from the latest `master` branch. Images for the other tools, such as the connectors runtime and the MCP server, are also on [Docker Hub](https://hub.docker.com/u/apache?page=1&search=iggy). + +The published image listens on `127.0.0.1` inside the container, so the host can't reach it by default. This command makes it reachable and sets the root credentials: + +```bash +docker run -d --name iggy \ + --cap-add=SYS_NICE --security-opt seccomp=unconfined --ulimit memlock=-1:-1 \ + -e IGGY_ROOT_USERNAME=iggy -e IGGY_ROOT_PASSWORD=iggy \ + -e IGGY_TCP_ADDRESS=0.0.0.0:8090 -e IGGY_HTTP_ADDRESS=0.0.0.0:3000 \ + -e IGGY_NODE_ADVERTISED_ADDRESS=localhost \ + -p 8090:8090 -p 3000:3000 \ + -v iggy:/app/local_data \ + apache/iggy +``` + +The CLI is on the image's `PATH`: -You can also find the images for all the different tooling such as Connectors, MCP Server etc. at [Docker Hub](https://hub.docker.com/u/apache?page=1&search=iggy). +```bash +docker exec -it iggy iggy -u iggy -p iggy stream list +``` -Please note that the images tagged as `latest` are based on the official, stable releases, while the `edge` ones are updated directly from latest version of the `master` branch. +The `SYS_NICE` capability, the seccomp setting and the unlimited locked memory are required. The [Docker & Helm](https://iggy.apache.org/docs/server/docker) page explains why, and has a `docker-compose.yml` example and the Helm chart. -You can find the `Dockerfile` and `docker-compose` in the root of the repository. To build and start the server, run: `docker compose up`. +On operating systems other than Linux, Docker runs in a VM, so the container might run slower. -Additionally, you can run the `CLI` which is available in the running container, by executing: `docker exec -it iggy-server /iggy`. +To build the image from source instead, run `docker compose up` from the root of the repository. That image listens on all interfaces and keeps its data in `/local_data`. The container is named `iggy-server`, and the CLI is at `/iggy`. Set `IGGY_ROOT_USERNAME` and `IGGY_ROOT_PASSWORD` in `docker-compose.yml`. Otherwise the server generates a root password and prints it once in the container log. + +--- -Keep in mind that running the container on the OS other than Linux, where the Docker is running in the VM, might result in the performance degradation. +## Kubernetes -Also, when running the container, **make sure to include the additional capabilities**, as you can find in [docker-compose](https://github.com/apache/iggy/blob/master/docker-compose.yml) file: +A Helm chart for the server and the Web UI is in [helm/charts/iggy](helm/charts/iggy): -```yml -cap_add: - - SYS_NICE -security_opt: - - seccomp:unconfined -ulimits: - memlock: - soft: -1 - hard: -1 +```bash +helm install iggy ./helm/charts/iggy --set server.image.tag=0.9.0 ``` +See the chart [README](helm/charts/iggy/README.md) and the [Docker & Helm](https://iggy.apache.org/docs/server/docker) page for its settings. + --- ## Configuration @@ -231,11 +282,23 @@ The configuration file is loaded from the current working directory, but you can When config file is not found, the default values from embedded `config.toml` file are used. +Topic creation accepts two independent policies: `durability` for message acknowledgments and `consumer_offset_durability` for explicit offset stores and deletes. Both default to `replicated`. This means VSR quorum commit without waiting for stable storage. `persisted` also requires recoverable stable-storage copies on the replication quorum. Both policies normally store data on disk. Poll auto-commit remains asynchronous and is not covered by the poll response's completion. + +The data directory is configured with `path` or `IGGY_PATH`. The layout beneath it is `streams//topics//partitions/`, with fixed directory names. + +The HTTP `Iggy-Durability` header reports `replicated` or `persisted` for awaited writes, and `none` for early dispatch acceptance. + +Segment flush thresholds control scheduling, independently of acknowledgment durability. + +Rust HTTP callers can use `HttpClient::send_messages_with_durability` to read the advertised guarantee alongside confirmations. + +The CLI exposes `--durability persisted` and `--consumer-offset-durability persisted` on `topic create`. Select either independently. The policy names describe completion guarantees and do not prescribe an I/O syscall. + For the detailed documentation of the configuration file, please refer to the [configuration](https://iggy.apache.org/docs/server/configuration) section. --- -## Quick start +## Run the server Build the project (the longer compilation time is due to [LTO](https://doc.rust-lang.org/rustc/linker-plugin-lto.html) enabled in release [profile](https://github.com/apache/iggy/blob/master/Cargo.toml#L2): @@ -264,14 +327,13 @@ $env:IGGY_ROOT_USERNAME = "iggy" $env:IGGY_ROOT_PASSWORD = "iggy" ``` -By default, `iggy-server` will generate a randomized root user password and print it to `stdout`, when there's -NO users created. +If no root credentials are set and no users exist yet, `iggy-server` generates a random root user password and prints it once in the server log. Start the server: `cargo run --bin iggy-server` -All the data used by the server will be persisted under the `local_data` directory by default, unless specified differently in the configuration (see `system.path` in `config.toml`). +All the data used by the server will be persisted under the `local_data` directory by default, unless specified differently in the configuration (see `path` in `config.toml`). One can use default root credentials with optional `--with-default-root-credentials`. This flag is equivalent to setting `IGGY_ROOT_USERNAME=iggy` and `IGGY_ROOT_PASSWORD=iggy`, plus @@ -288,10 +350,10 @@ For configuration options and detailed help: You can also use environment variables to override any configuration setting: - Override TCP address - `IGGY_TCP_ADDRESS=0.0.0.0:8090 cargo run --bin iggy-server` + `IGGY_TCP_ADDRESS=127.0.0.1:8090 cargo run --bin iggy-server` - Set custom data path - `IGGY_SYSTEM_PATH=/data/iggy cargo run --bin iggy-server` + `IGGY_PATH=/data/iggy cargo run --bin iggy-server` - Enable HTTP transport `IGGY_HTTP_ENABLED=true cargo run --bin iggy-server` @@ -303,7 +365,11 @@ To quickly generate the sample data: `cargo run --bin data-seeder-tool` -*Please note that all commands below are using `iggy` binary, which is part of release (`cli` sub-crate).* +--- + +## Try the CLI + +The commands below use the `iggy` CLI from the `core/cli` crate, run against the server started above. Create a stream with name `dev` (numerical ID will be assigned by server automatically) using default credentials and `tcp` transport (available transports: `quic`, `websocket`, `tcp`, `http`, default `tcp`): @@ -317,7 +383,7 @@ Get `dev` stream details: `cargo run --bin iggy -- -u -p stream get dev` -Create a topic named `sample` (numerical ID will be assigned by server automatically) for stream `dev`, with 2 partitions (IDs 1 and 2), no topic compression (`none`), and disabled message expiry (skipped optional parameter). Other compression values are reserved for future server-side support: +Create a topic named `sample` (numerical ID will be assigned by server automatically) for stream `dev`, with 2 partitions (IDs 0 and 1), no topic compression (`none`), and disabled message expiry (skipped optional parameter). Other compression values are reserved for future server-side support: `cargo run --bin iggy -- -u -p topic create dev sample 2 none` @@ -329,23 +395,23 @@ Get topic details for topic `sample` in stream `dev`: `cargo run --bin iggy -- -u -p topic get dev sample` -Send a message 'hello world' (message ID 1) to the stream `dev` to topic `sample` and partition 1: +Send the first message 'hello world' to the stream `dev` to topic `sample` and partition 0: -`cargo run --bin iggy -- -u -p message send --partition-id 1 dev sample "hello world"` +`cargo run --bin iggy -- -u -p message send --partition-id 0 dev sample "hello world"` -Send another message 'lorem ipsum' (message ID 2) to the same stream, topic and partition: +Send a second message 'lorem ipsum' to the same stream, topic and partition: -`cargo run --bin iggy -- -u -p message send --partition-id 1 dev sample "lorem ipsum"` +`cargo run --bin iggy -- -u -p message send --partition-id 0 dev sample "lorem ipsum"` -Poll messages by a regular consumer with ID 1 from the stream `dev` for topic `sample` and partition with ID 1, starting with offset 0, messages count 2, without auto commit (storing consumer offset on server): +Poll messages by a regular consumer with ID 1 from the stream `dev` for topic `sample` and partition with ID 0, starting with offset 0, messages count 2, with auto commit (storing consumer offset on server): -`cargo run --bin iggy -- -u -p message poll --consumer 1 --offset 0 --message-count 2 --auto-commit dev sample 1` +`cargo run --bin iggy -- -u -p message poll --consumer 1 --offset 0 --message-count 2 --auto-commit dev sample 0` Finally, restart the server to see it is able to load the persisted data. The HTTP API endpoints can be found in [server.http](https://github.com/apache/iggy/blob/master/core/server/server.http) file, which can be used with [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) extension for VS Code. -To see the detailed logs from the CLI/server, run it with `RUST_LOG=trace` environment variable. See images below: +To see the detailed logs from the CLI/server, run it with the `RUST_LOG=trace` environment variable. --- @@ -355,6 +421,8 @@ You can find comprehensive sample applications under the `examples/rust` directo For detailed information about available examples and how to run them, please see the [Examples README](examples/rust/README.md). +There are also examples for [C#](examples/csharp/README.md), [Go](examples/go/README.md), [Java](examples/java/README.md), [Node.js](examples/node/README.md), [PHP](examples/php/README.md) and [Python](examples/python/README.md). + --- ## SDK @@ -430,7 +498,7 @@ To benchmark the project, first build the project in release mode: cargo build --release ``` -Then, run the benchmarking app with the desired options: +Start `iggy-server` separately, then run the benchmarking app with the desired options: 1. Sending (writing) benchmark @@ -474,14 +542,26 @@ Then, run the benchmarking app with the desired options: cargo run --bin iggy-bench -r -- end-to-end-producing-consumer tcp ``` -These benchmarks would start the server with the default configuration, create a stream, topic and partition, and then send or poll the messages. The default configuration is optimized for the best performance, so you might want to tweak it for your needs. If you need more options, please refer to `iggy-bench` subcommands `help` and `examples`. +8. End to end producing and consuming through a consumer group: + + ```bash + cargo run --bin iggy-bench -r -- end-to-end-producing-consumer-group tcp + ``` -For example, to run the benchmark for the already started server, provide the additional argument `--server-address 0.0.0.0:8090`. +The benchmark connects to a running server and creates the streams, topics, and partitions needed by the selected workload. Use `iggy-bench --help` and `iggy-bench examples` for all benchmark variants, transports, and topic-option examples. Both message and consumer-offset durability independently default to `replicated`. + +For example, to run the benchmark for the already started server, provide the additional argument `--server-address 127.0.0.1:8090`. **Iggy is already capable of processing millions of messages per second at the microseconds range for p99+ latency** Depending on the hardware, transport protocol (`quic`, `websocket`, `tcp` or `http`) and payload size (`messages-per-batch * message-size`) you might expect **over 5000 MB/s (e.g. 5M of 1 KB msg/sec) throughput for writes and reads**. Please refer to the mentioned [benchmarking platform](https://benchmarks.iggy.apache.org) where you can browse the results achieved on the different hardware configurations, using the different Iggy server versions. +### Host preparation + +Check `io_uring` access, process limits, memory headroom, CPU/NUMA placement, and sustained disk/network capacity before comparing runs. Measure host-tuning changes with the same workload and durability policies. + +Use the [benchmark host checklist](core/bench/README.md#host-preparation) for practical setup and repeatable measurements. The [Linux tuning guide](https://iggy.apache.org/docs/server/linux-tuning) explains swappiness, huge pages, writeback, CPU placement, and networking, with commands and upstream references. + --- ## Contributing diff --git a/assets/cli.png b/assets/cli.png index 43acc469dd..8c677c5ca2 100644 Binary files a/assets/cli.png and b/assets/cli.png differ diff --git a/assets/server.png b/assets/server.png index 02e27e0e79..a922d4faf9 100644 Binary files a/assets/server.png and b/assets/server.png differ diff --git a/assets/web_ui.png b/assets/web_ui.png index 1fa0d7db6c..7bc862f494 100644 Binary files a/assets/web_ui.png and b/assets/web_ui.png differ diff --git a/bdd/cpp/features/step_definitions/background_steps.cpp b/bdd/cpp/features/step_definitions/background_steps.cpp index c4f593f596..33fcb008ec 100644 --- a/bdd/cpp/features/step_definitions/background_steps.cpp +++ b/bdd/cpp/features/step_definitions/background_steps.cpp @@ -27,15 +27,19 @@ #include #include +#include #include #include #include "world.hpp" namespace { -std::string env_or(const char *name, const std::string &fallback) { +std::string required_env(const char *name) { const char *value = std::getenv(name); - return value != nullptr ? std::string(value) : fallback; + if (value == nullptr || *value == '\0') { + throw std::runtime_error(std::string(name) + " must be set; run the suite via scripts/run-bdd-tests.sh"); + } + return std::string(value); } } // namespace @@ -44,7 +48,7 @@ GIVEN("^I have a running Iggy server$") { // Empty address makes the SDK fall back to its default TCP endpoint; in CI the address // is supplied via IGGY_TCP_ADDRESS (e.g. iggy-server:8090). - const std::string address = env_or("IGGY_TCP_ADDRESS", ""); + const std::string address = required_env("IGGY_TCP_ADDRESS"); iggy::ffi::IggyClientConfig config{}; config.server_address = address; iggy::ffi::Client *client = iggy::ffi::new_connection(std::move(config)); @@ -57,7 +61,7 @@ GIVEN("^I am authenticated as the root user$") { cucumber::ScenarioScope context; ASSERT_NE(context->client, nullptr); - const std::string username = env_or("IGGY_ROOT_USERNAME", "iggy"); - const std::string password = env_or("IGGY_ROOT_PASSWORD", "iggy"); + const std::string username = required_env("IGGY_ROOT_USERNAME"); + const std::string password = required_env("IGGY_ROOT_PASSWORD"); context->client->login_user(username, password); } diff --git a/bdd/cpp/features/step_definitions/messaging_steps.cpp b/bdd/cpp/features/step_definitions/messaging_steps.cpp index 90ee3ba7e4..f29b74b9d1 100644 --- a/bdd/cpp/features/step_definitions/messaging_steps.cpp +++ b/bdd/cpp/features/step_definitions/messaging_steps.cpp @@ -27,10 +27,10 @@ #include #include +#include #include #include -#include "iggy.hpp" #include "world.hpp" GIVEN("^I have no streams in the system$") { @@ -69,18 +69,19 @@ WHEN("^I create a topic with name \"([^\"]{1,255})\" in stream ([0-9]+) with ([0 REGEX_PARAM(int, partitions_count); cucumber::ScenarioScope context; - // Drive the SDK through the typed helpers in iggy.hpp rather than raw strings. Options that - // still take plain strings below (partitioning kind, consumer kind) mark where the C++ SDK - // does not yet expose a typed wrapper. - const auto compression = iggy::CompressionAlgorithm::None(); - const auto message_expiry = iggy::Expiry::NeverExpire(); - const auto max_topic_size = iggy::MaxTopicSize::ServerDefault(); + iggy::ffi::TopicCreateOptions opts{}; + opts.has_partitions_count = true; + opts.partitions_count = static_cast(partitions_count); + opts.has_compression_algorithm = true; + opts.compression_algorithm = "none"; + opts.has_message_expiry = true; + opts.message_expiry_kind = "never_expire"; + opts.message_expiry_value = std::numeric_limits::max(); + opts.has_max_topic_size = true; + opts.max_topic_size = "server_default"; context->client->create_topic(bdd::make_numeric_identifier(static_cast(stream_id)), topic_name, - static_cast(partitions_count), - std::string(compression.CompressionAlgorithmValue()), - std::string(message_expiry.ExpiryKind()), message_expiry.ExpiryValue(), - std::string(max_topic_size.MaxTopicSizeValue()), {}); + std::move(opts)); } THEN("^the topic should be created successfully$") { @@ -121,7 +122,7 @@ WHEN("^I send ([0-9]+) messages to stream ([0-9]+), topic ([0-9]+), partition ([ iggy::ffi::make_message(bdd::to_payload(bdd::expected_payload(static_cast(index))), rust::Vec()); // Assign an explicit, 1-based id so the last-sent/last-polled comparison is meaningful. - message.id_lo = static_cast(index + 1); + message.id_lo = static_cast(index) + 1; messages.push_back(std::move(message)); } @@ -146,12 +147,10 @@ WHEN("^I poll messages from stream ([0-9]+), topic ([0-9]+), partition ([0-9]+) REGEX_PARAM(int, offset); cucumber::ScenarioScope context; - const auto polling_strategy = iggy::PollingStrategy::Offset(static_cast(offset)); - const auto polled = context->client->poll_messages( + const auto polled = context->client->poll_messages( bdd::make_numeric_identifier(static_cast(stream_id)), bdd::make_numeric_identifier(static_cast(topic_id)), static_cast(partition_id), - "consumer", bdd::make_numeric_identifier(1), std::string(polling_strategy.PollingStrategyKind()), - polling_strategy.PollingStrategyValue(), 100, false); + "consumer", bdd::make_numeric_identifier(1), "offset", static_cast(offset), 100, false); context->polled.count = polled.count; context->polled.offsets.clear(); diff --git a/bdd/docker-compose.cluster.yml b/bdd/docker-compose.cluster.yml index 9976d342b1..d84da3475d 100644 --- a/bdd/docker-compose.cluster.yml +++ b/bdd/docker-compose.cluster.yml @@ -56,6 +56,9 @@ x-cluster-topology: &cluster-topology IGGY_ROOT_PASSWORD: iggy IGGY_CLUSTER_ENABLED: "true" IGGY_CLUSTER_NAME: test-cluster + # Keep role-specific scenarios on view 0 when a loaded CI runner delays + # one replica during startup. + IGGY_CLUSTER_HEARTBEAT_TIMEOUT: "30s" IGGY_CLUSTER_NODES_0_NAME: leader-node IGGY_CLUSTER_NODES_0_IP: 172.28.0.101 IGGY_CLUSTER_NODES_0_REPLICA_ID: "0" @@ -102,7 +105,7 @@ services: environment: <<: *cluster-topology RUST_LOG: info - IGGY_SYSTEM_PATH: local_data_leader + IGGY_PATH: local_data_leader IGGY_TCP_ADDRESS: 0.0.0.0:8091 IGGY_HTTP_ADDRESS: 0.0.0.0:3001 IGGY_QUIC_ADDRESS: 0.0.0.0:8081 @@ -115,6 +118,9 @@ services: iggy-follower: <<: *cluster-node + depends_on: + iggy-leader: + condition: service_healthy command: [ "--replica-id", "1" ] healthcheck: test: [ "CMD", "/usr/local/bin/iggy", "--tcp-server-address", "172.28.0.102:8092", "ping" ] @@ -125,7 +131,7 @@ services: environment: <<: *cluster-topology RUST_LOG: info - IGGY_SYSTEM_PATH: local_data_follower + IGGY_PATH: local_data_follower IGGY_TCP_ADDRESS: 0.0.0.0:8092 IGGY_HTTP_ADDRESS: 0.0.0.0:3002 IGGY_QUIC_ADDRESS: 0.0.0.0:8082 diff --git a/bdd/docker-compose.server.yml b/bdd/docker-compose.server.yml index e22484533c..88868ace30 100644 --- a/bdd/docker-compose.server.yml +++ b/bdd/docker-compose.server.yml @@ -63,8 +63,9 @@ services: - RUST_LOG=info - IGGY_ROOT_USERNAME=iggy - IGGY_ROOT_PASSWORD=iggy - - IGGY_SYSTEM_PATH=local_data + - IGGY_PATH=local_data - IGGY_TCP_ADDRESS=0.0.0.0:8090 + - IGGY_NODE_ADVERTISED_ADDRESS=iggy-server - IGGY_HTTP_ADDRESS=0.0.0.0:3000 - IGGY_QUIC_ADDRESS=0.0.0.0:8080 - IGGY_WEBSOCKET_ADDRESS=0.0.0.0:8070 @@ -79,6 +80,9 @@ services: python-bdd: <<: *server-bdd-deps + php-bdd: + <<: *server-bdd-deps + go-bdd: <<: *server-bdd-deps diff --git a/bdd/docker-compose.yml b/bdd/docker-compose.yml index 9d577ab191..01dee92925 100644 --- a/bdd/docker-compose.yml +++ b/bdd/docker-compose.yml @@ -77,14 +77,9 @@ services: build: context: .. dockerfile: bdd/php/Dockerfile - depends_on: - iggy-server: - condition: service_healthy environment: - - IGGY_HOST=iggy-server - - IGGY_PORT=8090 - - IGGY_USERNAME=iggy - - IGGY_PASSWORD=iggy + - IGGY_ROOT_USERNAME=iggy + - IGGY_ROOT_PASSWORD=iggy - BDD_FEATURE=${BDD_FEATURE:-all} volumes: - ./scenarios/basic_messaging.feature:/app/features/basic_messaging.feature diff --git a/bdd/go/go.mod b/bdd/go/go.mod index 1106967af4..319092af0e 100644 --- a/bdd/go/go.mod +++ b/bdd/go/go.mod @@ -8,8 +8,8 @@ require ( github.com/apache/iggy/foreign/go v0.0.0-00010101000000-000000000000 github.com/cucumber/godog v0.16.0 github.com/google/uuid v1.6.0 - github.com/onsi/ginkgo/v2 v2.32.0 - github.com/onsi/gomega v1.42.1 + github.com/onsi/ginkgo/v2 v2.32.2 + github.com/onsi/gomega v1.43.0 ) require ( @@ -24,11 +24,11 @@ require ( github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect - github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect diff --git a/bdd/go/go.sum b/bdd/go/go.sum index 37673653b2..081a35a329 100644 --- a/bdd/go/go.sum +++ b/bdd/go/go.sum @@ -8,8 +8,6 @@ github.com/cucumber/godog v0.16.0 h1:ezQbgItuWqZrjPUQwLJ3muwIlvzXBOfZso5QZfG7efE github.com/cucumber/godog v0.16.0/go.mod h1:EDUX9yCqANK+GpbftMDeu61sUDtdLuo1JJgXD2n3bbM= github.com/cucumber/messages/go/v34 v34.2.0 h1:VCbcNOMz+f8ccjjOOx1NLBNhwvE7/X49Atc8klJa+i8= github.com/cucumber/messages/go/v34 v34.2.0/go.mod h1:LYUPjqlTS1kS0pdkdf6sS5uirnjwiIzEGyXPezXNhL8= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= @@ -41,8 +39,8 @@ github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iP github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= -github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= -github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -53,18 +51,16 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= -github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= -github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= -github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg= +github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= +github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= @@ -77,8 +73,8 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= @@ -93,8 +89,5 @@ golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/bdd/go/tests/basic_messaging.go b/bdd/go/tests/basic_messaging.go index e5d7089f68..21ad3e12b7 100644 --- a/bdd/go/tests/basic_messaging.go +++ b/bdd/go/tests/basic_messaging.go @@ -21,8 +21,8 @@ import ( "context" "errors" "fmt" - "os" + "github.com/apache/iggy/bdd/go/tests/env" "github.com/apache/iggy/foreign/go/client" "github.com/apache/iggy/foreign/go/client/tcp" iggcon "github.com/apache/iggy/foreign/go/contracts" @@ -52,10 +52,7 @@ type basicMessagingSteps struct{} func (s basicMessagingSteps) givenRunningServer(ctx context.Context) error { c := getBasicMessagingCtx(ctx) - addr := os.Getenv("IGGY_TCP_ADDRESS") - if addr == "" { - addr = "127.0.0.1:8090" - } + addr := env.ServerAddress() c.serverAddr = &addr return nil } @@ -80,7 +77,8 @@ func (s basicMessagingSteps) givenAuthenticationAsRoot(ctx context.Context) erro return fmt.Errorf("error pinging client: %w", err) } - if _, err = cli.LoginUser(ctx, "iggy", "iggy"); err != nil { + username, password := env.RootCredentials() + if _, err = cli.LoginUser(ctx, username, password); err != nil { return fmt.Errorf("error logging in: %v", err) } diff --git a/bdd/go/tests/env/env.go b/bdd/go/tests/env/env.go new file mode 100644 index 0000000000..002c9bfceb --- /dev/null +++ b/bdd/go/tests/env/env.go @@ -0,0 +1,55 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package env reads the endpoints and credentials the BDD suites run +// against. A default here would turn a dropped compose variable into a run +// against whatever happens to listen on the fallback address, so a missing +// value aborts the suite instead. +package env + +import ( + "fmt" + "os" +) + +func required(name string) string { + value, ok := os.LookupEnv(name) + if !ok || value == "" { + panic(fmt.Sprintf("%s must be set; run the suite via scripts/run-bdd-tests.sh", name)) + } + return value +} + +// ServerAddress returns the address of the single-node server. +func ServerAddress() string { + return required("IGGY_TCP_ADDRESS") +} + +// LeaderAddress returns the address of the cluster leader. +func LeaderAddress() string { + return required("IGGY_TCP_ADDRESS_LEADER") +} + +// FollowerAddress returns the address of the cluster follower. +func FollowerAddress() string { + return required("IGGY_TCP_ADDRESS_FOLLOWER") +} + +// RootCredentials returns the root username and password. +func RootCredentials() (string, string) { + return required("IGGY_ROOT_USERNAME"), required("IGGY_ROOT_PASSWORD") +} diff --git a/bdd/go/tests/leader_redirection.go b/bdd/go/tests/leader_redirection.go index cca2aa66b7..8593f55127 100644 --- a/bdd/go/tests/leader_redirection.go +++ b/bdd/go/tests/leader_redirection.go @@ -22,12 +22,11 @@ import ( "errors" "fmt" "net" - "os" "regexp" - "strconv" "strings" "time" + "github.com/apache/iggy/bdd/go/tests/env" "github.com/apache/iggy/foreign/go/client" "github.com/apache/iggy/foreign/go/client/tcp" iggcon "github.com/apache/iggy/foreign/go/contracts" @@ -35,9 +34,6 @@ import ( "github.com/cucumber/godog" ) -const defaultRootUsername = "iggy" -const defaultRootPassword = "iggy" - type leaderCtxKey struct{} type leaderCtx struct { Clients map[string]iggcon.Client @@ -90,25 +86,16 @@ func resolveServerAddress(role string, port uint16) string { switch { case role == "leader" && port == 8091: - if addr, ok := os.LookupEnv("IGGY_TCP_ADDRESS_LEADER"); ok { - return addr - } - return "iggy-leader:8091" + return env.LeaderAddress() case role == "follower" && port == 8092: - if addr, ok := os.LookupEnv("IGGY_TCP_ADDRESS_FOLLOWER"); ok { - return addr - } - return "iggy-follower:8092" + return env.FollowerAddress() case port == 8090: - if addr, ok := os.LookupEnv("IGGY_TCP_ADDRESS"); ok { - return addr - } - return "iggy-server:8090" + return env.ServerAddress() default: - return "iggy-server:" + strconv.Itoa(int(port)) + panic(fmt.Sprintf("no address mapping for role %q on port %d", role, port)) } } @@ -323,12 +310,13 @@ func (s leaderSteps) whenAuthenticateRoot(ctx context.Context) error { names = []string{"main"} } + username, password := env.RootCredentials() for _, name := range names { cli, ok := c.Clients[name] if !ok { return fmt.Errorf("client %s should be created", name) } - if _, err := cli.LoginUser(ctx, defaultRootUsername, defaultRootPassword); err != nil { + if _, err := cli.LoginUser(ctx, username, password); err != nil { return err } // Small delay between multiple authentications to avoid race conditions diff --git a/bdd/go/tests/raw_command.go b/bdd/go/tests/raw_command.go index 8bafdf1c13..290dfe3252 100644 --- a/bdd/go/tests/raw_command.go +++ b/bdd/go/tests/raw_command.go @@ -21,8 +21,8 @@ import ( "context" "errors" "fmt" - "os" + "github.com/apache/iggy/bdd/go/tests/env" "github.com/apache/iggy/foreign/go/client" "github.com/apache/iggy/foreign/go/client/tcp" iggcon "github.com/apache/iggy/foreign/go/contracts" @@ -46,11 +46,7 @@ func getRawCommandCtx(ctx context.Context) *rawCommandCtx { type rawCommandSteps struct{} func (rawCommandSteps) givenRunningServer(ctx context.Context) error { - address := os.Getenv("IGGY_TCP_ADDRESS") - if address == "" { - address = "127.0.0.1:8090" - } - getRawCommandCtx(ctx).serverAddr = address + getRawCommandCtx(ctx).serverAddr = env.ServerAddress() return nil } @@ -63,7 +59,8 @@ func (rawCommandSteps) givenAuthenticationAsRoot(ctx context.Context) error { if err = iggyClient.Connect(ctx); err != nil { return fmt.Errorf("connect client: %w", err) } - if _, err = iggyClient.LoginUser(ctx, "iggy", "iggy"); err != nil { + username, password := env.RootCredentials() + if _, err = iggyClient.LoginUser(ctx, username, password); err != nil { return fmt.Errorf("authenticate client: %w", err) } state.client = iggyClient diff --git a/bdd/go/tests/tcp_test/offset_feature_delete.go b/bdd/go/tests/tcp_test/offset_feature_delete.go index 0d8d93d925..f515375609 100644 --- a/bdd/go/tests/tcp_test/offset_feature_delete.go +++ b/bdd/go/tests/tcp_test/offset_feature_delete.go @@ -129,12 +129,25 @@ var _ = ginkgo.Describe("DELETE CONSUMER OFFSET:", func() { &partitionId, ) - // A consumer-offset request is routed by its packed namespace, so - // the shard that answers reports a missing resource rather than - // naming the group. - itShouldReturnSpecificError(err, ierror.ErrResourceNotFound) + // The stream and topic resolve, so the server names the group. + itShouldReturnSpecificError(err, ierror.ErrConsumerGroupIdNotFound) }) + ginkgo.Context("and attempts to delete an offset from a non-existing named consumer group", func() { + client := createAuthorizedConnection() + streamId, _ := successfullyCreateStream(prefix, client) + defer deleteStreamAfterTests(streamId, client) + topicId, _ := successfullyCreateTopic(streamId, client) + streamIdentifier, _ := iggcon.NewIdentifier(streamId) + topicIdentifier, _ := iggcon.NewIdentifier(topicId) + groupIdentifier, _ := iggcon.NewIdentifier("missing-offset-group") + partitionId := uint32(1) + err := client.DeleteConsumerOffset( + context.Background(), iggcon.NewGroupConsumer(groupIdentifier), + streamIdentifier, topicIdentifier, &partitionId, + ) + itShouldReturnSpecificError(err, ierror.ErrConsumerGroupNameNotFound) + }) ginkgo.Context("and attempts to delete an offset from a non-existing stream", func() { client := createAuthorizedConnection() consumer := iggcon.NewGroupConsumer(randomU32Identifier()) diff --git a/bdd/go/tests/tcp_test/offset_feature_deserialize.go b/bdd/go/tests/tcp_test/offset_feature_deserialize.go index 28bb0b89c8..229d200ddb 100644 --- a/bdd/go/tests/tcp_test/offset_feature_deserialize.go +++ b/bdd/go/tests/tcp_test/offset_feature_deserialize.go @@ -21,6 +21,7 @@ import ( "context" iggcon "github.com/apache/iggy/foreign/go/contracts" + ierror "github.com/apache/iggy/foreign/go/errors" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -175,7 +176,10 @@ var _ = ginkgo.Describe("GET CONSUMER OFFSET:", func() { consumer := iggcon.NewGroupConsumer(randomU32Identifier()) partitionId := uint32(1) - offset, err := client.GetConsumerOffset( + // An unresolved stream is an addressing error, not a fresh + // consumer: a nil offset would let a typo'd or deleted target read + // back as "no offset stored" and silently reprocess. + _, err := client.GetConsumerOffset( context.Background(), consumer, randomU32Identifier(), @@ -183,8 +187,7 @@ var _ = ginkgo.Describe("GET CONSUMER OFFSET:", func() { &partitionId, ) - itShouldNotReturnError(err) - itShouldReturnNilOffsetForNewConsumerGroup(offset) + itShouldReturnSpecificError(err, ierror.ErrStreamIdNotFound) }) }) diff --git a/bdd/go/tests/tcp_test/session_feature_login.go b/bdd/go/tests/tcp_test/session_feature_login.go index ccb1b08e51..9d6315178c 100644 --- a/bdd/go/tests/tcp_test/session_feature_login.go +++ b/bdd/go/tests/tcp_test/session_feature_login.go @@ -20,16 +20,19 @@ package tcp_test import ( "context" + "github.com/apache/iggy/bdd/go/tests/env" iggcon "github.com/apache/iggy/foreign/go/contracts" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) var _ = ginkgo.Describe("LOGIN FEATURE:", func() { + rootUsername, rootPassword := env.RootCredentials() + ginkgo.When("user is already logged in", func() { ginkgo.Context("and tries to log with correct data", func() { client := createAuthorizedConnection() - user, err := client.LoginUser(context.Background(), "iggy", "iggy") + user, err := client.LoginUser(context.Background(), rootUsername, rootPassword) itShouldNotReturnError(err) itShouldReturnUserId(user, 0) @@ -47,7 +50,7 @@ var _ = ginkgo.Describe("LOGIN FEATURE:", func() { ginkgo.When("user is not logged in", func() { ginkgo.Context("and tries to log with correct data", func() { client := createClient() - user, err := client.LoginUser(context.Background(), "iggy", "iggy") + user, err := client.LoginUser(context.Background(), rootUsername, rootPassword) itShouldNotReturnError(err) itShouldReturnUserId(user, 0) diff --git a/bdd/go/tests/tcp_test/test_helpers.go b/bdd/go/tests/tcp_test/test_helpers.go index efcdd18310..b122b8c4b4 100644 --- a/bdd/go/tests/tcp_test/test_helpers.go +++ b/bdd/go/tests/tcp_test/test_helpers.go @@ -20,10 +20,10 @@ package tcp_test import ( "context" "math/rand" - "os" "strings" "time" + "github.com/apache/iggy/bdd/go/tests/env" "github.com/apache/iggy/foreign/go/client" iggcon "github.com/apache/iggy/foreign/go/contracts" @@ -32,7 +32,8 @@ import ( func createAuthorizedConnection() iggcon.Client { cli := createClient() - _, err := cli.LoginUser(context.Background(), "iggy", "iggy") + username, password := env.RootCredentials() + _, err := cli.LoginUser(context.Background(), username, password) if err != nil { panic(err) } @@ -40,13 +41,9 @@ func createAuthorizedConnection() iggcon.Client { } func createClient() iggcon.Client { - addr := os.Getenv("IGGY_TCP_ADDRESS") - if addr == "" { - addr = "127.0.0.1:8090" - } cli, err := client.NewIggyClient( client.WithTcp( - tcp.WithServerAddress(addr), + tcp.WithServerAddress(env.ServerAddress()), ), ) if err != nil { diff --git a/bdd/java/Dockerfile b/bdd/java/Dockerfile index 63ee0cbb99..4fb9296697 100644 --- a/bdd/java/Dockerfile +++ b/bdd/java/Dockerfile @@ -15,7 +15,7 @@ # specific language governing permissions and limitations # under the License. -FROM gradle:9.7.0-jdk17 +FROM gradle:9.7.1-jdk17 WORKDIR /workspace COPY . . diff --git a/bdd/java/build.gradle.kts b/bdd/java/build.gradle.kts index f62ec9c44a..dfa4855154 100644 --- a/bdd/java/build.gradle.kts +++ b/bdd/java/build.gradle.kts @@ -20,7 +20,7 @@ plugins { java jacoco - id("com.diffplug.spotless") version "8.9.0" + id("com.diffplug.spotless") version "8.10.2" } repositories { @@ -29,10 +29,10 @@ repositories { dependencies { testImplementation("org.apache.iggy:iggy") - testImplementation("io.cucumber:cucumber-java:7.34.6") - testImplementation("io.cucumber:cucumber-junit-platform-engine:7.34.6") - testImplementation("org.junit.jupiter:junit-jupiter:6.1.2") - testImplementation("org.junit.platform:junit-platform-suite:6.1.2") + testImplementation("io.cucumber:cucumber-java:7.34.8") + testImplementation("io.cucumber:cucumber-junit-platform-engine:7.34.8") + testImplementation("org.junit.jupiter:junit-jupiter:6.1.3") + testImplementation("org.junit.platform:junit-platform-suite:6.1.3") } spotless { diff --git a/bdd/java/gradle/wrapper/gradle-wrapper.properties b/bdd/java/gradle/wrapper/gradle-wrapper.properties index a9db11550c..ad7845be30 100644 --- a/bdd/java/gradle/wrapper/gradle-wrapper.properties +++ b/bdd/java/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/bdd/java/gradlew b/bdd/java/gradlew index f8de29016f..f14608ef47 100755 --- a/bdd/java/gradlew +++ b/bdd/java/gradlew @@ -112,8 +112,8 @@ die () { # ############################################################################## -GRADLE_WRAPPER_VERSION=9.6.1 -REQUIRED_WRAPPER_JAR_CHECKSUM="497c8c2a7e5031f6aa847f88104aa80a93532ec32ee17bdb8d1d2f67a194a9c7" +GRADLE_WRAPPER_VERSION=9.7.1 +REQUIRED_WRAPPER_JAR_CHECKSUM="7a9ce74cff467ca1bf60a4fcd9f05185acceda4d0f382434d393e17864262c5d" for _ in 1 2 3; do if [ ! -e "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" ]; then diff --git a/bdd/java/src/test/java/org/apache/iggy/bdd/BasicMessagingSteps.java b/bdd/java/src/test/java/org/apache/iggy/bdd/BasicMessagingSteps.java index 8cc810960d..f5b9a8c19f 100644 --- a/bdd/java/src/test/java/org/apache/iggy/bdd/BasicMessagingSteps.java +++ b/bdd/java/src/test/java/org/apache/iggy/bdd/BasicMessagingSteps.java @@ -54,7 +54,7 @@ public class BasicMessagingSteps { @Given("I have a running Iggy server") public void runningServer() { - context.serverAddr = getenvOrDefault("IGGY_TCP_ADDRESS", "127.0.0.1:8090"); + context.serverAddr = TestEnvironment.serverAddress(); HostPort hostPort = HostPort.parse(context.serverAddr); IggyTcpClient client = @@ -67,9 +67,7 @@ public void runningServer() { @Given("I am authenticated as the root user") public void authenticatedRootUser() { - String username = getenvOrDefault("IGGY_ROOT_USERNAME", "iggy"); - String password = getenvOrDefault("IGGY_ROOT_PASSWORD", "iggy"); - getClient().users().login(username, password); + getClient().users().login(TestEnvironment.rootUsername(), TestEnvironment.rootPassword()); } @Given("I have no streams in the system") @@ -144,8 +142,16 @@ public void deleteStreamByNumericId() { @Then("getting the stream by its numeric ID should return no stream") public void getStreamReturnsNoStream() { + // The assertion is "not the stream we deleted", not "nothing at this id": + // the server hands out the lowest free stream id, so once these scenarios + // run concurrently against one server a fresh create can legitimately + // occupy the deleted stream's id. Named, so a missing pre-value fails + // here instead of making the comparison below vacuously true. + assertNotNull(context.lastStreamName, "Stream should have been created"); Optional stream = getClient().streams().getStream(context.lastStreamId); - assertTrue(stream.isEmpty(), "Deleted stream should not be returned"); + assertTrue( + stream.isEmpty() || !stream.get().name().equals(context.lastStreamName), + "Deleted stream should not be returned"); } @When("I create a topic with name {string} in stream {int} with {int} partitions") @@ -301,11 +307,6 @@ private IggyBaseClient getClient() { return context.client; } - private static String getenvOrDefault(String key, String defaultValue) { - String value = System.getenv(key); - return value == null || value.isBlank() ? defaultValue : value; - } - private static final class HostPort { private final String host; private final int port; diff --git a/bdd/java/src/test/java/org/apache/iggy/bdd/LeaderRedirectionSteps.java b/bdd/java/src/test/java/org/apache/iggy/bdd/LeaderRedirectionSteps.java index 4f9645377a..23f556e8f4 100644 --- a/bdd/java/src/test/java/org/apache/iggy/bdd/LeaderRedirectionSteps.java +++ b/bdd/java/src/test/java/org/apache/iggy/bdd/LeaderRedirectionSteps.java @@ -208,8 +208,8 @@ private void createAndConnectClient(String name, String address) { } private void authenticateAllClients() { - String username = getenvOrDefault("IGGY_ROOT_USERNAME", "iggy"); - String password = getenvOrDefault("IGGY_ROOT_PASSWORD", "iggy"); + String username = TestEnvironment.rootUsername(); + String password = TestEnvironment.rootPassword(); for (IggyTcpClient client : clients.values()) { String initialAddress = client.getConnectionInfo().serverAddress(); client.users().login(username, password); @@ -275,8 +275,8 @@ private static void assertAddressMatchesPort(String address, int port, String de private static String addressForRole(String role) { return switch (role) { - case "leader" -> getenvOrDefault("IGGY_TCP_ADDRESS_LEADER", "127.0.0.1:8091"); - case "follower" -> getenvOrDefault("IGGY_TCP_ADDRESS_FOLLOWER", "127.0.0.1:8092"); + case "leader" -> TestEnvironment.leaderAddress(); + case "follower" -> TestEnvironment.followerAddress(); default -> throw new IllegalArgumentException("Unknown role: " + role); }; } @@ -291,11 +291,6 @@ private static String addressForPort(int port) { } private static String singleServerAddress() { - return getenvOrDefault("IGGY_TCP_ADDRESS", "127.0.0.1:8090"); - } - - private static String getenvOrDefault(String key, String defaultValue) { - String value = System.getenv(key); - return value == null || value.isBlank() ? defaultValue : value; + return TestEnvironment.serverAddress(); } } diff --git a/bdd/java/src/test/java/org/apache/iggy/bdd/TestEnvironment.java b/bdd/java/src/test/java/org/apache/iggy/bdd/TestEnvironment.java new file mode 100644 index 0000000000..0a3cdc9aae --- /dev/null +++ b/bdd/java/src/test/java/org/apache/iggy/bdd/TestEnvironment.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.iggy.bdd; + +/** + * Endpoints and credentials the BDD suite runs against. + * + *

A default here would turn a dropped compose variable into a run against whatever happens to + * listen on the fallback address, so a missing value aborts the suite instead. + */ +final class TestEnvironment { + + private TestEnvironment() {} + + static String require(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException(name + " must be set; run the suite via scripts/run-bdd-tests.sh"); + } + return value; + } + + static String serverAddress() { + return require("IGGY_TCP_ADDRESS"); + } + + static String leaderAddress() { + return require("IGGY_TCP_ADDRESS_LEADER"); + } + + static String followerAddress() { + return require("IGGY_TCP_ADDRESS_FOLLOWER"); + } + + static String rootUsername() { + return require("IGGY_ROOT_USERNAME"); + } + + static String rootPassword() { + return require("IGGY_ROOT_PASSWORD"); + } +} diff --git a/bdd/php/Dockerfile b/bdd/php/Dockerfile index 5e9acc4778..b5d7adb749 100644 --- a/bdd/php/Dockerfile +++ b/bdd/php/Dockerfile @@ -45,8 +45,6 @@ ENV RUSTUP_HOME=/usr/local/rustup ENV PATH=/usr/local/cargo/bin:$PATH ENV PHP=/usr/bin/php8.3 ENV PHP_CONFIG=/usr/bin/php-config8.3 -ENV IGGY_HOST=iggy-server -ENV IGGY_PORT=8090 ENV BDD_FEATURE_FILE=/workspace/bdd/scenarios/basic_messaging.feature RUN rustup component add llvm-tools-preview \ diff --git a/bdd/php/tests/BasicMessagingFeatureTest.php b/bdd/php/tests/BasicMessagingFeatureTest.php index d47b09f609..8c4f8b068b 100644 --- a/bdd/php/tests/BasicMessagingFeatureTest.php +++ b/bdd/php/tests/BasicMessagingFeatureTest.php @@ -19,6 +19,7 @@ declare(strict_types=1); require_once __DIR__ . '/SharedFeatureParser.php'; +require_once __DIR__ . '/TestEnvironment.php'; use Iggy\Client as IggyClient; use Iggy\PollingStrategy; @@ -71,7 +72,7 @@ public static function scenarioCases(): array private function runStep(string $step): void { if ($step === 'I have a running Iggy server') { - $this->client = new IggyClient(server_host() . ':' . server_port()); + $this->client = new IggyClient(TestEnvironment::serverAddress()); $this->client->connect(); $this->client->ping(); @@ -79,7 +80,7 @@ private function runStep(string $step): void } if ($step === 'I am authenticated as the root user') { - $this->requireClient()->loginUser(env_or_default('IGGY_USERNAME', 'iggy'), env_or_default('IGGY_PASSWORD', 'iggy')); + $this->requireClient()->loginUser(TestEnvironment::rootUsername(), TestEnvironment::rootPassword()); return; } diff --git a/bdd/php/tests/RawCommandFeatureTest.php b/bdd/php/tests/RawCommandFeatureTest.php index db9aeb8f87..181ca9bcdb 100644 --- a/bdd/php/tests/RawCommandFeatureTest.php +++ b/bdd/php/tests/RawCommandFeatureTest.php @@ -19,6 +19,7 @@ declare(strict_types=1); require_once __DIR__ . '/SharedFeatureParser.php'; +require_once __DIR__ . '/TestEnvironment.php'; use Iggy\Client as IggyClient; use Iggy\Exception\IggyException; @@ -52,15 +53,15 @@ public static function scenarioCases(): array private function runStep(string $step): void { if ($step === 'I have a running Iggy server') { - $this->client = new IggyClient(server_host() . ':' . server_port()); + $this->client = new IggyClient(TestEnvironment::serverAddress()); $this->client->connect(); $this->client->ping(); return; } if ($step === 'I am authenticated as the root user') { $this->requireClient()->loginUser( - env_or_default('IGGY_USERNAME', 'iggy'), - env_or_default('IGGY_PASSWORD', 'iggy'), + TestEnvironment::rootUsername(), + TestEnvironment::rootPassword(), ); return; } diff --git a/bdd/php/tests/TestEnvironment.php b/bdd/php/tests/TestEnvironment.php new file mode 100644 index 0000000000..3c999fe2bc --- /dev/null +++ b/bdd/php/tests/TestEnvironment.php @@ -0,0 +1,53 @@ +=3.10" @@ -47,115 +30,130 @@ wheels = [ [[packages]] name = "coverage" -version = "7.14.0" +version = "7.15.4" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", upload-time = 2026-05-10T18:02:31Z, size = 919489, hashes = { sha256 = "057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74" } } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", upload-time = 2026-08-06T13:50:24Z, size = 936952, hashes = { sha256 = "0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", upload-time = 2026-05-10T17:59:23Z, size = 219668, hashes = { sha256 = "84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075" } }, - { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", upload-time = 2026-05-10T17:59:26Z, size = 220192, hashes = { sha256 = "7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82" } }, - { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T17:59:27Z, size = 246932, hashes = { sha256 = "0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c" } }, - { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T17:59:29Z, size = 248762, hashes = { sha256 = "3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893" } }, - { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T17:59:31Z, size = 250625, hashes = { sha256 = "731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20" } }, - { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T17:59:32Z, size = 252539, hashes = { sha256 = "c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec" } }, - { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T17:59:34Z, size = 247636, hashes = { sha256 = "dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757" } }, - { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T17:59:36Z, size = 248666, hashes = { sha256 = "0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a" } }, - { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", upload-time = 2026-05-10T17:59:38Z, size = 246670, hashes = { sha256 = "fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea" } }, - { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T17:59:39Z, size = 250484, hashes = { sha256 = "b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb" } }, - { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T17:59:41Z, size = 246942, hashes = { sha256 = "d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218" } }, - { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T17:59:43Z, size = 247544, hashes = { sha256 = "d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85" } }, - { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", upload-time = 2026-05-10T17:59:44Z, size = 222285, hashes = { sha256 = "65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323" } }, - { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", upload-time = 2026-05-10T17:59:46Z, size = 223215, hashes = { sha256 = "b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a" } }, - { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", upload-time = 2026-05-10T17:59:48Z, size = 219795, hashes = { sha256 = "6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480" } }, - { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", upload-time = 2026-05-10T17:59:49Z, size = 220299, hashes = { sha256 = "a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4" } }, - { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T17:59:51Z, size = 250721, hashes = { sha256 = "d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7" } }, - { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T17:59:53Z, size = 252633, hashes = { sha256 = "9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed" } }, - { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T17:59:55Z, size = 254743, hashes = { sha256 = "9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980" } }, - { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T17:59:56Z, size = 256700, hashes = { sha256 = "d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0" } }, - { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T17:59:57Z, size = 250854, hashes = { sha256 = "a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742" } }, - { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T17:59:59Z, size = 252433, hashes = { sha256 = "5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5" } }, - { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", upload-time = 2026-05-10T18:00:01Z, size = 250494, hashes = { sha256 = "741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327" } }, - { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T18:00:03Z, size = 254261, hashes = { sha256 = "664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d" } }, - { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T18:00:05Z, size = 250216, hashes = { sha256 = "c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20" } }, - { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T18:00:06Z, size = 251125, hashes = { sha256 = "bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c" } }, - { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", upload-time = 2026-05-10T18:00:08Z, size = 222300, hashes = { sha256 = "731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3" } }, - { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", upload-time = 2026-05-10T18:00:10Z, size = 223241, hashes = { sha256 = "bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1" } }, - { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", upload-time = 2026-05-10T18:00:12Z, size = 221908, hashes = { sha256 = "7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627" } }, - { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", upload-time = 2026-05-10T18:00:13Z, size = 219967, hashes = { sha256 = "7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5" } }, - { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", upload-time = 2026-05-10T18:00:15Z, size = 220329, hashes = { sha256 = "829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662" } }, - { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T18:00:17Z, size = 251839, hashes = { sha256 = "b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f" } }, - { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T18:00:18Z, size = 254576, hashes = { sha256 = "ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67" } }, - { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T18:00:20Z, size = 255690, hashes = { sha256 = "92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9" } }, - { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T18:00:22Z, size = 257949, hashes = { sha256 = "7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb" } }, - { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T18:00:24Z, size = 252242, hashes = { sha256 = "1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e" } }, - { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T18:00:25Z, size = 253608, hashes = { sha256 = "8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3" } }, - { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", upload-time = 2026-05-10T18:00:27Z, size = 251753, hashes = { sha256 = "d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4" } }, - { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T18:00:29Z, size = 255823, hashes = { sha256 = "1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1" } }, - { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T18:00:30Z, size = 251323, hashes = { sha256 = "72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5" } }, - { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T18:00:32Z, size = 253197, hashes = { sha256 = "fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595" } }, - { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", upload-time = 2026-05-10T18:00:33Z, size = 222515, hashes = { sha256 = "5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27" } }, - { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", upload-time = 2026-05-10T18:00:35Z, size = 223324, hashes = { sha256 = "70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2" } }, - { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", upload-time = 2026-05-10T18:00:37Z, size = 221944, hashes = { sha256 = "91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d" } }, - { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", upload-time = 2026-05-10T18:00:38Z, size = 219990, hashes = { sha256 = "f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef" } }, - { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", upload-time = 2026-05-10T18:00:40Z, size = 220365, hashes = { sha256 = "23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66" } }, - { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T18:00:42Z, size = 251363, hashes = { sha256 = "22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b" } }, - { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T18:00:44Z, size = 253961, hashes = { sha256 = "9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca" } }, - { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T18:00:45Z, size = 255193, hashes = { sha256 = "90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7" } }, - { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T18:00:47Z, size = 257326, hashes = { sha256 = "a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2" } }, - { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T18:00:49Z, size = 251582, hashes = { sha256 = "c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367" } }, - { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T18:00:51Z, size = 253325, hashes = { sha256 = "1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9" } }, - { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", upload-time = 2026-05-10T18:00:52Z, size = 251291, hashes = { sha256 = "55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087" } }, - { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T18:00:54Z, size = 255448, hashes = { sha256 = "49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef" } }, - { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T18:00:56Z, size = 251110, hashes = { sha256 = "9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52" } }, - { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T18:00:57Z, size = 252885, hashes = { sha256 = "7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe" } }, - { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", upload-time = 2026-05-10T18:00:59Z, size = 222539, hashes = { sha256 = "fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae" } }, - { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", upload-time = 2026-05-10T18:01:01Z, size = 223344, hashes = { sha256 = "0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e" } }, - { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", upload-time = 2026-05-10T18:01:03Z, size = 221966, hashes = { sha256 = "b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96" } }, - { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", upload-time = 2026-05-10T18:01:05Z, size = 220679, hashes = { sha256 = "953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90" } }, - { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", upload-time = 2026-05-10T18:01:07Z, size = 221033, hashes = { sha256 = "98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1" } }, - { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T18:01:08Z, size = 262333, hashes = { sha256 = "668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd" } }, - { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T18:01:10Z, size = 264410, hashes = { sha256 = "9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc" } }, - { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T18:01:12Z, size = 266836, hashes = { sha256 = "68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426" } }, - { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T18:01:13Z, size = 267974, hashes = { sha256 = "6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899" } }, - { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T18:01:15Z, size = 261578, hashes = { sha256 = "cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b" } }, - { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T18:01:17Z, size = 264394, hashes = { sha256 = "45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90" } }, - { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", upload-time = 2026-05-10T18:01:19Z, size = 262022, hashes = { sha256 = "8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f" } }, - { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T18:01:21Z, size = 265732, hashes = { sha256 = "a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d" } }, - { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T18:01:23Z, size = 260921, hashes = { sha256 = "63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47" } }, - { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T18:01:25Z, size = 263109, hashes = { sha256 = "827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477" } }, - { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", upload-time = 2026-05-10T18:01:27Z, size = 223212, hashes = { sha256 = "7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab" } }, - { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", upload-time = 2026-05-10T18:01:29Z, size = 224272, hashes = { sha256 = "3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917" } }, - { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", upload-time = 2026-05-10T18:01:31Z, size = 222530, hashes = { sha256 = "6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8" } }, - { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", upload-time = 2026-05-10T18:01:33Z, size = 220036, hashes = { sha256 = "9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d" } }, - { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", upload-time = 2026-05-10T18:01:34Z, size = 220368, hashes = { sha256 = "ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63" } }, - { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T18:01:36Z, size = 251417, hashes = { sha256 = "3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212" } }, - { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T18:01:38Z, size = 253924, hashes = { sha256 = "5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3" } }, - { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T18:01:40Z, size = 255269, hashes = { sha256 = "6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97" } }, - { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T18:01:42Z, size = 257583, hashes = { sha256 = "3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8" } }, - { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T18:01:44Z, size = 251434, hashes = { sha256 = "3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb" } }, - { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T18:01:46Z, size = 253280, hashes = { sha256 = "15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe" } }, - { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", upload-time = 2026-05-10T18:01:48Z, size = 251241, hashes = { sha256 = "9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa" } }, - { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T18:01:50Z, size = 255516, hashes = { sha256 = "2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5" } }, - { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T18:01:52Z, size = 251059, hashes = { sha256 = "454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c" } }, - { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T18:01:54Z, size = 252716, hashes = { sha256 = "65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca" } }, - { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", upload-time = 2026-05-10T18:01:56Z, size = 222788, hashes = { sha256 = "6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828" } }, - { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", upload-time = 2026-05-10T18:01:58Z, size = 223600, hashes = { sha256 = "45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d" } }, - { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", upload-time = 2026-05-10T18:02:00Z, size = 222168, hashes = { sha256 = "4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9" } }, - { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", upload-time = 2026-05-10T18:02:02Z, size = 220766, hashes = { sha256 = "f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1" } }, - { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", upload-time = 2026-05-10T18:02:04Z, size = 221035, hashes = { sha256 = "a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c" } }, - { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T18:02:05Z, size = 262405, hashes = { sha256 = "a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84" } }, - { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T18:02:07Z, size = 264530, hashes = { sha256 = "7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436" } }, - { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T18:02:09Z, size = 266932, hashes = { sha256 = "0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a" } }, - { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T18:02:11Z, size = 268062, hashes = { sha256 = "362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f" } }, - { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T18:02:13Z, size = 261504, hashes = { sha256 = "acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb" } }, - { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T18:02:15Z, size = 264398, hashes = { sha256 = "29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490" } }, - { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", upload-time = 2026-05-10T18:02:16Z, size = 262000, hashes = { sha256 = "b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9" } }, - { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T18:02:18Z, size = 265732, hashes = { sha256 = "5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020" } }, - { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T18:02:20Z, size = 260847, hashes = { sha256 = "9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6" } }, - { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T18:02:22Z, size = 263166, hashes = { sha256 = "1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db" } }, - { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", upload-time = 2026-05-10T18:02:24Z, size = 223573, hashes = { sha256 = "a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2" } }, - { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", upload-time = 2026-05-10T18:02:25Z, size = 224680, hashes = { sha256 = "29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644" } }, - { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", upload-time = 2026-05-10T18:02:27Z, size = 222703, hashes = { sha256 = "742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b" } }, - { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", upload-time = 2026-05-10T18:02:29Z, size = 211764, hashes = { sha256 = "8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1" } }, + { url = "https://files.pythonhosted.org/packages/30/70/b052a519a584663a7bd052841a2debe11c8309ec49a7786340003f9c0a02/coverage-7.15.4-cp310-cp310-macosx_10_9_x86_64.whl", upload-time = 2026-08-06T13:46:55Z, size = 222245, hashes = { sha256 = "d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264" } }, + { url = "https://files.pythonhosted.org/packages/67/39/892fa511aba3d1c3c8f49509a0ff5c71eab9f9f88d08e1a38da395821660/coverage-7.15.4-cp310-cp310-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:46:57Z, size = 222762, hashes = { sha256 = "b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d" } }, + { url = "https://files.pythonhosted.org/packages/9f/95/b2c724ce1e64bc23cb5b1d7eeffa9548dc3d811f7a6297b2d01607f4e062/coverage-7.15.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:46:59Z, size = 249498, hashes = { sha256 = "cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150" } }, + { url = "https://files.pythonhosted.org/packages/0b/4f/b1973f67a1382af65b572a31ed692f8e490a6ad707191eab59148376832a/coverage-7.15.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:47:00Z, size = 251328, hashes = { sha256 = "83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15" } }, + { url = "https://files.pythonhosted.org/packages/a2/09/03efa6722a132abcac91b32a60b64b240dd707c189c64eee697e48992c96/coverage-7.15.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:47:01Z, size = 253194, hashes = { sha256 = "8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0" } }, + { url = "https://files.pythonhosted.org/packages/45/63/8299201d9c80fb65551ce99c966cab83d706ec4066ac999bef08201346de/coverage-7.15.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:47:03Z, size = 255106, hashes = { sha256 = "4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d" } }, + { url = "https://files.pythonhosted.org/packages/ee/16/26fd8a691eb8d9a230128685f6d23309d7402cb030aa553001788c8c50fc/coverage-7.15.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:47:04Z, size = 250177, hashes = { sha256 = "a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4" } }, + { url = "https://files.pythonhosted.org/packages/ad/ef/3c7556f33783a0a566e01443ca62bd8eb2cdfe22d271efdc02e08beb5654/coverage-7.15.4-cp310-cp310-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:47:06Z, size = 251234, hashes = { sha256 = "317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54" } }, + { url = "https://files.pythonhosted.org/packages/29/49/640a34043edac950738f36a3567832db5731d4cb2ed84b59cdb89c6bccbf/coverage-7.15.4-cp310-cp310-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:47:07Z, size = 249237, hashes = { sha256 = "8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20" } }, + { url = "https://files.pythonhosted.org/packages/48/f5/e80f212669dd1be954ff844f883ef11a437ef4fd0089c6e0effc7b66b15d/coverage-7.15.4-cp310-cp310-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:47:08Z, size = 253050, hashes = { sha256 = "425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325" } }, + { url = "https://files.pythonhosted.org/packages/c7/e9/e5da0fe39f7fde1bca9edc09c60921bb5fdba4cec7db5bbad41ddfd8c230/coverage-7.15.4-cp310-cp310-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:47:10Z, size = 249508, hashes = { sha256 = "69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88" } }, + { url = "https://files.pythonhosted.org/packages/7d/38/41bf25774a0c8bba6b467f917cb1c9a0a2605e02dc93aad489fc7050ed59/coverage-7.15.4-cp310-cp310-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:47:11Z, size = 250110, hashes = { sha256 = "81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04" } }, + { url = "https://files.pythonhosted.org/packages/89/6e/26f2e54b79acc29d179ee4272922625aedb69198c4eb61f7ff4f098f3c78/coverage-7.15.4-cp310-cp310-win32.whl", upload-time = 2026-08-06T13:47:12Z, size = 224294, hashes = { sha256 = "cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338" } }, + { url = "https://files.pythonhosted.org/packages/7b/06/9a318fc3ae040d4d6cb2d86101c6aa963fab20899a5c58666adf52cde0ca/coverage-7.15.4-cp310-cp310-win_amd64.whl", upload-time = 2026-08-06T13:47:14Z, size = 224919, hashes = { sha256 = "3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360" } }, + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", upload-time = 2026-08-06T13:47:15Z, size = 222367, hashes = { sha256 = "bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490" } }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:47:16Z, size = 222874, hashes = { sha256 = "357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e" } }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:47:18Z, size = 253287, hashes = { sha256 = "21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7" } }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:47:19Z, size = 255199, hashes = { sha256 = "7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6" } }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:47:21Z, size = 257308, hashes = { sha256 = "a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d" } }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:47:22Z, size = 259268, hashes = { sha256 = "de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce" } }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:47:23Z, size = 253392, hashes = { sha256 = "6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7" } }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:47:25Z, size = 255001, hashes = { sha256 = "986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b" } }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:47:26Z, size = 253061, hashes = { sha256 = "c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc" } }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:47:28Z, size = 256831, hashes = { sha256 = "d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571" } }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:47:29Z, size = 252781, hashes = { sha256 = "b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719" } }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:47:31Z, size = 253692, hashes = { sha256 = "a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7" } }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", upload-time = 2026-08-06T13:47:32Z, size = 224461, hashes = { sha256 = "69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e" } }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", upload-time = 2026-08-06T13:47:34Z, size = 224937, hashes = { sha256 = "63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc" } }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", upload-time = 2026-08-06T13:47:36Z, size = 224479, hashes = { sha256 = "ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890" } }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", upload-time = 2026-08-06T13:47:37Z, size = 222543, hashes = { sha256 = "d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22" } }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:47:38Z, size = 222905, hashes = { sha256 = "37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97" } }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:47:40Z, size = 254407, hashes = { sha256 = "fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d" } }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:47:41Z, size = 257145, hashes = { sha256 = "899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2" } }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:47:43Z, size = 258257, hashes = { sha256 = "d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931" } }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:47:45Z, size = 260517, hashes = { sha256 = "002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8" } }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:47:46Z, size = 254785, hashes = { sha256 = "e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9" } }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:47:48Z, size = 256176, hashes = { sha256 = "e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839" } }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:47:49Z, size = 254321, hashes = { sha256 = "af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72" } }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:47:51Z, size = 258390, hashes = { sha256 = "1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52" } }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:47:52Z, size = 253894, hashes = { sha256 = "d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c" } }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:47:54Z, size = 255763, hashes = { sha256 = "c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4" } }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", upload-time = 2026-08-06T13:47:56Z, size = 224597, hashes = { sha256 = "5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b" } }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", upload-time = 2026-08-06T13:47:57Z, size = 225135, hashes = { sha256 = "6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd" } }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", upload-time = 2026-08-06T13:47:58Z, size = 224515, hashes = { sha256 = "67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f" } }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", upload-time = 2026-08-06T13:48:00Z, size = 222565, hashes = { sha256 = "c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921" } }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:48:02Z, size = 222936, hashes = { sha256 = "3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e" } }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:48:04Z, size = 253926, hashes = { sha256 = "5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36" } }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:48:05Z, size = 256523, hashes = { sha256 = "12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4" } }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:48:08Z, size = 257759, hashes = { sha256 = "349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c" } }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:48:09Z, size = 259890, hashes = { sha256 = "4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7" } }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:48:11Z, size = 254121, hashes = { sha256 = "d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25" } }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:48:13Z, size = 255891, hashes = { sha256 = "2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b" } }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:48:14Z, size = 253859, hashes = { sha256 = "8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78" } }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:48:16Z, size = 258011, hashes = { sha256 = "1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f" } }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:48:18Z, size = 253676, hashes = { sha256 = "337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d" } }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:48:20Z, size = 255453, hashes = { sha256 = "96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff" } }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", upload-time = 2026-08-06T13:48:21Z, size = 224605, hashes = { sha256 = "c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c" } }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", upload-time = 2026-08-06T13:48:23Z, size = 225148, hashes = { sha256 = "2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4" } }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", upload-time = 2026-08-06T13:48:25Z, size = 224536, hashes = { sha256 = "6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf" } }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", upload-time = 2026-08-06T13:48:26Z, size = 222608, hashes = { sha256 = "770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f" } }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:48:28Z, size = 222940, hashes = { sha256 = "d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c" } }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:48:29Z, size = 253985, hashes = { sha256 = "5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082" } }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:48:31Z, size = 256492, hashes = { sha256 = "ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac" } }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:48:33Z, size = 257837, hashes = { sha256 = "5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734" } }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:48:34Z, size = 260152, hashes = { sha256 = "474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d" } }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:48:36Z, size = 253978, hashes = { sha256 = "7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf" } }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:48:38Z, size = 255846, hashes = { sha256 = "39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b" } }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:48:39Z, size = 253808, hashes = { sha256 = "f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25" } }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:48:41Z, size = 258081, hashes = { sha256 = "93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303" } }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:48:43Z, size = 253624, hashes = { sha256 = "141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f" } }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:48:45Z, size = 255280, hashes = { sha256 = "81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5" } }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", upload-time = 2026-08-06T13:48:47Z, size = 224768, hashes = { sha256 = "7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7" } }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", upload-time = 2026-08-06T13:48:49Z, size = 225259, hashes = { sha256 = "f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425" } }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", upload-time = 2026-08-06T13:48:51Z, size = 224684, hashes = { sha256 = "9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8" } }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", upload-time = 2026-08-06T13:48:52Z, size = 223338, hashes = { sha256 = "7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8" } }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:48:54Z, size = 223609, hashes = { sha256 = "f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a" } }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:48:56Z, size = 264970, hashes = { sha256 = "46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b" } }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:48:58Z, size = 267088, hashes = { sha256 = "3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5" } }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:49:00Z, size = 269508, hashes = { sha256 = "3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba" } }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:49:02Z, size = 270629, hashes = { sha256 = "05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982" } }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:49:04Z, size = 264043, hashes = { sha256 = "226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c" } }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:49:05Z, size = 266963, hashes = { sha256 = "ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57" } }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:49:07Z, size = 264569, hashes = { sha256 = "8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26" } }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:49:09Z, size = 268299, hashes = { sha256 = "07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429" } }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:49:11Z, size = 263413, hashes = { sha256 = "a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017" } }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:49:13Z, size = 265725, hashes = { sha256 = "460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839" } }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", upload-time = 2026-08-06T13:49:15Z, size = 225079, hashes = { sha256 = "cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85" } }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", upload-time = 2026-08-06T13:49:17Z, size = 225911, hashes = { sha256 = "3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e" } }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", upload-time = 2026-08-06T13:49:19Z, size = 225219, hashes = { sha256 = "3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e" } }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", upload-time = 2026-08-06T13:49:21Z, size = 222604, hashes = { sha256 = "2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9" } }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:49:23Z, size = 222944, hashes = { sha256 = "e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a" } }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:49:25Z, size = 254050, hashes = { sha256 = "7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54" } }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:49:26Z, size = 256967, hashes = { sha256 = "d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb" } }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:49:28Z, size = 258587, hashes = { sha256 = "ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed" } }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:49:30Z, size = 260785, hashes = { sha256 = "a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce" } }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:49:32Z, size = 254545, hashes = { sha256 = "050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c" } }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:49:34Z, size = 256682, hashes = { sha256 = "d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced" } }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:49:36Z, size = 254560, hashes = { sha256 = "c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800" } }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:49:38Z, size = 258792, hashes = { sha256 = "1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3" } }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:49:39Z, size = 253968, hashes = { sha256 = "4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768" } }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:49:41Z, size = 255893, hashes = { sha256 = "1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753" } }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", upload-time = 2026-08-06T13:49:44Z, size = 224768, hashes = { sha256 = "43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2" } }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", upload-time = 2026-08-06T13:49:45Z, size = 225242, hashes = { sha256 = "be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809" } }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", upload-time = 2026-08-06T13:49:48Z, size = 224674, hashes = { sha256 = "def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d" } }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", upload-time = 2026-08-06T13:49:50Z, size = 223333, hashes = { sha256 = "c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d" } }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:49:52Z, size = 223630, hashes = { sha256 = "2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26" } }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:49:54Z, size = 264489, hashes = { sha256 = "4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645" } }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:49:56Z, size = 267567, hashes = { sha256 = "8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a" } }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:49:58Z, size = 270123, hashes = { sha256 = "050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624" } }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:50:00Z, size = 271107, hashes = { sha256 = "1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa" } }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:50:03Z, size = 264955, hashes = { sha256 = "8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f" } }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:50:05Z, size = 267949, hashes = { sha256 = "ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f" } }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:50:07Z, size = 264421, hashes = { sha256 = "d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67" } }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:50:09Z, size = 269121, hashes = { sha256 = "f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc" } }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:50:11Z, size = 264565, hashes = { sha256 = "150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88" } }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:50:13Z, size = 266522, hashes = { sha256 = "a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc" } }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", upload-time = 2026-08-06T13:50:15Z, size = 225068, hashes = { sha256 = "ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a" } }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", upload-time = 2026-08-06T13:50:17Z, size = 225895, hashes = { sha256 = "288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b" } }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", upload-time = 2026-08-06T13:50:19Z, size = 225213, hashes = { sha256 = "68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278" } }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", upload-time = 2026-08-06T13:50:22Z, size = 214332, hashes = { sha256 = "964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84" } }, ] [[packages]] @@ -188,11 +186,11 @@ wheels = [ [[packages]] name = "mako" -version = "1.3.12" +version = "1.4.1" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", upload-time = 2026-04-28T19:01:08Z, size = 400219, hashes = { sha256 = "9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a" } } +sdist = { url = "https://files.pythonhosted.org/packages/2a/12/b5fa2353e2754cd67fb9f83793fa48ff42c213a5da7e719869d2301f6ab8/mako-1.4.1.tar.gz", upload-time = 2026-08-05T06:10:56Z, size = 410165, hashes = { sha256 = "d7904710b662996425a21627710c4777c45053146942cf8a7aebf757c92b8c27" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", upload-time = 2026-04-28T19:01:10Z, size = 78521, hashes = { sha256 = "8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9" } }, + { url = "https://files.pythonhosted.org/packages/a5/54/12ed58d458474aaab5c3d180173e745a4fe131bb330370596876d19ff60f/mako-1.4.1-py3-none-any.whl", upload-time = 2026-08-05T06:10:58Z, size = 80010, hashes = { sha256 = "a359d9a94a541213958742b2698d0a7757bb83551767bc468a74b9905aba9617" } }, ] [[packages]] @@ -282,20 +280,20 @@ wheels = [ [[packages]] name = "packaging" -version = "26.2" +version = "26.3" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", upload-time = 2026-04-24T20:15:23Z, size = 228134, hashes = { sha256 = "ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" } } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", upload-time = 2026-08-04T18:15:28Z, size = 313412, hashes = { sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", upload-time = 2026-04-24T20:15:22Z, size = 100195, hashes = { sha256 = "5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" } }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", upload-time = 2026-08-04T18:15:27Z, size = 129956, hashes = { sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" } }, ] [[packages]] name = "parse" -version = "1.22.0" +version = "1.22.1" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/7b/a2/dd269daedd5ac3a244ca7855b4878d8655393fd4554d5c24a56bc31e302a/parse-1.22.0.tar.gz", upload-time = 2026-05-02T01:36:25Z, size = 36767, hashes = { sha256 = "d4987d68ccf08b6ba3bf80b5004ff7de61c4337cba2d8350ae5c9925794979d9" } } +sdist = { url = "https://files.pythonhosted.org/packages/a4/f2/0b504486c2a5564798607d3860e48ed19c6443d5e9cc3ec61cc6b8b4ef58/parse-1.22.1.tar.gz", upload-time = 2026-05-26T03:44:52Z, size = 36970, hashes = { sha256 = "d3a4740ec3da338e2b258b2d69741b731eadfddca59e24a14bc4ee5fce38c911" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/3a/0c2cf5922c6133b74c1cebe4b66f6949818e2cf8121aa59e3ebcd64ac6ac/parse-1.22.0-py2.py3-none-any.whl", upload-time = 2026-05-02T01:36:24Z, size = 20839, hashes = { sha256 = "eea8ed34e2614cea65d9c1d4af9cb68cce26aea13d44bdcaf83c1b40884fe945" } }, + { url = "https://files.pythonhosted.org/packages/6f/c5/7c16e99869e1f422629092cfd23e3b58e461988c3f9c36fd3624bb4142e6/parse-1.22.1-py2.py3-none-any.whl", upload-time = 2026-05-26T03:44:51Z, size = 20925, hashes = { sha256 = "20f0925a46f06602485ac90d751764d0697fd8455aaa97489ba8953a4b66de32" } }, ] [[packages]] @@ -327,37 +325,39 @@ wheels = [ [[packages]] name = "pyrefly" -version = "1.0.0" +version = "1.2.0" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/9f/3a/9045b0097ac58979c7c30a4fa0e673db942d4adbc7b6d439bd54ae58c441/pyrefly-1.0.0.tar.gz", upload-time = 2026-05-12T20:12:46Z, size = 5677995, hashes = { sha256 = "5c2b810ffcebd84be71de5df1223651edee951653a66935c6f091e957c452455" } } +sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", upload-time = 2026-08-01T02:56:27Z, size = 6243654, hashes = { sha256 = "5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/c6/90788819bac9c61dd7bacba53b79f3c12d47ccbe5e51b3d6d89f2387e1d2/pyrefly-1.0.0-py3-none-macosx_10_12_x86_64.whl", upload-time = 2026-05-12T20:12:20Z, size = 13122950, hashes = { sha256 = "e355a0908555348ed4b9585ef25c76ff566673e345c866c325f1633f44d890b6" } }, - { url = "https://files.pythonhosted.org/packages/82/91/a3cf2a1e87d336eaa804a1e6fc93266faf6dc2a97eecdbc7eae289628022/pyrefly-1.0.0-py3-none-macosx_11_0_arm64.whl", upload-time = 2026-05-12T20:12:23Z, size = 12599494, hashes = { sha256 = "a7038efc3a40f8294edee339895633cf22db268c0d434cdbcbefc34f78a9ecc3" } }, - { url = "https://files.pythonhosted.org/packages/cd/ab/74d1e11e737e99b1c003ecc5d7d2e846c4ea1f328966bfdbbd0ac63fad0a/pyrefly-1.0.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2026-05-12T20:12:25Z, size = 12995507, hashes = { sha256 = "da331ca515ed1c08791da2b5f664cf9c1294c48fd802133262e7d5d51e0f4416" } }, - { url = "https://files.pythonhosted.org/packages/7c/ac/2df0899f8464c97e5d995f994c97c5cb5b0f58610432aa90d26d924e1db5/pyrefly-1.0.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2026-05-12T20:12:29Z, size = 13947693, hashes = { sha256 = "c74219d8f3e63cdaa5501a0b21d1c9d37011820f9606728d0ed06f09ae86a878" } }, - { url = "https://files.pythonhosted.org/packages/6b/3e/b247c24321e36f04b7d51f9ccf3df93e5009e4b29939524b36ec2e17dc2a/pyrefly-1.0.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2026-05-12T20:12:31Z, size = 13925803, hashes = { sha256 = "c0d05543b1bb6ee6d64149eb5d6b2fb15aa72d3962d6a97abca0afaca8b0c131" } }, - { url = "https://files.pythonhosted.org/packages/61/16/cfa2d61a4aa1e1f7bca48bb37acd01c6a09db4864b16a54f9587092765ff/pyrefly-1.0.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-05-12T20:12:35Z, size = 13470398, hashes = { sha256 = "1382d5b1fcdb49a4de9f34d112d2bddf290a78ff93ee8149492ad5f1077ddffc" } }, - { url = "https://files.pythonhosted.org/packages/cb/2b/6372c7dddb326223e24a46b17efd0d4bd7b4fe22c821e523157577eed2d2/pyrefly-1.0.0-py3-none-win32.whl", upload-time = 2026-05-12T20:12:38Z, size = 12222643, hashes = { sha256 = "aa8b5d0e47080e3202a2547b39f7a5a61d2c781c712b3b67884f745ca2c759d2" } }, - { url = "https://files.pythonhosted.org/packages/be/ad/1d23be700b6b2ddaeb362360c7145917a8edbbf7240ae428d40541772fce/pyrefly-1.0.0-py3-none-win_amd64.whl", upload-time = 2026-05-12T20:12:41Z, size = 13146369, hashes = { sha256 = "c8abcb0f2082e83c890375128f9cff4aa4d3f210b85eea7b3046c1ae764e77f5" } }, - { url = "https://files.pythonhosted.org/packages/8c/38/16589134f3012fd097a10dcc85771555f1a5fb76e04b682597180743af30/pyrefly-1.0.0-py3-none-win_arm64.whl", upload-time = 2026-05-12T20:12:43Z, size = 12538326, hashes = { sha256 = "d150fa9e40e8392832be81c3bcfc0497c146674ce4d0f8e04e1ec29e775ffb8c" } }, + { url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", upload-time = 2026-08-01T02:56:02Z, size = 14026305, hashes = { sha256 = "7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e" } }, + { url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", upload-time = 2026-08-01T02:56:04Z, size = 13463880, hashes = { sha256 = "756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d" } }, + { url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2026-08-01T02:56:07Z, size = 13907329, hashes = { sha256 = "e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d" } }, + { url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2026-08-01T02:56:09Z, size = 15039020, hashes = { sha256 = "5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd" } }, + { url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2026-08-01T02:56:11Z, size = 14986199, hashes = { sha256 = "25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a" } }, + { url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-08-01T02:56:14Z, size = 14393715, hashes = { sha256 = "90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1" } }, + { url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", upload-time = 2026-08-01T02:56:16Z, size = 13933008, hashes = { sha256 = "368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf" } }, + { url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", upload-time = 2026-08-01T02:56:18Z, size = 14431827, hashes = { sha256 = "d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe" } }, + { url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", upload-time = 2026-08-01T02:56:20Z, size = 13229447, hashes = { sha256 = "8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa" } }, + { url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", upload-time = 2026-08-01T02:56:23Z, size = 14087387, hashes = { sha256 = "8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170" } }, + { url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", upload-time = 2026-08-01T02:56:25Z, size = 13430873, hashes = { sha256 = "3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e" } }, ] [[packages]] name = "pytest" -version = "9.0.3" +version = "9.1.1" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", upload-time = 2026-04-07T17:16:18Z, size = 1572165, hashes = { sha256 = "b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c" } } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", upload-time = 2026-06-19T10:58:32Z, size = 1636369, hashes = { sha256 = "1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", upload-time = 2026-04-07T17:16:16Z, size = 375249, hashes = { sha256 = "2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9" } }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", upload-time = 2026-06-19T10:58:31Z, size = 386536, hashes = { sha256 = "37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" } }, ] [[packages]] name = "pytest-asyncio" -version = "1.3.0" +version = "1.4.0" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", upload-time = 2025-11-10T16:07:47Z, size = 50087, hashes = { sha256 = "d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5" } } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", upload-time = 2026-05-26T09:56:04Z, size = 58514, hashes = { sha256 = "c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", upload-time = 2025-11-10T16:07:45Z, size = 15075, hashes = { sha256 = "611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5" } }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", upload-time = 2026-05-26T09:56:02Z, size = 16930, hashes = { sha256 = "933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1" } }, ] [[packages]] @@ -380,27 +380,27 @@ wheels = [ [[packages]] name = "ruff" -version = "0.15.13" +version = "0.16.4" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", upload-time = 2026-05-14T13:44:37Z, size = 4678180, hashes = { sha256 = "f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7" } } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", upload-time = 2026-08-20T17:43:59Z, size = 4899731, hashes = { sha256 = "13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", upload-time = 2026-05-14T13:44:18Z, size = 10738279, hashes = { sha256 = "444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8" } }, - { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", upload-time = 2026-05-14T13:44:06Z, size = 11124798, hashes = { sha256 = "6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7" } }, - { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", upload-time = 2026-05-14T13:44:04Z, size = 10460761, hashes = { sha256 = "1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629" } }, - { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2026-05-14T13:44:25Z, size = 10804451, hashes = { sha256 = "9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5" } }, - { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", upload-time = 2026-05-14T13:44:08Z, size = 10534285, hashes = { sha256 = "bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22" } }, - { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2026-05-14T13:44:11Z, size = 11312063, hashes = { sha256 = "ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9" } }, - { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2026-05-14T13:44:01Z, size = 12183079, hashes = { sha256 = "2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55" } }, - { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2026-05-14T13:43:59Z, size = 11440833, hashes = { sha256 = "e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6" } }, - { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-05-14T13:44:27Z, size = 11434486, hashes = { sha256 = "cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca" } }, - { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", upload-time = 2026-05-14T13:44:13Z, size = 11385189, hashes = { sha256 = "768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd" } }, - { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", upload-time = 2026-05-14T13:43:56Z, size = 10781380, hashes = { sha256 = "fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6" } }, - { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", upload-time = 2026-05-14T13:44:20Z, size = 10540605, hashes = { sha256 = "8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51" } }, - { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", upload-time = 2026-05-14T13:44:16Z, size = 11036554, hashes = { sha256 = "7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2" } }, - { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", upload-time = 2026-05-14T13:44:22Z, size = 11528133, hashes = { sha256 = "f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b" } }, - { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", upload-time = 2026-05-14T13:44:35Z, size = 10721455, hashes = { sha256 = "4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41" } }, - { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", upload-time = 2026-05-14T13:44:30Z, size = 11900409, hashes = { sha256 = "7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4" } }, - { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", upload-time = 2026-05-14T13:44:33Z, size = 11179336, hashes = { sha256 = "2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21" } }, + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", upload-time = 2026-08-20T17:43:16Z, size = 10006909, hashes = { sha256 = "df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7" } }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", upload-time = 2026-08-20T17:43:19Z, size = 10240201, hashes = { sha256 = "0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604" } }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", upload-time = 2026-08-20T17:43:21Z, size = 9835122, hashes = { sha256 = "963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255" } }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2026-08-20T17:43:24Z, size = 9977162, hashes = { sha256 = "32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade" } }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", upload-time = 2026-08-20T17:43:26Z, size = 9829789, hashes = { sha256 = "b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff" } }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2026-08-20T17:43:29Z, size = 10527949, hashes = { sha256 = "7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80" } }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2026-08-20T17:43:31Z, size = 11333695, hashes = { sha256 = "3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07" } }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2026-08-20T17:43:34Z, size = 10727741, hashes = { sha256 = "e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7" } }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-08-20T17:43:37Z, size = 10286522, hashes = { sha256 = "f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3" } }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", upload-time = 2026-08-20T17:43:39Z, size = 10584182, hashes = { sha256 = "6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454" } }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", upload-time = 2026-08-20T17:43:42Z, size = 10134195, hashes = { sha256 = "bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b" } }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", upload-time = 2026-08-20T17:43:44Z, size = 9825821, hashes = { sha256 = "b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d" } }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", upload-time = 2026-08-20T17:43:46Z, size = 10267658, hashes = { sha256 = "8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0" } }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", upload-time = 2026-08-20T17:43:49Z, size = 10697071, hashes = { sha256 = "fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d" } }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", upload-time = 2026-08-20T17:43:52Z, size = 10021687, hashes = { sha256 = "312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e" } }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", upload-time = 2026-08-20T17:43:54Z, size = 10567657, hashes = { sha256 = "05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c" } }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", upload-time = 2026-08-20T17:43:57Z, size = 10451579, hashes = { sha256 = "a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21" } }, ] [[packages]] @@ -469,9 +469,9 @@ wheels = [ [[packages]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", upload-time = 2025-08-25T13:49:26Z, size = 109391, hashes = { sha256 = "0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", upload-time = 2026-07-02T08:40:05Z, size = 113555, hashes = { sha256 = "dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", upload-time = 2025-08-25T13:49:24Z, size = 44614, hashes = { sha256 = "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" } }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", upload-time = 2026-07-02T08:40:04Z, size = 45571, hashes = { sha256 = "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" } }, ] diff --git a/bdd/python/tests/conftest.py b/bdd/python/tests/conftest.py index 4c968eea19..a4a43e892d 100644 --- a/bdd/python/tests/conftest.py +++ b/bdd/python/tests/conftest.py @@ -44,6 +44,27 @@ class GlobalContext: last_raw_error: RuntimeError | None = None +def required_env(name: str) -> str: + """Read a variable the suite cannot run without. + + A default here would turn a dropped compose variable into a run against + whatever happens to listen on the fallback address, so a missing value + aborts the suite instead. + """ + value = os.environ.get(name) + if not value: + raise RuntimeError( + f"{name} must be set; run the suite via scripts/run-bdd-tests.sh" + ) + return value + + +@pytest.fixture(scope="session") +def root_credentials() -> tuple[str, str]: + """Root username and password the server was started with.""" + return required_env("IGGY_ROOT_USERNAME"), required_env("IGGY_ROOT_PASSWORD") + + @pytest.fixture(scope="session") def event_loop(): """Create an instance of the default event loop for the test session.""" @@ -57,7 +78,6 @@ def context(): """Create a fresh context for each test scenario.""" ctx = GlobalContext() - # Get server address from environment or use default - ctx.server_addr = os.environ.get("IGGY_TCP_ADDRESS", "127.0.0.1:8090") + ctx.server_addr = required_env("IGGY_TCP_ADDRESS") yield ctx diff --git a/bdd/python/tests/test_basic_messaging.py b/bdd/python/tests/test_basic_messaging.py index cbaf3869a4..b34d0cdc79 100644 --- a/bdd/python/tests/test_basic_messaging.py +++ b/bdd/python/tests/test_basic_messaging.py @@ -52,11 +52,11 @@ async def _connect(): @given("I am authenticated as the root user") -def authenticated_root_user(context): +def authenticated_root_user(context, root_credentials): """Authenticate as root user""" async def _login(): - await context.client.login_user("iggy", "iggy") + await context.client.login_user(*root_credentials) asyncio.run(_login()) diff --git a/bdd/python/tests/test_raw_command.py b/bdd/python/tests/test_raw_command.py index cc0842530b..e2b040d30b 100644 --- a/bdd/python/tests/test_raw_command.py +++ b/bdd/python/tests/test_raw_command.py @@ -41,9 +41,9 @@ async def connect(): @given("I am authenticated as the root user") -def authenticated_root_user(context): +def authenticated_root_user(context, root_credentials): async def login(): - await context.client.login_user("iggy", "iggy") + await context.client.login_user(*root_credentials) asyncio.run(login()) diff --git a/bdd/python/uv.lock b/bdd/python/uv.lock index b401a9ae2c..b88ba7ab6a 100644 --- a/bdd/python/uv.lock +++ b/bdd/python/uv.lock @@ -8,13 +8,13 @@ exclude-newer-span = "P7D" [[package]] name = "apache-iggy" -version = "0.9.0.dev5" +version = "0.9.0" source = { directory = "../../foreign/python" } [package.metadata] requires-dist = [ - { name = "maturin", marker = "extra == 'all'", specifier = ">=1.14.1,<2.0" }, - { name = "maturin", marker = "extra == 'dev'", specifier = ">=1.14.1,<2.0" }, + { name = "maturin", marker = "extra == 'all'", specifier = ">=1.15.0,<2.0" }, + { name = "maturin", marker = "extra == 'dev'", specifier = ">=1.15.0,<2.0" }, { name = "pyrefly", marker = "extra == 'all'", specifier = ">=1.2.0" }, { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=1.2.0" }, { name = "pytest", marker = "extra == 'all'", specifier = ">=9.1.1,<10.0" }, @@ -25,8 +25,6 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'testing'", specifier = ">=4.0,<8.0" }, { name = "pytest-timeout", marker = "extra == 'all'", specifier = ">=2.0,<3.0" }, { name = "pytest-timeout", marker = "extra == 'testing'", specifier = ">=2.0,<3.0" }, - { name = "pytest-xdist", marker = "extra == 'all'", specifier = ">=3.0,<4.0" }, - { name = "pytest-xdist", marker = "extra == 'testing'", specifier = ">=3.0,<4.0" }, { name = "ruff", marker = "extra == 'all'", specifier = ">=0.1.0,<1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0,<1.0" }, { name = "testcontainers", marker = "extra == 'all'", specifier = ">=4.15.0,<5.0" }, @@ -471,27 +469,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.3" +version = "0.16.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, - { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, - { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, - { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, - { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, - { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, - { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] [[package]] diff --git a/bdd/rust/tests/helpers/cluster.rs b/bdd/rust/tests/helpers/cluster.rs index 8a954c22af..db636586ef 100644 --- a/bdd/rust/tests/helpers/cluster.rs +++ b/bdd/rust/tests/helpers/cluster.rs @@ -15,23 +15,18 @@ // specific language governing permissions and limitations // under the License. +use crate::helpers::env::{follower_address, leader_address, server_address}; use iggy::prelude::*; -use std::env; use std::net::{SocketAddr, ToSocketAddrs}; use std::sync::Arc; -/// Resolves server address based on role and port, checking environment variables first +/// Resolves the server address for a role and port from the environment pub fn resolve_server_address(role: &str, port: u16) -> String { match (role.to_lowercase().as_str(), port) { - ("leader", 8091) => { - env::var("IGGY_TCP_ADDRESS_LEADER").unwrap_or_else(|_| "iggy-leader:8091".to_string()) - } - ("follower", 8092) => env::var("IGGY_TCP_ADDRESS_FOLLOWER") - .unwrap_or_else(|_| "iggy-follower:8092".to_string()), - ("single", 8090) | (_, 8090) => { - env::var("IGGY_TCP_ADDRESS").unwrap_or_else(|_| "iggy-server:8090".to_string()) - } - _ => format!("iggy-server:{}", port), + ("leader", 8091) => leader_address(), + ("follower", 8092) => follower_address(), + (_, 8090) => server_address(), + _ => panic!("no address mapping for role '{role}' on port {port}"), } } diff --git a/bdd/rust/tests/helpers/env.rs b/bdd/rust/tests/helpers/env.rs new file mode 100644 index 0000000000..75c65683a0 --- /dev/null +++ b/bdd/rust/tests/helpers/env.rs @@ -0,0 +1,50 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use std::env; + +/// Reads a variable the suite cannot run without. +/// +/// A default here would turn a dropped compose variable into a run against +/// whatever happens to listen on the fallback address, so a missing value +/// aborts the suite instead. +fn required_env(name: &str) -> String { + match env::var(name) { + Ok(value) if !value.is_empty() => value, + _ => panic!("{name} must be set; run the suite via scripts/run-bdd-tests.sh"), + } +} + +pub fn server_address() -> String { + required_env("IGGY_TCP_ADDRESS") +} + +pub fn leader_address() -> String { + required_env("IGGY_TCP_ADDRESS_LEADER") +} + +pub fn follower_address() -> String { + required_env("IGGY_TCP_ADDRESS_FOLLOWER") +} + +pub fn root_username() -> String { + required_env("IGGY_ROOT_USERNAME") +} + +pub fn root_password() -> String { + required_env("IGGY_ROOT_PASSWORD") +} diff --git a/bdd/rust/tests/helpers/mod.rs b/bdd/rust/tests/helpers/mod.rs index 26173fc631..7521d25264 100644 --- a/bdd/rust/tests/helpers/mod.rs +++ b/bdd/rust/tests/helpers/mod.rs @@ -16,4 +16,5 @@ // under the License. pub mod cluster; +pub mod env; pub mod test_data; diff --git a/bdd/rust/tests/steps/auth.rs b/bdd/rust/tests/steps/auth.rs index f5fc554bee..bca0c06876 100644 --- a/bdd/rust/tests/steps/auth.rs +++ b/bdd/rust/tests/steps/auth.rs @@ -16,6 +16,7 @@ // under the License. use crate::common::global_context::GlobalContext; +use crate::helpers::env::{root_password, root_username}; use cucumber::given; use iggy::prelude::*; use std::sync::Arc; @@ -42,7 +43,7 @@ pub async fn given_authenticated_as_root(world: &mut GlobalContext) { client.ping().await.expect("Server should respond to ping"); client - .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD) + .login_user(&root_username(), &root_password()) .await .expect("Failed to login as root"); diff --git a/bdd/rust/tests/steps/leader_redirection.rs b/bdd/rust/tests/steps/leader_redirection.rs index c035bd848b..dd0f4f02c9 100644 --- a/bdd/rust/tests/steps/leader_redirection.rs +++ b/bdd/rust/tests/steps/leader_redirection.rs @@ -17,6 +17,7 @@ use crate::common::leader_context::LeaderContext; use crate::helpers::cluster; +use crate::helpers::env::{root_password, root_username}; use cucumber::{given, then, when}; use iggy::prelude::*; use std::time::Duration; @@ -160,7 +161,7 @@ async fn when_authenticate_root(world: &mut LeaderContext) { .unwrap_or_else(|| panic!("Client {} should be created", client_name)); client - .login_user(DEFAULT_ROOT_USERNAME, DEFAULT_ROOT_PASSWORD) + .login_user(&root_username(), &root_password()) .await .expect("Failed to login as root"); diff --git a/bdd/rust/tests/steps/server.rs b/bdd/rust/tests/steps/server.rs index d621b699cc..73e8b7491e 100644 --- a/bdd/rust/tests/steps/server.rs +++ b/bdd/rust/tests/steps/server.rs @@ -16,12 +16,11 @@ // under the License. use crate::common::global_context::GlobalContext; +use crate::helpers::env::server_address; use cucumber::given; #[given("I have a running Iggy server")] pub async fn given_running_server(world: &mut GlobalContext) { // External server mode - connect to server from environment - let server_addr = - std::env::var("IGGY_TCP_ADDRESS").unwrap_or_else(|_| "localhost:8090".to_string()); - world.server_addr = Some(server_addr); + world.server_addr = Some(server_address()); } diff --git a/bdd/rust/tests/steps/streams.rs b/bdd/rust/tests/steps/streams.rs index ba1248f3fa..f94ef4027c 100644 --- a/bdd/rust/tests/steps/streams.rs +++ b/bdd/rust/tests/steps/streams.rs @@ -18,11 +18,6 @@ use crate::common::global_context::GlobalContext; use cucumber::{given, then, when}; use iggy::prelude::{Identifier, StreamClient, StreamUpdateOptions}; -use std::time::Duration; -use tokio::time::{Instant, sleep}; - -const METADATA_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(2); -const METADATA_CONVERGENCE_POLL: Duration = Duration::from_millis(10); #[given("I have no streams in the system")] pub async fn given_no_streams(world: &mut GlobalContext) { @@ -128,18 +123,20 @@ pub async fn when_delete_stream_by_numeric_id(world: &mut GlobalContext) { #[then("getting the stream by its numeric ID should return no stream")] pub async fn then_get_stream_returns_no_stream(world: &mut GlobalContext) { - let deadline = Instant::now() + METADATA_CONVERGENCE_TIMEOUT; - loop { - get_stream_by_numeric_id(world).await; - if world.last_stream_name.is_none() { - return; - } - assert!( - Instant::now() < deadline, - "Deleted stream should not be returned after {METADATA_CONVERGENCE_TIMEOUT:?}" - ); - sleep(METADATA_CONVERGENCE_POLL).await; - } + // Read before the get overwrites it. The assertion is "not the stream we + // deleted", not "nothing at this id": `IdSlab::insert` hands out the lowest + // free key and these scenarios share one server, so a concurrent create can + // legitimately occupy the deleted stream's id. + let deleted = world + .last_stream_name + .clone() + .expect("Stream should have been created"); + get_stream_by_numeric_id(world).await; + assert_ne!( + world.last_stream_name.as_ref(), + Some(&deleted), + "Deleted stream should not be returned" + ); } async fn create_stream(world: &mut GlobalContext, stream_name: &str) { diff --git a/core/ai/mcp/Cargo.toml b/core/ai/mcp/Cargo.toml index bab4769b3e..7ab0b86ab6 100644 --- a/core/ai/mcp/Cargo.toml +++ b/core/ai/mcp/Cargo.toml @@ -17,7 +17,7 @@ [package] name = "iggy-mcp" -version = "0.5.0-edge.4" +version = "0.5.0" description = "MCP Server for Iggy message streaming platform" edition = "2024" license = "Apache-2.0" diff --git a/core/ai/mcp/README.md b/core/ai/mcp/README.md index 97d580a581..12ed06259a 100644 --- a/core/ai/mcp/README.md +++ b/core/ai/mcp/README.md @@ -2,11 +2,17 @@ The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is an open protocol that standardizes how applications provide context to LLMs. The Apache Iggy MCP Server is an implementation of the MCP protocol for the message streaming infrastructure. -To start the MCP server, simply run `cargo run --bin iggy-mcp`. +Start an Iggy broker from the same checkout first. For a development broker started with `--with-default-root-credentials`, run from the repository root: -The [docker image](https://hub.docker.com/r/apache/iggy-mcp) is available, and can be fetched via `docker pull apache/iggy-mcp`. +```sh +IGGY_MCP_IGGY_USERNAME=iggy IGGY_MCP_IGGY_PASSWORD=iggy cargo run --bin iggy-mcp +``` + +Use the credentials or PAT of your existing broker when connecting to another installation. -The minimal viable configuration requires at least the Iggy credentials, to create the connection with the running Iggy server using TCP with which the MCP server will communicate. You can choose between HTTP and STDIO transports (e.g. for the local usage with tools such as [Claude Desktop](https://claude.ai/download) choose `stdio`). +The [docker image](https://hub.docker.com/r/apache/iggy-mcp) is available, and can be fetched via `docker pull apache/iggy-mcp:edge`. + +The minimal viable configuration requires at least the Iggy credentials, to create the connection with the running Iggy server using TCP with which the MCP server will communicate. You can choose between HTTP (the default) and STDIO transports (e.g. for the local usage with tools such as [Claude Desktop](https://claude.ai/download) choose `stdio`). ```toml transport = "stdio" # http or stdio are supported @@ -48,9 +54,13 @@ update = true delete = true ``` -Keep in mind that either of `toml`, `yaml`, or `json` formats are supported for the configuration file. The path to the configuration can be overridden by `IGGY_MCP_CONFIG_PATH` environment variable. Each configuration section can be also additionally updated by using the following convention `IGGY_MCP_SECTION_NAME.KEY_NAME` e.g. `IGGY_MCP_IGGY_USERNAME` and so on. +The configuration file must use TOML. The default path is `core/ai/mcp/config.toml`, relative to the working directory; override it with `IGGY_MCP_CONFIG_PATH`. Embedded defaults are loaded first, then the file if present, then environment overrides such as `IGGY_MCP_IGGY_USERNAME` and `IGGY_MCP_HTTP_ADDRESS`. Nested settings also use underscores, for example `IGGY_MCP_IGGY_TLS_ENABLED`. + +Set `IGGY_MCP_ENV_PATH` to load a particular dotenv file. Otherwise `.env` is searched for in the current directory and its parents. Existing environment variables take precedence over dotenv values. + +A non-empty `iggy.token` takes precedence over username and password. It accepts a literal PAT or a `file:` reference such as `file:/run/secrets/iggy_pat`; file contents are trimmed and a leading `~/` expands to the home directory. -Here's the example configuration to be used with Claude Desktop: +Set `command` to the absolute path of the built executable. This Claude Desktop example uses the development broker credentials: ```json { @@ -59,7 +69,10 @@ Here's the example configuration to be used with Claude Desktop: "command": "/path/to/iggy-mcp", "args": [], "env": { - "IGGY_MCP_TRANSPORT": "stdio" + "IGGY_MCP_TRANSPORT": "stdio", + "IGGY_MCP_IGGY_ADDRESS": "localhost:8090", + "IGGY_MCP_IGGY_USERNAME": "iggy", + "IGGY_MCP_IGGY_PASSWORD": "iggy" } } } @@ -70,6 +83,19 @@ Here's the example configuration to be used with Claude Desktop: ![MCP](../../../assets/iggy_mcp_server.png) +## Tool permissions + +Each tool checks these MCP permissions before forwarding the request. The broker also enforces the authenticated Iggy account's permissions. + +| Permission | Tools | +| --- | --- | +| `read` | `ping`, `get_cluster_metadata`, `get_stream`, `get_streams`, `get_topic`, `get_topics`, `poll_messages`, `get_stats`, `get_me`, `get_client`, `get_clients`, `snapshot`, `get_consumer_group`, `get_consumer_groups`, `get_consumer_offset`, `get_personal_access_tokens`, `get_user`, `get_users` | +| `create` | `create_stream`, `create_topic`, `create_partitions`, `send_messages`, `create_consumer_group`, `create_personal_access_token`, `create_user` | +| `update` | `update_stream`, `update_topic`, `store_consumer_offset`, `update_user`, `update_permissions`, `change_password` | +| `delete` | `delete_stream`, `purge_stream`, `delete_topic`, `purge_topic`, `delete_partitions`, `delete_segments`, `delete_consumer_group`, `delete_consumer_offset`, `delete_personal_access_token`, `delete_user` | + +`poll_messages` additionally requires `update` when `auto_commit = true` or `strategy = "next"`. The `next` strategy enables auto-commit even when `auto_commit` is omitted or false. For read-only polling, use `offset`, `first`, `last`, or `timestamp` with auto-commit disabled. + ## Systemd integration Build with the `systemd` feature to enable systemd readiness and watchdog notifications: @@ -78,8 +104,7 @@ Build with the `systemd` feature to enable systemd readiness and watchdog notifi cargo build --bin iggy-mcp --release --features iggy-mcp/systemd ``` -The MCP server behaves the same way the Iggy server does under systemd. See -[Systemd integration](../../server/README.md#systemd-integration) for details. +Readiness is sent after the HTTP listener starts or the stdio session initializes. The watchdog sends keep-alive notifications at half the interval supplied by systemd. SIGINT, SIGTERM, and stdio client disconnect trigger shutdown and a stopping notification. ## Telemetry @@ -98,3 +123,7 @@ endpoint = "http://localhost:4317" transport = "grpc" # Options: "grpc", "http" endpoint = "http://localhost:4317" ``` + +For HTTP export, set `transport = "http"` and use complete signal URLs: `http://localhost:4318/v1/logs` for logs and `http://localhost:4318/v1/traces` for traces. The MCP server does not append those paths. + +Shutdown flushes pending logs and spans before stopping the async runtime. diff --git a/core/ai/mcp/src/error.rs b/core/ai/mcp/src/error.rs index 81086bd493..5b6e0347e1 100644 --- a/core/ai/mcp/src/error.rs +++ b/core/ai/mcp/src/error.rs @@ -19,6 +19,10 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum McpRuntimeError { + #[error("Failed to create async runtime: {0}")] + RuntimeCreation(#[source] std::io::Error), + #[error("Failed to register shutdown signal: {0}")] + SignalRegistration(#[source] std::io::Error), #[error("Failed to create service")] FailedToCreateService, #[error("Failed to start HTTP server")] diff --git a/core/ai/mcp/src/log.rs b/core/ai/mcp/src/log.rs index ceff6c668f..1dee4e2730 100644 --- a/core/ai/mcp/src/log.rs +++ b/core/ai/mcp/src/log.rs @@ -21,18 +21,46 @@ use opentelemetry::{KeyValue, global}; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; use opentelemetry_otlp::WithExportConfig; use opentelemetry_sdk::Resource; +use opentelemetry_sdk::logs::SdkLoggerProvider; +use opentelemetry_sdk::logs::log_processor_with_async_runtime; use opentelemetry_sdk::propagation::TraceContextPropagator; -use tracing::info; +use opentelemetry_sdk::runtime::Tokio; +use opentelemetry_sdk::trace::SdkTracerProvider; +use opentelemetry_sdk::trace::span_processor_with_async_runtime; +use tracing::{error, info}; use tracing_opentelemetry::OpenTelemetryLayer; use tracing_subscriber::EnvFilter; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; +pub struct Telemetry { + logger_provider: SdkLoggerProvider, + tracer_provider: SdkTracerProvider, +} + +impl Telemetry { + pub async fn shutdown(self) { + // Provider shutdown blocks while the processors finish on Tokio workers. + if let Err(error) = tokio::task::spawn_blocking(move || { + if let Err(error) = self.tracer_provider.shutdown() { + error!("Failed to shut down telemetry traces: {error}"); + } + if let Err(error) = self.logger_provider.shutdown() { + error!("Failed to shut down telemetry logs: {error}"); + } + }) + .await + { + error!("Telemetry shutdown task failed: {error}"); + } + } +} + pub fn init_logging( telemetry_config: &TelemetryConfig, transport: McpTransport, version: &'static str, -) { +) -> Option { // STDIO transport needs stderr output with no ANSI codes // HTTP transport can use normal stdout with ANSI let (default_level, use_stderr, use_ansi) = match transport { @@ -44,18 +72,18 @@ pub fn init_logging( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level)); if telemetry_config.enabled { - let (logger_provider, tracer_provider) = init_telemetry(telemetry_config, version); + let telemetry = init_telemetry(telemetry_config, version); let service_name = telemetry_config.service_name.clone(); - let tracer = tracer_provider.tracer(service_name); - global::set_tracer_provider(tracer_provider); + let tracer = telemetry.tracer_provider.tracer(service_name); + global::set_tracer_provider(telemetry.tracer_provider.clone()); global::set_text_map_propagator(TraceContextPropagator::new()); if use_stderr { let fmt_layer = tracing_subscriber::fmt::layer() .with_writer(std::io::stderr) .with_ansi(use_ansi); - let otel_logs_layer = OpenTelemetryTracingBridge::new(&logger_provider); + let otel_logs_layer = OpenTelemetryTracingBridge::new(&telemetry.logger_provider); let otel_traces_layer = OpenTelemetryLayer::new(tracer); tracing_subscriber::registry() .with(env_filter) @@ -65,7 +93,7 @@ pub fn init_logging( .init(); } else { let fmt_layer = tracing_subscriber::fmt::layer().with_ansi(use_ansi); - let otel_logs_layer = OpenTelemetryTracingBridge::new(&logger_provider); + let otel_logs_layer = OpenTelemetryTracingBridge::new(&telemetry.logger_provider); let otel_traces_layer = OpenTelemetryLayer::new(tracer); tracing_subscriber::registry() .with(env_filter) @@ -79,6 +107,7 @@ pub fn init_logging( "Logging initialized with telemetry enabled, service name: {}", telemetry_config.service_name ); + return Some(telemetry); } else if use_stderr { tracing_subscriber::fmt() .with_env_filter(env_filter) @@ -91,15 +120,10 @@ pub fn init_logging( .with(env_filter) .init(); } + None } -fn init_telemetry( - telemetry_config: &TelemetryConfig, - version: &'static str, -) -> ( - opentelemetry_sdk::logs::SdkLoggerProvider, - opentelemetry_sdk::trace::SdkTracerProvider, -) { +fn init_telemetry(telemetry_config: &TelemetryConfig, version: &'static str) -> Telemetry { let service_name = telemetry_config.service_name.clone(); let resource = Resource::builder() .with_service_name(service_name) @@ -112,7 +136,10 @@ fn init_telemetry( let logger_provider = init_logs_exporter(telemetry_config, resource.clone()); let tracer_provider = init_traces_exporter(telemetry_config, resource); - (logger_provider, tracer_provider) + Telemetry { + logger_provider, + tracer_provider, + } } fn init_logs_exporter( @@ -139,7 +166,13 @@ fn init_logs_exporter( .expect("Failed to initialize HTTP logger."); opentelemetry_sdk::logs::SdkLoggerProvider::builder() .with_resource(resource) - .with_batch_exporter(log_exporter) + .with_log_processor( + log_processor_with_async_runtime::BatchLogProcessor::builder( + log_exporter, + Tokio, + ) + .build(), + ) .build() } } @@ -169,8 +202,108 @@ fn init_traces_exporter( .expect("Failed to initialize HTTP tracer."); opentelemetry_sdk::trace::SdkTracerProvider::builder() .with_resource(resource) - .with_batch_exporter(trace_exporter) + .with_span_processor( + span_processor_with_async_runtime::BatchSpanProcessor::builder( + trace_exporter, + Tokio, + ) + .build(), + ) .build() } } } + +#[cfg(test)] +mod tests { + use axum::Router; + use axum::body::Bytes; + use axum::http::{HeaderMap, StatusCode, Uri}; + use opentelemetry::logs::{LogRecord, Logger, LoggerProvider}; + use opentelemetry::trace::{Span, Tracer, TracerProvider}; + use tokio::net::TcpListener; + use tokio::sync::{mpsc, oneshot}; + + use super::init_telemetry; + use crate::configs::{ + TelemetryConfig, TelemetryLogsConfig, TelemetryTracesConfig, TelemetryTransport, + }; + + const SIGNAL_COUNT: usize = 2; + const TEST_SCOPE: &str = "mcp-telemetry-test"; + const LOG_BODY: &str = "mcp-telemetry-log"; + const TRACE_NAME: &str = "mcp-telemetry-span"; + + #[tokio::test] + async fn given_http_telemetry_when_shutdown_should_export_logs_and_traces() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind local telemetry collector"); + let endpoint = format!( + "http://{}", + listener.local_addr().expect("collector address") + ); + let (requests, mut received) = mpsc::channel(SIGNAL_COUNT); + let collector = Router::new().fallback(move |uri: Uri, headers: HeaderMap, body: Bytes| { + let requests = requests.clone(); + async move { + requests + .try_send((uri, headers, body)) + .expect("record exported signal"); + StatusCode::OK + } + }); + let (stop, stopped) = oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve(listener, collector) + .with_graceful_shutdown(async { + let _ = stopped.await; + }) + .await + .expect("serve telemetry collector"); + }); + let config = TelemetryConfig { + enabled: true, + logs: TelemetryLogsConfig { + transport: TelemetryTransport::Http, + endpoint: format!("{endpoint}/v1/logs"), + }, + traces: TelemetryTracesConfig { + transport: TelemetryTransport::Http, + endpoint: format!("{endpoint}/v1/traces"), + }, + ..TelemetryConfig::default() + }; + let telemetry = init_telemetry(&config, env!("CARGO_PKG_VERSION")); + let logger = telemetry.logger_provider.logger(TEST_SCOPE); + let mut record = logger.create_log_record(); + record.set_body(LOG_BODY.into()); + logger.emit(record); + let tracer = telemetry.tracer_provider.tracer(TEST_SCOPE); + let mut span = tracer.start(TRACE_NAME); + span.end(); + + telemetry.shutdown().await; + stop.send(()).expect("stop telemetry collector"); + server.await.expect("collector task should complete"); + + let requests: Vec<_> = std::iter::from_fn(|| received.try_recv().ok()).collect(); + assert_eq!( + requests.len(), + SIGNAL_COUNT, + "both signals must be exported" + ); + for (path, marker) in [("/v1/logs", LOG_BODY), ("/v1/traces", TRACE_NAME)] { + let (_, headers, body) = requests + .iter() + .find(|(uri, _, _)| uri.path() == path) + .expect("signal must reach its configured endpoint"); + assert_eq!(headers["content-type"], "application/x-protobuf"); + assert!( + body.windows(marker.len()) + .any(|bytes| bytes == marker.as_bytes()), + "exported signal must contain its emitted record: {path}" + ); + } + } +} diff --git a/core/ai/mcp/src/main.rs b/core/ai/mcp/src/main.rs index 0f2688d531..c2d24fbd26 100644 --- a/core/ai/mcp/src/main.rs +++ b/core/ai/mcp/src/main.rs @@ -24,6 +24,9 @@ use iggy::prelude::{Client, Identifier}; use rmcp::{ServiceExt, model::ErrorData, transport::stdio}; use service::IggyService; use std::{env, sync::Arc}; +use tokio::runtime::Builder; +#[cfg(unix)] +use tokio::signal::unix::{SignalKind, signal}; use tracing::{error, info}; mod api; @@ -39,8 +42,18 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); const DEFAULT_CONFIG_PATH: &str = "core/ai/mcp/config.toml"; -#[tokio::main] -async fn main() -> Result<(), McpRuntimeError> { +fn main() -> Result<(), McpRuntimeError> { + let runtime = Builder::new_multi_thread() + .enable_all() + .build() + .map_err(McpRuntimeError::RuntimeCreation)?; + let result = runtime.block_on(run()); + // Tokio cannot cancel its blocking stdin read when a signal ends a live session. + runtime.shutdown_background(); + result +} + +async fn run() -> Result<(), McpRuntimeError> { let standard_font = FIGlet::standard().unwrap(); let figure = standard_font.convert("Iggy MCP Server"); eprintln!("{}", figure.unwrap()); @@ -64,9 +77,16 @@ async fn main() -> Result<(), McpRuntimeError> { .await .expect("Failed to load configuration"); - let transport = config.transport; - log::init_logging(&config.telemetry, transport, VERSION); + let telemetry = log::init_logging(&config.telemetry, config.transport, VERSION); + let result = run_service(config).await; + if let Some(telemetry) = telemetry { + telemetry.shutdown().await; + } + result +} +async fn run_service(config: McpServerConfig) -> Result<(), McpRuntimeError> { + let transport = config.transport; info!("Starting Iggy MCP Server, transport: {transport}..."); let consumer = if config.iggy.consumer.is_empty() { @@ -92,53 +112,65 @@ async fn main() -> Result<(), McpRuntimeError> { #[cfg(feature = "systemd")] let watchdog_cancel = tokio_util::sync::CancellationToken::new(); - if transport == McpTransport::Stdio { - let Ok(service) = IggyService::new(iggy_client, iggy_consumer, permissions) - .serve(stdio()) - .await - .inspect_err(|error| { - error!("Serving error. {error}"); - }) - else { - error!("Failed to create service"); - return Err(McpRuntimeError::FailedToCreateService); - }; - - #[cfg(feature = "systemd")] - systemd::notify_ready(); - #[cfg(feature = "systemd")] - systemd::spawn_watchdog(watchdog_cancel.clone()); - - if let Err(error) = service.waiting().await { - error!("Waiting for service error. {error}"); - } - } else { - api::init(config.http, iggy_client, iggy_consumer, permissions).await?; - - #[cfg(feature = "systemd")] - systemd::notify_ready(); - #[cfg(feature = "systemd")] - systemd::spawn_watchdog(watchdog_cancel.clone()); - } - #[cfg(unix)] - let (mut ctrl_c, mut sigterm) = { - use tokio::signal::unix::{SignalKind, signal}; - ( - signal(SignalKind::interrupt()).expect("Failed to create SIGINT signal"), - signal(SignalKind::terminate()).expect("Failed to create SIGTERM signal"), - ) + let (mut ctrl_c, mut sigterm) = ( + signal(SignalKind::interrupt()).map_err(McpRuntimeError::SignalRegistration)?, + signal(SignalKind::terminate()).map_err(McpRuntimeError::SignalRegistration)?, + ); + + let serve = async { + if transport == McpTransport::Stdio { + let Ok(service) = IggyService::new(iggy_client, iggy_consumer, permissions) + .serve(stdio()) + .await + .inspect_err(|error| { + error!("Serving error. {error}"); + }) + else { + error!("Failed to create service"); + return Err(McpRuntimeError::FailedToCreateService); + }; + + #[cfg(feature = "systemd")] + systemd::notify_ready(); + #[cfg(feature = "systemd")] + systemd::spawn_watchdog(watchdog_cancel.clone()); + + if let Err(error) = service.waiting().await { + error!("Waiting for service error. {error}"); + } + } else { + api::init(config.http, iggy_client, iggy_consumer, permissions).await?; + + #[cfg(feature = "systemd")] + systemd::notify_ready(); + #[cfg(feature = "systemd")] + systemd::spawn_watchdog(watchdog_cancel.clone()); + std::future::pending::<()>().await; + } + Ok::<(), McpRuntimeError>(()) }; #[cfg(unix)] - tokio::select! { + let result = tokio::select! { + result = serve => result, _ = ctrl_c.recv() => { info!("Received SIGINT. Shutting down Iggy MCP Server..."); + Ok(()) }, _ = sigterm.recv() => { info!("Received SIGTERM. Shutting down Iggy MCP Server..."); + Ok(()) } - } + }; + + #[cfg(not(unix))] + let result = tokio::select! { + result = serve => result, + result = tokio::signal::ctrl_c() => { + result.map_err(McpRuntimeError::SignalRegistration) + } + }; #[cfg(feature = "systemd")] { @@ -146,7 +178,9 @@ async fn main() -> Result<(), McpRuntimeError> { systemd::notify_stopping(); } - client_to_shutdown.shutdown().await?; + let shutdown_result = client_to_shutdown.shutdown().await; + result?; + shutdown_result?; info!("Iggy MCP Server stopped successfully"); Ok(()) } diff --git a/core/ai/mcp/src/service/mod.rs b/core/ai/mcp/src/service/mod.rs index 7ba7b027dd..b8f8c04d4f 100644 --- a/core/ai/mcp/src/service/mod.rs +++ b/core/ai/mcp/src/service/mod.rs @@ -310,7 +310,9 @@ impl IggyService { ) } - #[tool(description = "Poll messages")] + #[tool( + description = "Poll messages. Requires read permission; auto_commit=true or strategy=next also requires update permission because it stores the consumer offset." + )] pub async fn poll_messages( &self, Parameters(PollMessages { @@ -327,7 +329,6 @@ impl IggyService { self.permissions.ensure_read()?; let offset = offset.unwrap_or(0); let count = count.unwrap_or(10); - let mut auto_commit = auto_commit.unwrap_or(false); let strategy = if let Some(strategy) = strategy { match strategy.as_str() { "offset" => PollingStrategy::offset(offset), @@ -342,8 +343,9 @@ impl IggyService { } else { PollingStrategy::offset(offset) }; - if strategy.kind == PollingKind::Next { - auto_commit = true; + let auto_commit = auto_commit.unwrap_or(false) || strategy.kind == PollingKind::Next; + if auto_commit { + self.permissions.ensure_update()?; } request( @@ -390,7 +392,7 @@ impl IggyService { ErrorData::invalid_request(format!("Invalid messages key: {error}"), None) })? } - "partition" => Partitioning::partition_id(partition_id.unwrap_or(1)), + "partition" => Partitioning::partition_id(partition_id.unwrap_or(0)), _ => Partitioning::balanced(), } } else { @@ -573,7 +575,7 @@ impl IggyService { offset, }): Parameters, ) -> Result { - self.permissions.ensure_read()?; + self.permissions.ensure_update()?; request( self.client .store_consumer_offset( @@ -596,7 +598,7 @@ impl IggyService { partition_id, }): Parameters, ) -> Result { - self.permissions.ensure_read()?; + self.permissions.ensure_delete()?; request( self.client .delete_consumer_offset( @@ -622,7 +624,7 @@ impl IggyService { CreatePersonalAccessToken, >, ) -> Result { - self.permissions.ensure_read()?; + self.permissions.ensure_create()?; let expiry = expiry .and_then(|expiry| expiry.parse().ok()) .unwrap_or_default(); @@ -638,7 +640,7 @@ impl IggyService { &self, Parameters(DeletePersonalAccessToken { name }): Parameters, ) -> Result { - self.permissions.ensure_read()?; + self.permissions.ensure_delete()?; request(self.client.delete_personal_access_token(&name).await) } diff --git a/core/ai/mcp/src/service/requests.rs b/core/ai/mcp/src/service/requests.rs index 0a498c65a7..1146b189fe 100644 --- a/core/ai/mcp/src/service/requests.rs +++ b/core/ai/mcp/src/service/requests.rs @@ -90,7 +90,7 @@ pub struct CreateTopic { #[schemars( description = "additional options as string key-values, e.g. {\"segment_size\": \"128 MiB\"}; \ - call describe_options for the keys the server accepts (optional)" + keys are validated by the Iggy server (optional)" )] #[serde(default)] pub options: BTreeMap, @@ -235,7 +235,7 @@ pub struct Message { #[schemars(description = "message identifier (optional, number)")] pub id: Option, - #[schemars(description = "message payload, base64 encoded string")] + #[schemars(description = "message payload as a UTF-8 string")] pub payload: String, } diff --git a/core/ai/mcp/src/systemd.rs b/core/ai/mcp/src/systemd.rs index 3f65758180..831b36e9cf 100644 --- a/core/ai/mcp/src/systemd.rs +++ b/core/ai/mcp/src/systemd.rs @@ -29,7 +29,7 @@ pub fn notify_stopping() { } /// Spawn the watchdog keep-alive task. It stops cooperatively when `cancel` -/// fires (driven by the SIGINT/SIGTERM handler in `main`). +/// fires during server shutdown. pub fn spawn_watchdog(cancel: CancellationToken) { let Some(timeout) = sd_notify::watchdog_enabled() else { return; diff --git a/core/bench/Cargo.toml b/core/bench/Cargo.toml index 60e521d910..2d9cad502b 100644 --- a/core/bench/Cargo.toml +++ b/core/bench/Cargo.toml @@ -17,7 +17,7 @@ [package] name = "iggy-bench" -version = "0.6.0-edge.5" +version = "0.6.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/apache/iggy" @@ -39,6 +39,7 @@ charming = { workspace = true } chrono = { workspace = true } clap = { workspace = true } comfy-table = { workspace = true } +configs = { workspace = true } figlet-rs = { workspace = true } futures-util = { workspace = true } governor = { workspace = true } diff --git a/core/bench/README.md b/core/bench/README.md index b3ae1ac4ef..66d1ab5ab0 100644 --- a/core/bench/README.md +++ b/core/bench/README.md @@ -5,3 +5,63 @@ The interactive Bench CLI allows you to perform various benchmarking on the Apac Iggy Bench CLI can be installed with `cargo install iggy-bench` and then simply accessed by typing `iggy-bench` in your terminal. ![CLI](../../assets/bench.png) + +The WebSocket transport command is `websocket`, with `ws` as a shorthand. + +Producer and consumer counts default to six. Pinned workloads also default to six streams. Override the counts for the workload and available CPUs. + +## Examples and topic options + +Start Iggy before running benchmarks. The tool connects to an existing server. Run `iggy-bench examples` for all eight benchmark kinds, their aliases, all four transports, and topic-option combinations. Global options precede the benchmark kind, kind-specific options follow it, and the server address follows the transport. + +Both `--durability` and `--consumer-offset-durability` independently default to `replicated`. The following runs use the same workload with different topic policies: + +```bash +# Both policies replicated, the default. +iggy-bench balanced-producer-and-consumer-group tcp + +# Persisted message acknowledgments with replicated offsets. +iggy-bench --durability persisted balanced-producer-and-consumer-group tcp + +# Replicated message acknowledgments with persisted offsets. +iggy-bench --consumer-offset-durability persisted balanced-producer-and-consumer-group tcp + +# Both policies persisted. +iggy-bench --durability persisted --consumer-offset-durability persisted balanced-producer-and-consumer-group tcp +``` + +These options apply when the benchmark creates topics. They do not change existing topics with `--reuse-streams`. Both policies normally write data to disk. `persisted` adds a stable-storage completion requirement. Consumer workloads using poll auto-commit still receive asynchronous poll responses, so this flag does not turn their poll latency into a measurement of acknowledged offset-store latency. + +`--messages-required-to-save` controls a topic's segment-flush cadence independently of durability. `--max-topic-size` and `--message-expiry` are kind-specific topic options. Use the selected kind's `--help` to inspect supported flags. OS writeback settings do not replace these policies. + +## Host preparation + +Use a dedicated server and a separate load generator where possible. If they share a host, assign disjoint CPU sets and leave CPU capacity for kernel and network work. Match the deployment's CPU topology, storage, replication-group size, and durability policies. + +| Area | Starting point | What to check | +| --- | --- | --- | +| Runtime access | Permit the service to create `io_uring` instances. | Startup diagnostics, container syscall policy, and effective process limits. | +| File descriptors and locked memory | Size limits for connections, partitions, and runtime allocations. | `/proc/PID/limits`, not just the shell's `ulimit`. Set systemd service limits explicitly. | +| Swap | With enough RAM and disk-backed swap, compare `vm.swappiness=10` against the existing value. | Swap activity, memory pressure, and p99 latency. This does not disable swap. | +| Huge pages | Start without an explicit reservation, then test a budgeted pool with a compatible allocator. | Actual page size, usage, NUMA placement, and memory left for page cache. | +| CPU placement | Use the server's `[sharding]` options within its allowed CPU set. | Per-core load, CPU steal time, cgroup throttling, and IRQ distribution. | +| Writeback | Start with the OS defaults. | Sustained disk latency and dirty-page buildup. Large percentage limits can mask short-run bottlenecks. | +| Networking | Start with the OS defaults. | Receive drops, retransmissions, connection backlogs, and bandwidth-delay requirements before increasing limits. | + +Read the [Linux tuning guide](https://iggy.apache.org/docs/server/linux-tuning) before reserving huge pages. `vm.nr_hugepages=2048` reserves 4 GiB only with 2 MiB pages. **`MIMALLOC_RESERVE_HUGE_OS_PAGES=2048` requests 2048 one-GiB pages, or 2 TiB.** They are different controls. The guide explains allocator configuration, THP, service limits, permissions, and reboot persistence. + +### Repeatable measurements + +1. Record the server and benchmark revisions, build profile, kernel, VM type, CPU allocation, allocator, memory limits, filesystem, and provisioned disk/network capacity. +2. Keep workload shape, both durability policies, replication-group size, and topic options identical between compared runs. Use an explicit warmup and repeat runs. +3. Observe `vmstat 1`, `iostat -xz 1`, `mpstat -P ALL 1`, memory/I/O pressure, and network errors. Confirm the client has spare CPU and network capacity. +4. Run long enough to include segment flushes, checkpoints, and sustained device limits. Distinguish warm-cache tests, cold-cache tests, and storage throughput. Do not drop caches during a production workload. +5. Change one host setting at a time, retain its previous value, and compare throughput, p50/p99/p99.9 latency, memory, and errors. Record successful changes in provisioning and recheck after reboot. + +The `output` subcommand records results and accepts context for the run: + +```bash +iggy-bench --warmup-time 10s --total-data 10GiB balanced-producer tcp output --identifier baseline --remark replicated-defaults +``` + +Choose `--total-data` for the duration and storage behavior being tested. This example is not a universal sufficient data volume. diff --git a/core/bench/dashboard/frontend/Cargo.toml b/core/bench/dashboard/frontend/Cargo.toml index 595cec1d09..9820b0bddd 100644 --- a/core/bench/dashboard/frontend/Cargo.toml +++ b/core/bench/dashboard/frontend/Cargo.toml @@ -18,7 +18,7 @@ [package] name = "bench-dashboard-frontend" license = "Apache-2.0" -version = "0.8.0-edge.3" +version = "0.8.0" edition = "2024" publish = false diff --git a/core/bench/dashboard/frontend/index.html b/core/bench/dashboard/frontend/index.html index 92fd763a23..5d3b066927 100644 --- a/core/bench/dashboard/frontend/index.html +++ b/core/bench/dashboard/frontend/index.html @@ -1,4 +1,4 @@ - + +Sends encode `Partitioning.PartitionId`, `Partitioning.Balanced` or +`Partitioning.MessageKey` in the payload. The server resolves the target +partition at admission. VSR works over TCP and TLS. It restricts `Client` to one pooled connection because authentication, request sequencing, and consumer-group assignments belong to one consensus session. Configurations requesting more than one pooled connection fail before a socket is opened. @@ -69,8 +68,9 @@ The client pings every `heartbeatInterval` milliseconds, 5000 by default, which keeps an idle session alive when the server's `[heartbeat]` eviction is enabled. `heartbeatInterval` also accepts a duration expression such as `"10s"` or `"1h 30m"`, like the Rust SDK. -The server evicts a connection silent for 36 s, which is 1.2 x its 30 s -heartbeat interval. Raising the client interval past that window, or setting it +With the default heartbeat settings, a group member becomes eligible for +eviction after 36 s of silence (1.2 x the 30 s interval); the verifier checks +once per interval. Raising the client interval past that window, or setting it to 0 to disable client heartbeats, exposes an idle consumer-group member to eviction; a connection holding no group membership is left alone. Any other unusable value is rejected instead of silently disabling the heartbeat. @@ -146,6 +146,13 @@ Durations accept the same expressions as the Rust SDK, for example `500ms`, ## use sources +Cluster auto-commit polling over TCP/TLS keeps group membership on the coordinator +and uses separate connections to partition primaries. It requires server support +for binary commands 14, 103 and 104. Pause binary auto-commit consumers for the +whole upgrade: upgrade every server first, then the SDKs, and restart consumers +so they rejoin their groups. Older SDKs can lose membership when a backup refuses +an offset commit; the new SDK does not fall back to legacy polling. + ### Install ```bash @@ -161,7 +168,7 @@ npm run build ### test note: use env var `IGGY_TCP_ADDRESS="host:port"` to set the server -address for bdd and e2e tests. +address for e2e tests. bdd tests need more variables, see below. #### unit tests @@ -179,15 +186,27 @@ npm run test:e2e #### bdd tests -bdd test expect an iggy-server at tcp://127.0.0.1:8090 +the bdd suite has no defaults and fails when `IGGY_TCP_ADDRESS`, +`IGGY_ROOT_USERNAME` or `IGGY_ROOT_PASSWORD` is missing. from the repository +root run ```bash -npm run test:bdd +./scripts/run-bdd-tests.sh node ``` +the script starts the server and sets every variable, so none of them have to be +exported by hand. to iterate against a server you started yourself, see +[src/bdd/README.md](./src/bdd/README.md). + #### run all test -`npm run test` runs unit, bdd and e2e tests suite (expect an iggy-server at tcp://127.0.0.1:8090) +`npm run test` runs unit, bdd and e2e tests suite against an iggy-server at +tcp://127.0.0.1:8090, started with the same root credentials + +```bash +IGGY_TCP_ADDRESS=127.0.0.1:8090 IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy \ + npm run test +``` ### lint diff --git a/foreign/node/package-lock.json b/foreign/node/package-lock.json index 31539e7159..99680f5cda 100644 --- a/foreign/node/package-lock.json +++ b/foreign/node/package-lock.json @@ -1,16 +1,16 @@ { "name": "apache-iggy", - "version": "0.10.0-edge.5", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "apache-iggy", - "version": "0.10.0-edge.5", + "version": "0.10.0", "license": "Apache-2.0", "dependencies": { - "@node-rs/xxhash": "1.7.7", - "@swc/core-win32-x64-msvc": "1.16.1", + "@node-rs/xxhash": "1.7.8", + "@swc/core-win32-x64-msvc": "1.16.2", "debug": "4.4.3", "generic-pool": "3.9.0", "uuidv7": "1.2.1" @@ -21,7 +21,7 @@ "@cucumber/cucumber": "13.2.1", "@swc-node/register": "1.12.1", "@types/debug": "4.1.13", - "@types/node": "26.2.0", + "@types/node": "26.5.1", "c8": "^12.0.0", "husky": "9.1.7", "typescript": "6.0.3", @@ -75,6 +75,32 @@ "node": ">=18" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -704,9 +730,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -827,10 +853,36 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@node-rs/xxhash": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash/-/xxhash-1.7.7.tgz", - "integrity": "sha512-lQflJcUTqTvIbFNvq+6DnXQr2MCAqEBsXt9IgTiuhQeaFPejoUJBRF2WlfU5hfUzsCjU+DrzZlxzob0Q4zdHLw==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash/-/xxhash-1.7.8.tgz", + "integrity": "sha512-sXiVUI18qaCei5Y5qXty7xfEKrk4Vx89qKNZkG2sGWZG/iTThO3Xmnp5IjQpXCy941IksVDcl41HhvWRuhDLyw==", "license": "MIT", "engines": { "node": ">= 12" @@ -840,25 +892,25 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@node-rs/xxhash-android-arm-eabi": "1.7.7", - "@node-rs/xxhash-android-arm64": "1.7.7", - "@node-rs/xxhash-darwin-arm64": "1.7.7", - "@node-rs/xxhash-darwin-x64": "1.7.7", - "@node-rs/xxhash-freebsd-x64": "1.7.7", - "@node-rs/xxhash-linux-arm-gnueabihf": "1.7.7", - "@node-rs/xxhash-linux-arm64-gnu": "1.7.7", - "@node-rs/xxhash-linux-arm64-musl": "1.7.7", - "@node-rs/xxhash-linux-x64-gnu": "1.7.7", - "@node-rs/xxhash-linux-x64-musl": "1.7.7", - "@node-rs/xxhash-win32-arm64-msvc": "1.7.7", - "@node-rs/xxhash-win32-ia32-msvc": "1.7.7", - "@node-rs/xxhash-win32-x64-msvc": "1.7.7" + "@node-rs/xxhash-android-arm-eabi": "1.7.8", + "@node-rs/xxhash-android-arm64": "1.7.8", + "@node-rs/xxhash-darwin-arm64": "1.7.8", + "@node-rs/xxhash-darwin-x64": "1.7.8", + "@node-rs/xxhash-freebsd-x64": "1.7.8", + "@node-rs/xxhash-linux-arm-gnueabihf": "1.7.8", + "@node-rs/xxhash-linux-arm64-gnu": "1.7.8", + "@node-rs/xxhash-linux-arm64-musl": "1.7.8", + "@node-rs/xxhash-linux-x64-gnu": "1.7.8", + "@node-rs/xxhash-linux-x64-musl": "1.7.8", + "@node-rs/xxhash-win32-arm64-msvc": "1.7.8", + "@node-rs/xxhash-win32-ia32-msvc": "1.7.8", + "@node-rs/xxhash-win32-x64-msvc": "1.7.8" } }, "node_modules/@node-rs/xxhash-android-arm-eabi": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-android-arm-eabi/-/xxhash-android-arm-eabi-1.7.7.tgz", - "integrity": "sha512-9uOAXzMmayilkSmoSgrtZDKG2a4E8I0f58Ydm0/us2ubfUGqBr6AjaxzRjipuDBGeNZYYQP1wmoUzLAmuUajIQ==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-android-arm-eabi/-/xxhash-android-arm-eabi-1.7.8.tgz", + "integrity": "sha512-dOwbmnDzULc5uRgbIvcrxDa7vGdk51GQVREcY98DdxTBd0lomRg/gqAR9ny/S533cD5QTRSvrK+q8KEJMqoG4w==", "cpu": [ "arm" ], @@ -872,9 +924,9 @@ } }, "node_modules/@node-rs/xxhash-android-arm64": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-android-arm64/-/xxhash-android-arm64-1.7.7.tgz", - "integrity": "sha512-NDqhut7aniQegfLEYBDxmhI5gFHcDur3D9G7E724SzF3aPw0lTAN9cLixd/Cs+BJRwq3oziOdGtYW9j0xM/B0Q==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-android-arm64/-/xxhash-android-arm64-1.7.8.tgz", + "integrity": "sha512-djHN4MBgYkD0uPIGM+IyWrO5aT4vEdsO5bzNxV2+NVl7r1Hdk3PB7GIv0BOKV1C+fkBGXHYJ5xezlOEY1LFDzA==", "cpu": [ "arm64" ], @@ -888,9 +940,9 @@ } }, "node_modules/@node-rs/xxhash-darwin-arm64": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-darwin-arm64/-/xxhash-darwin-arm64-1.7.7.tgz", - "integrity": "sha512-uwrEQELN50J2leUNBTir50nRxJJT3Dts3WDnvEij+MMJco++uI2dXoHbwP/UoavGuyoPtngrWxKP8XS9/mLvOA==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-darwin-arm64/-/xxhash-darwin-arm64-1.7.8.tgz", + "integrity": "sha512-cwhxfUesdIPesdN23VSwh7Ypx9Kx4gvVmH3v6UINADcavnBeizdWRXXefRzdckDphUM2y8WGLko9WMM/86KHYQ==", "cpu": [ "arm64" ], @@ -904,9 +956,9 @@ } }, "node_modules/@node-rs/xxhash-darwin-x64": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-darwin-x64/-/xxhash-darwin-x64-1.7.7.tgz", - "integrity": "sha512-y7AUeH573Ks5BDaJtmyvPxHhPDGedwZnU/hqFbS/DLUqpYGxXLwYkV5iSBtEfFnYyyae1MVjUBqSaCXB6wuLDg==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-darwin-x64/-/xxhash-darwin-x64-1.7.8.tgz", + "integrity": "sha512-YO1x98WbSnMdkaiJiKojnMxNaNxg2QrnHz7W9U4BTSw/IcvvpXGFNxNaSm0FYtO6lZAlfvQpiDr4XGXFXjJtJw==", "cpu": [ "x64" ], @@ -920,9 +972,9 @@ } }, "node_modules/@node-rs/xxhash-freebsd-x64": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-freebsd-x64/-/xxhash-freebsd-x64-1.7.7.tgz", - "integrity": "sha512-4kOYAbnPcFgcpOVQvthha09hjlmswIsfcZWm8uTZC1sau3tdynyyQy1/OcOG03EdP1W0qHOC3ORfUTS9oxbI9A==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-freebsd-x64/-/xxhash-freebsd-x64-1.7.8.tgz", + "integrity": "sha512-MDsskeigVvvGNEm7G9avNKyHeoqgzBCaDo6KLYsi0T/nEBih+xybVIjZ57+GPxTnpUbet8SrcmU64DgAQC5Rnw==", "cpu": [ "x64" ], @@ -936,9 +988,9 @@ } }, "node_modules/@node-rs/xxhash-linux-arm-gnueabihf": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-linux-arm-gnueabihf/-/xxhash-linux-arm-gnueabihf-1.7.7.tgz", - "integrity": "sha512-zO9DsqETMS+8/UarCugj1NW+PcD4haFfYQuVXFaPTskgtocvFY8nYY0N59YZukBWlSGh3xBtXPMVd39zeRiNKg==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-linux-arm-gnueabihf/-/xxhash-linux-arm-gnueabihf-1.7.8.tgz", + "integrity": "sha512-ld25nCGEGedwnmn2YgpFbgQNdKnvdQXDeqrwqMuORIbRhtc+QOm1dlcbq+tULN2Vii9G0bcpb6i1lPTEv5ejyQ==", "cpu": [ "arm" ], @@ -952,9 +1004,9 @@ } }, "node_modules/@node-rs/xxhash-linux-arm64-gnu": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-linux-arm64-gnu/-/xxhash-linux-arm64-gnu-1.7.7.tgz", - "integrity": "sha512-48kFnEIy8w1R8pGKblG+em6Q7YuDRWcLsSRpFCIrbpsA6aAQdvZnMXiyx+HSq5gEWwM35Hrb30L1sStUSetFmw==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-linux-arm64-gnu/-/xxhash-linux-arm64-gnu-1.7.8.tgz", + "integrity": "sha512-Z9YH6BQ1Zywk/nl0j9MG3jRBsIYOed6rSZAGTMT4p924wo9nHll39DZ9hd43kksofInkXOv3WMemzDy7zQ6QFw==", "cpu": [ "arm64" ], @@ -971,9 +1023,9 @@ } }, "node_modules/@node-rs/xxhash-linux-arm64-musl": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-linux-arm64-musl/-/xxhash-linux-arm64-musl-1.7.7.tgz", - "integrity": "sha512-m1Wz3ySODY7XZixTgLn9n59tUVSTmhO4iwcTLX5lRNnJaCOiWywsySPPbo3++oebSGTwAdJEmPJq5BLPSEZGGQ==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-linux-arm64-musl/-/xxhash-linux-arm64-musl-1.7.8.tgz", + "integrity": "sha512-L847azl5081s2QEsX8KqQF8sJAYw1W7j7rpBQH4l5IBC2/kITgdXTNVmPM/MrDHXI2OumKCtRVoNTVuEP8zYVg==", "cpu": [ "arm64" ], @@ -990,9 +1042,9 @@ } }, "node_modules/@node-rs/xxhash-linux-x64-gnu": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-linux-x64-gnu/-/xxhash-linux-x64-gnu-1.7.7.tgz", - "integrity": "sha512-rdLsrNjOUbOCdsdZZqfqeE24y/yeSR8c11w5Xkwj/Qkrcpaq3qB5f3aIW+6WuQJjdBsJwOxHCspSuUiaY6Xx4g==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-linux-x64-gnu/-/xxhash-linux-x64-gnu-1.7.8.tgz", + "integrity": "sha512-tWvNBiFu73/gbZ57uU/3T/3tJ/q+NFAYQj06hsORt6qtllGLz0wJ7azYG/ZlJBfijJA0K7fT+3rThzKJlIdkZg==", "cpu": [ "x64" ], @@ -1009,9 +1061,9 @@ } }, "node_modules/@node-rs/xxhash-linux-x64-musl": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-linux-x64-musl/-/xxhash-linux-x64-musl-1.7.7.tgz", - "integrity": "sha512-1LmaKDAIoqZcSQJG22cZMH9aL4B6jg5kQygg6pY7NyICzw+taLPLHhaMMEE+E0n2BodRXzVjZvF15pxi4qXtug==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-linux-x64-musl/-/xxhash-linux-x64-musl-1.7.8.tgz", + "integrity": "sha512-HW29+JFyWbBHYzD/SfwkD9xxj5k2VH/VqG+Ltn9JH0PlJHvLQtG4IgXxLPboF9e6Kmwb1fjjBJn46GdT8hxA1w==", "cpu": [ "x64" ], @@ -1028,9 +1080,9 @@ } }, "node_modules/@node-rs/xxhash-win32-arm64-msvc": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-win32-arm64-msvc/-/xxhash-win32-arm64-msvc-1.7.7.tgz", - "integrity": "sha512-F2QaHmplRZbg1T/AlQuW11MmRsxLvNVEsofeQd92/u704L5+BBEVc13MutJwioXNIAW1GmgOpfwVG6ZGeLAoyA==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-win32-arm64-msvc/-/xxhash-win32-arm64-msvc-1.7.8.tgz", + "integrity": "sha512-3vf7hmj1LVOfrUXMfRZI67G9h6fNjWg39vmgbD/IQdC/CQ/39aAXvnNmOY0MJNEnVixml668CCiyRccll0ZiyQ==", "cpu": [ "arm64" ], @@ -1044,9 +1096,9 @@ } }, "node_modules/@node-rs/xxhash-win32-ia32-msvc": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-win32-ia32-msvc/-/xxhash-win32-ia32-msvc-1.7.7.tgz", - "integrity": "sha512-YnHHBQVHju1Sx8vc2s2RauAGXAwWeqFMC4CvaJJ8j5WPnW2x8p9hTQRURMl/ATPLp/t8j4C9AEmLNtrNdE6JXQ==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-win32-ia32-msvc/-/xxhash-win32-ia32-msvc-1.7.8.tgz", + "integrity": "sha512-1yqUghB7/Megd3AhDO5KkOi/SiVtK+l0ERunxOTiDLmxvO3Vs4nmb3IW2EmH6mNPB732YYN8I2Y4ZQi3eJRt9Q==", "cpu": [ "ia32" ], @@ -1060,9 +1112,9 @@ } }, "node_modules/@node-rs/xxhash-win32-x64-msvc": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/@node-rs/xxhash-win32-x64-msvc/-/xxhash-win32-x64-msvc-1.7.7.tgz", - "integrity": "sha512-0uVSktuJUetF467YZwHoGuBGcY8RF30DixboXTgMYG5amWze2j+OvaSpQKLsHhf0fs2UZH9W4KQG3SfUizlmGA==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@node-rs/xxhash-win32-x64-msvc/-/xxhash-win32-x64-msvc-1.7.8.tgz", + "integrity": "sha512-gccfpBxTsIAtwx23lvw40j+8moYQWBjc9/1ainrRBSke9HV4Oo8d4xQLgcR1wUXBhwE4mtK/f0T4EAE2oshOkg==", "cpu": [ "x64" ], @@ -1529,9 +1581,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.1.tgz", - "integrity": "sha512-zlJblJ8ncErD43lKdxjbUaUskJQf+LxiPXYcWXD8/8ZMV+7uuAT+CwjciLXpyZBd5Pq/S726bMpeeAwSeL1hhg==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz", + "integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==", "cpu": [ "arm64" ], @@ -1545,9 +1597,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.1.tgz", - "integrity": "sha512-IN0BmPWb0YAh/17mmlWB/HDBtTw2MfuW4hulf/tQAgTQBRH17l+z499bNJLK6LizSjqs0P7V+jU38Zj+vJC1DA==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz", + "integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==", "cpu": [ "x64" ], @@ -1561,9 +1613,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.1.tgz", - "integrity": "sha512-EYgrx2YOCQ2Twz2S793kqNjPkpvYVUPzzR95bIb7by+VQcyaai4lZZ2iz/tZvcFVKSNcN3/JTKwx+aBn2ZL52A==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz", + "integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==", "cpu": [ "arm" ], @@ -1577,9 +1629,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.1.tgz", - "integrity": "sha512-moyKm0YZlHdHohzm1YwgAyesqnE853rO0REMfJLFAova51wF9BNi+3ZW2PeS7Vqvn6HeJuepLpAHbBdZctxpHA==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz", + "integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==", "cpu": [ "arm64" ], @@ -1596,9 +1648,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.1.tgz", - "integrity": "sha512-kKGBO9wdapiSzuf5ZzZ2fYtlu1BNSYtIIUxvH1ir/gcelTOREEHGDCLTDFx/2Knf878nU11A40z7LxwasEFxqA==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz", + "integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==", "cpu": [ "arm64" ], @@ -1651,9 +1703,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.1.tgz", - "integrity": "sha512-VJQxqrisHV+B394IgrOu8YsIIXZgffnf5tO+yc9Z/hoUpuZEvuQTjWwlnpZdpyD+0nx6LTD1/3k646JYm43yJA==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz", + "integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==", "cpu": [ "x64" ], @@ -1670,9 +1722,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.1.tgz", - "integrity": "sha512-r9oV1mwxxsIGcLV1IQ/tw76MW3doatKze1QFWuC+a7QqJUkhY/bKTSVk6NpKKUGm2LDsE33Va8VqSClfA7vSiQ==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz", + "integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==", "cpu": [ "x64" ], @@ -1689,9 +1741,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.1.tgz", - "integrity": "sha512-6huNRessoBLxWEqBm5zJXyCQ27TO7anvkdiuQ5MDO4CJni0nOXEqKtV9RllQ2TdyENKKsUMXVnIfW2hIXx/R5Q==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz", + "integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==", "cpu": [ "arm64" ], @@ -1705,9 +1757,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.1.tgz", - "integrity": "sha512-OVKJFUzphrGmsh+BGtcZDesx0YryV7/Yvy5XGgTqnrZfjnyfcr5uaqYQugCckdIlupc5Vs3XtDjRAj12z4ZPlw==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz", + "integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==", "cpu": [ "ia32" ], @@ -1721,9 +1773,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.1.tgz", - "integrity": "sha512-Bt+VIhWYCGk4urklnkkteLUOeLv1VxigwTCeB/xC6rBZxY6IIKdDwCJf6on3E3SUGsIqmQS6QqtuJQc1VxF4Aw==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz", + "integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==", "cpu": [ "x64" ], @@ -2017,13 +2069,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/normalize-package-data": { @@ -2034,17 +2086,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", - "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/type-utils": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2057,15 +2109,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/parser": "^8.70.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", "dev": true, "license": "MIT", "engines": { @@ -2073,16 +2125,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3" }, "engines": { @@ -2098,14 +2150,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", - "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.67.0", - "@typescript-eslint/types": "^8.67.0", + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", "debug": "^4.4.3" }, "engines": { @@ -2120,14 +2172,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", - "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0" + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2138,9 +2190,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", - "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", "dev": true, "license": "MIT", "engines": { @@ -2155,15 +2207,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", - "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2180,9 +2232,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", - "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", "dev": true, "license": "MIT", "engines": { @@ -2194,16 +2246,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", - "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.67.0", - "@typescript-eslint/tsconfig-utils": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2222,16 +2274,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", - "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0" + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2246,13 +2298,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", - "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/types": "8.70.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2427,6 +2479,21 @@ } } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2745,9 +2812,9 @@ } }, "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", "dev": true, "license": "MIT", "peer": true, @@ -2760,7 +2827,7 @@ "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -2775,7 +2842,7 @@ "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", @@ -2955,9 +3022,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { @@ -3006,17 +3073,14 @@ } }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/find-up": { @@ -3050,18 +3114,16 @@ } }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/flatted": { @@ -3195,6 +3257,28 @@ "node": ">=8" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/hosted-git-info": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", @@ -3517,9 +3601,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -3539,14 +3623,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -3570,14 +3646,14 @@ "peer": true }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/knuth-shuffle-seeded": { @@ -3927,9 +4003,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -3978,6 +4054,28 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/read-package-up": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", @@ -4412,16 +4510,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", - "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.67.0", - "@typescript-eslint/parser": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0" + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4436,9 +4534,9 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" }, diff --git a/foreign/node/package.json b/foreign/node/package.json index 17cbfca34c..8ccf903e6e 100644 --- a/foreign/node/package.json +++ b/foreign/node/package.json @@ -1,7 +1,7 @@ { "name": "apache-iggy", "type": "module", - "version": "0.10.0-edge.5", + "version": "0.10.0", "description": "Official Apache Iggy NodeJS SDK", "keywords": [ "iggy", @@ -50,7 +50,7 @@ "author": "github.com/T1B0", "license": "Apache-2.0", "dependencies": { - "@node-rs/xxhash": "1.7.7", + "@node-rs/xxhash": "1.7.8", "debug": "4.4.3", "generic-pool": "3.9.0", "uuidv7": "1.2.1" @@ -61,7 +61,7 @@ "@cucumber/cucumber": "13.2.1", "@swc-node/register": "1.12.1", "@types/debug": "4.1.13", - "@types/node": "26.2.0", + "@types/node": "26.5.1", "c8": "^12.0.0", "husky": "9.1.7", "typescript": "6.0.3", diff --git a/foreign/node/scripts/check-vsr-protocol.mjs b/foreign/node/scripts/check-vsr-protocol.mjs index dcbe060fb3..41426e5bbc 100644 --- a/foreign/node/scripts/check-vsr-protocol.mjs +++ b/foreign/node/scripts/check-vsr-protocol.mjs @@ -252,7 +252,6 @@ const operationModule = await import( ); const internalStart = rustOperations.get('CreateTopicWithAssignments'); const metadataStart = rustOperations.get('CreateStream'); -const partitionStart = rustOperations.get('SendMessages'); const rustMetadataNames = new Set( [...(rustOperation.match( /fn is_metadata[\s\S]*?matches!\(\s*self,([\s\S]*?)\)\s*\n\s*\}/ @@ -276,11 +275,6 @@ for (const [name, value] of rustOperations) { metadata, `Node isMetadata(${name}) differs from Rust is_metadata` ); - assert.equal( - operationModule.isPartition(value), - value >= partitionStart, - `Node isPartition(${name}) differs from Rust is_partition` - ); assert.equal( operationModule.isResultFramed(value), metadata || rustResultFramedNames.has(name), diff --git a/foreign/node/src/bdd/README.md b/foreign/node/src/bdd/README.md index b7ec9eac04..4bdd5c81b3 100644 --- a/foreign/node/src/bdd/README.md +++ b/foreign/node/src/bdd/README.md @@ -6,19 +6,32 @@ scenario are located at [/bdd/scenarios](../../../../bdd/scenarios) ## env var -use env var `IGGY_TCP_ADDRESS="host:port"` to set expected server address for bdd test suite. +the bdd test suite has no defaults and fails when any of these is missing: -## Run via docker +- `IGGY_TCP_ADDRESS="host:port"` - server address +- `IGGY_ROOT_USERNAME` / `IGGY_ROOT_PASSWORD` - root credentials -see [/bdd/README.md](../../../../bdd/README.md) +## Run (recommended) + +from the repository root run + +```bash +./scripts/run-bdd-tests.sh node +``` + +the script starts the server and sets every variable above, so none of them have +to be exported by hand. see [/bdd/README.md](../../../../bdd/README.md) for the +sdk and feature matrix. ## Run locally -note: bdd test expect an iggy-server at tcp://127.0.0.1:8090 +for iterating against a server you started yourself. note: bdd test expect an +iggy-server started with the same root credentials from [/foreign/node](../../) run ```bash npm ci # if not already done -npm run test:bdd +IGGY_TCP_ADDRESS=127.0.0.1:8090 IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy \ + npm run test:bdd ``` diff --git a/foreign/node/src/bdd/auth.ts b/foreign/node/src/bdd/auth.ts index 2212893091..52b042677a 100644 --- a/foreign/node/src/bdd/auth.ts +++ b/foreign/node/src/bdd/auth.ts @@ -19,10 +19,10 @@ import assert from 'node:assert/strict'; import { Client } from '../client/index.js'; import { Given } from "@cucumber/cucumber"; import type { TestWorld } from './world.js'; -import { getIggyAddress } from '../tcp.sm.utils.js'; +import { getRootCredentials, getServerAddress } from './env.js'; -const credentials = { username: 'iggy', password: 'iggy' }; -const [host, port] = getIggyAddress(); +const credentials = getRootCredentials(); +const [host, port] = getServerAddress(); const opt = { transport: 'TCP' as const, diff --git a/foreign/node/src/bdd/env.ts b/foreign/node/src/bdd/env.ts new file mode 100644 index 0000000000..2c8bfc288f --- /dev/null +++ b/foreign/node/src/bdd/env.ts @@ -0,0 +1,39 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// A default here would turn a dropped compose variable into a run against +// whatever happens to listen on the fallback address, so a missing value +// aborts the suite instead. +const requiredEnv = (name: string): string => { + const value = process.env[name]; + if (!value) + throw new Error(`${name} must be set; run the suite via scripts/run-bdd-tests.sh`); + return value; +}; + +export const getServerAddress = (): [string, number] => { + const address = requiredEnv('IGGY_TCP_ADDRESS'); + const [host, port] = address.split(':'); + if (!host || !port) + throw new Error(`IGGY_TCP_ADDRESS must be "host:port", got "${address}"`); + return [host, parseInt(port, 10)]; +}; + +export const getRootCredentials = () => ({ + username: requiredEnv('IGGY_ROOT_USERNAME'), + password: requiredEnv('IGGY_ROOT_PASSWORD') +}); diff --git a/foreign/node/src/client/client.connection.ts b/foreign/node/src/client/client.connection.ts index 06103cffad..1b489ca94c 100644 --- a/foreign/node/src/client/client.connection.ts +++ b/foreign/node/src/client/client.connection.ts @@ -263,7 +263,7 @@ export class IggyConnection extends EventEmitter { * * @returns Promise that resolves when connected */ - connect(): Promise { + connect(boundDial = false): Promise { if (this.ending) return Promise.reject(new Error('connection is closed')); if (this.connected) @@ -282,7 +282,7 @@ export class IggyConnection extends EventEmitter { // hold it with no timer of its own and the redial pass would never start. const connectPromise = this._dialWithin( socket, - this._redialCandidates().length > 1 + boundDial || this._redialCandidates().length > 1 ); this.connectPromise = connectPromise; const clearConnectPromise = () => { diff --git a/foreign/node/src/client/client.socket.test.ts b/foreign/node/src/client/client.socket.test.ts index 750635092b..aac3dacdd5 100644 --- a/foreign/node/src/client/client.socket.test.ts +++ b/foreign/node/src/client/client.socket.test.ts @@ -23,6 +23,13 @@ import { createServer, type Server } from 'node:net'; import { describe, it } from 'node:test'; import { createServer as createTlsServer } from 'node:tls'; import { COMMAND_CODE } from '../wire/command.code.js'; +import { LOGIN, LOGIN_WITH_TOKEN, LOGOUT } from '../wire/index.js'; +import { UPDATE_USER } from '../wire/user/update-user.command.js'; +import { CHANGE_PASSWORD } from '../wire/user/change-password.command.js'; +import { POLL_MESSAGES } from '../wire/message/poll-messages.command.js'; +import { PollingStrategy } from '../wire/message/poll.utils.js'; +import { ConsumerKind } from '../wire/offset/offset.utils.js'; +import { readWireName } from '../wire/vsr/index.js'; import { ResponseError } from '../wire/error.utils.js'; import { Command, @@ -270,6 +277,640 @@ const compressLeaderlessPoll = ( settlement.leaderlessPollInterval = 1; }; +const groupPollPayload = (autocommit = true, partitionId = 0): Buffer => + POLL_MESSAGES.serialize({ + streamId: 1, + topicId: 1, + consumer: { kind: ConsumerKind.Group, id: 1 }, + partitionId, + pollingStrategy: PollingStrategy.Next, + count: 1, + autocommit + }); + +const routingBody = (request: Buffer, port: number, watermark: bigint): Buffer => { + const attachment = Buffer.alloc(32); + request.copy(attachment, 0, REQUEST_OFFSET.client, REQUEST_OFFSET.client + 16); + request.copy(attachment, 16, REQUEST_OFFSET.session, REQUEST_OFFSET.session + 8); + attachment.writeBigUInt64LE(watermark, 24); + const metadata = singleNodeMetadataBody(port); + const nodeOffset = 4 + metadata.readUInt32LE(0) + 4; + return Buffer.concat([attachment, metadata.subarray(nodeOffset)]); +}; + +const countCommand = (server: VsrTestServer, code: number): number => + server.frames.filter((frame) => + frame.readUInt8(REQUEST_OFFSET.operation) === Operation.NonReplicated && + frame.readUInt32LE(REQUEST_OFFSET.reserved) === code).length; + +const registerCredentialsBody = (frame: Buffer): Buffer => { + const sdkName = readWireName(frame, HEADER_SIZE + 4); + const sdkVersion = readWireName(frame, sdkName.next); + return frame.subarray(sdkVersion.next); +}; + +const startPollCluster = async ( + primaryOverride?: (frame: Buffer, socket: Socket) => boolean, + coordinatorOverride?: (frame: Buffer, socket: Socket) => boolean, + transport: 'TCP' | 'TLS' = 'TCP' +) => { + const primary = await startVsrServer((frame, socket) => { + if (primaryOverride?.(frame, socket)) + return; + if (frame.readUInt32LE(REQUEST_OFFSET.reserved) === COMMAND_CODE.PollMessagesOnPrimary) { + socket.write(replyFrame(Operation.NonReplicated, Buffer.from('primary'))); + return; + } + singleNodeHandler(primary.port)(frame, socket); + }, transport); + const coordinator = await startVsrServer((frame, socket) => { + if (coordinatorOverride?.(frame, socket)) + return; + const code = frame.readUInt32LE(REQUEST_OFFSET.reserved); + if (code === COMMAND_CODE.GetClusterMetadata) { + socket.write(replyFrame(Operation.NonReplicated, + twoNodeMetadataBody(primary.port, coordinator.port))); + return; + } + if (code === COMMAND_CODE.GetPollRouting) { + socket.write(replyFrame(Operation.NonReplicated, routingBody(frame, primary.port, 1n))); + return; + } + singleNodeHandler(coordinator.port)(frame, socket); + }, transport); + const config = { + ...vsrConfig(coordinator.port), transport, heartbeatInterval: 0, + options: { + ...vsrConfig(coordinator.port).options, + ...(transport === 'TLS' ? { ca: TLS_CA_CERTIFICATE, servername: 'localhost' } : {}) + } + }; + const client = new CommandResponseStream(config); + return { + client, primary, coordinator, + close: async () => { + client.destroy(); + await Promise.all([coordinator.close(), primary.close()]); + } + }; +}; + +describe('primary auto-commit polling', () => { + for (const transport of ['TCP', 'TLS'] as const) { + for (const refused of [false, true]) { + it(`preserves raw poll responses over ${transport}${refused ? ' after a refusal' : ''}`, async () => { + const expected = replyFrame(Operation.NonReplicated, Buffer.from('raw primary')); + let polls = 0; + const cluster = await startPollCluster((frame, socket) => { + if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessagesOnPrimary) + return false; + polls += 1; + socket.write(refused && polls === 1 + ? replyFrame(Operation.NonReplicated, Buffer.alloc(0), 58) + : expected); + return true; + }, undefined, transport); + let resets = 0; + cluster.client.on('sessionReset', () => { resets += 1; }); + try { + for (let poll = 0; poll < 2; poll += 1) { + const response = await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload(), + { handleResponse: false }); + assert.ok(Buffer.isBuffer(response), 'raw polls must return the complete VSR frame'); + assert.deepEqual(response, expected); + } + assert.equal(polls, refused ? 3 : 2); + assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.GetPollRouting), refused ? 2 : 1); + assert.equal(countCommand(cluster.primary, COMMAND_CODE.AttachConsumerSession), refused ? 2 : 1); + assert.equal(resets, 0); + } finally { + await cluster.close(); + } + }); + } + } + + for (const transport of ['TCP', 'TLS'] as const) { + it(`retains the coordinator session and reuses authenticated primary polls over ${transport}`, async () => { + const cluster = await startPollCluster(undefined, undefined, transport); + const { client, primary, coordinator } = cluster; + let resets = 0; + client.on('sessionReset', () => { resets += 1; }); + try { + const first = await client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + const second = await client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + assert.equal(first.data.toString(), 'primary'); + assert.equal(second.data.toString(), 'primary'); + assert.equal(countCommand(coordinator, COMMAND_CODE.GetPollRouting), 1); + assert.equal(countCommand(coordinator, COMMAND_CODE.PollMessages), 0); + assert.equal(countCommand(primary, COMMAND_CODE.PollMessagesOnPrimary), 2); + assert.equal(countCommand(primary, COMMAND_CODE.AttachConsumerSession), 1); + assert.equal(countCommand(primary, COMMAND_CODE.GetClusterMetadata), 0, + 'auxiliary login must stay on the partition primary'); + assert.equal(primary.frames.filter((frame) => + frame[REQUEST_OFFSET.operation] === Operation.Register).length, 1); + const route = coordinator.frames.find((frame) => + frame.readUInt32LE(REQUEST_OFFSET.reserved) === COMMAND_CODE.GetPollRouting)!; + const attach = primary.frames.find((frame) => + frame.readUInt32LE(REQUEST_OFFSET.reserved) === COMMAND_CODE.AttachConsumerSession)!; + assert.deepEqual(attach.subarray(HEADER_SIZE, HEADER_SIZE + 16), + route.subarray(REQUEST_OFFSET.client, REQUEST_OFFSET.client + 16)); + assert.equal(attach.readBigUInt64LE(HEADER_SIZE + 16), TEST_SESSION); + assert.notDeepEqual(attach.subarray(REQUEST_OFFSET.client, REQUEST_OFFSET.client + 16), + route.subarray(REQUEST_OFFSET.client, REQUEST_OFFSET.client + 16), + 'the auxiliary must authenticate under its own VSR identity'); + assert.equal(resets, 0); + } finally { + await cluster.close(); + } + }); + } + + it('preserves manual-commit and standalone poll paths', async () => { + const cluster = await startPollCluster(); + const server = await startVsrServer((frame, socket) => singleNodeHandler(server.port)(frame, socket)); + const standalone = new CommandResponseStream({ ...vsrConfig(server.port), heartbeatInterval: 0 }); + try { + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload(false)); + await standalone.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.PollMessages), 1); + assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.GetPollRouting), 0); + assert.equal(countCommand(server, COMMAND_CODE.PollMessages), 1); + assert.equal(countCommand(server, COMMAND_CODE.GetPollRouting), 0); + } finally { + standalone.destroy(); + await cluster.close(); + await server.close(); + } + }); + + it('refreshes the attachment after a metadata acknowledgement but ignores partition commits', async () => { + const cluster = await startPollCluster(undefined, (frame, socket) => { + const operation = frame[REQUEST_OFFSET.operation]; + if (operation !== Operation.UpdateTopic && operation !== Operation.SendMessages) + return false; + const response = replyFrame(operation, operation === Operation.UpdateTopic ? Buffer.alloc(4) : Buffer.alloc(0)); + response.writeBigUInt64LE(operation === Operation.UpdateTopic ? 15n : 900n, REPLY_OFFSET.commit); + socket.write(response); + return true; + }); + try { + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + await cluster.client.sendCommand(COMMAND_CODE.SendMessages, Buffer.alloc(0)); + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.GetPollRouting), 1); + await cluster.client.sendCommand(COMMAND_CODE.UpdateTopic, Buffer.alloc(0), { handleResponse: false }); + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + const attachments = cluster.primary.frames.filter((frame) => + frame.readUInt32LE(REQUEST_OFFSET.reserved) === COMMAND_CODE.AttachConsumerSession); + assert.deepEqual(attachments.map((frame) => frame.readBigUInt64LE(HEADER_SIZE + 24)), [1n, 15n]); + assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.GetPollRouting), 2); + await cluster.client.sendCommand(LOGOUT.code, LOGOUT.serialize()); + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + const replacement = cluster.primary.frames.filter((frame) => + frame.readUInt32LE(REQUEST_OFFSET.reserved) === COMMAND_CODE.AttachConsumerSession).at(-1)!; + assert.equal(replacement.readBigUInt64LE(HEADER_SIZE + 24), 15n, + 'a new coordinator session must retain previously acknowledged metadata'); + } finally { + await cluster.close(); + } + }); + + it('refreshes a not-admitted route without moving the coordinator', async () => { + let polls = 0; + const cluster = await startPollCluster((frame, socket) => { + if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessagesOnPrimary) + return false; + polls += 1; + if (polls > 1) + return false; + socket.write(replyFrame(Operation.NonReplicated, Buffer.alloc(0), 58)); + return true; + }); + let resets = 0; + cluster.client.on('sessionReset', () => { resets += 1; }); + try { + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + assert.equal(polls, 2); + assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.GetPollRouting), 2); + assert.equal(countCommand(cluster.primary, COMMAND_CODE.AttachConsumerSession), 2); + assert.equal(resets, 0); + } finally { + await cluster.close(); + } + }); + + for (const refusing of [COMMAND_CODE.GetPollRouting, COMMAND_CODE.AttachConsumerSession, + COMMAND_CODE.PollMessagesOnPrimary]) { + it(`reports not accepted after command ${refusing} refuses the whole poll budget`, async () => { + const refuse = (frame: Buffer, socket: Socket): boolean => { + if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== refusing) + return false; + socket.write(replyFrame(Operation.NonReplicated, Buffer.alloc(0), 58)); + return true; + }; + const cluster = await startPollCluster(refuse, refuse); + let resets = 0; + cluster.client.on('sessionReset', () => { resets += 1; }); + try { + await assert.rejects(cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload(), + { deadline: Date.now() + 250 }), (error: unknown) => + error instanceof ResponseError && error.errorCode === 58); + assert.ok(countCommand(cluster.coordinator, COMMAND_CODE.GetPollRouting) >= 2); + if (refusing === COMMAND_CODE.GetPollRouting) + assert.equal(cluster.primary.frames.length, 0); + if (refusing === COMMAND_CODE.AttachConsumerSession) + assert.equal(countCommand(cluster.primary, COMMAND_CODE.PollMessagesOnPrimary), 0); + assert.equal(resets, 0); + } finally { + await cluster.close(); + } + }); + } + + it('reports a typed error when the primary has no TCP endpoint', async () => { + const cluster = await startPollCluster(undefined, (frame, socket) => { + if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.GetPollRouting) + return false; + socket.write(replyFrame(Operation.NonReplicated, routingBody(frame, 0, 1n))); + return true; + }); + let resets = 0; + cluster.client.on('sessionReset', () => { resets += 1; }); + try { + await assert.rejects(cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()), + (error: unknown) => error instanceof ResponseError && error.errorCode === 5); + assert.equal(cluster.primary.frames.length, 0); + assert.equal(resets, 0); + } finally { + await cluster.close(); + } + }); + + for (const standalone of [false, true]) { + for (const failure of ['empty reply', 'empty roster', 'unavailable']) { + it(`requires valid topology after ${failure} before ${standalone ? 'standalone' : 'cluster'} polling`, async () => { + const clusterName = Buffer.from('cluster-without-nodes'); + const emptyRoster = Buffer.alloc(4 + clusterName.length + 4); + emptyRoster.writeUInt32LE(clusterName.length, 0); + clusterName.copy(emptyRoster, 4); + let rejectMetadata = true; + let coordinatorPort = 0; + const cluster = await startPollCluster(undefined, (frame, socket) => { + if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.GetClusterMetadata) + return false; + if (rejectMetadata) { + socket.write(replyFrame(Operation.NonReplicated, + failure === 'empty roster' ? emptyRoster : Buffer.alloc(0), + failure === 'unavailable' ? 5 : 0)); + return true; + } + if (standalone) { + socket.write(replyFrame(Operation.NonReplicated, singleNodeMetadataBody(coordinatorPort))); + return true; + } + return false; + }); + coordinatorPort = cluster.coordinator.port; + let resets = 0; + cluster.client.on('sessionReset', () => { resets += 1; }); + try { + await cluster.client.authenticate(vsrConfig(coordinatorPort).credentials); + await assert.rejects(cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload())); + assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.PollMessages), 0); + assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.GetPollRouting), 0); + assert.equal(cluster.primary.frames.length, 0); + rejectMetadata = false; + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + rejectMetadata = true; + const refresh = cluster.client.sendCommand(COMMAND_CODE.GetClusterMetadata, Buffer.alloc(0)); + if (failure === 'unavailable') + await assert.rejects(refresh, (error: unknown) => error instanceof ResponseError && error.errorCode === 5); + else + await refresh; + const reads = countCommand(cluster.coordinator, COMMAND_CODE.GetClusterMetadata); + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.GetClusterMetadata), reads); + assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.PollMessages), standalone ? 2 : 0); + assert.equal(countCommand(cluster.coordinator, COMMAND_CODE.GetPollRouting), standalone ? 0 : 1); + assert.equal(countCommand(cluster.primary, COMMAND_CODE.PollMessagesOnPrimary), standalone ? 0 : 2); + assert.equal(resets, 0); + } finally { + await cluster.close(); + } + }); + } + } + + it('refreshes a stalled auxiliary TLS dial within the shared poll budget', async () => { + const held: Socket[] = []; + const silent = createServer((socket) => { + held.push(socket); + socket.resume(); + }); + silent.listen(0, '127.0.0.1'); + await once(silent, 'listening'); + const silentPort = (silent.address() as AddressInfo).port; + let routes = 0; + const cluster = await startPollCluster(undefined, (frame, socket) => { + if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.GetPollRouting || ++routes > 1) + return false; + socket.write(replyFrame(Operation.NonReplicated, routingBody(frame, silentPort, 1n))); + return true; + }, 'TLS'); + let resets = 0; + cluster.client.on('sessionReset', () => { resets += 1; }); + try { + const response = await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload(), + { deadline: Date.now() + 5_000 }); + assert.equal(response.data.toString(), 'primary'); + assert.equal(routes, 2); + assert.equal(held.length, 1); + assert.equal(held[0].destroyed, true); + assert.equal(countCommand(cluster.primary, COMMAND_CODE.PollMessagesOnPrimary), 1); + assert.equal(resets, 0); + } finally { + await cluster.close(); + held.forEach((socket) => socket.destroy()); + await new Promise((resolve) => silent.close(() => resolve())); + } + }); + + for (const operation of [Operation.TruncatePartition, Operation.DeleteSegments]) { + it(`raises the attachment floor for a DeleteSegments acknowledgement with operation ${operation}`, async () => { + const cluster = await startPollCluster(undefined, (frame, socket) => { + if (frame[REQUEST_OFFSET.operation] !== Operation.DeleteSegments) + return false; + const response = replyFrame(operation, + operation === Operation.TruncatePartition ? Buffer.alloc(4) : Buffer.alloc(0)); + response.writeBigUInt64LE(50n, REPLY_OFFSET.commit); + socket.write(response); + return true; + }); + try { + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + await cluster.client.sendCommand(COMMAND_CODE.DeleteSegments, Buffer.alloc(0)); + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + const attachments = cluster.primary.frames.filter((frame) => + frame.readUInt32LE(REQUEST_OFFSET.reserved) === COMMAND_CODE.AttachConsumerSession); + assert.deepEqual(attachments.map((frame) => frame.readBigUInt64LE(HEADER_SIZE + 24)), [1n, 50n]); + assert.equal(cluster.primary.frames.filter((frame) => frame[REQUEST_OFFSET.operation] === Operation.Register).length, 1); + } finally { + await cluster.close(); + } + }); + } + + it('rechecks metadata after waiting behind another primary poll', async () => { + let firstPoll!: (socket: Socket) => void; + const admitted = new Promise((resolve) => { firstPoll = resolve; }); + let polls = 0; + const cluster = await startPollCluster((frame, socket) => { + if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessagesOnPrimary || ++polls !== 1) + return false; + firstPoll(socket); + return true; + }, (frame, socket) => { + if (frame[REQUEST_OFFSET.operation] !== Operation.UpdateTopic) + return false; + const response = replyFrame(Operation.UpdateTopic, Buffer.alloc(4)); + response.writeBigUInt64LE(21n, REPLY_OFFSET.commit); + socket.write(response); + return true; + }); + try { + const first = cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + const socket = await admitted; + const second = cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + await cluster.client.sendCommand(COMMAND_CODE.UpdateTopic, Buffer.alloc(0)); + assert.equal(polls, 1, 'concurrent polls must share a serialized primary connection'); + socket.write(replyFrame(Operation.NonReplicated, Buffer.from('primary'))); + await Promise.all([first, second]); + assert.equal(polls, 2); + const attachments = cluster.primary.frames.filter((frame) => + frame.readUInt32LE(REQUEST_OFFSET.reserved) === COMMAND_CODE.AttachConsumerSession); + assert.deepEqual(attachments.map((frame) => frame.readBigUInt64LE(HEADER_SIZE + 24)), [1n, 21n]); + } finally { + await cluster.close(); + } + }); + + for (const failure of ['disconnect', 'not committed', 'timeout', 'partial reply', 'stale session', 'unauthenticated'] as const) { + it(`does not replay an uncertain primary poll after ${failure}`, async () => { + let polls = 0; + const cluster = await startPollCluster((frame, socket) => { + if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessagesOnPrimary) + return false; + polls += 1; + if (polls > 1) + return false; + if (failure === 'disconnect') socket.destroy(); + if (failure === 'not committed') + socket.write(replyFrame(Operation.NonReplicated, Buffer.alloc(0), 57)); + if (failure === 'stale session') + socket.write(replyFrame(Operation.NonReplicated, Buffer.alloc(0), 30)); + if (failure === 'unauthenticated') + socket.write(replyFrame(Operation.NonReplicated, Buffer.alloc(0), 40)); + if (failure === 'partial reply') + socket.write(replyFrame(Operation.NonReplicated, Buffer.from('partial')).subarray(0, HEADER_SIZE + 1)); + return true; + }); + let resets = 0; + cluster.client.on('sessionReset', () => { resets += 1; }); + try { + await assert.rejects(cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload(), + { deadline: Date.now() + 300 }), (error: unknown) => + error instanceof ResponseError && error.errorCode === 57); + assert.equal(polls, 1); + assert.equal(resets, 0); + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + assert.equal(polls, 2); + assert.equal(countCommand(cluster.primary, COMMAND_CODE.AttachConsumerSession), 2); + } finally { + await cluster.close(); + } + }); + } + + for (const status of [30, 40]) { + it(`rebuilds an auxiliary rejected with session status ${status} before poll admission`, async () => { + let attachments = 0; + const cluster = await startPollCluster((frame, socket) => { + if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.AttachConsumerSession || ++attachments > 1) + return false; + socket.write(replyFrame(Operation.NonReplicated, Buffer.alloc(0), status)); + return true; + }); + let resets = 0; + cluster.client.on('sessionReset', () => { resets += 1; }); + try { + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + assert.equal(attachments, 2); + assert.equal(countCommand(cluster.primary, COMMAND_CODE.PollMessagesOnPrimary), 1); + assert.equal(cluster.primary.frames.filter((frame) => frame[REQUEST_OFFSET.operation] === Operation.Register).length, 2); + assert.equal(resets, 0); + } finally { + await cluster.close(); + } + }); + + it(`replaces the coordinator socket after a control session status ${status}`, async () => { + const registered = new Set(); + let routes = 0; + const cluster = await startPollCluster(undefined, (frame, socket) => { + if (frame[REQUEST_OFFSET.operation] === Operation.Register) { + assert.equal(registered.has(socket), false, + 'a fresh VSR identity cannot register on the old bound transport'); + registered.add(socket); + } + if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.GetPollRouting || ++routes > 1) + return false; + socket.write(replyFrame(Operation.NonReplicated, Buffer.alloc(0), status)); + return true; + }); + try { + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + assert.equal(registered.size, 2); + assert.equal(countCommand(cluster.primary, COMMAND_CODE.PollMessagesOnPrimary), 1); + } finally { + await cluster.close(); + } + }); + } + + it('uses manual login and updated credentials for new primary connections', async () => { + const cluster = await startPollCluster(undefined, (frame, socket) => { + const operation = frame[REQUEST_OFFSET.operation]; + if (operation !== Operation.UpdateUser && operation !== Operation.ChangePassword) + return false; + socket.write(replyFrame(operation, Buffer.alloc(4))); + return true; + }); + try { + await cluster.client.sendCommand(LOGIN.code, LOGIN.serialize({ username: 'manual', password: 'first' })); + await cluster.client.sendCommand(UPDATE_USER.code, UPDATE_USER.serialize({ userId: 7, username: 'renamed' })); + await cluster.client.sendCommand(CHANGE_PASSWORD.code, CHANGE_PASSWORD.serialize({ + userId: 7, currentPassword: 'first', newPassword: 'second' + })); + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + const login = cluster.primary.frames.find((frame) => frame[REQUEST_OFFSET.operation] === Operation.Register)!; + const credentials = registerCredentialsBody(login); + const username = readWireName(credentials, 0); + const password = readWireName(credentials, username.next); + assert.equal(username.value, 'renamed'); + assert.equal(password.value, 'second'); + await cluster.client.sendCommand(LOGOUT.code, LOGOUT.serialize()); + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + const replacement = cluster.primary.frames.filter((frame) => + frame[REQUEST_OFFSET.operation] === Operation.Register).at(-1)!; + assert.equal(readWireName(registerCredentialsBody(replacement), 0).value, 'iggy'); + } finally { + await cluster.close(); + } + }); + + it('authenticates auxiliary connections with the active personal access token', async () => { + const cluster = await startPollCluster(); + try { + await cluster.client.sendCommand(LOGIN_WITH_TOKEN.code, LOGIN_WITH_TOKEN.serialize({ token: 'manual-token' })); + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + const login = cluster.primary.frames.find((frame) => frame[REQUEST_OFFSET.operation] === Operation.Register)!; + const sdkName = readWireName(login, HEADER_SIZE + 4); + const sdkVersion = readWireName(login, sdkName.next); + assert.equal(readWireName(login, sdkVersion.next).value, 'manual-token'); + } finally { + await cluster.close(); + } + }); + + for (const manual of [ + { command: LOGIN.code, payload: LOGIN.serialize({ username: 'manual', password: 'first' }), identity: 'manual' }, + { command: LOGIN_WITH_TOKEN.code, payload: LOGIN_WITH_TOKEN.serialize({ token: 'manual-token' }), identity: 'manual-token' } + ]) { + it(`retains ${manual.identity} across recovery and restores configured credentials after logout`, async () => { + let coordinatorSocket!: Socket; + const cluster = await startPollCluster(undefined, (_frame, socket) => { + coordinatorSocket = socket; + return false; + }); + try { + await cluster.client.sendCommand(manual.command, manual.payload); + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + const reset = once(cluster.client, 'sessionReset'); + coordinatorSocket.destroy(); + await reset; + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + for (const server of [cluster.coordinator, cluster.primary]) { + const login = server.frames.filter((frame) => frame[REQUEST_OFFSET.operation] === Operation.Register).at(-1)!; + assert.equal(readWireName(registerCredentialsBody(login), 0).value, manual.identity); + } + await cluster.client.sendCommand(LOGOUT.code, LOGOUT.serialize()); + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + for (const server of [cluster.coordinator, cluster.primary]) { + const login = server.frames.filter((frame) => frame[REQUEST_OFFSET.operation] === Operation.Register).at(-1)!; + const credentials = registerCredentialsBody(login); + const username = readWireName(credentials, 0); + assert.equal(username.value, 'iggy'); + assert.equal(readWireName(credentials, username.next).value, 'iggy'); + } + } finally { + await cluster.close(); + } + }); + } + + it('updates the configured identity after its password and username change', async () => { + const cluster = await startPollCluster(undefined, (frame, socket) => { + const operation = frame[REQUEST_OFFSET.operation]; + if (operation !== Operation.UpdateUser && operation !== Operation.ChangePassword) + return false; + socket.write(replyFrame(operation, Buffer.alloc(4))); + return true; + }); + try { + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + await cluster.client.sendCommand(UPDATE_USER.code, UPDATE_USER.serialize({ userId: 7, username: 'renamed' })); + await cluster.client.sendCommand(CHANGE_PASSWORD.code, CHANGE_PASSWORD.serialize({ + userId: 7, currentPassword: 'iggy', newPassword: 'second' + })); + await cluster.client.sendCommand(LOGOUT.code, LOGOUT.serialize()); + await cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + for (const server of [cluster.coordinator, cluster.primary]) { + const login = server.frames.filter((frame) => frame[REQUEST_OFFSET.operation] === Operation.Register).at(-1)!; + const credentials = registerCredentialsBody(login); + const username = readWireName(credentials, 0); + assert.equal(username.value, 'renamed'); + assert.equal(readWireName(credentials, username.next).value, 'second'); + } + } finally { + await cluster.close(); + } + }); + + it('closes a waiting primary exchange on coordinator logout without replay', async () => { + let admitted!: () => void; + const pendingAdmission = new Promise((resolve) => { admitted = resolve; }); + const cluster = await startPollCluster((frame) => { + if (frame.readUInt32LE(REQUEST_OFFSET.reserved) !== COMMAND_CODE.PollMessagesOnPrimary) + return false; + admitted(); + return true; + }); + try { + const polling = cluster.client.sendCommand(POLL_MESSAGES.code, groupPollPayload()); + const failed = assert.rejects(polling, (error: unknown) => + error instanceof ResponseError && error.errorCode === 57); + await pendingAdmission; + await cluster.client.sendCommand(LOGOUT.code, LOGOUT.serialize()); + await failed; + assert.equal(countCommand(cluster.primary, COMMAND_CODE.PollMessagesOnPrimary), 1); + assert.equal(cluster.client.isAuthenticated, false); + } finally { + await cluster.close(); + } + }); +}); + describe('VSR client socket', () => { it('exchanges VSR frames over TLS', async () => { const server = await startVsrServer( diff --git a/foreign/node/src/client/client.socket.ts b/foreign/node/src/client/client.socket.ts index 7e47a03d33..5d13626d57 100644 --- a/foreign/node/src/client/client.socket.ts +++ b/foreign/node/src/client/client.socket.ts @@ -32,11 +32,18 @@ import { } from './client.connection.js'; import { LOGIN, LOGIN_WITH_TOKEN, LOGOUT, PING } from '../wire/index.js'; import { GET_CLUSTER_METADATA } from '../wire/cluster/get-cluster-metadata.command.js'; +import { deserializeNode } from '../wire/cluster/cluster.utils.js'; import { COMMAND_CODE } from '../wire/command.code.js'; +import { serializeIdentifier } from '../wire/identifier.utils.js'; +import { + Command, HEADER_SIZE, REPLY_OFFSET, peekCommand, readReplyOperation, readStatus +} from '../wire/vsr/header.js'; +import { Operation, isKnownOperation } from '../wire/vsr/operation.js'; import { decodeVsrResponse, prepareVsrCommand, readRegisteredSession, + readWireName, VsrEvictionError, VsrSession, } from '../wire/vsr/index.js'; @@ -49,6 +56,25 @@ const LEADERLESS_POLL_INTERVAL_MS = 250; const MAX_LEADER_REDIRECTS = 3; const TRANSIENT_NOT_COMMITTED = 57; const TRANSIENT_NOT_ACCEPTED = 58; +const UNAUTHENTICATED = 40; +const STALE_CLIENT = 30; +const FEATURE_UNAVAILABLE = 5; +const MAX_POLL_ROUTES = 4096; +const MAX_POLL_CONNECTIONS = 256; +const CONSUMER_SESSION_SIZE = 32; +const METADATA_WATERMARK_OFFSET = 24; +const POLL_OPTIONS_SIZE = 9 + 4 + 1; + +type PollRoute = { + endpoint: Endpoint, + attachment: Buffer, +}; + +type PollConnection = { + client: CommandResponseStream, + attachment?: Buffer, + busy?: Promise, +}; /** * How long a `TRANSIENT_NOT_ACCEPTED` request replays on the same connection * before the roster is re-read. A node that stopped being primary refuses @@ -181,6 +207,12 @@ export class CommandResponseStream extends EventEmitter { heartbeatIntervalHandler?: NodeJS.Timeout; /** Whether a heartbeat ping is still awaiting its response */ private heartbeatInFlight: boolean; + private rememberedCredentials?: ClientCredentials; + private clustered?: boolean; + private metadataWatermark = 0n; + private routingGeneration = 0; + private pollRoutes = new Map(); + private pollConnections = new Map(); /** * Creates a new CommandResponseStream. @@ -282,12 +314,30 @@ export class CommandResponseStream extends EventEmitter { last = true, followsLeaderMoves = true } = options; + const autoCommitPoll = command === COMMAND_CODE.PollMessages && + payload.length > POLL_OPTIONS_SIZE && payload.at(-1) === 1; + const pollDeadline = autoCommitPoll + ? options.deadline ?? Date.now() + VSR_RESPONSE_TIMEOUT_MS + : undefined; if (!this.connection.connected) - await this.connection.connect() + await (pollDeadline === undefined ? this.connection.connect() + : withinDeadline(this.connection.connect(), pollDeadline)); if (!this.isAuthenticated && !this.isUnloggedCommand(command)) - await this.authenticate(this.options.credentials); + await (pollDeadline === undefined ? this.authenticate(this._signInCredentials()) + : withinDeadline(this.authenticate(this._signInCredentials()), pollDeadline)); + + if (pollDeadline !== undefined) { + const deadline = pollDeadline; + if (this.clustered === undefined) + await withinDeadline(this.sendCommand(GET_CLUSTER_METADATA.code, + GET_CLUSTER_METADATA.serialize(), { deadline, followsLeaderMoves: false }), deadline); + if (this.clustered === undefined) + throw new Error('cannot determine the poll routing topology'); + if (this.clustered) + return await this._pollOnPrimary(payload, deadline, handleResponse); + } // The roster read is itself a queued command and the queue is // single-flighted, so the leader re-check cannot happen inside @@ -370,7 +420,7 @@ export class CommandResponseStream extends EventEmitter { // command fails client-side, a non-replicated one goes out with // session 0. if (!this.isAuthenticated && !this.isUnloggedCommand(command)) - await this.authenticate(this.options.credentials); + await this.authenticate(this._signInCredentials()); } } if (!isLoginCommand(command) || this.settlingLeader) @@ -519,6 +569,9 @@ export class CommandResponseStream extends EventEmitter { private _rememberRoster(response: CommandResponse): void { try { const metadata = GET_CLUSTER_METADATA.deserialize(response); + if (metadata.nodes.length === 0) + return; + this.clustered = metadata.nodes.length > 1; this.connection.rememberRoster( metadata.nodes .filter((node) => node.endpoints.tcp !== 0) @@ -530,6 +583,211 @@ export class CommandResponseStream extends EventEmitter { } } + private async _pollOnPrimary( + payload: Buffer, + deadline: number, + handleResponse: boolean + ): Promise { + const key = payload.subarray(0, -POLL_OPTIONS_SIZE).toString('hex'); + while (Date.now() < deadline && !this.connection.ending) { + try { + const generation = this.routingGeneration; + let route = this.pollRoutes.get(key); + if (!route || + route.attachment.readBigUInt64LE(METADATA_WATERMARK_OFFSET) < + this.metadataWatermark) { + const response = await withinDeadline( + this._pollRoutingControl(payload, deadline), deadline); + if (generation !== this.routingGeneration) + throw responseError(COMMAND_CODE.PollMessages, TRANSIENT_NOT_ACCEPTED); + if (response.data.length < CONSUMER_SESSION_SIZE) + throw new Error('poll routing response is incomplete'); + const node = deserializeNode(response.data, CONSUMER_SESSION_SIZE); + if (node.length + CONSUMER_SESSION_SIZE !== response.data.length || !node.data.ip) + throw new Error('poll routing response has no valid TCP endpoint'); + if (node.data.endpoints.tcp === 0) + throw responseError(COMMAND_CODE.PollMessages, FEATURE_UNAVAILABLE); + const attachment = Buffer.from(response.data.subarray(0, CONSUMER_SESSION_SIZE)); + if (attachment.readBigUInt64LE(METADATA_WATERMARK_OFFSET) < this.metadataWatermark) + attachment.writeBigUInt64LE(this.metadataWatermark, METADATA_WATERMARK_OFFSET); + route = { + endpoint: { host: node.data.ip, port: node.data.endpoints.tcp }, + attachment + }; + if (this.pollRoutes.size >= MAX_POLL_ROUTES) + this.pollRoutes.clear(); + this.pollRoutes.set(key, route); + } + + const endpoint = endpointKey(route.endpoint); + let entry = this.pollConnections.get(endpoint); + if (!entry) { + if (this.pollConnections.size >= MAX_POLL_CONNECTIONS) { + const idle = Array.from(this.pollConnections).find(([, value]) => !value.busy); + if (!idle) + throw responseError(COMMAND_CODE.PollMessages, TRANSIENT_NOT_ACCEPTED); + this.pollConnections.delete(idle[0]); + idle[1].client.destroy(); + } + entry = { + client: new CommandResponseStream({ + ...this.options, + options: { ...this.options.options, ...route.endpoint }, + heartbeatInterval: 0, + reconnect: { enabled: false, interval: 0, maxRetries: 0 } + }) + }; + this.pollConnections.set(endpoint, entry); + } + while (entry.busy) + await withinDeadline(entry.busy, deadline); + if (generation !== this.routingGeneration || + this.pollConnections.get(endpoint) !== entry || + route.attachment.readBigUInt64LE(METADATA_WATERMARK_OFFSET) < + this.metadataWatermark) + throw responseError(COMMAND_CODE.PollMessages, TRANSIENT_NOT_ACCEPTED); + + let release!: () => void; + entry.busy = new Promise((resolve) => { release = resolve; }); + let polling = false; + try { + if (!entry.client.isAuthenticated) { + entry.attachment = undefined; + await withinDeadline(entry.client.connection.connect(true), deadline, + () => entry.client.destroy()); + const credentials = this._signInCredentials(); + const login = 'token' in credentials ? LOGIN_WITH_TOKEN : LOGIN; + const loginPayload = 'token' in credentials + ? LOGIN_WITH_TOKEN.serialize(credentials) + : LOGIN.serialize(credentials); + await entry.client._queueCommand(login.code, loginPayload, + true, true, false, deadline); + } + if (!entry.attachment?.equals(route.attachment)) { + await entry.client._queueCommand(COMMAND_CODE.AttachConsumerSession, + route.attachment, true, true, false, deadline); + entry.attachment = route.attachment; + } + if (generation !== this.routingGeneration || + route.attachment.readBigUInt64LE(METADATA_WATERMARK_OFFSET) < + this.metadataWatermark) + throw responseError(COMMAND_CODE.PollMessages, TRANSIENT_NOT_ACCEPTED); + polling = true; + return await entry.client._queueCommand(COMMAND_CODE.PollMessagesOnPrimary, + payload, handleResponse, true, false, deadline); + } catch (error) { + if (error instanceof ResponseError && + error.errorCode === TRANSIENT_NOT_ACCEPTED) { + entry.attachment = undefined; + } else { + if (this.pollConnections.get(endpoint) === entry) + this.pollConnections.delete(endpoint); + entry.client.destroy(); + } + if (!(error instanceof ResponseError) || error instanceof VsrEvictionError || + error.errorCode === UNAUTHENTICATED || error.errorCode === STALE_CLIENT) + throw responseError(COMMAND_CODE.PollMessages, + polling ? TRANSIENT_NOT_COMMITTED : TRANSIENT_NOT_ACCEPTED); + throw error; + } finally { + entry.busy = undefined; + release(); + } + } catch (error) { + this.pollRoutes.delete(key); + if (error instanceof VsrResponseTimeoutError) + throw responseError(COMMAND_CODE.PollMessages, TRANSIENT_NOT_COMMITTED); + if (!(error instanceof ResponseError) || + error.errorCode !== TRANSIENT_NOT_ACCEPTED) + throw error instanceof ResponseError + ? responseError(COMMAND_CODE.PollMessages, error.errorCode) + : error; + if (!worthAnotherAttempt(deadline)) + throw responseError(COMMAND_CODE.PollMessages, TRANSIENT_NOT_ACCEPTED); + await delay(VSR_RETRY_INTERVAL_MS); + } + } + throw responseError(COMMAND_CODE.PollMessages, TRANSIENT_NOT_ACCEPTED); + } + + private async _pollRoutingControl(payload: Buffer, deadline: number): Promise { + const options = { deadline, followsLeaderMoves: false }; + try { + return await this.sendCommand(COMMAND_CODE.GetPollRouting, payload, options); + } catch (error) { + if (error instanceof ResponseError && + error.errorCode !== UNAUTHENTICATED && error.errorCode !== STALE_CLIENT) + throw error; + if (Date.now() >= deadline) + throw error; + // Only a failed coordinator exchange uses normal session recovery. + // A healthy coordinator refusing a route must retain its group membership. + if (error instanceof ResponseError) { + this.connection.abort(); + this.connection.connected = false; + this._resetSession(); + } + await this.sendCommand(PING.code, PING.serialize(), { deadline }); + return this.sendCommand(COMMAND_CODE.GetPollRouting, payload, options); + } + } + + private _observeMetadataReply(frame: Buffer): void { + if (frame.length < HEADER_SIZE || peekCommand(frame) !== Command.Reply) + return; + const operation = readReplyOperation(frame); + if (!isKnownOperation(operation) || operation === Operation.NonReplicated || + operation === Operation.SendMessages || operation === Operation.StoreConsumerOffset || + operation === Operation.DeleteConsumerOffset) + return; + const commit = frame.readBigUInt64LE(REPLY_OFFSET.commit); + if (commit > this.metadataWatermark) + this.metadataWatermark = commit; + } + + private _rememberCredentials(command: number, payload: Buffer): void { + if (command === LOGIN.code) { + const username = readWireName(payload, 0); + this.rememberedCredentials = { + username: username.value, password: readWireName(payload, username.next).value + }; + } else if (command === LOGIN_WITH_TOKEN.code) { + this.rememberedCredentials = { token: readWireName(payload, 0).value }; + } else if ((command === COMMAND_CODE.ChangePassword || command === COMMAND_CODE.UpdateUser) && + this.userId !== undefined && this.rememberedCredentials && + 'username' in this.rememberedCredentials) { + const credentials = this.rememberedCredentials; + const identifier = [serializeIdentifier(this.userId), serializeIdentifier(credentials.username)] + .find((encoded) => payload.subarray(0, encoded.length).equals(encoded)); + if (!identifier) + return; + if (command === COMMAND_CODE.ChangePassword) { + const currentPassword = readWireName(payload, identifier.length); + const password = readWireName(payload, currentPassword.next).value; + this.rememberedCredentials = { ...credentials, password }; + if ('username' in this.options.credentials && this.options.credentials.username === credentials.username) + this.options.credentials = { ...this.options.credentials, password }; + } else if (payload[identifier.length] === 1) { + const username = readWireName(payload, identifier.length + 1).value; + this.rememberedCredentials = { ...credentials, username }; + if ('username' in this.options.credentials && this.options.credentials.username === credentials.username) + this.options.credentials = { ...this.options.credentials, username }; + } + } + } + + private _signInCredentials(): ClientCredentials { + return this.rememberedCredentials ?? this.options.credentials; + } + + private _clearPollRouting(): void { + this.routingGeneration += 1; + this.pollRoutes.clear(); + for (const entry of this.pollConnections.values()) + entry.client.destroy(); + this.pollConnections.clear(); + } + /** * Processes queued commands sequentially. * Emits 'finishQueue' when all commands are processed. @@ -653,8 +911,15 @@ export class CommandResponseStream extends EventEmitter { } finally { requestWritten ||= exchangeState.written; } - if (!handleResp) + this._observeMetadataReply(response); + if (!handleResp) { + // Routing still needs refusals when the caller decodes the frame. + if (command === COMMAND_CODE.PollMessagesOnPrimary && + peekCommand(response) === Command.Reply && + readStatus(response) === TRANSIENT_NOT_ACCEPTED) + throw responseError(command, TRANSIENT_NOT_ACCEPTED); return response as unknown as CommandResponse; + } try { parsed = decodeVsrResponse(response, command); break; @@ -662,6 +927,10 @@ export class CommandResponseStream extends EventEmitter { if (!(error instanceof ResponseError) || !isTransientVsrError(error.errorCode)) throw error; + if (command === COMMAND_CODE.PollMessagesOnPrimary || + command === COMMAND_CODE.GetPollRouting || + command === COMMAND_CODE.AttachConsumerSession) + throw error; lastTransientError = error; // A not-admitted refusal is a statement about who leads, not about // load: a node that stopped being primary refuses forever, so @@ -689,7 +958,9 @@ export class CommandResponseStream extends EventEmitter { this.isAuthenticated = true; this.userId = parsed.data.readUInt32LE(0); } + this._rememberCredentials(command, payload); if (prepared.command === COMMAND_CODE.LogoutUser) { + this.rememberedCredentials = undefined; this._resetSession(); } // Every roster read feeds the redial candidates, whoever asked for it @@ -880,6 +1151,8 @@ export class CommandResponseStream extends EventEmitter { } private _resetSession(): void { + this._clearPollRouting(); + this.clustered = undefined; if (!this.isAuthenticated && this.userId === undefined && !this.vsrSession.hasActivity) @@ -1012,6 +1285,8 @@ export class CommandResponseStream extends EventEmitter { * Stops heartbeat and destroys the connection. */ destroy() { + this.rememberedCredentials = undefined; + this._clearPollRouting(); if (this.heartbeatIntervalHandler) clearInterval(this.heartbeatIntervalHandler); return this.connection._destroy(); @@ -1036,6 +1311,25 @@ const isLoginCommand = (command: number): boolean => const delay = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); +const withinDeadline = async ( + pending: Promise, deadline: number, expired?: () => void +): Promise => { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + pending, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + expired?.(); + reject(new VsrResponseTimeoutError(VSR_RESPONSE_TIMEOUT_MS)); + }, Math.max(0, deadline - Date.now())); + }) + ]); + } finally { + clearTimeout(timer); + } +}; + const isTransientVsrError = (errorCode: number): boolean => errorCode === TRANSIENT_NOT_COMMITTED || errorCode === TRANSIENT_NOT_ACCEPTED; diff --git a/foreign/node/src/wire/command.code.ts b/foreign/node/src/wire/command.code.ts index e4fec901b5..9af84711fa 100644 --- a/foreign/node/src/wire/command.code.ts +++ b/foreign/node/src/wire/command.code.ts @@ -23,6 +23,7 @@ export const COMMAND_CODE = { GetSnapshot: 11, // @TODO GET_SNAPSHOT_FILE_CODE: u32 = 11 GetClusterMetadata: 12, DescribeOptions: 13, + AttachConsumerSession: 14, GetMe: 20, GetClient: 21, GetClients: 22, @@ -44,9 +45,12 @@ export const COMMAND_CODE = { PollMessages: 100, SendMessages: 101, FlushUnsavedBuffers: 102, + GetPollRouting: 103, + PollMessagesOnPrimary: 104, GetOffset: 120, StoreOffset: 121, DeleteConsumerOffset: 122, + GetConsumerOffsetRouting: 123, GetStream: 200, GetStreams: 201, CreateStream: 202, diff --git a/foreign/node/src/wire/error.code.test.ts b/foreign/node/src/wire/error.code.test.ts index 680d91be21..dc768e2d20 100644 --- a/foreign/node/src/wire/error.code.test.ts +++ b/foreign/node/src/wire/error.code.test.ts @@ -19,6 +19,10 @@ import assert from 'node:assert/strict'; import { it } from 'node:test'; import { translateErrorCode } from './error.code.js'; +it('translates the consumer-offset capacity error', () => { + assert.equal(translateErrorCode(3024), 'Consumer offset limit reached for partition, raise [partition] consumer_offsets_max'); +}); + it('translates the consumer-group error range', () => { assert.equal( translateErrorCode(5000), diff --git a/foreign/node/src/wire/error.code.ts b/foreign/node/src/wire/error.code.ts index efec357035..58773d957a 100644 --- a/foreign/node/src/wire/error.code.ts +++ b/foreign/node/src/wire/error.code.ts @@ -165,6 +165,7 @@ export const translateErrorCode = (code: number): string => { case '3021': return "Consumer offset for consumer with ID: {0} was not found."; case '3022': return "Failed to resolve consumer with ID: {0}"; case '3023': return "Cannot open consumer offsets file for path: {0}"; + case '3024': return "Consumer offset limit reached for partition, raise [partition] consumer_offsets_max"; case '3013': return "Partition id space exhausted for this topic"; // MESSAGE diff --git a/foreign/node/src/wire/message/message.utils.test.ts b/foreign/node/src/wire/message/message.utils.test.ts new file mode 100644 index 0000000000..c58a9882eb --- /dev/null +++ b/foreign/node/src/wire/message/message.utils.test.ts @@ -0,0 +1,129 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { u128ToBuf } from "../number.utils.js"; +import { + isValidMessageId, + serializeMessageId, + resolveMessageId, + mintMessageId, +} from "./message.utils.js"; + +const MESSAGE_ID_SIZE = 16; +const MAX_U128 = (1n << 128n) - 1n; +const NIL_UUID = "00000000-0000-0000-0000-000000000000"; +const isZero = (b: Buffer) => b.every((byte) => byte === 0); + +describe("isValidMessageId", () => { + it("accepts undefined, string, number, and bigint", () => { + assert.ok(isValidMessageId(undefined)); + assert.ok(isValidMessageId("id")); + assert.ok(isValidMessageId(7)); + assert.ok(isValidMessageId(7n)); + }); + + it("rejects other types", () => { + assert.ok(!isValidMessageId(null)); + assert.ok(!isValidMessageId({})); + }); +}); + +describe("serializeMessageId", () => { + it("serializes undefined to a zero u128", () => { + assert.deepEqual(serializeMessageId(), Buffer.alloc(MESSAGE_ID_SIZE, 0)); + }); + + it("serializes a number as little-endian u128", () => { + assert.deepEqual(serializeMessageId(7), u128ToBuf(7n)); + }); + + it("serializes a bigint as little-endian u128", () => { + assert.deepEqual(serializeMessageId(8n), u128ToBuf(8n)); + }); + + it("serializes a UUID string to the same bytes as its numeric value", () => { + const uuid = "00000000-0000-0000-0000-000000000007"; + assert.deepEqual(serializeMessageId(uuid), u128ToBuf(7n)); + }); + + it("accepts the largest u128", () => { + assert.deepEqual(serializeMessageId(MAX_U128), u128ToBuf(MAX_U128)); + }); + + it("rejects a numeric id at or above 2^128", () => { + assert.throws(() => serializeMessageId(1n << 128n), /2\^128/); + }); + + it("rejects a negative numeric id", () => { + assert.throws(() => serializeMessageId(-1n), />= 0/); + }); + + it("rejects an unparsable string", () => { + assert.throws(() => serializeMessageId("not-a-uuid"), /invalid message id/); + }); + + it("rejects an invalid type", () => { + assert.throws(() => serializeMessageId({}), /invalid message id/); + }); +}); + +describe("resolveMessageId", () => { + it("mints a non-zero id for undefined, 0, and 0n", () => { + for (const id of [undefined, 0, 0n]) { + const b = resolveMessageId(id); + assert.equal(b.length, MESSAGE_ID_SIZE); + assert.ok(!isZero(b)); + } + }); + + it("mints for the all-zero nil UUID string", () => { + assert.ok(!isZero(resolveMessageId(NIL_UUID))); + }); + + it("passes a provided non-zero id through unchanged", () => { + assert.deepEqual(resolveMessageId(7n), serializeMessageId(7n)); + }); +}); + +describe("mintMessageId", () => { + it("returns a non-zero 16-byte buffer", () => { + const b = mintMessageId(); + assert.equal(b.length, MESSAGE_ID_SIZE); + assert.ok(!isZero(b)); + }); + + it("produces unique, non-zero ids across pool refills", () => { + const seen = new Set(); + for (let i = 0; i < 10_000; i++) { + const hex = mintMessageId().toString("hex"); + assert.notEqual(hex, "0".repeat(MESSAGE_ID_SIZE * 2)); + seen.add(hex); + } + assert.equal(seen.size, 10_000); + }); + + it("returns owned bytes that survive a later refill", () => { + const first = mintMessageId().toString("hex"); + const held = mintMessageId(); + const snapshot = held.toString("hex"); + for (let i = 0; i < 10_000; i++) mintMessageId(); + assert.equal(held.toString("hex"), snapshot); + assert.notEqual(snapshot, first); + }); +}); diff --git a/foreign/node/src/wire/message/message.utils.ts b/foreign/node/src/wire/message/message.utils.ts index 8ed7c0b289..576dd01d4b 100644 --- a/foreign/node/src/wire/message/message.utils.ts +++ b/foreign/node/src/wire/message/message.utils.ts @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -import { uuidv4 } from 'uuidv7'; +import { randomFillSync } from 'node:crypto'; import { uint32ToBuf, u128ToBuf, uint8ToBuf } from '../number.utils.js'; import { serializeHeaders, type Headers } from './header.utils.js'; import { serializeIdentifier, type Id } from '../identifier.utils.js'; @@ -32,6 +32,9 @@ import { /** Size of the message ID in bytes (u128) */ const MESSAGE_ID_SIZE = 16; +/** Exclusive upper bound for a numeric message ID: it must be < 2^128 */ +const MESSAGE_ID_UPPER_BOUND = 1n << BigInt((MESSAGE_ID_SIZE * 8)); + /** Largest representable frame timestamp delta (u32, microseconds) */ const MAX_TIMESTAMP_DELTA = 0xFFFF_FFFFn; @@ -99,6 +102,8 @@ export const serializeMessageId = (id?: unknown) => { throw new Error(`invalid message id: '${id}' (numeric id must be >= 0)`) const idValue = 'number' === typeof id ? BigInt(id) : id; + if (idValue >= MESSAGE_ID_UPPER_BOUND) + throw new Error(`invalid message id: '${id}' (numeric id must be < 2^${MESSAGE_ID_SIZE * 8})`) return u128ToBuf(idValue); } @@ -114,17 +119,44 @@ export const serializeMessageId = (id?: unknown) => { } +/** Number of ids drawn from the pool per CSPRNG refill */ +const ID_POOL_COUNT = 4096; + +/** Pooled random bytes and a cursor into them, filled lazily on first mint */ +const idPool = Buffer.allocUnsafe(ID_POOL_COUNT * MESSAGE_ID_SIZE); +let idPoolCursor = idPool.length; // past the end -> refill on first use + +/** + * Mints a random 16-byte message ID from the pool, refilling when drained. + * + * @returns 16-byte buffer of random bytes owned by the caller + */ +export const mintMessageId = (): Buffer => { + if (idPoolCursor + MESSAGE_ID_SIZE > idPool.length) { + randomFillSync(idPool); + idPoolCursor = 0; + } + const id = Buffer.allocUnsafe(MESSAGE_ID_SIZE); + idPool.copy(id, 0, idPoolCursor, idPoolCursor + MESSAGE_ID_SIZE); + idPoolCursor += MESSAGE_ID_SIZE; + return id; +}; + /** - * Serializes a message ID, minting a random UUID when the ID is - * absent or zero. + * Resolves a message ID to a 16-byte little-endian buffer, minting a random + * one when the ID is absent or zero. * * @param id - Optional message ID * @returns 16-byte little-endian buffer containing a non-zero ID */ -const resolveMessageId = (id?: MessageIdKind): Buffer => { +export const resolveMessageId = (id?: MessageIdKind): Buffer => { + // An absent or zero id mints a random one. + if (id === undefined || id === 0 || id === 0n) + return mintMessageId(); const bId = serializeMessageId(id); - return bId.every((byte) => byte === 0) - ? u128ToBuf(BigInt(`0x${uuidv4().replaceAll('-', '')}`)) + // A string id can still be the all-zero nil UUID; mint in that case too. + return 'string' === typeof id && bId.every((byte) => byte === 0) + ? mintMessageId() : bId; }; diff --git a/foreign/node/src/wire/message/poll-messages.command.test.ts b/foreign/node/src/wire/message/poll-messages.command.test.ts index 274b8e578d..ed9ac7336f 100644 --- a/foreign/node/src/wire/message/poll-messages.command.test.ts +++ b/foreign/node/src/wire/message/poll-messages.command.test.ts @@ -276,6 +276,20 @@ describe('VSR consumer-group polling', () => { ); }); + it('does not replay an ambiguous auto-commit poll when its coordinator also resets', async () => { + const { client, commands } = stubClient([ + assignment(1n, [4]), + new ResponseError(COMMAND_CODE.PollMessages, 57) + ], (command, emitter) => { + if (command === COMMAND_CODE.PollMessages) + emitter.emit('sessionReset'); + }); + await assert.rejects(pollMessages(async () => client)({ ...groupRequest, autocommit: true }), + (error: unknown) => error instanceof ResponseError && error.errorCode === 57); + assert.deepEqual(commands.map(({ command }) => command), + [COMMAND_CODE.SyncGroup, COMMAND_CODE.PollMessages]); + }); + it('resynchronizes twice before returning an empty result', async () => { const resyncPartition = 0xFFFF_FFFF; const { client, commands } = stubClient([ diff --git a/foreign/node/src/wire/message/poll-messages.command.ts b/foreign/node/src/wire/message/poll-messages.command.ts index da6a0b4d65..29351bc1c8 100644 --- a/foreign/node/src/wire/message/poll-messages.command.ts +++ b/foreign/node/src/wire/message/poll-messages.command.ts @@ -44,6 +44,7 @@ const GROUP_POLL_MAX_ATTEMPTS = 2; const GROUP_ASSIGNMENT_REFRESH_MS = 5_000; const GROUP_MEMBER_NOT_FOUND = 5006; const GROUP_PARTITION_NOT_OWNED = 5009; +const TRANSIENT_NOT_COMMITTED = 57; type GroupCursor = { generation: bigint, @@ -257,7 +258,9 @@ export const pollMessages = (getClient: ClientProvider) => // Retry only when the command stream confirms that the VSR session // was reset. Other command, authorization, and decode errors remain // terminal for the caller. - if (state.sessionGeneration === sessionGeneration) + if (state.sessionGeneration === sessionGeneration || + (error instanceof ResponseError && + error.errorCode === TRANSIENT_NOT_COMMITTED)) throw error; } } diff --git a/foreign/node/src/wire/message/send-messages.command.ts b/foreign/node/src/wire/message/send-messages.command.ts index 99c5981887..62965b237b 100644 --- a/foreign/node/src/wire/message/send-messages.command.ts +++ b/foreign/node/src/wire/message/send-messages.command.ts @@ -59,9 +59,8 @@ export type SendMessagesConfirmation = { * * Delivery is at-least-once, so an earlier retry of the same batch may * already have committed at a lower offset: this never identifies a batch - * uniquely. A batch is confirmed once it is committed in memory, not once it - * is fsynced, so a crash-restart can stamp a later batch with an offset a - * client has already recorded. + * uniquely. Confirmation follows VSR quorum commit. Persisted message + * durability also requires recoverable stable-storage copies on the quorum. */ baseOffset: bigint, }; diff --git a/foreign/node/src/wire/number.utils.test.ts b/foreign/node/src/wire/number.utils.test.ts new file mode 100644 index 0000000000..1a53f552ae --- /dev/null +++ b/foreign/node/src/wire/number.utils.test.ts @@ -0,0 +1,67 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { u128ToBuf } from "./number.utils.js"; + +const MAX_U128 = (1n << 128n) - 1n; +const hex = (v: bigint) => u128ToBuf(v).toString("hex"); + +describe("u128ToBuf", () => { + it("encodes zero as 16 zero bytes", () => { + assert.equal(hex(0n), "0".repeat(32)); + }); + + it("encodes a small value little-endian", () => { + assert.equal(hex(7n), "07" + "0".repeat(30)); + }); + + it("orders all 16 bytes little-endian", () => { + // Distinct bytes so a byte-order slip is visible. + assert.equal( + hex(0x0102030405060708090a0b0c0d0e0f10n), + "100f0e0d0c0b0a090807060504030201", + ); + }); + + it("spans the 64-bit half boundary", () => { + assert.equal(hex((1n << 64n) - 1n), "ff".repeat(8) + "00".repeat(8)); + assert.equal(hex(1n << 64n), "00".repeat(8) + "01" + "00".repeat(7)); + }); + + it("encodes the largest u128 as all ones", () => { + assert.equal(hex(MAX_U128), "ff".repeat(16)); + }); + + it("round-trips through readBigUInt64LE halves", () => { + for (const v of [0n, 1n, 42n, 1n << 64n, MAX_U128]) { + const b = u128ToBuf(v); + const low = b.readBigUInt64LE(0); + const high = b.readBigUInt64LE(8); + assert.equal((high << 64n) | low, v); + } + }); + + it("throws for a value at or above 2^128", () => { + assert.throws(() => u128ToBuf(1n << 128n)); + }); + + it("throws for a negative value", () => { + assert.throws(() => u128ToBuf(-1n)); + }); +}); diff --git a/foreign/node/src/wire/number.utils.ts b/foreign/node/src/wire/number.utils.ts index 3ea8f691fb..aa2ce6a589 100644 --- a/foreign/node/src/wire/number.utils.ts +++ b/foreign/node/src/wire/number.utils.ts @@ -147,17 +147,26 @@ export const doubleToBuf = (v: number) => { return b; } +/** Mask selecting the low 64 bits of a u128, for little-endian half-writes. */ +const U64_MASK = 0xFFFF_FFFF_FFFF_FFFFn; + /** - * Converts a BigInt to a 128-bit unsigned integer Buffer in little-endian format. + * Converts a u128 value to a 16-byte unsigned integer Buffer in little-endian + * format. * - * @param num - BigInt value to convert - * @param width - Width in bytes (default: 16) - * @returns Buffer containing the value in little-endian byte order + * Writes the two 64-bit halves directly rather than round-tripping through a hex + * string. `value` must be a non-negative integer below 2^128; anything outside + * that range throws (a `RangeError` from the underlying write), which is + * preferable to silently truncating a message id. + * + * @param value - u128 value (0 <= value < 2^128) + * @returns 16-byte buffer containing the value in little-endian byte order */ -export function u128ToBuf(num: bigint, width = 16): Buffer { - const hex = num.toString(16); - const b = Buffer.from(hex.padStart(width * 2, '0').slice(0, width * 2), 'hex'); - return b.reverse(); +export function u128ToBuf(value: bigint): Buffer { + const b = Buffer.allocUnsafe(16); + b.writeBigUInt64LE(value & U64_MASK, 0); + b.writeBigUInt64LE(value >> 64n, 8); + return b; } /** diff --git a/foreign/node/src/wire/options.utils.test.ts b/foreign/node/src/wire/options.utils.test.ts index 7fbc381d8f..e2c399fa17 100644 --- a/foreign/node/src/wire/options.utils.test.ts +++ b/foreign/node/src/wire/options.utils.test.ts @@ -34,23 +34,26 @@ const prefixed = (block: Buffer): Buffer => { * * Rust pins the identical bytes in `core/binary_protocol/src/primitives/options.rs`, * as do the Go and Java SDKs. Round-tripping through this SDK's own decoder proves - * nothing about interoperability; these bytes are the contract. + * nothing about interoperability. This insertion-order vector covers Bool, Uint64 + * and String values, independently of Rust's sorted map order. */ const GOLDEN_OPTIONS_BLOCK = Buffer.from([ - 2, 13, 0, 0, 0, - ...Buffer.from('enforce_fsync'), + 2, 20, 0, 0, 0, + ...Buffer.from('preallocate_segments'), 3, 1, 0, 0, 0, 1, 2, 12, 0, 0, 0, ...Buffer.from('segment_size'), 12, 8, 0, 0, 0, - 0, 0, 0, 64, 0, 0, 0, 0 + 0, 0, 0, 64, 0, 0, 0, 0, + 2, 10, 0, 0, 0, 100, 117, 114, 97, 98, 105, 108, 105, 116, 121, 2, 9, 0, 0, 0, 112, 101, 114, 115, 105, 115, 116, 101, 100 ]); describe('serializeOptions', () => { it('encodes the cross-SDK golden vector byte for byte', () => { const encoded = serializeOptions([ - { key: 'enforce_fsync', value: HeaderValue.Bool(true) }, - { key: 'segment_size', value: HeaderValue.Uint64(1_073_741_824n) } + { key: 'preallocate_segments', value: HeaderValue.Bool(true) }, + { key: 'segment_size', value: HeaderValue.Uint64(1_073_741_824n) }, + { key: 'durability', value: HeaderValue.String('persisted') } ]); assert.deepEqual(encoded, GOLDEN_OPTIONS_BLOCK); diff --git a/foreign/node/src/wire/token/token.utils.test.ts b/foreign/node/src/wire/token/token.utils.test.ts new file mode 100644 index 0000000000..571146bd24 --- /dev/null +++ b/foreign/node/src/wire/token/token.utils.test.ts @@ -0,0 +1,88 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { deserializeTokens } from "./token.utils.js"; + +// Real server wire shape: [nameLength: u8][name][expiry: 8-byte LE, always present, 0 = never-expiring]. +// See core/binary_protocol/.../get_personal_access_tokens.rs and core/server/src/responses.rs:1195-1209. +const tokenRecord = (name: string, expiry: bigint = 0n): Buffer => { + const nameBuf = Buffer.from(name, "utf-8"); + const head = Buffer.from([nameBuf.length]); + const expiryBuf = Buffer.alloc(8); + expiryBuf.writeBigUInt64LE(expiry); + return Buffer.concat([head, nameBuf, expiryBuf]); +}; + +describe("deserializeTokens", () => { + it("reads all tokens in a 3-token buffer, including the one after the 2nd", () => { + const t1 = tokenRecord("ci-a", 123n); + const t2 = tokenRecord("ci-b"); + const t3 = tokenRecord("x"); + const buffer = Buffer.concat([t1, t2, t3]); + + const tokens = deserializeTokens(buffer); + + assert.deepEqual( + tokens.map((t) => t.name), + ["ci-a", "ci-b", "x"], + ); + assert.notEqual(tokens[0].expiry, null); + assert.equal(tokens[1].expiry, null); + assert.equal(tokens[2].expiry, null); + }); + + it("reads all tokens cleanly regardless of record length ordering", () => { + const t1 = tokenRecord("short"); + const t2 = tokenRecord("second"); + const t3 = tokenRecord("a-much-longer-token-name-here"); + const t4 = tokenRecord("last"); + const buffer = Buffer.concat([t1, t2, t3, t4]); + + const tokens = deserializeTokens(buffer); + + assert.deepEqual( + tokens.map((t) => t.name), + ["short", "second", "a-much-longer-token-name-here", "last"], + ); + assert.ok(tokens.every((t) => t.expiry === null)); + }); + + it("decodes a single token with no trailing data", () => { + const buffer = tokenRecord("solo"); + + const tokens = deserializeTokens(buffer); + + assert.deepEqual(tokens, [{ name: "solo", expiry: null }]); + }); + + it("decodes a non-zero expiry into the correct point in time", () => { + const expiryMicros = 1700000000000000n; + const buffer = tokenRecord("with-expiry", expiryMicros); + + const tokens = deserializeTokens(buffer); + + assert.equal(tokens[0].expiry?.getTime(), Number(expiryMicros / 1000n)); + }); + + it("throws on a buffer truncated before the expiry field", () => { + const partial = Buffer.concat([Buffer.from([4]), Buffer.from("iggy")]); + + assert.throws(() => deserializeTokens(partial), RangeError); + }); +}); diff --git a/foreign/node/src/wire/token/token.utils.ts b/foreign/node/src/wire/token/token.utils.ts index e4b7a641c7..efe1b3de49 100644 --- a/foreign/node/src/wire/token/token.utils.ts +++ b/foreign/node/src/wire/token/token.utils.ts @@ -78,13 +78,9 @@ export const deserializeCreateToken = (p: Buffer, pos = 0): TokenDeserialized => export const deserializeToken = (p: Buffer, pos = 0): TokenSerialized => { const nameLength = p.readUInt8(pos); const name = p.subarray(pos + 1, pos + 1 + nameLength).toString(); - const rest = p.subarray(pos + 1 + nameLength); - let expiry = null; - let bytesRead = pos + 1 + nameLength; - if (rest.length >= 8) { - expiry = toDate(rest.readBigUInt64LE(0)); - bytesRead += 8; - } + const expiryRaw = p.readBigUInt64LE(pos + 1 + nameLength); + const expiry = expiryRaw === 0n ? null : toDate(expiryRaw); + const bytesRead = 1 + nameLength + 8; return { bytesRead, data: { diff --git a/foreign/node/src/wire/topic/create-topic.command.test.ts b/foreign/node/src/wire/topic/create-topic.command.test.ts index 0ca484abbe..868d00a5d7 100644 --- a/foreign/node/src/wire/topic/create-topic.command.test.ts +++ b/foreign/node/src/wire/topic/create-topic.command.test.ts @@ -17,6 +17,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; +import { Durability } from './topic.utils.js'; import { CREATE_TOPIC } from './create-topic.command.js'; import { deserializeOptions } from '../options.utils.js'; import { HeaderValue } from '../message/header.utils.js'; @@ -37,13 +38,15 @@ describe('CreateTopic', () => { // TLV field: [kind:u8][len:u32_le][bytes] const tlvSize = (bytes: number) => 1 + 4 + bytes; const identifierSize = 1 + 1 + 4; // numeric stream id + const defaultPolicySize = tlvSize('durability'.length) + tlvSize('replicated'.length) + + tlvSize('consumer_offset_durability'.length) + tlvSize('replicated'.length); const fixedSize = identifierSize + 4 + 1; // + partitions_count + name_len it('serialize name and default options into buffer', () => { - // Server-default sentinels are omitted, leaving an empty options block. + // Durability defaults are explicit, while the other sentinels are omitted. assert.deepEqual( CREATE_TOPIC.serialize(t1).length, - fixedSize + t1.name.length + fixedSize + t1.name.length + defaultPolicySize ); }); @@ -52,7 +55,7 @@ describe('CreateTopic', () => { const b = CREATE_TOPIC.serialize(t); assert.equal(b.readUInt32LE(identifierSize), 7); assert.equal(b.readUInt8(identifierSize + 4), t.name.length); - assert.equal(b.subarray(fixedSize).toString(), t.name); + assert.equal(b.subarray(fixedSize, fixedSize + t.name.length).toString(), t.name); }); it('serialize non-default options into buffer', () => { @@ -64,7 +67,7 @@ describe('CreateTopic', () => { }; assert.deepEqual( CREATE_TOPIC.serialize(t).length, - fixedSize + t1.name.length + fixedSize + t1.name.length + defaultPolicySize + tlvSize('compression_algorithm'.length) + tlvSize('gzip'.length) + tlvSize('message_expiry'.length) + tlvSize(8) + tlvSize('max_topic_size'.length) + tlvSize(8) @@ -75,16 +78,16 @@ describe('CreateTopic', () => { const t = { ...t1, segmentSize: 1048576n, - enforceFsync: true, + durability: Durability.Persisted, messagesRequiredToSave: 1000, sizeOfMessagesRequiredToSave: 4096n, preallocateSegments: false }; assert.deepEqual( CREATE_TOPIC.serialize(t).length, - fixedSize + t1.name.length + fixedSize + t1.name.length + defaultPolicySize + tlvSize('segment_size'.length) + tlvSize(8) - + tlvSize('enforce_fsync'.length) + tlvSize(1) + + 'persisted'.length - 'replicated'.length + tlvSize('messages_required_to_save'.length) + tlvSize(4) + tlvSize('size_of_messages_required_to_save'.length) + tlvSize(8) + tlvSize('preallocate_segments'.length) + tlvSize(1) @@ -96,7 +99,7 @@ describe('CreateTopic', () => { ...t1, maxTopicSize: 4096n, options: [ - { key: 'enforce_fsync', value: HeaderValue.Bool(true) }, + { key: 'preallocate_segments', value: HeaderValue.Bool(true) }, // The typed field covers this key, so the caller's entry is dropped: // a duplicate key makes the server refuse the whole block. { key: 'max_topic_size', value: HeaderValue.String('1 GiB') } @@ -107,11 +110,30 @@ describe('CreateTopic', () => { // The create payload runs its options block to the end, unprefixed. const options = deserializeOptions(b, fixedSize + t.name.length); - assert.deepEqual(Object.keys(options).sort(), ['enforce_fsync', 'max_topic_size']); - assert.equal(options.enforce_fsync, true); + assert.deepEqual(Object.keys(options).sort(), ['consumer_offset_durability', 'durability', 'max_topic_size', 'preallocate_segments']); + assert.equal(options.preallocate_segments, true); assert.equal(options.max_topic_size, 4096n); }); + it('keeps each omitted durability policy replicated', () => { + for (const selected of [ + { durability: Durability.Persisted }, + { consumerOffsetDurability: Durability.Persisted } + ]) { + const input = { ...t1, ...selected }; + const encoded = CREATE_TOPIC.serialize(input); + const options = deserializeOptions(encoded, fixedSize + input.name.length); + assert.equal(options.durability, selected.durability ?? 'replicated'); + assert.equal(options.consumer_offset_durability, selected.consumerOffsetDurability ?? 'replicated'); + } + }); + + it('rejects a conflicting raw durability instead of weakening it', () => { + assert.throws(() => CREATE_TOPIC.serialize({ ...t1, options: [ + { key: 'durability', value: HeaderValue.String('persisted') } + ] })); + }); + it('throw on name < 1', () => { const t = { ...t1, name: '' }; assert.throws( diff --git a/foreign/node/src/wire/topic/create-topic.command.ts b/foreign/node/src/wire/topic/create-topic.command.ts index bdbd9d2c67..cc57d39ede 100644 --- a/foreign/node/src/wire/topic/create-topic.command.ts +++ b/foreign/node/src/wire/topic/create-topic.command.ts @@ -22,7 +22,7 @@ import { COMMAND_CODE } from '../command.code.js'; import { dedupeOptions, serializeOptions, type OptionEntry } from '../options.utils.js'; import { HeaderValue } from '../message/header.utils.js'; import { - isValidCompressionAlgorithm, CompressionAlgorithm, + isValidCompressionAlgorithm, CompressionAlgorithm, Durability, compressionAlgorithmName, deserializeTopic, type Topic, @@ -56,8 +56,9 @@ export type CreateTopic = { maxTopicSize?: bigint, /** Segment size in bytes: 512-byte multiple between 1 MiB and 1 GiB */ segmentSize?: bigint, - /** Fsync every write instead of leaving it to the page cache */ - enforceFsync?: boolean, + /** Message completion policy. Defaults to replicated, independently of offsets. */ + durability?: Durability, + consumerOffsetDurability?: Durability, /** Message count that triggers a save (must be non-zero) */ messagesRequiredToSave?: number, /** Accumulated message bytes that trigger a save */ @@ -90,7 +91,8 @@ export const CREATE_TOPIC = { messageExpiry = 0n, maxTopicSize = 0n, segmentSize, - enforceFsync, + durability = Durability.Replicated, + consumerOffsetDurability = Durability.Replicated, messagesRequiredToSave, sizeOfMessagesRequiredToSave, preallocateSegments, @@ -130,10 +132,19 @@ export const CREATE_TOPIC = { options.push({ key: 'segment_size', value: HeaderValue.Uint64(segmentSize) }); - if (enforceFsync !== undefined) - options.push({ - key: 'enforce_fsync', value: HeaderValue.Bool(enforceFsync) - }); + for (const [key, policy] of [ + ['durability', durability], + ['consumer_offset_durability', consumerOffsetDurability] + ] as const) { + if (policy !== Durability.Replicated && policy !== Durability.Persisted) + throw new Error(`Invalid ${key}: ${policy}`); + const expected = HeaderValue.String(policy); + for (const entry of extraOptions) { + if (entry.key === key && (entry.value.kind !== expected.kind || entry.value.value !== expected.value)) + throw new Error(`Conflicting ${key}`); + } + options.push({ key, value: expected }); + } if (messagesRequiredToSave !== undefined) options.push({ key: 'messages_required_to_save', diff --git a/foreign/node/src/wire/topic/index.ts b/foreign/node/src/wire/topic/index.ts index 8baa49a180..7569700d5b 100644 --- a/foreign/node/src/wire/topic/index.ts +++ b/foreign/node/src/wire/topic/index.ts @@ -22,4 +22,4 @@ export * from './get-topics.command.js'; export * from './purge-topic.command.js'; export * from './update-topic.command.js'; export * from './ensure-topic.virtual.command.js'; -export { CompressionAlgorithm } from './topic.utils.js'; +export { CompressionAlgorithm, Durability } from './topic.utils.js'; diff --git a/foreign/node/src/wire/topic/topic.utils.ts b/foreign/node/src/wire/topic/topic.utils.ts index 6f35e792e6..e691a1c860 100644 --- a/foreign/node/src/wire/topic/topic.utils.ts +++ b/foreign/node/src/wire/topic/topic.utils.ts @@ -240,3 +240,10 @@ export const deserializeTopics = (p: Buffer, pos = 0): Topic[] => { } return topics; }; + +export const Durability = { + Replicated: 'replicated', + Persisted: 'persisted' +} as const; + +export type Durability = typeof Durability[keyof typeof Durability]; diff --git a/foreign/node/src/wire/vsr/header.ts b/foreign/node/src/wire/vsr/header.ts index d54182c5ba..8fc608a0aa 100644 --- a/foreign/node/src/wire/vsr/header.ts +++ b/foreign/node/src/wire/vsr/header.ts @@ -50,6 +50,7 @@ export const REQUEST_OFFSET = { export const REPLY_OFFSET = { size: 48, command: 60, + commit: 184, operation: 208, status: 216 } as const; @@ -113,8 +114,10 @@ const U64_MASK = 0xFFFFFFFFFFFFFFFFn; /** * Encodes a 256-byte request header. Only the six fields the server reads - * are written; the checksums stay zero, matching the Rust SDK's contract - * with the VSR server. + * are written. The checksums stay zero: the frame and body checksums are not + * read on the client request path, and `request_checksum` treats zero as + * unstamped, which opts out of the server's payload comparison. Stamping it is + * optional -- the Rust SDK does for deduped ops, this SDK does not yet. */ export const encodeRequestHeader = (fields: RequestHeaderFields): Buffer => { const header = Buffer.alloc(HEADER_SIZE); diff --git a/foreign/node/src/wire/vsr/index.ts b/foreign/node/src/wire/vsr/index.ts index 24091ca1fe..f0fbbfc19d 100644 --- a/foreign/node/src/wire/vsr/index.ts +++ b/foreign/node/src/wire/vsr/index.ts @@ -20,11 +20,7 @@ import type { CommandResponse } from '../../client/client.type.js'; import { COMMAND_CODE } from '../command.code.js'; import { responseError } from '../error.utils.js'; import { HEADER_SIZE, encodeRequestHeader } from './header.js'; -import { - Operation, - isPartition, - operationForCode, -} from './operation.js'; +import { Operation, operationForCode } from './operation.js'; import { deserializeLoginRegister, serializeLoginRegister, @@ -80,9 +76,10 @@ export class VsrSession { } else { if (this.state.session === null) throw responseError(command, UNAUTHENTICATED); - request = isPartition(operation) - ? this.state.currentRequestId() - : this.state.nextRequestId(); + // Partition ops consume an id too, even though no partition-plane dedup + // exists yet: dedup needs each send to carry a distinct number, and the + // metadata watermark tolerates the gaps. + request = this.state.nextRequestId(); session = this.state.session; } @@ -136,7 +133,7 @@ const registerCommand = (command: number): boolean => command === COMMAND_CODE.LoginRegister || command === COMMAND_CODE.LoginRegisterWithAccessToken; -const readWireName = ( +export const readWireName = ( payload: Buffer, offset: number ): { value: string, next: number } => { diff --git a/foreign/node/src/wire/vsr/operation.test.ts b/foreign/node/src/wire/vsr/operation.test.ts index daac920474..4ce539ffe9 100644 --- a/foreign/node/src/wire/vsr/operation.test.ts +++ b/foreign/node/src/wire/vsr/operation.test.ts @@ -22,7 +22,6 @@ import { isInternal, isKnownOperation, isMetadata, - isPartition, isResultFramed, Operation, operationForCode @@ -87,8 +86,6 @@ describe('VSR operation classification', () => { assert.equal(isMetadata(Operation.LeaveConsumerGroup), true); assert.equal(isMetadata(Operation.DeleteSegments), false); assert.equal(isMetadata(150), false); - assert.equal(isPartition(Operation.SendMessages), true); - assert.equal(isPartition(159), false); assert.equal(isResultFramed(Operation.StoreConsumerOffset), true); assert.equal(isResultFramed(Operation.DeleteConsumerOffset), true); assert.equal(isResultFramed(Operation.SendMessages), false); diff --git a/foreign/node/src/wire/vsr/operation.ts b/foreign/node/src/wire/vsr/operation.ts index 2781668885..030d9f0fef 100644 --- a/foreign/node/src/wire/vsr/operation.ts +++ b/foreign/node/src/wire/vsr/operation.ts @@ -63,7 +63,6 @@ export const Operation = { const INTERNAL_START = 64; const METADATA_START = 128; -const PARTITION_START = 160; /** * Replicated command code to `Operation` mapping, the client half of the @@ -126,10 +125,6 @@ export const isMetadata = (operation: number): boolean => { operation <= Operation.LeaveConsumerGroup; }; -/** Partition band is a bare range, mirroring `Operation::is_partition`. */ -export const isPartition = (operation: number): boolean => - operation >= PARTITION_START; - /** Whether a reply body leads with a committed result section. */ export const isResultFramed = (operation: number): boolean => isMetadata(operation) || diff --git a/foreign/node/src/wire/vsr/session.ts b/foreign/node/src/wire/vsr/session.ts index f61cbc3f0b..5f381fbbbf 100644 --- a/foreign/node/src/wire/vsr/session.ts +++ b/foreign/node/src/wire/vsr/session.ts @@ -24,9 +24,9 @@ const MAX_U64 = 0xFFFF_FFFF_FFFF_FFFFn; * * Each client instance generates an ephemeral random `clientId` (u128). * After a Register commits, the server assigns a `session` number (commit op - * number). Replicated metadata requests advance a monotonic request watermark. - * Non-replicated and partition-plane requests reuse the current value because - * the server only applies request sequencing to replicated metadata. + * number). Every replicated request (metadata and partition) advances a + * monotonic request watermark; non-replicated requests reuse the current + * value because they bypass server-side request sequencing. */ export class ConsensusSession { private _clientId: bigint; diff --git a/foreign/node/src/wire/vsr/vsr.test.ts b/foreign/node/src/wire/vsr/vsr.test.ts index 60e9c19088..cbc9feb7e4 100644 --- a/foreign/node/src/wire/vsr/vsr.test.ts +++ b/foreign/node/src/wire/vsr/vsr.test.ts @@ -79,7 +79,7 @@ describe('VSR custom request framing', () => { ); }); - it('does not advance for non-replicated or partition operations', () => { + it('does not advance for non-replicated operations', () => { const session = new VsrSession(7n); session.bind(42n); const custom = session.encode( @@ -88,26 +88,43 @@ describe('VSR custom request framing', () => { ); assert.equal(custom.readBigUInt64LE(REQUEST_OFFSET.request), 1n); - const partition = session.encode( - COMMAND_CODE.SendMessages, - serializeSendMessages( - 1, - 2, - [{ payload: 'x' }], - Partitioning.PartitionId(3) - ) + const metadata = session.encode( + COMMAND_CODE.CreateStream, + Buffer.alloc(0) ); + assert.equal(metadata.readBigUInt64LE(REQUEST_OFFSET.request), 1n); + assert.equal(metadata.length, HEADER_SIZE); + }); + + it('partition operations consume a distinct id per send', () => { + // Dedup identity requires each send to carry a distinct number, so + // partition ops advance the counter exactly like metadata ops and the + // two planes interleave on one sequence. + const session = new VsrSession(7n); + session.bind(42n); + const sendMessages = () => + session.encode( + COMMAND_CODE.SendMessages, + serializeSendMessages( + 1, + 2, + [{ payload: 'x' }], + Partitioning.PartitionId(3) + ) + ); + + const first = sendMessages(); assert.equal( - partition.readUInt8(REQUEST_OFFSET.operation), + first.readUInt8(REQUEST_OFFSET.operation), Operation.SendMessages ); - assert.equal(partition.readBigUInt64LE(REQUEST_OFFSET.request), 1n); + assert.equal(first.readBigUInt64LE(REQUEST_OFFSET.request), 1n); + assert.equal(sendMessages().readBigUInt64LE(REQUEST_OFFSET.request), 2n); const metadata = session.encode( COMMAND_CODE.CreateStream, Buffer.alloc(0) ); - assert.equal(metadata.readBigUInt64LE(REQUEST_OFFSET.request), 1n); - assert.equal(metadata.length, HEADER_SIZE); + assert.equal(metadata.readBigUInt64LE(REQUEST_OFFSET.request), 3n); }); }); diff --git a/foreign/php/Cargo.toml b/foreign/php/Cargo.toml index 58502591d8..ca70405af0 100644 --- a/foreign/php/Cargo.toml +++ b/foreign/php/Cargo.toml @@ -17,7 +17,7 @@ [package] name = "iggy-php" -version = "0.1.2" +version = "0.1.3" edition = "2024" authors = ["Iggy Committers "] license = "Apache-2.0" @@ -29,11 +29,11 @@ repository = "https://github.com/apache/iggy" crate-type = ["cdylib"] [dependencies] -bytes = "1.11.1" -futures = "0.3.32" +bytes = "1.12.1" +futures = "0.3.34" ext-php-rs = "=0.15.14" iggy = { path = "../../core/sdk" } -tokio = "1.52.3" +tokio = "1.53.1" [profile.release] strip = "debuginfo" diff --git a/foreign/php/Dockerfile.test b/foreign/php/Dockerfile.test index 7c9a74173f..e5ce15a7da 100644 --- a/foreign/php/Dockerfile.test +++ b/foreign/php/Dockerfile.test @@ -36,8 +36,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ENV PHP=/usr/bin/php ENV PHP_CONFIG=/usr/bin/php-config -ENV IGGY_HOST=iggy-server -ENV IGGY_PORT=8090 +ENV IGGY_TCP_ADDRESS=iggy-server:8090 WORKDIR /workspace diff --git a/foreign/php/README.md b/foreign/php/README.md index 73a31cfa42..dc2ef121c3 100644 --- a/foreign/php/README.md +++ b/foreign/php/README.md @@ -11,7 +11,7 @@ future resolves; it does not provide fiber-aware or non-blocking I/O. ## Requirements - Rust and Cargo -- PHP 8.3 or newer with `php-config` +- PHP 8.3 or newer with `php-config` (non-thread-safe builds only, no ZTS) - `cargo-php` - Composer, for installing PHPUnit - Docker, for running the integration test server @@ -33,9 +33,12 @@ cargo build --release Generate IDE stubs after changing the exported PHP API: ```sh -cargo php stubs --manifest Cargo.toml -o iggy-php.stubs.php +cargo build +cargo php stubs target/debug/libiggy_php.so -o /tmp/iggy-php.stubs.php ``` +Stub generation requires a debug build. On macOS, use `libiggy_php.dylib`. +Preserve the existing Apache license header when updating `iggy-php.stubs.php`. The CI lint job regenerates this file and fails if the checked-in stubs drift from the Rust signatures. @@ -60,11 +63,18 @@ php -r 'var_dump(extension_loaded("iggy-php"));' ## Run Iggy +Use Iggy 0.9.0. When testing unreleased SDK changes, build the server from the +same source checkout. + ```sh docker run --rm --name iggy-php-test \ + --cap-add=SYS_NICE --security-opt seccomp=unconfined --ulimit memlock=-1:-1 \ -p 8090:8090 \ -p 3000:3000 \ - apache/iggy:latest + -e IGGY_TCP_ADDRESS=0.0.0.0:8090 -e IGGY_HTTP_ADDRESS=0.0.0.0:3000 \ + -e IGGY_NODE_ADVERTISED_ADDRESS=localhost \ + -e IGGY_ROOT_USERNAME=iggy -e IGGY_ROOT_PASSWORD=iggy \ + apache/iggy:0.9.0 ``` You can also run a local server from the repository root: @@ -73,6 +83,10 @@ You can also run a local server from the repository root: cargo run --bin iggy-server -- --fresh --with-default-root-credentials ``` +The root variables bootstrap a new data directory; they do not replace stored +credentials. Environment credentials override `--with-default-root-credentials`. +Use `--fresh` only with disposable development data: it deletes local replica state. + The tests assume: - host: `127.0.0.1` @@ -80,7 +94,7 @@ The tests assume: - username: `iggy` - password: `iggy` -Override them with `IGGY_HOST`, `IGGY_PORT`, `IGGY_USERNAME`, and `IGGY_PASSWORD`. +Override them with `IGGY_TCP_ADDRESS`, `IGGY_ROOT_USERNAME`, and `IGGY_ROOT_PASSWORD`. ## Usage @@ -99,7 +113,8 @@ $client->createStream($stream); $client->createTopic($stream, $topic, 1, null, null, null, null); $client->sendMessages($stream, $topic, $partitionId, [ - new \Iggy\SendMessage('hello from PHP'), + new \Iggy\SendMessage('hello from PHP 1'), + new \Iggy\SendMessage('hello from PHP 2'), ]); $messages = $client->pollMessages( @@ -116,7 +131,10 @@ foreach ($messages as $message) { } ``` -Consumer group callbacks require a finite message limit: +Consumer group callbacks require a finite message limit. The partition id +argument is ignored for a consumer group, since the member reads the partitions +the server assigns to it. After the example above, each loop below consumes one +of its two messages: ```php consumerGroup( 'php-consumer', $stream, $topic, - $partitionId, + null, \Iggy\PollingStrategy::next(), 10, \Iggy\AutoCommit::disabled(), @@ -140,19 +158,17 @@ $consumer = $client->consumerGroup( $consumer->consumeMessages( function (\Iggy\ReceiveMessage $message) use ($consumer): void { - process($message->payload()); + echo $message->payload(), PHP_EOL; $consumer->storeOffset($message->offset(), $message->partitionId()); }, - 100, + 1, ); foreach ($consumer->iterMessages() as $message) { - process($message->payload()); + echo $message->payload(), PHP_EOL; $consumer->storeOffset($message->offset(), $message->partitionId()); - if (shouldStop()) { - break; - } + break; } ``` @@ -171,10 +187,11 @@ composer install composer test ``` -Run Rust verification: +Run Rust verification with a matching PHP embedding library (`libphp`) available +to the linker and dynamic loader: ```sh -cargo test +cargo test --features ext-php-rs/embed ``` TLS tests are opt-in because they require a TLS-enabled Iggy server and certificate @@ -212,8 +229,8 @@ iggy+tcp://iggy:iggy@127.0.0.1:8090?tls=true&tls_domain=localhost&tls_ca_file=/p - `Iggy\SendMessage::payload` and `Iggy\ReceiveMessage::payload()` copy the payload bytes into a PHP string on each read. Cache large payloads in PHP if they will be read repeatedly. -- Large unsigned values that can overflow PHP integers, such as message checksums, - are returned as decimal strings. +- Message IDs and checksums are returned as decimal strings. Offset and timestamp + getters return PHP integers and are limited to `PHP_INT_MAX`. - `Iggy\Client::sendBinaryRequest(int $code, string $payload): string` sends a command code and payload and returns the raw response body. - `Iggy\Client` is synchronous and blocks the current PHP thread. diff --git a/foreign/php/composer.json b/foreign/php/composer.json index 7433a0bd81..73b9a8d546 100644 --- a/foreign/php/composer.json +++ b/foreign/php/composer.json @@ -30,7 +30,7 @@ "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^10.5" + "phpunit/phpunit": "^12.5" }, "php-ext": { "extension-name": "iggy_php", diff --git a/foreign/php/docker-compose.test.yml b/foreign/php/docker-compose.test.yml index 3831e39bde..c80c759d0b 100644 --- a/foreign/php/docker-compose.test.yml +++ b/foreign/php/docker-compose.test.yml @@ -31,6 +31,7 @@ services: environment: - IGGY_HTTP_ADDRESS=0.0.0.0:3000 - IGGY_TCP_ADDRESS=0.0.0.0:8090 + - IGGY_NODE_ADVERTISED_ADDRESS=iggy-server - IGGY_QUIC_ADDRESS=0.0.0.0:8080 - IGGY_WEBSOCKET_ADDRESS=0.0.0.0:8092 - IGGY_ROOT_USERNAME=iggy @@ -61,10 +62,9 @@ services: networks: - php-test-network environment: - - IGGY_HOST=iggy-server - - IGGY_PORT=8090 - - IGGY_USERNAME=iggy - - IGGY_PASSWORD=iggy + - IGGY_TCP_ADDRESS=iggy-server:8090 + - IGGY_ROOT_USERNAME=iggy + - IGGY_ROOT_PASSWORD=iggy volumes: - ./test-results:/workspace/foreign/php/test-results diff --git a/foreign/php/iggy-php.stubs.php b/foreign/php/iggy-php.stubs.php index 211e6f120f..62c012c392 100644 --- a/foreign/php/iggy-php.stubs.php +++ b/foreign/php/iggy-php.stubs.php @@ -94,6 +94,9 @@ public function connect(): void {} /** * Creates and initializes a consumer group consumer. * + * `$partition_id` is ignored for a consumer group: the member reads the partitions + * the server assigns to it. + * * @param string $name * @param string $stream * @param string $topic @@ -132,13 +135,14 @@ public function createStream(string $name): void {} * @param int|null $message_expiry_micros * @param int|null $max_topic_size * @param int|null $segment_size - * @param bool|null $enforce_fsync + * @param \Iggy\Durability|null $durability + * @param \Iggy\Durability|null $consumer_offset_durability * @param int|null $messages_required_to_save * @param int|null $size_of_messages_required_to_save * @param bool|null $preallocate_segments * @return void */ - public function createTopic(mixed $stream, string $name, int $partitions_count, ?string $compression_algorithm = null, ?int $message_expiry_micros = null, ?int $max_topic_size = null, ?int $segment_size = null, ?bool $enforce_fsync = null, ?int $messages_required_to_save = null, ?int $size_of_messages_required_to_save = null, ?bool $preallocate_segments = null): void {} + public function createTopic(mixed $stream, string $name, int $partitions_count, ?string $compression_algorithm = null, ?int $message_expiry_micros = null, ?int $max_topic_size = null, ?int $segment_size = null, ?\Iggy\Durability $durability = null, ?\Iggy\Durability $consumer_offset_durability = null, ?int $messages_required_to_save = null, ?int $size_of_messages_required_to_save = null, ?bool $preallocate_segments = null): void {} /** * Deletes a stream by id or name. @@ -328,6 +332,11 @@ public function stream(): string {} public function topic(): string {} } + enum Durability: string { + case Replicated = 'replicated'; + case Persisted = 'persisted'; + } + class MessageIterator implements \Iterator { public function __construct() {} @@ -512,9 +521,8 @@ class SendMessagesConfirmation { * Delivery is at-least-once, so an earlier retry may already have committed the * same batch at a lower offset. The value never implies uniqueness. * - * A batch is confirmed once it is committed in memory, not once it is fsynced. A - * crash-restart can stamp a later batch with an offset a client has already - * recorded. + * Confirmation follows VSR quorum commit. Persisted message durability also + * requires recoverable stable-storage copies on the quorum. * * @var int */ diff --git a/foreign/php/phpunit.xml.dist b/foreign/php/phpunit.xml.dist index aef6443c5c..8cf59e2cca 100644 --- a/foreign/php/phpunit.xml.dist +++ b/foreign/php/phpunit.xml.dist @@ -19,7 +19,7 @@ under the License. --> diff --git a/foreign/php/scripts/test.sh b/foreign/php/scripts/test.sh index effe9e7a06..f01ec8516b 100755 --- a/foreign/php/scripts/test.sh +++ b/foreign/php/scripts/test.sh @@ -21,12 +21,13 @@ set -euo pipefail echo "PHP SDK Test Runner" echo "===================" -IGGY_HOST="${IGGY_HOST:-127.0.0.1}" -IGGY_PORT="${IGGY_PORT:-8090}" +IGGY_TCP_ADDRESS="${IGGY_TCP_ADDRESS:-127.0.0.1:8090}" +host="${IGGY_TCP_ADDRESS%%:*}" +port="${IGGY_TCP_ADDRESS##*:}" -echo "Waiting for Iggy server at ${IGGY_HOST}:${IGGY_PORT}..." +echo "Waiting for Iggy server at ${host}:${port}..." timeout 60 bash -c " - until timeout 5 bash -c ' PhpResult { + // QUIC creates its endpoint before a blocking API call enters the runtime. + let _guard = runtime().enter(); let client = RustIggyClient::from_connection_string(&connection_string).map_err(to_php_exception)?; @@ -136,7 +139,8 @@ impl IggyClient { message_expiry_micros: Option, max_topic_size: Option, segment_size: Option, - enforce_fsync: Option, + durability: Option, + consumer_offset_durability: Option, messages_required_to_save: Option, size_of_messages_required_to_save: Option, preallocate_segments: Option, @@ -161,7 +165,8 @@ impl IggyClient { message_expiry: (expiry != IggyExpiry::ServerDefault).then_some(expiry), max_topic_size: (max_size != MaxTopicSize::ServerDefault).then_some(max_size), segment_size: segment_size.map(IggyByteSize::from), - enforce_fsync, + durability: durability.unwrap_or_default().into(), + consumer_offset_durability: consumer_offset_durability.unwrap_or_default().into(), messages_required_to_save, size_of_messages_required_to_save: size_of_messages_required_to_save .map(IggyByteSize::from), @@ -293,6 +298,9 @@ impl IggyClient { } /// Creates and initializes a consumer group consumer. + /// + /// `$partition_id` is ignored for a consumer group: the member reads the partitions + /// the server assigns to it. #[allow(clippy::too_many_arguments)] #[php(defaults( create_consumer_group_if_not_exists = true, diff --git a/foreign/php/src/durability.rs b/foreign/php/src/durability.rs new file mode 100644 index 0000000000..9cdaa7ebd6 --- /dev/null +++ b/foreign/php/src/durability.rs @@ -0,0 +1,38 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use ext_php_rs::php_enum; + +#[php_enum] +#[php(name = "Iggy\\Durability")] +#[derive(Clone, Copy, Default)] +pub enum Durability { + #[default] + #[php(value = "replicated")] + Replicated, + #[php(value = "persisted")] + Persisted, +} + +impl From for iggy::prelude::Durability { + fn from(value: Durability) -> Self { + match value { + Durability::Replicated => Self::Replicated, + Durability::Persisted => Self::Persisted, + } + } +} diff --git a/foreign/php/src/lib.rs b/foreign/php/src/lib.rs index 49b944225f..02bc6a9b57 100644 --- a/foreign/php/src/lib.rs +++ b/foreign/php/src/lib.rs @@ -17,6 +17,7 @@ pub mod client; pub mod consumer; +pub mod durability; pub mod error; pub mod identifier; pub mod message_iterator; @@ -43,6 +44,7 @@ use crate::topic::TopicDetails; #[php_module] pub fn get_module(module: ModuleBuilder) -> ModuleBuilder { module + .enumeration::() // Parent classes must be registered before subclasses because ext-php-rs resolves // the parent ClassEntry during child registration. .class::() diff --git a/foreign/php/src/send_message.rs b/foreign/php/src/send_message.rs index 19b09a291e..476562af6b 100644 --- a/foreign/php/src/send_message.rs +++ b/foreign/php/src/send_message.rs @@ -151,9 +151,8 @@ impl SendMessagesConfirmation { /// Delivery is at-least-once, so an earlier retry may already have committed the /// same batch at a lower offset. The value never implies uniqueness. /// - /// A batch is confirmed once it is committed in memory, not once it is fsynced. A - /// crash-restart can stamp a later batch with an offset a client has already - /// recorded. + /// Confirmation follows VSR quorum commit. Persisted message durability also + /// requires recoverable stable-storage copies on the quorum. #[php(getter)] pub fn base_offset(&self) -> u64 { self.inner.base_offset diff --git a/foreign/php/tests/IggySdkTest.php b/foreign/php/tests/IggySdkTest.php index 6ad9f057ea..336fb206b8 100644 --- a/foreign/php/tests/IggySdkTest.php +++ b/foreign/php/tests/IggySdkTest.php @@ -30,6 +30,12 @@ final class IggySdkTest extends TestCase { + private const CREATE_USER_CODE = 33; + private const DELETE_USER_CODE = 34; + private const GET_ME_CODE = 20; + private const ACTIVE_USER_STATUS = 1; + private const STRING_IDENTIFIER_KIND = 2; + #[TestDox('A connected client can ping the server')] public function testPing(): void { @@ -52,7 +58,7 @@ public function testEmptyClientAddressUsesDefault(): void $client = new IggyClient(''); $client->connect(); - $client->loginUser(env_or_default('IGGY_USERNAME', 'iggy'), env_or_default('IGGY_PASSWORD', 'iggy')); + $client->loginUser(env_or_default('IGGY_ROOT_USERNAME', 'iggy'), env_or_default('IGGY_ROOT_PASSWORD', 'iggy')); $client->ping(); assert_true($client instanceof IggyClient); @@ -67,6 +73,62 @@ public function testClientFromConnectionString(): void assert_true($client instanceof IggyClient); } + #[TestDox('Examples authenticate using literal environment credentials')] + public function testExamplesAuthenticateWithLiteralEnvironmentCredentials(): void + { + require_once __DIR__ . '/../../../examples/php/src/common.php'; + + $admin = new_client(); + $username = unique_name('php-example-user'); + $password = 'example % @:+= password'; + $request = chr(strlen($username)) . $username + . chr(strlen($password)) . $password + . chr(self::ACTIVE_USER_STATUS) . chr(0); + $created = $admin->sendBinaryRequest(self::CREATE_USER_CODE, $request); + $userId = unpack('VuserId', $created)['userId']; + $keys = ['IGGY_CONNECTION_STRING', 'IGGY_HOST', 'IGGY_PORT', 'IGGY_USERNAME', 'IGGY_PASSWORD']; + $previous = array_combine($keys, array_map('getenv', $keys)); + + try { + putenv('IGGY_CONNECTION_STRING'); + putenv('IGGY_HOST=' . server_host()); + putenv('IGGY_PORT=' . server_port()); + putenv('IGGY_USERNAME=' . $username); + putenv('IGGY_PASSWORD=' . $password); + + $client = iggy_client(); + $identity = $client->sendBinaryRequest(self::GET_ME_CODE, ''); + assert_same($userId, unpack('VclientId/VuserId', $identity)['userId']); + + putenv('IGGY_PASSWORD=incorrect-password'); + assert_instance_of( + \Iggy\Exception\AuthenticationException::class, + assert_throws(static fn () => iggy_client()), + ); + } finally { + foreach ($previous as $key => $value) { + putenv($value === false ? $key : $key . '=' . $value); + } + $admin->sendBinaryRequest( + self::DELETE_USER_CODE, + chr(self::STRING_IDENTIFIER_KIND) . chr(strlen($username)) . $username, + ); + } + } + + #[TestDox('Connection strings construct clients for every transport before connecting')] + public function testConnectionStringsConstructEveryTransport(): void + { + foreach ([ + 'iggy+tcp://iggy:iggy@127.0.0.1:8090', + 'iggy+quic://iggy:iggy@127.0.0.1:8080', + 'iggy+http://iggy:iggy@127.0.0.1:3000', + 'iggy+ws://iggy:iggy@127.0.0.1:8092', + ] as $connectionString) { + assert_instance_of(IggyClient::class, IggyClient::fromConnectionString($connectionString)); + } + } + #[TestDox('A stream can be created and fetched by name')] public function testCreateAndGetStream(): void { diff --git a/foreign/php/tests/bootstrap.php b/foreign/php/tests/bootstrap.php index de0866f691..88f63f86e7 100644 --- a/foreign/php/tests/bootstrap.php +++ b/foreign/php/tests/bootstrap.php @@ -84,14 +84,19 @@ function env_or_default(string $name, string $default): string return $value === false || $value === '' ? $default : $value; } +function server_address(): string +{ + return env_or_default('IGGY_TCP_ADDRESS', '127.0.0.1:8090'); +} + function server_host(): string { - return env_or_default('IGGY_HOST', '127.0.0.1'); + return explode(':', server_address(), 2)[0]; } function server_port(): int { - return (int) env_or_default('IGGY_PORT', '8090'); + return (int) (explode(':', server_address(), 2)[1] ?? '8090'); } function wait_for_server(string $host, int $port, int $timeoutSeconds = 30): void @@ -116,9 +121,9 @@ function wait_for_server(string $host, int $port, int $timeoutSeconds = 30): voi function new_client(): IggyClient { - $client = new IggyClient(server_host() . ':' . server_port()); + $client = new IggyClient(server_address()); $client->connect(); - $client->loginUser(env_or_default('IGGY_USERNAME', 'iggy'), env_or_default('IGGY_PASSWORD', 'iggy')); + $client->loginUser(env_or_default('IGGY_ROOT_USERNAME', 'iggy'), env_or_default('IGGY_ROOT_PASSWORD', 'iggy')); return $client; } @@ -127,8 +132,8 @@ function new_connection_string_client(): IggyClient { $host = server_host(); $port = server_port(); - $username = rawurlencode(env_or_default('IGGY_USERNAME', 'iggy')); - $password = rawurlencode(env_or_default('IGGY_PASSWORD', 'iggy')); + $username = rawurlencode(env_or_default('IGGY_ROOT_USERNAME', 'iggy')); + $password = rawurlencode(env_or_default('IGGY_ROOT_PASSWORD', 'iggy')); $client = IggyClient::fromConnectionString("iggy+tcp://{$username}:{$password}@{$host}:{$port}"); $client->connect(); diff --git a/foreign/python/Cargo.toml b/foreign/python/Cargo.toml index eefeaa1c6f..62f1511915 100644 --- a/foreign/python/Cargo.toml +++ b/foreign/python/Cargo.toml @@ -17,7 +17,7 @@ [package] name = "apache-iggy" -version = "0.9.0-dev5" +version = "0.9.0" edition = "2024" authors = ["Iggy Committers "] license = "Apache-2.0" @@ -36,10 +36,10 @@ doc = false [dependencies] bytes = "1.12.1" -futures = "0.3.33" -iggy = { path = "../../core/sdk", version = "0.11.0-edge.5" } +futures = "0.3.34" +iggy = { path = "../../core/sdk", version = "0.11.0" } paste = "1" -pyo3 = "0.29.0" +pyo3 = "0.29.2" pyo3-async-runtimes = { version = "0.29.0", features = [ "attributes", "tokio-runtime", diff --git a/foreign/python/README.md b/foreign/python/README.md index dbbdbe02ca..c727cb5aec 100644 --- a/foreign/python/README.md +++ b/foreign/python/README.md @@ -28,14 +28,19 @@ pip install apache-iggy ### Prerequisites -Every installation below compiles the Rust extension, so you'll need: - - Python 3.10+ + +Published wheels include the Rust extension; installing a wheel does not require +Rust. Building from source and running the development checks below also requires: + - Rust toolchain: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` - `uv`: `curl -LsSf https://astral.sh/uv/install.sh | sh` - All checks tooling from [CONTRIBUTING.md](https://github.com/apache/iggy/blob/master/CONTRIBUTING.md). - Docker +Use an SDK release compatible with your server. For unreleased changes, build +the SDK and server from the same source checkout. + ### Local Development **IMPORTANT: All commands are supposed to be ran from `foreign/python` unless it's specified to run in repository's root folder.** @@ -92,12 +97,11 @@ Every installation below compiles the Rust extension, so you'll need: pytest tests/ -v # make sure iggy-server is running and the venv is activated ``` -4. To update the stubs, only after changing the pyo3 API surface (nothing in CI checks stub freshness, so unconditional regen just invites `.pyi` churn), use +4. To update the stubs, after changing the pyo3 API surface, use ```bash # run from foreign/python cargo run --bin stub_gen - # TODO: Known bug: running this from a subdirectory of `foreign/python` corrupts the tracked stub, see https://github.com/apache/iggy/pull/3825/changes/BASE..773a27971b4ddb7b44773ded395ed23afb1de4c9#r3727691619 ``` 5. Before committing, test the pre-commit and pre-push hooks. `prek` only inspects staged content, so stage your work first: @@ -135,7 +139,8 @@ running prek / committing / pushing. This list is not exhaustive and other hook ## Client Configuration -`IggyClient` takes either a server address or a `TcpConfig`: +`IggyClient` takes a server address, a `TcpConfig`, a `QuicConfig`, an +`HttpConfig`, or a `WebSocketConfig`: ```python import asyncio @@ -169,6 +174,190 @@ async def main(): asyncio.run(main()) ``` +`IggyClient(...)` also accepts a `QuicConfig` for the QUIC transport, an +`HttpConfig` for the HTTP transport, and a `WebSocketConfig` for the WebSocket +transport. `examples/python/getting-started/producer.py` shows each swap in +context. + +`HttpConfig` differs from TCP in two ways. There is no reconnection policy and no +`AutoLogin`: `connect()` does not dial over HTTP, but it does start the +heartbeat that `heartbeat_interval` configures, so call it and then +`login_user(...)`. And HTTP is single-consumer only: the `consumer_group(...)` +path always fails with `Feature is unavailable`, at the join by default and at +the returned consumer's first poll if you disable `auto_join_consumer_group`, +so disabling it is not a workaround. A direct +`poll_messages(consumer=Consumer.Group(...))` fails the same way unless you +pass an explicit `partition_id`, and with one it degrades silently instead: the +consumer kind is not carried on the HTTP wire, so the group is served as an +ordinary consumer named after it, with no membership or partition assignment +behind it. Use `Consumer.Single(...)` with `poll_messages(...)`. Delivery is +also at-least-once: the default `retries=3` replays the full request body, so a +send whose response was lost is applied twice, and only `retries=0` opts out. + +```python +import asyncio + +from apache_iggy import HttpConfig, IggyClient + + +async def main(): + client = IggyClient(HttpConfig(api_url="http://127.0.0.1:3000")) + await client.connect() + await client.login_user("iggy", "iggy") + + +asyncio.run(main()) +``` + +## High-Level Producer + +The Python high-level producer API is a port of the Rust high-level producer +API. For detailed producer semantics and configuration guidance, see the +[Rust high-level SDK documentation](https://iggy.apache.org/docs/sdk/rust/high-level-sdk/). + +Use `IggyClient.producer()` when an application repeatedly publishes to one +stream and topic. Producer creation is asynchronous because it initializes the +destination before returning. By default, it creates a missing stream and topic, +uses balanced partitioning, sends directly in batches of up to 1,000 messages, +and retries failed sends up to three times with a one-second retry interval. + +The default mode is direct. Pass `BackgroundProducerConfig` to queue sends on +background workers instead. + +```python +import asyncio +from datetime import timedelta + +from apache_iggy import DirectProducerConfig, IggyClient, Partitioning, SendMessage + + +async def main(): + client = IggyClient.from_connection_string("iggy+tcp://iggy:iggy@127.0.0.1:8090") + await client.connect() + + producer = await client.producer( + "orders", + "created", + partitioning=Partitioning.balanced(), + mode=DirectProducerConfig( + batch_length=500, + linger_time=timedelta(milliseconds=5), + ), + create_stream_if_not_exists=True, + create_topic_if_not_exists=True, + topic_partitions_count=3, + topic_message_expiry=None, + topic_max_size=None, + send_retries=3, + send_retry_interval=timedelta(seconds=1), + ) + + async with producer: + await producer.send_one(SendMessage("order-1")) + response = await producer.send([SendMessage("order-2"), SendMessage("order-3")]) + print(f"Received {len(response.confirmations)} partition confirmations") + + +asyncio.run(main()) +``` + +The producer is bound to the stream and topic passed to `producer()`. Use +`send_with_partitioning(messages, partitioning)` to override its partitioning +strategy for one call, or `send_to(stream, topic, messages, partitioning)` to +send to another existing destination. `send_to()` does not create or initialize +that destination. + +Direct sends use at-least-once delivery. A request can commit even when its +response is lost, so any retry can write the same batch again. `send_retries` +counts retries after the initial attempt. The first retry runs immediately, and +`send_retry_interval` delays only later retries. Set `send_retries` to `None` or +`0` to disable producer retries. Set `send_retry_interval` to `None` to run all +enabled retries without a delay. A zero interval raises `ValueError`. + +Transport retries are separate from producer retries. For example, the default +`HttpConfig(retries=3)` gives each producer attempt up to four HTTP attempts. + +A failed direct send raises `ProducerSendError`, which is a `RuntimeError` +subclass. Its `cause` property contains the underlying error text. Its +`committed` property contains confirmations from completed chunks, and `failed` +contains the remaining unconfirmed messages. If encryption is enabled, the +failed messages contain encrypted payloads. Restore the original payloads +before submitting them to the same producer again. + +### Background Mode + +A successful background send means the dispatcher accepted the messages. The +worker writes them later, so all four send methods return a +`SendMessagesResponse` with an empty `confirmations` list. It does not mean the +server has committed the messages. + +```python +from datetime import timedelta + +from apache_iggy import ( + BackgroundProducerConfig, + BackpressureMode, + ProducerSharding, + SendMessage, +) + +producer = await client.producer( + "orders", + "created", + mode=BackgroundProducerConfig( + num_shards=4, + linger_time=timedelta(milliseconds=10), + batch_size=1024 * 1024, + batch_length=100, + max_buffer_size=32 * 1024 * 1024, + failure_mode=BackpressureMode.block_with_timeout(timedelta(seconds=1)), + max_in_flight=4, + sharding=ProducerSharding.ORDERED, + ), +) + +async with producer: + accepted = await producer.send_one(SendMessage("order-1")) + assert accepted.confirmations == [] +``` + +Each shard flushes when any configured condition is met: `batch_length` queued +send calls, `batch_size` reported bytes, or `linger_time` since the first send +entered an empty buffer. `batch_length` counts calls, not individual messages. +Zero disables either batching threshold, while a zero linger flushes as soon as +the worker receives a send. + +`ProducerSharding.ORDERED` hashes the stream/topic destination so its sends use +one sequential worker and retain dispatch order. `ProducerSharding.BALANCED` +assigns consecutive sends round-robin across the shards for throughput; ordering +for one destination is not guaranteed. + +`max_buffer_size` bounds bytes queued or in flight across the whole producer. +When it is full, `BackpressureMode.block()` waits indefinitely, +`block_with_timeout(duration)` waits up to that duration, and +`fail_immediately()` raises `RuntimeError` without accepting the batch. A single +batch larger than the whole budget always fails. A zero byte budget is +unlimited. `max_in_flight` separately bounds concurrent write requests across +all shards; zero uses the runtime maximum. + +Producer retries and transport reconnection happen inside background workers. +The Python API does not currently expose a background error callback, so a write +that still fails after its retries is logged by the Rust SDK and its unconfirmed +messages are dropped. + +See the complete runnable +[`background_producer.py`](../../examples/python/high-level/background_producer.py) +example for all background configuration fields and deterministic shutdown. + +Cleanup is asynchronous and must be explicit. Prefer `async with`, as above, so +shutdown runs on both successful and exceptional exits. Otherwise, call +`await producer.shutdown()` in a `finally` block. Shutdown waits for active +sends, is safe to call more than once, and rejects later sends with +`RuntimeError`. In background mode it also drains every queue and flushes all +accepted messages before returning. Object destruction does not perform +asynchronous cleanup; dropping a background producer without `shutdown()` can +lose buffered messages. + ## Examples Refer to the [examples/python/](https://github.com/apache/iggy/tree/master/examples/python) directory for usage examples. diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 254b23f41c..579fca81c7 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -26,29 +26,44 @@ import enum import typing __all__ = [ + "Durability", "AutoCommit", "AutoCommitAfter", "AutoCommitWhen", "AutoLogin", + "BackgroundProducerConfig", + "BackpressureMode", + "CacheMetrics", + "CacheMetricsKey", "Consumer", "ConsumerGroup", "ConsumerGroupDetails", "ConsumerGroupMember", + "DirectProducerConfig", "GlobalPermissions", "HeaderKey", "HeaderValue", + "HttpConfig", "IggyClient", "IggyConsumer", "IggyExpiry", + "IggyProducer", "MaxTopicSize", "OptionSpec", "Partition", + "Partitioning", "Permissions", "PollingStrategy", + "ProducerSendError", + "ProducerSharding", + "QuicConfig", + "QuicReconnectionConfig", "ReceiveMessage", "SendMessage", "SendMessagesConfirmation", "SendMessagesResponse", + "Stats", + "Stream", "StreamDetails", "StreamPermissions", "TcpConfig", @@ -60,6 +75,9 @@ __all__ = [ "UserInfo", "UserInfoDetails", "UserStatus", + "WebSocketConfig", + "WebSocketFramingConfig", + "WebSocketReconnectionConfig", ] class AutoCommit: @@ -290,6 +308,158 @@ class AutoLogin: """ def __repr__(self) -> builtins.str: ... +@typing.final +class BackgroundProducerConfig: + r""" + Immutable configuration for a producer that queues sends on background workers. + + For detailed background-producer semantics, see + https://iggy.apache.org/docs/sdk/rust/high-level-sdk/. + """ + @property + def num_shards(self) -> builtins.int: + r""" + Number of background worker shards, each with its own queue. + A value of zero is treated as one shard. + """ + @property + def linger_time(self) -> datetime.timedelta: + r""" + Maximum time a worker holds a non-empty buffer before flushing it. + A zero duration flushes as soon as the worker receives a send. + """ + @property + def batch_size(self) -> builtins.int: + r""" + Per-worker flush threshold in buffered bytes. + A value of zero disables this threshold. + """ + @property + def batch_length(self) -> builtins.int: + r""" + Per-worker flush threshold in queued sends, not individual messages. + A value of zero disables this threshold. + """ + @property + def max_buffer_size(self) -> builtins.int: + r""" + Maximum bytes buffered or in flight across all worker shards. + A value of zero makes the byte budget unlimited. + """ + @property + def failure_mode(self) -> BackpressureMode: + r""" + Behavior when `max_buffer_size` is exhausted. + """ + @property + def max_in_flight(self) -> builtins.int: + r""" + Maximum number of requests written concurrently across all workers. + A value of zero uses the runtime's maximum semaphore permit count. + """ + @property + def sharding(self) -> ProducerSharding: + r""" + Strategy used to assign each send to a worker shard. + Ordered sharding preserves per-destination dispatch order, while balanced + sharding distributes sends round-robin and may reorder them. + """ + def __new__( + cls, + *, + num_shards: builtins.int = 1, + linger_time: datetime.timedelta = ..., + batch_size: builtins.int = 1048576, + batch_length: builtins.int = 1000, + max_buffer_size: builtins.int = 33554432, + failure_mode: BackpressureMode = ..., + max_in_flight: builtins.int = 1, + sharding: ProducerSharding = ..., + ) -> BackgroundProducerConfig: + r""" + Constructs background batching, capacity, backpressure, and sharding configuration. + """ + def __repr__(self) -> builtins.str: ... + +@typing.final +class BackpressureMode: + r""" + What a background send does when the producer buffer is full. + """ + @property + def timeout(self) -> datetime.timedelta | None: + r""" + The configured timeout, or `None` for modes without one. + """ + def __eq__(self, other: builtins.object, /) -> builtins.bool: ... + @staticmethod + def block() -> BackpressureMode: + r""" + Wait indefinitely for buffer capacity. + """ + @staticmethod + def block_with_timeout(timeout: datetime.timedelta) -> BackpressureMode: + r""" + Wait up to `timeout` for buffer capacity. + """ + @staticmethod + def fail_immediately() -> BackpressureMode: + r""" + Fail immediately when the producer buffer is full. + """ + def __repr__(self) -> builtins.str: ... + +@typing.final +class CacheMetrics: + r""" + Cache metrics for a specific partition. + """ + @property + def hits(self) -> builtins.int: + r""" + Number of cache hits. + """ + @property + def misses(self) -> builtins.int: + r""" + Number of cache misses. + """ + @property + def hit_ratio(self) -> builtins.float: + r""" + Hit ratio (hits / (hits + misses)). + """ + def __repr__(self) -> builtins.str: ... + +@typing.final +class CacheMetricsKey: + r""" + Key identifying the partition a `CacheMetrics` entry belongs to. + + Hashable and comparable, so it can key the `Stats.cache_metrics` dict. + """ + @property + def stream_id(self) -> builtins.int: + r""" + The unique identifier (numeric) of the stream. + """ + @property + def topic_id(self) -> builtins.int: + r""" + The unique identifier (numeric) of the topic within the stream. + """ + @property + def partition_id(self) -> builtins.int: + r""" + The unique identifier (numeric) of the partition within the topic. + """ + def __eq__(self, other: builtins.object, /) -> builtins.bool: ... + def __hash__(self) -> builtins.int: ... + def __new__( + cls, stream_id: builtins.int, topic_id: builtins.int, partition_id: builtins.int + ) -> CacheMetricsKey: ... + def __repr__(self) -> builtins.str: ... + class Consumer: r""" The consumer polling the messages. It selects both the consumer kind and the @@ -388,6 +558,30 @@ class ConsumerGroupMember: Gets the collection of partitions the consumer group member is consuming. """ +@typing.final +class DirectProducerConfig: + r""" + Configuration for a producer that sends from the calling task. + """ + @property + def batch_length(self) -> builtins.int: + r""" + Maximum number of messages sent in one request. + A value of zero uses the internal limit of 1,000,000 messages. + """ + @property + def linger_time(self) -> datetime.timedelta: + r""" + Minimum gap requested between sequential direct sends. + """ + def __new__( + cls, *, batch_length: builtins.int = 1000, linger_time: datetime.timedelta = ... + ) -> DirectProducerConfig: + r""" + Constructs direct-producer batching and pacing configuration. + """ + def __repr__(self) -> builtins.str: ... + @typing.final class GlobalPermissions: r""" @@ -837,29 +1031,112 @@ class HeaderValue: def value(self) -> builtins.float: ... def __new__(cls, value: builtins.float) -> HeaderValue.Float64: ... +@typing.final +class HttpConfig: + r""" + Configuration for the HTTP transport, accepted by `IggyClient(...)`. + + Every field is keyword-only and optional. + + There is no `AutoLogin` and no reconnection policy, and `connect()` does not + dial: it only starts the heartbeat, so `login_user(...)` has to follow it. + + HTTP is single-consumer only. `consumer_group(...)` fails with + `Feature is unavailable`, and so does a `Consumer.Group(...)` poll unless it + names an explicit `partition_id`. With one, the consumer kind is not carried + on the HTTP wire, so the poll is served as an ordinary consumer named after + the group, with no membership, no partition assignment, and no rebalancing + behind it. Pass `Consumer.Single(...)` explicitly. + """ + @property + def api_url(self) -> builtins.str: ... + @property + def retries(self) -> builtins.int: ... + @property + def has_jwt(self) -> builtins.bool: + r""" + Whether a JWT is configured, without exposing the token itself. + """ + @property + def heartbeat_interval(self) -> datetime.timedelta: ... + def __new__( + cls, + *, + api_url: builtins.str | None = None, + retries: builtins.int | None = None, + jwt: builtins.str | None = None, + heartbeat_interval: datetime.timedelta | None = None, + ) -> HttpConfig: + r""" + Constructs an HTTP configuration. + + Args: + api_url: Base URL of the Iggy HTTP API, as `scheme://host[:port]` + only - no path, query, fragment, or credentials. Defaults to + `http://127.0.0.1:3000`. + retries: Number of retries to perform on transient errors, each one + replaying the full request (including its body) via automatic + middleware. Defaults to 3. Delivery is therefore at-least-once: + if the original request actually committed but its response + was lost (e.g. to a timeout), a retried call applies the same + operation again. Set to 0 to disable automatic replay and match + the other transports, which surface the failure instead of + silently resending. + jwt: JWT token for A2A (Agent-to-Agent) authentication. Defaults to + `None`. Stored trimmed, since a token read from a file carries a + trailing newline that the `Authorization` header value rejects. + Rejected if empty or whitespace-only: accepting it would make + `has_jwt` report `True` while every call still fails + `Unauthenticated`. + heartbeat_interval: Interval between the client's liveness probes + (a bare `GET /ping`). Defaults to 5 seconds. Unlike TCP/QUIC, + HTTP has no persistent connection or session for this to keep + alive; it only proves the server is reachable. + + Raises: + ValueError: If `api_url` is not a valid URL, if `retries` is outside + the range of an unsigned 32-bit integer, if `jwt` is empty or + whitespace-only, if a duration is negative, or if + `heartbeat_interval` is zero. + OverflowError: If `retries` does not fit a signed 64-bit integer, + raised by the underlying conversion before this constructor runs. + """ + def __repr__(self) -> builtins.str: ... + @typing.final class IggyClient: r""" A Python class representing the Iggy client. It provides asynchronous functionality through the contained runtime. """ - def __new__(cls, conn: TcpConfig | builtins.str | None = None) -> IggyClient: - r""" - Constructs a new IggyClient from a TCP server address or a `TcpConfig`. - This initializes a new runtime for asynchronous operations. - Future versions might utilize asyncio for more Pythonic async. + def __new__( + cls, + conn: TcpConfig + | QuicConfig + | HttpConfig + | WebSocketConfig + | builtins.str + | None = None, + ) -> IggyClient: + r""" + Constructs a new IggyClient from a TCP server address, a `TcpConfig`, a + `QuicConfig`, an `HttpConfig`, or a `WebSocketConfig`. Construction is + synchronous; async methods return asyncio awaitables backed by the shared + Tokio runtime. Args: - conn: Either a `host:port` address, or a `TcpConfig` carrying the full - transport configuration. Defaults to `127.0.0.1:8090` with auto-login - disabled. A malformed address is reported differently by the two - forms: the string form raises `RuntimeError` here, while `TcpConfig` - raises `ValueError` when it is constructed, before it ever reaches - this call. Neither exception is a subclass of the other. + conn: A `host:port` address, a `TcpConfig`, a `QuicConfig`, an + `HttpConfig`, or a `WebSocketConfig`. Defaults to `127.0.0.1:8090` + over TCP with auto-login disabled. A malformed address is reported + differently depending on the form: the string form raises + `RuntimeError` here, while every config type raises `ValueError` + when it is constructed, before any of them reaches this call. Neither + exception is a subclass of the other. Raises: RuntimeError: If the address passed as a string is not a valid - `host:port` pair. + `host:port` pair, or if a `QuicConfig` client cannot bind its + local UDP socket (for example the port is already in use). """ @classmethod def from_connection_string(cls, connection_string: builtins.str) -> IggyClient: @@ -872,6 +1149,42 @@ class IggyClient: Sends a ping request to the server to check connectivity. Raises `RuntimeError` if the connection fails. """ + def get_stats(self) -> collections.abc.Awaitable[Stats]: + r""" + Get the statistics and details of the server and its running process. + + Requires an authenticated session whose user holds the `read_servers` + or `manage_servers` global permission. + + Returns: + An awaitable that resolves to `Stats`. + + Raises: + RuntimeError: If the client is not connected, the session is not + authenticated, the user lacks the permission, or the request + fails. + """ + def describe_options( + self, scope: builtins.str + ) -> collections.abc.Awaitable[list[OptionSpec]]: + r""" + Describe the option catalog for a resource scope. + + This is the discovery surface for the `options` argument on + `create_topic`/`update_topic`: a key outside the catalog is refused at + create, and the binary transports carry only the error code back. + + Args: + scope: One of `"topic"`, `"stream"`, `"user"`. + + Returns: + An awaitable that resolves to `list[OptionSpec]`, empty for a scope + with no keys yet. + + Raises: + ValueError: If the scope name is not one of the three above. + RuntimeError: If the request fails. + """ def login_user( self, username: builtins.str, password: builtins.str ) -> collections.abc.Awaitable[None]: @@ -933,6 +1246,7 @@ class IggyClient: user_id: builtins.str | builtins.int, username: builtins.str | None = None, status: UserStatus | None = None, + options: builtins.dict[builtins.str, builtins.str] | None = None, ) -> collections.abc.Awaitable[None]: r""" Update a user by unique ID or username. @@ -941,6 +1255,9 @@ class IggyClient: user_id: User identifier as `str | int`. username: New username as `str | None`; unchanged when `None`. status: New status as `UserStatus | None`; unchanged when `None`. + options: Reserved for future use. Additional option keys as + `dict[str, str] | None`, forwarded to the server. No user update + option key exists yet, so a current server rejects every key. Returns: An awaitable that resolves to `None` when the user is updated. @@ -1018,8 +1335,10 @@ class IggyClient: """ def connect(self) -> collections.abc.Awaitable[None]: r""" - Connects the IggyClient to its service. - Raises `RuntimeError` if the connection fails. + Connects the IggyClient to its service and starts the heartbeat task. + Raises `RuntimeError` if the connection fails. Over HTTP there is no + connection to establish, so only the heartbeat starts and this call + succeeds even against an unreachable server. """ def create_stream(self, name: builtins.str) -> collections.abc.Awaitable[None]: r""" @@ -1034,26 +1353,92 @@ class IggyClient: Returns the stream details, or `None` if the stream does not exist. Raises `RuntimeError` on failure. """ - def describe_options( - self, scope: builtins.str - ) -> collections.abc.Awaitable[builtins.list[OptionSpec]]: + def get_streams(self) -> collections.abc.Awaitable[list[Stream]]: r""" - Describe the option catalog for a resource scope. + Return all streams. - This is the discovery surface for the `options` argument on - `create_topic`/`update_topic`: a key outside the catalog is refused at - create, and the binary transports carry only the error code back. + Returns: + A list of `Stream` summaries. + + Raises: + RuntimeError: If the client is not authenticated, the user lacks global + `read_streams` or `manage_streams` permission, or the request fails. + """ + def update_stream( + self, + stream_id: builtins.str | builtins.int, + name: builtins.str, + options: builtins.dict[builtins.str, builtins.str] | None = None, + ) -> collections.abc.Awaitable[None]: + r""" + Rename a stream selected by name or numeric ID. + + `stream_id` accepts a stream name as `str` or numeric ID as `int`. A + decimal-only string is interpreted as a numeric ID. `name` must be unique + and contain between 1 and 255 UTF-8 bytes. Renaming a stream to its current + name succeeds without changing it. Args: - scope: One of `"topic"`, `"stream"`, `"user"`. + stream_id: Stream identifier as `str | int`. + name: New stream name as `str`. + options: Additional option keys as `dict[str, str] | None`, forwarded + to the server. Current server versions reject all stream update + option keys. Returns: - An awaitable that resolves to `list[OptionSpec]`, empty for a scope - with no keys yet. + None. Raises: - ValueError: If the scope name is not one of the three above. - RuntimeError: If the request fails. + TypeError: If `stream_id` is not `str` or an integer in + `0..=2**32 - 1`, or `name` is not `str`. + ValueError: If a string identifier is empty or exceeds 255 UTF-8 bytes. + RuntimeError: If the client is not authenticated, the user lacks global + `manage_streams` or per-stream `manage_stream` permission, the + stream does not exist, the new name is invalid or already used, or + the request fails. + """ + def delete_stream( + self, stream_id: builtins.str | builtins.int + ) -> collections.abc.Awaitable[None]: + r""" + Delete a stream selected by name or numeric ID. + + Deletion removes the stream and all of its topics, partitions, and messages. + `stream_id` accepts a stream name as `str` or numeric ID as `int`. A + decimal-only string is interpreted as a numeric ID. + + Returns: + None. + + Raises: + TypeError: If `stream_id` is not `str` or an integer in + `0..=2**32 - 1`. + ValueError: If a string identifier is empty or exceeds 255 UTF-8 bytes. + RuntimeError: If the client is not authenticated, the user lacks global + `manage_streams` or per-stream `manage_stream` permission, the + stream does not exist, or the request fails. + """ + def purge_stream( + self, stream_id: builtins.str | builtins.int + ) -> collections.abc.Awaitable[None]: + r""" + Delete all messages from every topic in a stream. + + The stream, topics, and partitions remain available. Repeated purges of an + existing empty stream succeed. `stream_id` accepts a stream name as `str` + or numeric ID as `int`. A decimal-only string is interpreted as a numeric + ID. + + Returns: + None. + + Raises: + TypeError: If `stream_id` is not `str` or an integer in + `0..=2**32 - 1`. + ValueError: If a string identifier is empty or exceeds 255 UTF-8 bytes. + RuntimeError: If the client is not authenticated, the user lacks global + `manage_streams` or per-stream `manage_stream` permission, the + stream does not exist, or the request fails. """ def create_topic( self, @@ -1064,7 +1449,8 @@ class IggyClient: message_expiry: IggyExpiry | None = None, max_topic_size: MaxTopicSize | None = None, segment_size: builtins.int | None = None, - enforce_fsync: builtins.bool | None = None, + durability: Durability | None = None, + consumer_offset_durability: Durability | None = None, messages_required_to_save: builtins.int | None = None, size_of_messages_required_to_save: builtins.int | None = None, preallocate_segments: builtins.bool | None = None, @@ -1076,12 +1462,13 @@ class IggyClient: Args: stream: Stream identifier as `str | int`. name: Topic name as `str`. - partitions_count: Number of partitions as `int`. + partitions_count: Number of partitions as `int`, at most 1000. compression_algorithm: Compression algorithm as `str | None`. message_expiry: Message expiry as `IggyExpiry | None`. max_topic_size: Maximum topic size as `MaxTopicSize | None`. segment_size: Per-topic segment size in bytes as `int | None`. - enforce_fsync: Per-topic fsync enforcement as `bool | None`. + durability: Message completion policy, defaulting to replicated. + consumer_offset_durability: Independent offset policy, defaulting to replicated. messages_required_to_save: Message-count flush threshold as `int | None`. size_of_messages_required_to_save: Byte flush threshold as `int | None`. preallocate_segments: Reserve segment bytes on open as `bool | None`. @@ -1194,6 +1581,66 @@ class IggyClient: Raises: RuntimeError: If an identifier is invalid or the request fails. """ + def create_partitions( + self, + stream_id: builtins.str | builtins.int, + topic_id: builtins.str | builtins.int, + partitions_count: builtins.int, + ) -> collections.abc.Awaitable[None]: + r""" + Create partitions for a topic. New partition IDs continue from one past the + current highest ID; IDs removed by deletion can be reused. Existing consumer + groups are immediately rebalanced across all partitions, advancing their + generation and dropping pending revocations. + + Args: + stream_id: Stream identifier as `str | int`. + topic_id: Topic identifier as `str | int`. + partitions_count: Number of partitions to create as `int`, between 1 and + 1000 inclusive. + + Returns: + An awaitable that resolves to `None` when the partitions are committed; + storage materialization completes asynchronously. + + Raises: + ValueError: If an identifier is invalid. + OverflowError: If `partitions_count` is outside the unsigned 32-bit range. + RuntimeError: If the client is not authenticated, lacks global + `manage_streams` or `manage_topics`, per-stream `manage_stream` or + `manage_topics`, or per-topic `manage_topic` permission, or the + request fails. + """ + def delete_partitions( + self, + stream_id: builtins.str | builtins.int, + topic_id: builtins.str | builtins.int, + partitions_count: builtins.int, + ) -> collections.abc.Awaitable[None]: + r""" + Delete the last partitions from a topic, including all messages stored in them. + Existing consumer groups are immediately rebalanced across the remaining + partitions, advancing their generation and dropping pending revocations. + + Args: + stream_id: Stream identifier as `str | int`. + topic_id: Topic identifier as `str | int`. + partitions_count: Number of partitions to delete as `int` from the end of + the topic; must be between 1 and 1000 inclusive and no greater than + its current count. + + Returns: + An awaitable that resolves to `None` when deletion is accepted; storage + teardown completes asynchronously. + + Raises: + ValueError: If an identifier is invalid. + OverflowError: If `partitions_count` is outside the unsigned 32-bit range. + RuntimeError: If the client is not authenticated, lacks global + `manage_streams` or `manage_topics`, per-stream `manage_stream` or + `manage_topics`, or per-topic `manage_topic` permission, or the + request fails. + """ def create_consumer_group( self, stream_id: builtins.str | builtins.int, @@ -1331,15 +1778,58 @@ class IggyClient: self, stream: builtins.str | builtins.int, topic: builtins.str | builtins.int, - partitioning: builtins.int, + partitioning: Partitioning | builtins.int, messages: list[SendMessage], ) -> collections.abc.Awaitable[SendMessagesResponse]: r""" - Sends a list of messages to the specified topic. - Returns a SendMessagesResponse carrying the per-partition commit - confirmations, or a PyRuntimeError on failure. The confirmation list is - empty when the server reports no offsets, and the legacy server never - reports any. + Sends a batch of messages to a topic using the selected partitioning strategy. + + Args: + stream: Stream identifier as `str | int`. + topic: Topic identifier as `str | int`. + partitioning: A `Partitioning` strategy or an integer partition ID. + Use `Partitioning.balanced()`, `Partitioning.partition_id(id)`, or + `Partitioning.messages_key(key)`. An integer is shorthand for + `Partitioning.partition_id(id)`. + messages: Messages to send as `list[SendMessage]`. + + Returns: + An awaitable that resolves to `SendMessagesResponse`. Its confirmations + report the committed partition and batch base offset. The list is empty + when the server reports no offsets. + + Raises: + ValueError: If a string stream or topic identifier is invalid. + TypeError: If `partitioning` or `messages` has an unsupported type. + OverflowError: If a numeric stream, topic, or partition ID is outside + the supported unsigned 32-bit range. + RuntimeError: If the request fails. + """ + def producer( + self, + stream: builtins.str, + topic: builtins.str, + partitioning: Partitioning | None = None, + mode: DirectProducerConfig | BackgroundProducerConfig | None = None, + create_stream_if_not_exists: builtins.bool = True, + create_topic_if_not_exists: builtins.bool = True, + topic_partitions_count: builtins.int = 1, + topic_message_expiry: IggyExpiry | None = None, + topic_max_size: MaxTopicSize | None = None, + send_retries: builtins.int | None = 3, + send_retry_interval: datetime.timedelta | None = ..., + ) -> collections.abc.Awaitable[IggyProducer]: + r""" + Creates and initializes a high-level producer bound to a stream and topic. + + This is a Python port of the Rust high-level producer API. For detailed + producer semantics, see https://iggy.apache.org/docs/sdk/rust/high-level-sdk/. + `None` selects direct mode. `BackgroundProducerConfig` starts background + workers and makes successful sends mean queue acceptance rather than a + server commit. The returned producer is ready to send. + + Raises `ValueError` for invalid names or numeric ranges and `RuntimeError` + when stream/topic initialization fails. """ def poll_messages( self, @@ -1377,10 +1867,21 @@ class IggyClient: ) -> collections.abc.Awaitable[IggyConsumer]: r""" Creates a new consumer group consumer. + `partition_id` is ignored for a consumer group: the member reads the partitions + the server assigns to it. Returns the consumer or a RuntimeError on failure. Raises `ValueError` if `poll_interval`, `polling_retry_interval`, `init_retry_interval` or an `AutoCommit` interval is negative, or if any of those except `poll_interval` is zero. + + Consumer groups are not available over HTTP. With `auto_join_consumer_group` + left on, this call fails at the join with `Feature is unavailable`. + Turning it off is not a workaround: the join is skipped, but a group + member always polls without a partition, so the first poll fails with + the same error. Use `Consumer.Single(...)` with `poll_messages(...)` + instead - a `Consumer.Group(...)` poll with an explicit `partition_id` + does reach the server, but is served as an ordinary consumer named + after the group. """ def send_binary_request( self, code: builtins.int, payload: builtins.bytes @@ -1523,19 +2024,76 @@ class IggyExpiry: ... -class MaxTopicSize: +@typing.final +class IggyProducer: r""" - The maximum size of a topic. + Python port of the Rust high-level producer API, bound to one stream and topic. + + For detailed producer semantics, see + https://iggy.apache.org/docs/sdk/rust/high-level-sdk/. + + Direct sends complete after the server responds and contain commit confirmations. + Background sends complete once accepted by a worker and contain no confirmations. + Always use the async context manager or call `shutdown()` explicitly; dropping a + background producer can lose accepted buffered messages. """ - @typing.final - class ServerDefault(MaxTopicSize): + def send( + self, messages: list[SendMessage] + ) -> collections.abc.Awaitable[SendMessagesResponse]: r""" - Use the maximum topic size configured on the server, rather than an - explicit value set by the client. + Sends a batch to the producer's bound stream and topic. + In background mode, success means accepted into the dispatcher and the + returned confirmation list is empty. """ - - __match_args__ = () - def __new__(cls) -> MaxTopicSize.ServerDefault: ... + def send_one( + self, message: SendMessage + ) -> collections.abc.Awaitable[SendMessagesResponse]: + r""" + Sends one message to the producer's bound stream and topic. + It has the same mode-dependent completion semantics as `send()`. + """ + def send_with_partitioning( + self, messages: list[SendMessage], partitioning: Partitioning | None = None + ) -> collections.abc.Awaitable[SendMessagesResponse]: + r""" + Sends a batch with an optional per-call partitioning override. + It has the same mode-dependent completion semantics as `send()`. + """ + def send_to( + self, + stream: builtins.str | builtins.int, + topic: builtins.str | builtins.int, + messages: list[SendMessage], + partitioning: Partitioning | None = None, + ) -> collections.abc.Awaitable[SendMessagesResponse]: + r""" + Sends a batch to another existing stream and topic. + It has the same mode-dependent completion semantics as `send()` and does + not create the alternate destination. + """ + def shutdown(self) -> collections.abc.Awaitable[None]: + r""" + Waits for active sends and closes the producer. Repeated calls are safe. + Background shutdown flushes every accepted buffered message before returning. + """ + def __aenter__(self) -> collections.abc.Awaitable[IggyProducer]: ... + def __aexit__( + self, _exc_type: typing.Any, _exc_value: typing.Any, _traceback: typing.Any + ) -> collections.abc.Awaitable[builtins.bool]: ... + +class MaxTopicSize: + r""" + The maximum size of a topic. + """ + @typing.final + class ServerDefault(MaxTopicSize): + r""" + Use the maximum topic size configured on the server, rather than an + explicit value set by the client. + """ + + __match_args__ = () + def __new__(cls) -> MaxTopicSize.ServerDefault: ... def __len__(self) -> builtins.int: ... def __getitem__(self, key: builtins.int, /) -> typing.Any: ... @@ -1639,6 +2197,42 @@ class Partition: The number of messages in the partition. """ +@typing.final +class Partitioning: + r""" + Defines how a batch of messages is assigned to a topic partition. + """ + @staticmethod + def balanced() -> Partitioning: + r""" + Routes the batch to one partition selected by round-robin. + """ + @staticmethod + def partition_id(partition_id: builtins.int) -> Partitioning: + r""" + Routes the batch to the specified partition. + + `partition_id` must be between 0 and `2**32 - 1`. The topic must contain + that partition when the batch is sent. + + Raises: + TypeError: If `partition_id` is not an integer. + OverflowError: If `partition_id` is outside the supported unsigned + 32-bit range. + """ + @staticmethod + def messages_key(key: builtins.str | bytes) -> Partitioning: + r""" + Routes the batch to one partition selected by hashing `key`. + + `key` may be `str` or `bytes`. Strings are encoded as UTF-8; the encoded + key must contain between 1 and 255 bytes. + + Raises: + ValueError: If the encoded key is empty or exceeds 255 bytes. + TypeError: If `key` is not `str` or `bytes`. + """ + @typing.final class Permissions: r""" @@ -1704,6 +2298,178 @@ class PollingStrategy: ... +@typing.final +class ProducerSendError(builtins.RuntimeError): + r""" + A direct producer error that preserves partial-send recovery state. + """ + @property + def cause(self) -> builtins.str: + r""" + The underlying Iggy error message. + """ + @property + def failed(self) -> builtins.list[SendMessage]: + r""" + Messages without a usable confirmation after the failure. + An encryptor can leave these messages encrypted, so do not submit them + to the same producer without restoring their original payloads. + """ + @property + def committed(self) -> builtins.list[SendMessagesConfirmation]: + r""" + Confirmations returned for chunks committed before the failure. + """ + +@typing.final +class QuicConfig: + r""" + Configuration for the QUIC transport, accepted by `IggyClient(...)`. + + Every field is keyword-only and optional. + """ + @property + def server_address(self) -> builtins.str: ... + @property + def client_address(self) -> builtins.str: ... + @property + def server_name(self) -> builtins.str: ... + @property + def auto_login(self) -> AutoLogin: ... + @property + def reconnection(self) -> QuicReconnectionConfig: ... + @property + def heartbeat_interval(self) -> datetime.timedelta: ... + @property + def response_buffer_size(self) -> builtins.int: ... + @property + def max_concurrent_bidi_streams(self) -> builtins.int: ... + @property + def datagram_send_buffer_size(self) -> builtins.int: ... + @property + def initial_mtu(self) -> builtins.int: ... + @property + def send_window(self) -> builtins.int: ... + @property + def receive_window(self) -> builtins.int: ... + @property + def keep_alive_interval(self) -> datetime.timedelta: ... + @property + def max_idle_timeout(self) -> datetime.timedelta: ... + @property + def validate_certificate(self) -> builtins.bool: ... + def __new__( + cls, + *, + server_address: builtins.str | None = None, + client_address: builtins.str | None = None, + server_name: builtins.str | None = None, + auto_login: AutoLogin | None = None, + reconnection: QuicReconnectionConfig | None = None, + heartbeat_interval: datetime.timedelta | None = None, + response_buffer_size: builtins.int | None = None, + max_concurrent_bidi_streams: builtins.int | None = None, + datagram_send_buffer_size: builtins.int | None = None, + initial_mtu: builtins.int | None = None, + send_window: builtins.int | None = None, + receive_window: builtins.int | None = None, + keep_alive_interval: datetime.timedelta | None = None, + max_idle_timeout: datetime.timedelta | None = None, + validate_certificate: builtins.bool | None = None, + ) -> QuicConfig: + r""" + Constructs a QUIC configuration. + + Args: + server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8080`. + client_address: `host:port` to bind the local UDP socket to. Defaults to + `127.0.0.1:0`, which binds to any available port. That exact value, + passed or defaulted, binds `[::1]:0` instead when `server_address` + resolves to IPv6, so the socket in use may not be the address read + back here. Any other value binds as given. + server_name: Server name used for the QUIC/TLS handshake. Defaults to + `localhost`. + auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`. + reconnection: Reconnection policy. Defaults to `QuicReconnectionConfig()`. + heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds. + response_buffer_size: Size of the response buffer in bytes. Defaults to 10 MB. + max_concurrent_bidi_streams: Maximum number of concurrent bidirectional + streams. Defaults to 10,000. + datagram_send_buffer_size: Size of the datagram send buffer in bytes. + Defaults to 100,000. + initial_mtu: Initial MTU in bytes. Defaults to 1200. + send_window: Send window size in bytes. Defaults to 100,000. + receive_window: Receive window size in bytes. Defaults to 100,000. + keep_alive_interval: Interval between QUIC keep-alive pings, or a zero + duration to disable them. Defaults to 5 seconds. + max_idle_timeout: How long the connection tolerates silence before it is + considered dead, or a zero duration to use quinn's own default (30 + seconds) instead, since `configure()` skips the setter entirely when + zero. Defaults to 10 seconds. + validate_certificate: Whether to validate the server certificate. Defaults + to disabled; only the TCP transport validates by default. + + Raises: + ValueError: If `server_address` or `client_address` is not a valid + `host:port` pair, if a duration is negative, if + `heartbeat_interval` is zero, if `keep_alive_interval` or + `max_idle_timeout` is not a whole number of milliseconds, if + `initial_mtu` is below quinn's minimum of 1200, or if a numeric + field is outside the range of its underlying wire type. + OverflowError: If a numeric field does not fit a signed 64-bit integer, + raised by the underlying conversion before this constructor runs. + """ + def __repr__(self) -> builtins.str: ... + +@typing.final +class QuicReconnectionConfig: + r""" + How the QUIC client reconnects after the connection to the server is lost. + """ + @property + def enabled(self) -> builtins.bool: ... + @property + def max_retries(self) -> builtins.int | None: ... + @property + def interval(self) -> datetime.timedelta: ... + @property + def reestablish_after(self) -> datetime.timedelta: ... + def __new__( + cls, + *, + enabled: builtins.bool | None = None, + max_retries: builtins.int | None = None, + interval: datetime.timedelta | None = None, + reestablish_after: datetime.timedelta | None = None, + ) -> QuicReconnectionConfig: + r""" + Constructs a reconnection policy. + + Args: + enabled: Whether to reconnect at all. Defaults to enabled. + max_retries: Redials of the configured server address after the first + attempt, or `None` for unlimited; `0` still makes that first + attempt. Unlike the TCP transport, QUIC redials the one address + it was configured with rather than walking a cluster roster, so + this counts dials. Defaults to unlimited, which means a call + awaited while the server is down never returns: `connect()` + waits inside the retry loop, as do `send_messages()` and + `poll_messages()` once auto-login is configured. Set a finite + number for request/reply style usage, so a call fails instead. + interval: Delay before each redial. Defaults to 1 second. + reestablish_after: Cooldown before redialing after a previously + successful connection, measured from when it was established, so + a session that outlived the interval is redialed at once. + Defaults to 5 seconds. + + Raises: + ValueError: If a duration is negative, if `max_retries` is outside the + range of an unsigned 32-bit integer, or if `interval` is zero. + OverflowError: If `max_retries` does not fit a signed 64-bit integer, + raised by the underlying conversion before this constructor runs. + """ + def __repr__(self) -> builtins.str: ... + @typing.final class ReceiveMessage: r""" @@ -1805,12 +2571,8 @@ class SendMessagesConfirmation: at-least-once, so an earlier retry may already have committed these messages at a lower offset. - A batch is confirmed once it is committed in memory, not once it is - fsynced. A crash-restart can stamp a later batch with an offset a client - has already recorded. - - The legacy server confirms nothing, so its confirmation list is empty - and this value is never reached. + Confirmation follows VSR quorum commit. A topic with persisted message + durability also waits for recoverable stable-storage copies on the quorum. """ @typing.final @@ -1823,27 +2585,255 @@ class SendMessagesResponse: r""" Gets the commit confirmations, one per partition the batch was written to. - The list is empty when the server reports no offsets, and the legacy - server never reports any, so branch on it being empty rather than - indexing into it. + The list is empty when the server reports no offsets, so check whether + it is empty before indexing into it. A reported `base_offset` never implies uniqueness, because delivery is at-least-once and an earlier retry may already have committed the same - messages at a lower offset. A batch is confirmed once it is committed in - memory, not once it is fsynced. A crash-restart can stamp a later batch - with an offset a client has already recorded. + messages at a lower offset. Confirmation follows the topic's message + durability policy: quorum commit, plus stable storage for persisted topics. + """ + +@typing.final +class Stats: + r""" + The statistics and details of the server and its running process. + + The fields are gathered from several sources while the request is served + (metadata counters, a process probe, a disk probe), so they are not an + atomic snapshot of one instant. + """ + @property + def process_id(self) -> builtins.int: + r""" + The unique identifier of the server process. + """ + @property + def cpu_usage(self) -> builtins.float: + r""" + The CPU usage of the server process, in percent summed over the cores + it ran on, so it exceeds 100 whenever the process uses more than one + core. + + Measured as a delta since the previous `get_stats` served by the same + server shard, so the first sample a shard serves is 0. + """ + @property + def total_cpu_usage(self) -> builtins.float: + r""" + The total CPU usage of the system, in percent averaged over the cores + the server may run on when confined by an affinity/cpuset mask (over + every host core otherwise), so it stays within 0-100. + + Same per-shard delta sampling as `cpu_usage`: the first sample a shard + serves is 0. + """ + @property + def memory_usage(self) -> builtins.int: + r""" + The memory usage of the server process, in bytes. + """ + @property + def total_memory(self) -> builtins.int: + r""" + The total memory of the system, in bytes, or the effective cgroup memory + limit when the server runs inside a memory-capped cgroup (container, + systemd slice). + """ + @property + def available_memory(self) -> builtins.int: + r""" + The available memory of the system, in bytes, scoped to the cgroup + limit when one applies. + """ + @property + def run_time(self) -> datetime.timedelta: + r""" + The run time of the server process, with whole-second precision. + """ + @property + def start_time(self) -> builtins.int: + r""" + The start time of the server process, in microseconds since the Unix + epoch, with whole-second precision. + """ + @property + def read_bytes(self) -> builtins.int: + r""" + The total number of bytes read. + """ + @property + def written_bytes(self) -> builtins.int: + r""" + The total number of bytes written. + """ + @property + def messages_size_bytes(self) -> builtins.int: + r""" + The total size of the messages, in bytes. + """ + @property + def streams_count(self) -> builtins.int: + r""" + The total number of streams. + """ + @property + def topics_count(self) -> builtins.int: + r""" + The total number of topics. + """ + @property + def partitions_count(self) -> builtins.int: + r""" + The total number of partitions. + """ + @property + def segments_count(self) -> builtins.int: + r""" + The total number of segments. + """ + @property + def messages_count(self) -> builtins.int: + r""" + The total number of messages. + """ + @property + def clients_count(self) -> builtins.int: + r""" + The total number of connected clients. + """ + @property + def consumer_groups_count(self) -> builtins.int: + r""" + The total number of consumer groups. + """ + @property + def hostname(self) -> builtins.str: + r""" + The name of the host the server runs on. + """ + @property + def os_name(self) -> builtins.str: + r""" + The name of the operating system. + """ + @property + def os_version(self) -> builtins.str: + r""" + The version of the operating system. + """ + @property + def kernel_version(self) -> builtins.str: + r""" + The version of the kernel. + """ + @property + def iggy_server_version(self) -> builtins.str: + r""" + The version of the Iggy server. + """ + @property + def iggy_server_semver(self) -> builtins.int | None: + r""" + The numeric semantic version of the Iggy server, or `None` when unknown. + E.g. 1.2.3 -> 1002003 (major * 1000000 + minor * 1000 + patch). + """ + @property + def cache_metrics(self) -> builtins.dict[CacheMetricsKey, CacheMetrics]: + r""" + Cache metrics per partition. + + Current servers do not populate this and reply with an empty map. Each + access builds a fresh dict, so mutating the returned dict does not + change the stats. + """ + @property + def threads_count(self) -> builtins.int: + r""" + The number of threads in the server process. + """ + @property + def free_disk_space(self) -> builtins.int: + r""" + The available (free) disk space for the data directory, in bytes. + + 0 when the server does not know its data directory or the disk probe + fails. + """ + @property + def total_disk_space(self) -> builtins.int: + r""" + The total disk space for the data directory, in bytes. + + 0 when the server does not know its data directory or the disk probe + fails. + """ + def __repr__(self) -> builtins.str: ... + +@typing.final +class Stream: + r""" + Summary information returned by `IggyClient.get_streams()`. + + `created_at` is Unix time in microseconds. `size` is the stream's current + stored size in bytes. + """ + @property + def id(self) -> builtins.int: + r""" + Numeric stream identifier. + """ + @property + def created_at(self) -> builtins.int: + r""" + Stream creation time as Unix time in microseconds. + """ + @property + def name(self) -> builtins.str: + r""" + Unique stream name. + """ + @property + def size(self) -> builtins.int: + r""" + Current stored stream size in bytes. + """ + @property + def messages_count(self) -> builtins.int: + r""" + Total messages across all topics in the stream. + """ + @property + def topics_count(self) -> builtins.int: + r""" + Number of topics in the stream. """ @typing.final class StreamDetails: @property + def created_at(self) -> builtins.int: + r""" + Stream creation time as Unix time in microseconds. + """ + @property def id(self) -> builtins.int: ... @property def name(self) -> builtins.str: ... @property + def size(self) -> builtins.int: + r""" + Current stored stream size in bytes. + """ + @property def messages_count(self) -> builtins.int: ... @property def topics_count(self) -> builtins.int: ... + @property + def topics(self) -> builtins.list[Topic]: + r""" + Returns the topics in the stream. + """ @typing.final class StreamPermissions: @@ -2039,6 +3029,8 @@ class TcpReconnectionConfig: Raises: ValueError: If a duration is negative, if `max_retries` is outside the range of an unsigned 32-bit integer, or if `interval` is zero. + OverflowError: If `max_retries` does not fit a signed 64-bit integer, + raised by the underlying conversion before this constructor runs. """ def __repr__(self) -> builtins.str: ... @@ -2103,8 +3095,8 @@ class Topic: r""" Options admission resolved for the keys the client did not send. - Same shape as `options`. These would have resolved differently under - another server configuration. + Same shape as `options`. These would have resolved differently + under another server configuration. """ @typing.final @@ -2168,8 +3160,8 @@ class TopicDetails: r""" Options admission resolved for the keys the client did not send. - Same shape as `options`. These would have resolved differently under - another server configuration. + Same shape as `options`. These would have resolved differently + under another server configuration. """ @property def partitions(self) -> builtins.list[Partition]: @@ -2317,6 +3309,195 @@ class UserInfoDetails: The permissions of the user, or `None` when the user has none assigned. """ +@typing.final +class WebSocketConfig: + r""" + Configuration for the WebSocket transport, accepted by `IggyClient(...)`. + + Every field is keyword-only and optional. + """ + @property + def server_address(self) -> builtins.str: ... + @property + def auto_login(self) -> AutoLogin: ... + @property + def reconnection(self) -> WebSocketReconnectionConfig: ... + @property + def heartbeat_interval(self) -> datetime.timedelta: ... + @property + def framing(self) -> WebSocketFramingConfig: ... + @property + def tls_enabled(self) -> builtins.bool: ... + @property + def tls_domain(self) -> builtins.str: ... + @property + def tls_ca_file(self) -> builtins.str | None: ... + @property + def tls_validate_certificate(self) -> builtins.bool: ... + def __new__( + cls, + *, + server_address: builtins.str | None = None, + auto_login: AutoLogin | None = None, + reconnection: WebSocketReconnectionConfig | None = None, + heartbeat_interval: datetime.timedelta | None = None, + framing: WebSocketFramingConfig | None = None, + tls_enabled: builtins.bool | None = None, + tls_domain: builtins.str | None = None, + tls_ca_file: builtins.str | None = None, + tls_validate_certificate: builtins.bool | None = None, + ) -> WebSocketConfig: + r""" + Constructs a WebSocket configuration. + + Args: + server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8092`. + auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`. + reconnection: Reconnection policy. Defaults to `WebSocketReconnectionConfig()`. + heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds. + framing: Frame- and buffer-level options. Defaults to `WebSocketFramingConfig()`. + tls_enabled: Whether to connect over TLS. Defaults to disabled. + tls_domain: Domain to validate the certificate against. Defaults to + `localhost`. Empty means it is taken from the IP `server_address` + resolves to. + tls_ca_file: Path to the CA file for TLS. Read only when `tls_enabled` + and `tls_validate_certificate` are both on; with either one off it + is kept but never consulted, so pairing it with + `tls_validate_certificate=False` pins nothing. + tls_validate_certificate: Whether to validate the server certificate. + Defaults to `False`; only the TCP transport validates by default. + Disabling this accepts any certificate the server presents, + including self-signed and mismatched ones, and takes precedence + over `tls_ca_file`. + + Raises: + ValueError: If `server_address` is not a valid `host:port` pair, if a + duration is negative, or if `heartbeat_interval` is zero. + """ + def __repr__(self) -> builtins.str: ... + +@typing.final +class WebSocketFramingConfig: + r""" + Frame- and buffer-level options passed through to the underlying WebSocket + implementation, accepted by `WebSocketConfig`'s `framing` argument. + + Every field is keyword-only and optional; unset fields fall back to the + underlying WebSocket library's own defaults. + """ + @property + def read_buffer_size(self) -> builtins.int | None: ... + @property + def write_buffer_size(self) -> builtins.int | None: ... + @property + def max_write_buffer_size(self) -> builtins.int | None: ... + @property + def max_message_size(self) -> builtins.int | None: ... + @property + def max_frame_size(self) -> builtins.int | None: ... + @property + def accept_unmasked_frames(self) -> builtins.bool: ... + def __new__( + cls, + *, + read_buffer_size: builtins.int | None = None, + write_buffer_size: builtins.int | None = None, + max_write_buffer_size: builtins.int | None = None, + max_message_size: builtins.int | None = 64 << 20, + max_frame_size: builtins.int | None = 16 << 20, + accept_unmasked_frames: builtins.bool | None = None, + ) -> WebSocketFramingConfig: + r""" + Constructs a WebSocket framing configuration. + + Args: + read_buffer_size: Read buffer size in bytes. Defaults to 128 KiB. + write_buffer_size: Write buffer size in bytes. Defaults to 128 KiB. + max_write_buffer_size: Maximum write buffer size in bytes. Defaults to + unbounded, which reads back as the largest value a pointer-sized + unsigned integer holds rather than as `None`. + max_message_size: Maximum message size in bytes, or an explicit `None` + to lift the limit entirely. Omitting the argument is not the same + as passing `None`: it keeps the underlying default of 64 MiB. + Lifting the limit lets a peer queue an arbitrarily large message + in memory, so prefer a finite value. + max_frame_size: Maximum frame size in bytes, or an explicit `None` to + lift the limit entirely. Omitting the argument keeps the + underlying default of 16 MiB, with the same caveat as + `max_message_size`. + accept_unmasked_frames: Whether to accept unmasked frames. Defaults to + `False`; clients should typically keep this off for RFC compliance. + + Raises: + ValueError: If a numeric field is outside the range of a pointer-sized + unsigned integer, or if `max_write_buffer_size` does not come out + greater than `write_buffer_size`. tungstenite enforces the same + invariant with an `assert!` at connect time, which would otherwise + surface as an unrecoverable Rust panic instead of a `ValueError`. + OverflowError: If a numeric field does not fit a signed 128-bit integer, + raised by the underlying conversion before this constructor runs. + """ + def __repr__(self) -> builtins.str: ... + +@typing.final +class WebSocketReconnectionConfig: + r""" + How the WebSocket client reconnects after the connection to the server is lost. + """ + @property + def enabled(self) -> builtins.bool: ... + @property + def max_retries(self) -> builtins.int | None: ... + @property + def interval(self) -> datetime.timedelta: ... + @property + def reestablish_after(self) -> datetime.timedelta: ... + def __new__( + cls, + *, + enabled: builtins.bool | None = None, + max_retries: builtins.int | None = None, + interval: datetime.timedelta | None = None, + reestablish_after: datetime.timedelta | None = None, + ) -> WebSocketReconnectionConfig: + r""" + Constructs a reconnection policy. + + Args: + enabled: Whether to reconnect at all. Defaults to enabled. + max_retries: Redials of the configured server address after the first + attempt, or `None` for unlimited; `0` still makes that first + attempt. Unlike the TCP transport, WebSocket redials the one + address it was configured with rather than walking a cluster + roster, so this counts dials. Defaults to unlimited, which means + a call awaited while the server is down never returns: + `connect()` waits inside the retry loop, as do `send_messages()` + and `poll_messages()` once auto-login is configured. Set a finite + number for request/reply style usage, so a call fails instead. + interval: Delay before each redial. Defaults to 1 second. + reestablish_after: Cooldown before redialing after a previously + successful connection, measured from when it was established, so + a session that outlived the interval is redialed at once. Applied + from the first redial onward, not to the initial connect. + Defaults to 5 seconds. + + Raises: + ValueError: If a duration is negative, if `max_retries` is outside the + range of an unsigned 32-bit integer, or if `interval` is zero. + OverflowError: If `max_retries` does not fit a signed 64-bit integer, + raised by the underlying conversion before this constructor runs. + """ + def __repr__(self) -> builtins.str: ... + +@typing.final +class ProducerSharding(enum.Enum): + r""" + How a background producer distributes sends among its workers. + """ + + ORDERED = ... + BALANCED = ... + @typing.final class UserStatus(enum.Enum): r""" @@ -2331,3 +3512,7 @@ class UserStatus(enum.Enum): r""" The user account is inactive and cannot be used. """ + +class Durability(str, enum.Enum): + REPLICATED = "replicated" + PERSISTED = "persisted" diff --git a/foreign/python/docker-compose.test.yml b/foreign/python/docker-compose.test.yml index 78caed9929..c16eded3cc 100644 --- a/foreign/python/docker-compose.test.yml +++ b/foreign/python/docker-compose.test.yml @@ -33,9 +33,11 @@ services: - "3000:3000" - "8080:8080" - "8090:8090" + - "8092:8092" environment: - IGGY_HTTP_ADDRESS=0.0.0.0:3000 - IGGY_TCP_ADDRESS=0.0.0.0:8090 + - IGGY_NODE_ADVERTISED_ADDRESS=iggy-server - IGGY_QUIC_ADDRESS=0.0.0.0:8080 - IGGY_WEBSOCKET_ADDRESS=0.0.0.0:8092 - IGGY_ROOT_USERNAME=iggy @@ -64,6 +66,7 @@ services: - IGGY_SERVER_TCP_PORT=8090 - IGGY_SERVER_HTTP_PORT=3000 - IGGY_SERVER_QUIC_PORT=8080 + - IGGY_SERVER_WS_PORT=8092 - PYTHONPATH=/workspace/foreign/python - PYTEST_ARGS=-v --tb=short volumes: diff --git a/foreign/python/pylock.toml b/foreign/python/pylock.toml index 4c01b652b7..d88e4e29c6 100644 --- a/foreign/python/pylock.toml +++ b/foreign/python/pylock.toml @@ -1,22 +1,5 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - # This file was autogenerated by uv via the following command: -# uv export --format pylock.toml --output-file pylock.toml --all-extras +# uv export --locked --format pylock.toml --output-file pylock.toml --all-extras lock-version = "1.0" created-by = "uv" requires-python = ">=3.10" @@ -37,116 +20,174 @@ wheels = [ [[packages]] name = "certifi" -version = "2026.4.22" +version = "2026.7.22" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", upload-time = 2026-04-22T11:26:11Z, size = 137077, hashes = { sha256 = "8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580" } } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", upload-time = 2026-07-22T03:35:12Z, size = 138112, hashes = { sha256 = "741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", upload-time = 2026-04-22T11:26:09Z, size = 135707, hashes = { sha256 = "3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a" } }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", upload-time = 2026-07-22T03:35:11Z, size = 136983, hashes = { sha256 = "62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775" } }, ] [[packages]] name = "charset-normalizer" -version = "3.4.7" +version = "3.5.1" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", upload-time = 2026-04-02T09:28:39Z, size = 144271, hashes = { sha256 = "ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5" } } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", upload-time = 2026-08-15T08:20:44Z, size = 171764, hashes = { sha256 = "6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", upload-time = 2026-04-02T09:25:40Z, size = 315182, hashes = { sha256 = "cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d" } }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-04-02T09:25:42Z, size = 209329, hashes = { sha256 = "e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8" } }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-04-02T09:25:44Z, size = 231230, hashes = { sha256 = "511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790" } }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-04-02T09:25:45Z, size = 225890, hashes = { sha256 = "007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc" } }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-04-02T09:25:46Z, size = 216930, hashes = { sha256 = "cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393" } }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", upload-time = 2026-04-02T09:25:48Z, size = 202109, hashes = { sha256 = "12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153" } }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-04-02T09:25:49Z, size = 214684, hashes = { sha256 = "d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af" } }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", upload-time = 2026-04-02T09:25:50Z, size = 212785, hashes = { sha256 = "b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34" } }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", upload-time = 2026-04-02T09:25:51Z, size = 203055, hashes = { sha256 = "bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1" } }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", upload-time = 2026-04-02T09:25:53Z, size = 232502, hashes = { sha256 = "750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752" } }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", upload-time = 2026-04-02T09:25:54Z, size = 214295, hashes = { sha256 = "4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53" } }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", upload-time = 2026-04-02T09:25:55Z, size = 227145, hashes = { sha256 = "6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616" } }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", upload-time = 2026-04-02T09:25:57Z, size = 218884, hashes = { sha256 = "94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a" } }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", upload-time = 2026-04-02T09:25:58Z, size = 148343, hashes = { sha256 = "6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374" } }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", upload-time = 2026-04-02T09:25:59Z, size = 159174, hashes = { sha256 = "6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943" } }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", upload-time = 2026-04-02T09:26:00Z, size = 147805, hashes = { sha256 = "66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008" } }, - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", upload-time = 2026-04-02T09:26:02Z, size = 309705, hashes = { sha256 = "7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7" } }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-04-02T09:26:03Z, size = 206419, hashes = { sha256 = "202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7" } }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-04-02T09:26:04Z, size = 227901, hashes = { sha256 = "30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e" } }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-04-02T09:26:06Z, size = 222742, hashes = { sha256 = "532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c" } }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-04-02T09:26:08Z, size = 214061, hashes = { sha256 = "2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df" } }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", upload-time = 2026-04-02T09:26:09Z, size = 199239, hashes = { sha256 = "65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265" } }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-04-02T09:26:10Z, size = 210173, hashes = { sha256 = "08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4" } }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", upload-time = 2026-04-02T09:26:12Z, size = 209841, hashes = { sha256 = "e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e" } }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", upload-time = 2026-04-02T09:26:13Z, size = 200304, hashes = { sha256 = "38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38" } }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", upload-time = 2026-04-02T09:26:14Z, size = 229455, hashes = { sha256 = "1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c" } }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", upload-time = 2026-04-02T09:26:16Z, size = 210036, hashes = { sha256 = "1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b" } }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", upload-time = 2026-04-02T09:26:17Z, size = 224739, hashes = { sha256 = "d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c" } }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", upload-time = 2026-04-02T09:26:18Z, size = 216277, hashes = { sha256 = "ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d" } }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", upload-time = 2026-04-02T09:26:20Z, size = 147819, hashes = { sha256 = "adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad" } }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", upload-time = 2026-04-02T09:26:21Z, size = 159281, hashes = { sha256 = "8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00" } }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", upload-time = 2026-04-02T09:26:22Z, size = 147843, hashes = { sha256 = "d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1" } }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", upload-time = 2026-04-02T09:26:24Z, size = 311328, hashes = { sha256 = "eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46" } }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-04-02T09:26:25Z, size = 208061, hashes = { sha256 = "6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2" } }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-04-02T09:26:26Z, size = 229031, hashes = { sha256 = "e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b" } }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-04-02T09:26:28Z, size = 225239, hashes = { sha256 = "edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a" } }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-04-02T09:26:29Z, size = 216589, hashes = { sha256 = "5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116" } }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", upload-time = 2026-04-02T09:26:30Z, size = 202733, hashes = { sha256 = "203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb" } }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-04-02T09:26:31Z, size = 212652, hashes = { sha256 = "298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1" } }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", upload-time = 2026-04-02T09:26:33Z, size = 211229, hashes = { sha256 = "708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15" } }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", upload-time = 2026-04-02T09:26:34Z, size = 203552, hashes = { sha256 = "0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5" } }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", upload-time = 2026-04-02T09:26:36Z, size = 230806, hashes = { sha256 = "4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d" } }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", upload-time = 2026-04-02T09:26:37Z, size = 212316, hashes = { sha256 = "aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7" } }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", upload-time = 2026-04-02T09:26:38Z, size = 227274, hashes = { sha256 = "fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464" } }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", upload-time = 2026-04-02T09:26:40Z, size = 218468, hashes = { sha256 = "bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49" } }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", upload-time = 2026-04-02T09:26:41Z, size = 148460, hashes = { sha256 = "2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c" } }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", upload-time = 2026-04-02T09:26:42Z, size = 159330, hashes = { sha256 = "5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6" } }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", upload-time = 2026-04-02T09:26:44Z, size = 147828, hashes = { sha256 = "56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d" } }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", upload-time = 2026-04-02T09:26:45Z, size = 309627, hashes = { sha256 = "f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063" } }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-04-02T09:26:46Z, size = 207008, hashes = { sha256 = "0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c" } }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-04-02T09:26:48Z, size = 228303, hashes = { sha256 = "a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66" } }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-04-02T09:26:49Z, size = 224282, hashes = { sha256 = "3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18" } }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-04-02T09:26:50Z, size = 215595, hashes = { sha256 = "e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd" } }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", upload-time = 2026-04-02T09:26:52Z, size = 201986, hashes = { sha256 = "f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215" } }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-04-02T09:26:53Z, size = 211711, hashes = { sha256 = "e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859" } }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", upload-time = 2026-04-02T09:26:54Z, size = 210036, hashes = { sha256 = "7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8" } }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", upload-time = 2026-04-02T09:26:56Z, size = 202998, hashes = { sha256 = "481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5" } }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", upload-time = 2026-04-02T09:26:57Z, size = 230056, hashes = { sha256 = "f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832" } }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", upload-time = 2026-04-02T09:26:58Z, size = 211537, hashes = { sha256 = "f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6" } }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", upload-time = 2026-04-02T09:27:00Z, size = 226176, hashes = { sha256 = "3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48" } }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", upload-time = 2026-04-02T09:27:02Z, size = 217723, hashes = { sha256 = "64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a" } }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", upload-time = 2026-04-02T09:27:03Z, size = 148085, hashes = { sha256 = "4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e" } }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", upload-time = 2026-04-02T09:27:04Z, size = 158819, hashes = { sha256 = "3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110" } }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", upload-time = 2026-04-02T09:27:05Z, size = 147915, hashes = { sha256 = "80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b" } }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", upload-time = 2026-04-02T09:27:07Z, size = 309234, hashes = { sha256 = "c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0" } }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-04-02T09:27:08Z, size = 208042, hashes = { sha256 = "1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a" } }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-04-02T09:27:09Z, size = 228706, hashes = { sha256 = "54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b" } }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-04-02T09:27:11Z, size = 224727, hashes = { sha256 = "715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41" } }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-04-02T09:27:12Z, size = 215882, hashes = { sha256 = "bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e" } }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", upload-time = 2026-04-02T09:27:13Z, size = 200860, hashes = { sha256 = "c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae" } }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-04-02T09:27:15Z, size = 211564, hashes = { sha256 = "3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18" } }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", upload-time = 2026-04-02T09:27:16Z, size = 211276, hashes = { sha256 = "e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b" } }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", upload-time = 2026-04-02T09:27:18Z, size = 201238, hashes = { sha256 = "a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356" } }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", upload-time = 2026-04-02T09:27:19Z, size = 230189, hashes = { sha256 = "2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab" } }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", upload-time = 2026-04-02T09:27:20Z, size = 211352, hashes = { sha256 = "e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46" } }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", upload-time = 2026-04-02T09:27:22Z, size = 227024, hashes = { sha256 = "d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44" } }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", upload-time = 2026-04-02T09:27:23Z, size = 217869, hashes = { sha256 = "7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72" } }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", upload-time = 2026-04-02T09:27:25Z, size = 148541, hashes = { sha256 = "5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10" } }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", upload-time = 2026-04-02T09:27:26Z, size = 159634, hashes = { sha256 = "92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f" } }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", upload-time = 2026-04-02T09:27:28Z, size = 148384, hashes = { sha256 = "67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246" } }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", upload-time = 2026-04-02T09:27:29Z, size = 330133, hashes = { sha256 = "effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24" } }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-04-02T09:27:30Z, size = 216257, hashes = { sha256 = "fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79" } }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-04-02T09:27:32Z, size = 234851, hashes = { sha256 = "733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960" } }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-04-02T09:27:34Z, size = 233393, hashes = { sha256 = "a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4" } }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-04-02T09:27:35Z, size = 223251, hashes = { sha256 = "6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e" } }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", upload-time = 2026-04-02T09:27:36Z, size = 206609, hashes = { sha256 = "a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1" } }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-04-02T09:27:38Z, size = 220014, hashes = { sha256 = "3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44" } }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", upload-time = 2026-04-02T09:27:39Z, size = 218979, hashes = { sha256 = "8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e" } }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", upload-time = 2026-04-02T09:27:40Z, size = 209238, hashes = { sha256 = "cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3" } }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", upload-time = 2026-04-02T09:27:42Z, size = 236110, hashes = { sha256 = "0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0" } }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", upload-time = 2026-04-02T09:27:43Z, size = 219824, hashes = { sha256 = "752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e" } }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", upload-time = 2026-04-02T09:27:45Z, size = 233103, hashes = { sha256 = "8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb" } }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", upload-time = 2026-04-02T09:27:46Z, size = 225194, hashes = { sha256 = "ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe" } }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", upload-time = 2026-04-02T09:27:48Z, size = 159827, hashes = { sha256 = "c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0" } }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", upload-time = 2026-04-02T09:27:49Z, size = 174168, hashes = { sha256 = "03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c" } }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", upload-time = 2026-04-02T09:27:51Z, size = 153018, hashes = { sha256 = "c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d" } }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", upload-time = 2026-04-02T09:28:37Z, size = 61958, hashes = { sha256 = "3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d" } }, + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", upload-time = 2026-08-15T08:16:22Z, size = 369072, hashes = { sha256 = "d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa" } }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-15T08:16:24Z, size = 251142, hashes = { sha256 = "e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda" } }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", upload-time = 2026-08-15T08:16:26Z, size = 240714, hashes = { sha256 = "00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45" } }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-15T08:16:27Z, size = 279637, hashes = { sha256 = "ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab" } }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-08-15T08:16:29Z, size = 276543, hashes = { sha256 = "ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a" } }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-08-15T08:16:30Z, size = 261644, hashes = { sha256 = "96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3" } }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-15T08:16:32Z, size = 259609, hashes = { sha256 = "366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb" } }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", upload-time = 2026-08-15T08:16:33Z, size = 252457, hashes = { sha256 = "950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f" } }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", upload-time = 2026-08-15T08:16:35Z, size = 242240, hashes = { sha256 = "c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea" } }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-15T08:16:36Z, size = 280308, hashes = { sha256 = "07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f" } }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", upload-time = 2026-08-15T08:16:38Z, size = 258679, hashes = { sha256 = "f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182" } }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", upload-time = 2026-08-15T08:16:39Z, size = 277221, hashes = { sha256 = "a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa" } }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", upload-time = 2026-08-15T08:16:40Z, size = 263799, hashes = { sha256 = "494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818" } }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", upload-time = 2026-08-15T08:16:42Z, size = 182037, hashes = { sha256 = "94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20" } }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", upload-time = 2026-08-15T08:16:43Z, size = 206030, hashes = { sha256 = "be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d" } }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", upload-time = 2026-08-15T08:16:45Z, size = 185092, hashes = { sha256 = "a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5" } }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", upload-time = 2026-08-15T08:16:46Z, size = 363585, hashes = { sha256 = "eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30" } }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-15T08:16:48Z, size = 251189, hashes = { sha256 = "aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488" } }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", upload-time = 2026-08-15T08:16:49Z, size = 239724, hashes = { sha256 = "0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22" } }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-15T08:16:51Z, size = 280078, hashes = { sha256 = "6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731" } }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-08-15T08:16:52Z, size = 276650, hashes = { sha256 = "b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c" } }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-08-15T08:16:54Z, size = 262325, hashes = { sha256 = "c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8" } }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-15T08:16:55Z, size = 261140, hashes = { sha256 = "6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486" } }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", upload-time = 2026-08-15T08:16:57Z, size = 252791, hashes = { sha256 = "0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f" } }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", upload-time = 2026-08-15T08:16:58Z, size = 240730, hashes = { sha256 = "aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c" } }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-15T08:16:59Z, size = 280791, hashes = { sha256 = "bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18" } }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", upload-time = 2026-08-15T08:17:01Z, size = 259598, hashes = { sha256 = "f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5" } }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", upload-time = 2026-08-15T08:17:02Z, size = 278217, hashes = { sha256 = "6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b" } }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", upload-time = 2026-08-15T08:17:04Z, size = 263417, hashes = { sha256 = "3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6" } }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", upload-time = 2026-08-15T08:17:05Z, size = 181774, hashes = { sha256 = "ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b" } }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", upload-time = 2026-08-15T08:17:07Z, size = 206653, hashes = { sha256 = "f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9" } }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", upload-time = 2026-08-15T08:17:08Z, size = 185630, hashes = { sha256 = "ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10" } }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", upload-time = 2026-08-15T08:17:10Z, size = 344456, hashes = { sha256 = "5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f" } }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-15T08:17:11Z, size = 238530, hashes = { sha256 = "4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa" } }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", upload-time = 2026-08-15T08:17:12Z, size = 230200, hashes = { sha256 = "1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369" } }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-15T08:17:14Z, size = 262222, hashes = { sha256 = "e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893" } }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-08-15T08:17:16Z, size = 258951, hashes = { sha256 = "7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0" } }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-08-15T08:17:17Z, size = 248801, hashes = { sha256 = "b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08" } }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-15T08:17:19Z, size = 244070, hashes = { sha256 = "f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada" } }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", upload-time = 2026-08-15T08:17:20Z, size = 240110, hashes = { sha256 = "96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9" } }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", upload-time = 2026-08-15T08:17:21Z, size = 232836, hashes = { sha256 = "5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e" } }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-15T08:17:23Z, size = 262712, hashes = { sha256 = "aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6" } }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", upload-time = 2026-08-15T08:17:25Z, size = 242977, hashes = { sha256 = "7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf" } }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", upload-time = 2026-08-15T08:17:26Z, size = 260207, hashes = { sha256 = "4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71" } }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", upload-time = 2026-08-15T08:17:27Z, size = 250562, hashes = { sha256 = "ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573" } }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", upload-time = 2026-08-15T08:17:29Z, size = 178507, hashes = { sha256 = "cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2" } }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", upload-time = 2026-08-15T08:17:30Z, size = 200551, hashes = { sha256 = "3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2" } }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", upload-time = 2026-08-15T08:17:32Z, size = 180700, hashes = { sha256 = "88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a" } }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", upload-time = 2026-08-15T08:17:33Z, size = 211584, hashes = { sha256 = "4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9" } }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", upload-time = 2026-08-15T08:17:35Z, size = 223359, hashes = { sha256 = "88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491" } }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", upload-time = 2026-08-15T08:17:36Z, size = 194464, hashes = { sha256 = "9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7" } }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", upload-time = 2026-08-15T08:17:37Z, size = 197676, hashes = { sha256 = "977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e" } }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", upload-time = 2026-08-15T08:17:39Z, size = 340473, hashes = { sha256 = "e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d" } }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-15T08:17:40Z, size = 240156, hashes = { sha256 = "94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a" } }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", upload-time = 2026-08-15T08:17:42Z, size = 228246, hashes = { sha256 = "d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe" } }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-15T08:17:43Z, size = 263660, hashes = { sha256 = "0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac" } }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-08-15T08:17:45Z, size = 260354, hashes = { sha256 = "394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e" } }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-08-15T08:17:46Z, size = 250638, hashes = { sha256 = "62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3" } }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-15T08:17:47Z, size = 244583, hashes = { sha256 = "fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876" } }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", upload-time = 2026-08-15T08:17:49Z, size = 242038, hashes = { sha256 = "c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6" } }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", upload-time = 2026-08-15T08:17:50Z, size = 233677, hashes = { sha256 = "485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2" } }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-15T08:17:52Z, size = 264491, hashes = { sha256 = "5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591" } }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", upload-time = 2026-08-15T08:17:54Z, size = 245196, hashes = { sha256 = "90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c" } }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", upload-time = 2026-08-15T08:17:55Z, size = 261660, hashes = { sha256 = "35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c" } }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", upload-time = 2026-08-15T08:17:57Z, size = 252618, hashes = { sha256 = "fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f" } }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", upload-time = 2026-08-15T08:17:58Z, size = 140362, hashes = { sha256 = "9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4" } }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", upload-time = 2026-08-15T08:18:00Z, size = 177755, hashes = { sha256 = "d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3" } }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", upload-time = 2026-08-15T08:18:01Z, size = 199295, hashes = { sha256 = "aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6" } }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", upload-time = 2026-08-15T08:18:02Z, size = 179856, hashes = { sha256 = "fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0" } }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", upload-time = 2026-08-15T08:18:04Z, size = 212318, hashes = { sha256 = "774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8" } }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", upload-time = 2026-08-15T08:18:05Z, size = 224897, hashes = { sha256 = "26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102" } }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", upload-time = 2026-08-15T08:18:07Z, size = 194848, hashes = { sha256 = "09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5" } }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", upload-time = 2026-08-15T08:18:08Z, size = 198163, hashes = { sha256 = "4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3" } }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", upload-time = 2026-08-15T08:18:10Z, size = 341823, hashes = { sha256 = "c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c" } }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-15T08:18:11Z, size = 242458, hashes = { sha256 = "2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0" } }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", upload-time = 2026-08-15T08:18:13Z, size = 226717, hashes = { sha256 = "6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96" } }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-15T08:18:14Z, size = 266111, hashes = { sha256 = "ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc" } }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-08-15T08:18:16Z, size = 263128, hashes = { sha256 = "f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f" } }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-08-15T08:18:18Z, size = 251240, hashes = { sha256 = "15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90" } }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-15T08:18:19Z, size = 245282, hashes = { sha256 = "823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506" } }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", upload-time = 2026-08-15T08:18:21Z, size = 244597, hashes = { sha256 = "01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5" } }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", upload-time = 2026-08-15T08:18:22Z, size = 231376, hashes = { sha256 = "329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e" } }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-15T08:18:24Z, size = 266715, hashes = { sha256 = "bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5" } }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", upload-time = 2026-08-15T08:18:25Z, size = 245848, hashes = { sha256 = "fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3" } }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", upload-time = 2026-08-15T08:18:27Z, size = 264521, hashes = { sha256 = "6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3" } }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", upload-time = 2026-08-15T08:18:28Z, size = 253054, hashes = { sha256 = "9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36" } }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", upload-time = 2026-08-15T08:18:30Z, size = 140580, hashes = { sha256 = "f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7" } }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", upload-time = 2026-08-15T08:18:31Z, size = 180325, hashes = { sha256 = "1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b" } }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", upload-time = 2026-08-15T08:18:33Z, size = 204175, hashes = { sha256 = "c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b" } }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", upload-time = 2026-08-15T08:18:34Z, size = 184123, hashes = { sha256 = "4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687" } }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", upload-time = 2026-08-15T08:18:36Z, size = 381682, hashes = { sha256 = "fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348" } }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-15T08:18:37Z, size = 240826, hashes = { sha256 = "e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef" } }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", upload-time = 2026-08-15T08:18:39Z, size = 227861, hashes = { sha256 = "7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885" } }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-15T08:18:40Z, size = 260758, hashes = { sha256 = "a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375" } }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-08-15T08:18:42Z, size = 259950, hashes = { sha256 = "256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1" } }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-08-15T08:18:43Z, size = 249329, hashes = { sha256 = "58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65" } }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-15T08:18:45Z, size = 243137, hashes = { sha256 = "0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5" } }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", upload-time = 2026-08-15T08:18:46Z, size = 242820, hashes = { sha256 = "c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af" } }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", upload-time = 2026-08-15T08:18:48Z, size = 230504, hashes = { sha256 = "52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1" } }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-15T08:18:49Z, size = 263087, hashes = { sha256 = "2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9" } }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", upload-time = 2026-08-15T08:18:51Z, size = 243269, hashes = { sha256 = "9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a" } }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", upload-time = 2026-08-15T08:18:53Z, size = 258766, hashes = { sha256 = "496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032" } }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", upload-time = 2026-08-15T08:18:54Z, size = 250814, hashes = { sha256 = "85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e" } }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", upload-time = 2026-08-15T08:18:56Z, size = 191074, hashes = { sha256 = "58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4" } }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", upload-time = 2026-08-15T08:18:57Z, size = 216476, hashes = { sha256 = "acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00" } }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", upload-time = 2026-08-15T08:18:59Z, size = 194115, hashes = { sha256 = "fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f" } }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", upload-time = 2026-08-15T08:19:01Z, size = 342048, hashes = { sha256 = "9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af" } }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-15T08:19:02Z, size = 242997, hashes = { sha256 = "2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8" } }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", upload-time = 2026-08-15T08:19:03Z, size = 237014, hashes = { sha256 = "baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90" } }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-15T08:19:05Z, size = 266174, hashes = { sha256 = "8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20" } }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-08-15T08:19:07Z, size = 263361, hashes = { sha256 = "8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449" } }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-08-15T08:19:08Z, size = 252143, hashes = { sha256 = "5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a" } }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-15T08:19:10Z, size = 252086, hashes = { sha256 = "854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0" } }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", upload-time = 2026-08-15T08:19:11Z, size = 245231, hashes = { sha256 = "21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e" } }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", upload-time = 2026-08-15T08:19:13Z, size = 241546, hashes = { sha256 = "838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2" } }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-15T08:19:14Z, size = 267033, hashes = { sha256 = "195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626" } }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", upload-time = 2026-08-15T08:19:16Z, size = 252045, hashes = { sha256 = "978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5" } }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", upload-time = 2026-08-15T08:19:18Z, size = 264866, hashes = { sha256 = "cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774" } }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", upload-time = 2026-08-15T08:19:19Z, size = 253932, hashes = { sha256 = "687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7" } }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", upload-time = 2026-08-15T08:19:21Z, size = 180320, hashes = { sha256 = "706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9" } }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", upload-time = 2026-08-15T08:19:23Z, size = 204174, hashes = { sha256 = "92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712" } }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", upload-time = 2026-08-15T08:19:24Z, size = 184126, hashes = { sha256 = "5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7" } }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", upload-time = 2026-08-15T08:19:26Z, size = 381441, hashes = { sha256 = "59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663" } }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-15T08:19:27Z, size = 241742, hashes = { sha256 = "9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11" } }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", upload-time = 2026-08-15T08:19:29Z, size = 235298, hashes = { sha256 = "e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc" } }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-15T08:19:30Z, size = 262500, hashes = { sha256 = "ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a" } }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-08-15T08:19:32Z, size = 258888, hashes = { sha256 = "19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4" } }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = 2026-08-15T08:19:33Z, size = 250243, hashes = { sha256 = "cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004" } }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-15T08:19:35Z, size = 249871, hashes = { sha256 = "04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b" } }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", upload-time = 2026-08-15T08:19:36Z, size = 243580, hashes = { sha256 = "9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263" } }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", upload-time = 2026-08-15T08:19:38Z, size = 239807, hashes = { sha256 = "252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee" } }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-15T08:19:39Z, size = 264083, hashes = { sha256 = "6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c" } }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", upload-time = 2026-08-15T08:19:41Z, size = 250317, hashes = { sha256 = "77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e" } }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", upload-time = 2026-08-15T08:19:43Z, size = 258173, hashes = { sha256 = "a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d" } }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", upload-time = 2026-08-15T08:19:44Z, size = 251960, hashes = { sha256 = "4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61" } }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", upload-time = 2026-08-15T08:19:46Z, size = 191186, hashes = { sha256 = "ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f" } }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", upload-time = 2026-08-15T08:19:47Z, size = 215947, hashes = { sha256 = "35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb" } }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", upload-time = 2026-08-15T08:19:49Z, size = 193909, hashes = { sha256 = "fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a" } }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", upload-time = 2026-08-15T08:19:50Z, size = 331467, hashes = { sha256 = "41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2" } }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-15T08:19:52Z, size = 253057, hashes = { sha256 = "a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99" } }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-15T08:19:54Z, size = 240930, hashes = { sha256 = "cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2" } }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", upload-time = 2026-08-15T08:19:55Z, size = 230822, hashes = { sha256 = "343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235" } }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-15T08:19:57Z, size = 260037, hashes = { sha256 = "ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598" } }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", upload-time = 2026-08-15T08:19:59Z, size = 255097, hashes = { sha256 = "4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96" } }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-15T08:20:00Z, size = 250166, hashes = { sha256 = "b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962" } }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", upload-time = 2026-08-15T08:20:02Z, size = 241821, hashes = { sha256 = "a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3" } }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", upload-time = 2026-08-15T08:20:04Z, size = 232529, hashes = { sha256 = "36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950" } }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-15T08:20:05Z, size = 260348, hashes = { sha256 = "4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8" } }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", upload-time = 2026-08-15T08:20:07Z, size = 247234, hashes = { sha256 = "2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031" } }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", upload-time = 2026-08-15T08:20:09Z, size = 256917, hashes = { sha256 = "3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072" } }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", upload-time = 2026-08-15T08:20:10Z, size = 254846, hashes = { sha256 = "e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d" } }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", upload-time = 2026-08-15T08:20:12Z, size = 174216, hashes = { sha256 = "dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc" } }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", upload-time = 2026-08-15T08:20:13Z, size = 199764, hashes = { sha256 = "70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959" } }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", upload-time = 2026-08-15T08:20:15Z, size = 287318, hashes = { sha256 = "87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e" } }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", upload-time = 2026-08-15T08:20:43Z, size = 68658, hashes = { sha256 = "6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6" } }, ] [[packages]] @@ -161,124 +202,139 @@ wheels = [ [[packages]] name = "coverage" -version = "7.14.0" +version = "7.15.4" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", upload-time = 2026-05-10T18:02:31Z, size = 919489, hashes = { sha256 = "057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74" } } +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", upload-time = 2026-08-06T13:50:24Z, size = 936952, hashes = { sha256 = "0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", upload-time = 2026-05-10T17:59:23Z, size = 219668, hashes = { sha256 = "84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075" } }, - { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", upload-time = 2026-05-10T17:59:26Z, size = 220192, hashes = { sha256 = "7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82" } }, - { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T17:59:27Z, size = 246932, hashes = { sha256 = "0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c" } }, - { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T17:59:29Z, size = 248762, hashes = { sha256 = "3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893" } }, - { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T17:59:31Z, size = 250625, hashes = { sha256 = "731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20" } }, - { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T17:59:32Z, size = 252539, hashes = { sha256 = "c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec" } }, - { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T17:59:34Z, size = 247636, hashes = { sha256 = "dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757" } }, - { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T17:59:36Z, size = 248666, hashes = { sha256 = "0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a" } }, - { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", upload-time = 2026-05-10T17:59:38Z, size = 246670, hashes = { sha256 = "fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea" } }, - { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T17:59:39Z, size = 250484, hashes = { sha256 = "b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb" } }, - { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T17:59:41Z, size = 246942, hashes = { sha256 = "d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218" } }, - { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T17:59:43Z, size = 247544, hashes = { sha256 = "d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85" } }, - { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", upload-time = 2026-05-10T17:59:44Z, size = 222285, hashes = { sha256 = "65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323" } }, - { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", upload-time = 2026-05-10T17:59:46Z, size = 223215, hashes = { sha256 = "b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a" } }, - { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", upload-time = 2026-05-10T17:59:48Z, size = 219795, hashes = { sha256 = "6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480" } }, - { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", upload-time = 2026-05-10T17:59:49Z, size = 220299, hashes = { sha256 = "a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4" } }, - { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T17:59:51Z, size = 250721, hashes = { sha256 = "d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7" } }, - { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T17:59:53Z, size = 252633, hashes = { sha256 = "9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed" } }, - { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T17:59:55Z, size = 254743, hashes = { sha256 = "9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980" } }, - { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T17:59:56Z, size = 256700, hashes = { sha256 = "d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0" } }, - { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T17:59:57Z, size = 250854, hashes = { sha256 = "a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742" } }, - { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T17:59:59Z, size = 252433, hashes = { sha256 = "5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5" } }, - { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", upload-time = 2026-05-10T18:00:01Z, size = 250494, hashes = { sha256 = "741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327" } }, - { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T18:00:03Z, size = 254261, hashes = { sha256 = "664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d" } }, - { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T18:00:05Z, size = 250216, hashes = { sha256 = "c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20" } }, - { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T18:00:06Z, size = 251125, hashes = { sha256 = "bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c" } }, - { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", upload-time = 2026-05-10T18:00:08Z, size = 222300, hashes = { sha256 = "731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3" } }, - { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", upload-time = 2026-05-10T18:00:10Z, size = 223241, hashes = { sha256 = "bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1" } }, - { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", upload-time = 2026-05-10T18:00:12Z, size = 221908, hashes = { sha256 = "7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627" } }, - { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", upload-time = 2026-05-10T18:00:13Z, size = 219967, hashes = { sha256 = "7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5" } }, - { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", upload-time = 2026-05-10T18:00:15Z, size = 220329, hashes = { sha256 = "829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662" } }, - { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T18:00:17Z, size = 251839, hashes = { sha256 = "b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f" } }, - { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T18:00:18Z, size = 254576, hashes = { sha256 = "ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67" } }, - { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T18:00:20Z, size = 255690, hashes = { sha256 = "92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9" } }, - { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T18:00:22Z, size = 257949, hashes = { sha256 = "7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb" } }, - { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T18:00:24Z, size = 252242, hashes = { sha256 = "1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e" } }, - { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T18:00:25Z, size = 253608, hashes = { sha256 = "8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3" } }, - { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", upload-time = 2026-05-10T18:00:27Z, size = 251753, hashes = { sha256 = "d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4" } }, - { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T18:00:29Z, size = 255823, hashes = { sha256 = "1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1" } }, - { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T18:00:30Z, size = 251323, hashes = { sha256 = "72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5" } }, - { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T18:00:32Z, size = 253197, hashes = { sha256 = "fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595" } }, - { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", upload-time = 2026-05-10T18:00:33Z, size = 222515, hashes = { sha256 = "5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27" } }, - { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", upload-time = 2026-05-10T18:00:35Z, size = 223324, hashes = { sha256 = "70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2" } }, - { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", upload-time = 2026-05-10T18:00:37Z, size = 221944, hashes = { sha256 = "91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d" } }, - { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", upload-time = 2026-05-10T18:00:38Z, size = 219990, hashes = { sha256 = "f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef" } }, - { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", upload-time = 2026-05-10T18:00:40Z, size = 220365, hashes = { sha256 = "23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66" } }, - { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T18:00:42Z, size = 251363, hashes = { sha256 = "22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b" } }, - { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T18:00:44Z, size = 253961, hashes = { sha256 = "9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca" } }, - { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T18:00:45Z, size = 255193, hashes = { sha256 = "90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7" } }, - { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T18:00:47Z, size = 257326, hashes = { sha256 = "a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2" } }, - { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T18:00:49Z, size = 251582, hashes = { sha256 = "c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367" } }, - { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T18:00:51Z, size = 253325, hashes = { sha256 = "1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9" } }, - { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", upload-time = 2026-05-10T18:00:52Z, size = 251291, hashes = { sha256 = "55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087" } }, - { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T18:00:54Z, size = 255448, hashes = { sha256 = "49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef" } }, - { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T18:00:56Z, size = 251110, hashes = { sha256 = "9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52" } }, - { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T18:00:57Z, size = 252885, hashes = { sha256 = "7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe" } }, - { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", upload-time = 2026-05-10T18:00:59Z, size = 222539, hashes = { sha256 = "fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae" } }, - { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", upload-time = 2026-05-10T18:01:01Z, size = 223344, hashes = { sha256 = "0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e" } }, - { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", upload-time = 2026-05-10T18:01:03Z, size = 221966, hashes = { sha256 = "b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96" } }, - { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", upload-time = 2026-05-10T18:01:05Z, size = 220679, hashes = { sha256 = "953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90" } }, - { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", upload-time = 2026-05-10T18:01:07Z, size = 221033, hashes = { sha256 = "98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1" } }, - { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T18:01:08Z, size = 262333, hashes = { sha256 = "668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd" } }, - { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T18:01:10Z, size = 264410, hashes = { sha256 = "9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc" } }, - { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T18:01:12Z, size = 266836, hashes = { sha256 = "68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426" } }, - { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T18:01:13Z, size = 267974, hashes = { sha256 = "6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899" } }, - { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T18:01:15Z, size = 261578, hashes = { sha256 = "cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b" } }, - { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T18:01:17Z, size = 264394, hashes = { sha256 = "45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90" } }, - { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", upload-time = 2026-05-10T18:01:19Z, size = 262022, hashes = { sha256 = "8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f" } }, - { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T18:01:21Z, size = 265732, hashes = { sha256 = "a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d" } }, - { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T18:01:23Z, size = 260921, hashes = { sha256 = "63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47" } }, - { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T18:01:25Z, size = 263109, hashes = { sha256 = "827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477" } }, - { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", upload-time = 2026-05-10T18:01:27Z, size = 223212, hashes = { sha256 = "7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab" } }, - { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", upload-time = 2026-05-10T18:01:29Z, size = 224272, hashes = { sha256 = "3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917" } }, - { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", upload-time = 2026-05-10T18:01:31Z, size = 222530, hashes = { sha256 = "6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8" } }, - { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", upload-time = 2026-05-10T18:01:33Z, size = 220036, hashes = { sha256 = "9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d" } }, - { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", upload-time = 2026-05-10T18:01:34Z, size = 220368, hashes = { sha256 = "ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63" } }, - { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T18:01:36Z, size = 251417, hashes = { sha256 = "3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212" } }, - { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T18:01:38Z, size = 253924, hashes = { sha256 = "5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3" } }, - { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T18:01:40Z, size = 255269, hashes = { sha256 = "6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97" } }, - { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T18:01:42Z, size = 257583, hashes = { sha256 = "3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8" } }, - { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T18:01:44Z, size = 251434, hashes = { sha256 = "3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb" } }, - { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T18:01:46Z, size = 253280, hashes = { sha256 = "15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe" } }, - { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", upload-time = 2026-05-10T18:01:48Z, size = 251241, hashes = { sha256 = "9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa" } }, - { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T18:01:50Z, size = 255516, hashes = { sha256 = "2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5" } }, - { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T18:01:52Z, size = 251059, hashes = { sha256 = "454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c" } }, - { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T18:01:54Z, size = 252716, hashes = { sha256 = "65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca" } }, - { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", upload-time = 2026-05-10T18:01:56Z, size = 222788, hashes = { sha256 = "6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828" } }, - { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", upload-time = 2026-05-10T18:01:58Z, size = 223600, hashes = { sha256 = "45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d" } }, - { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", upload-time = 2026-05-10T18:02:00Z, size = 222168, hashes = { sha256 = "4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9" } }, - { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", upload-time = 2026-05-10T18:02:02Z, size = 220766, hashes = { sha256 = "f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1" } }, - { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", upload-time = 2026-05-10T18:02:04Z, size = 221035, hashes = { sha256 = "a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c" } }, - { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-05-10T18:02:05Z, size = 262405, hashes = { sha256 = "a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84" } }, - { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-05-10T18:02:07Z, size = 264530, hashes = { sha256 = "7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436" } }, - { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-05-10T18:02:09Z, size = 266932, hashes = { sha256 = "0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a" } }, - { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-05-10T18:02:11Z, size = 268062, hashes = { sha256 = "362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f" } }, - { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-05-10T18:02:13Z, size = 261504, hashes = { sha256 = "acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb" } }, - { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", upload-time = 2026-05-10T18:02:15Z, size = 264398, hashes = { sha256 = "29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490" } }, - { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", upload-time = 2026-05-10T18:02:16Z, size = 262000, hashes = { sha256 = "b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9" } }, - { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", upload-time = 2026-05-10T18:02:18Z, size = 265732, hashes = { sha256 = "5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020" } }, - { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", upload-time = 2026-05-10T18:02:20Z, size = 260847, hashes = { sha256 = "9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6" } }, - { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", upload-time = 2026-05-10T18:02:22Z, size = 263166, hashes = { sha256 = "1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db" } }, - { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", upload-time = 2026-05-10T18:02:24Z, size = 223573, hashes = { sha256 = "a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2" } }, - { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", upload-time = 2026-05-10T18:02:25Z, size = 224680, hashes = { sha256 = "29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644" } }, - { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", upload-time = 2026-05-10T18:02:27Z, size = 222703, hashes = { sha256 = "742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b" } }, - { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", upload-time = 2026-05-10T18:02:29Z, size = 211764, hashes = { sha256 = "8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1" } }, + { url = "https://files.pythonhosted.org/packages/30/70/b052a519a584663a7bd052841a2debe11c8309ec49a7786340003f9c0a02/coverage-7.15.4-cp310-cp310-macosx_10_9_x86_64.whl", upload-time = 2026-08-06T13:46:55Z, size = 222245, hashes = { sha256 = "d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264" } }, + { url = "https://files.pythonhosted.org/packages/67/39/892fa511aba3d1c3c8f49509a0ff5c71eab9f9f88d08e1a38da395821660/coverage-7.15.4-cp310-cp310-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:46:57Z, size = 222762, hashes = { sha256 = "b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d" } }, + { url = "https://files.pythonhosted.org/packages/9f/95/b2c724ce1e64bc23cb5b1d7eeffa9548dc3d811f7a6297b2d01607f4e062/coverage-7.15.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:46:59Z, size = 249498, hashes = { sha256 = "cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150" } }, + { url = "https://files.pythonhosted.org/packages/0b/4f/b1973f67a1382af65b572a31ed692f8e490a6ad707191eab59148376832a/coverage-7.15.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:47:00Z, size = 251328, hashes = { sha256 = "83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15" } }, + { url = "https://files.pythonhosted.org/packages/a2/09/03efa6722a132abcac91b32a60b64b240dd707c189c64eee697e48992c96/coverage-7.15.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:47:01Z, size = 253194, hashes = { sha256 = "8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0" } }, + { url = "https://files.pythonhosted.org/packages/45/63/8299201d9c80fb65551ce99c966cab83d706ec4066ac999bef08201346de/coverage-7.15.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:47:03Z, size = 255106, hashes = { sha256 = "4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d" } }, + { url = "https://files.pythonhosted.org/packages/ee/16/26fd8a691eb8d9a230128685f6d23309d7402cb030aa553001788c8c50fc/coverage-7.15.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:47:04Z, size = 250177, hashes = { sha256 = "a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4" } }, + { url = "https://files.pythonhosted.org/packages/ad/ef/3c7556f33783a0a566e01443ca62bd8eb2cdfe22d271efdc02e08beb5654/coverage-7.15.4-cp310-cp310-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:47:06Z, size = 251234, hashes = { sha256 = "317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54" } }, + { url = "https://files.pythonhosted.org/packages/29/49/640a34043edac950738f36a3567832db5731d4cb2ed84b59cdb89c6bccbf/coverage-7.15.4-cp310-cp310-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:47:07Z, size = 249237, hashes = { sha256 = "8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20" } }, + { url = "https://files.pythonhosted.org/packages/48/f5/e80f212669dd1be954ff844f883ef11a437ef4fd0089c6e0effc7b66b15d/coverage-7.15.4-cp310-cp310-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:47:08Z, size = 253050, hashes = { sha256 = "425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325" } }, + { url = "https://files.pythonhosted.org/packages/c7/e9/e5da0fe39f7fde1bca9edc09c60921bb5fdba4cec7db5bbad41ddfd8c230/coverage-7.15.4-cp310-cp310-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:47:10Z, size = 249508, hashes = { sha256 = "69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88" } }, + { url = "https://files.pythonhosted.org/packages/7d/38/41bf25774a0c8bba6b467f917cb1c9a0a2605e02dc93aad489fc7050ed59/coverage-7.15.4-cp310-cp310-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:47:11Z, size = 250110, hashes = { sha256 = "81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04" } }, + { url = "https://files.pythonhosted.org/packages/89/6e/26f2e54b79acc29d179ee4272922625aedb69198c4eb61f7ff4f098f3c78/coverage-7.15.4-cp310-cp310-win32.whl", upload-time = 2026-08-06T13:47:12Z, size = 224294, hashes = { sha256 = "cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338" } }, + { url = "https://files.pythonhosted.org/packages/7b/06/9a318fc3ae040d4d6cb2d86101c6aa963fab20899a5c58666adf52cde0ca/coverage-7.15.4-cp310-cp310-win_amd64.whl", upload-time = 2026-08-06T13:47:14Z, size = 224919, hashes = { sha256 = "3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360" } }, + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", upload-time = 2026-08-06T13:47:15Z, size = 222367, hashes = { sha256 = "bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490" } }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:47:16Z, size = 222874, hashes = { sha256 = "357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e" } }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:47:18Z, size = 253287, hashes = { sha256 = "21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7" } }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:47:19Z, size = 255199, hashes = { sha256 = "7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6" } }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:47:21Z, size = 257308, hashes = { sha256 = "a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d" } }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:47:22Z, size = 259268, hashes = { sha256 = "de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce" } }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:47:23Z, size = 253392, hashes = { sha256 = "6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7" } }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:47:25Z, size = 255001, hashes = { sha256 = "986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b" } }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:47:26Z, size = 253061, hashes = { sha256 = "c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc" } }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:47:28Z, size = 256831, hashes = { sha256 = "d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571" } }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:47:29Z, size = 252781, hashes = { sha256 = "b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719" } }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:47:31Z, size = 253692, hashes = { sha256 = "a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7" } }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", upload-time = 2026-08-06T13:47:32Z, size = 224461, hashes = { sha256 = "69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e" } }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", upload-time = 2026-08-06T13:47:34Z, size = 224937, hashes = { sha256 = "63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc" } }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", upload-time = 2026-08-06T13:47:36Z, size = 224479, hashes = { sha256 = "ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890" } }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", upload-time = 2026-08-06T13:47:37Z, size = 222543, hashes = { sha256 = "d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22" } }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:47:38Z, size = 222905, hashes = { sha256 = "37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97" } }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:47:40Z, size = 254407, hashes = { sha256 = "fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d" } }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:47:41Z, size = 257145, hashes = { sha256 = "899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2" } }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:47:43Z, size = 258257, hashes = { sha256 = "d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931" } }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:47:45Z, size = 260517, hashes = { sha256 = "002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8" } }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:47:46Z, size = 254785, hashes = { sha256 = "e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9" } }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:47:48Z, size = 256176, hashes = { sha256 = "e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839" } }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:47:49Z, size = 254321, hashes = { sha256 = "af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72" } }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:47:51Z, size = 258390, hashes = { sha256 = "1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52" } }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:47:52Z, size = 253894, hashes = { sha256 = "d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c" } }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:47:54Z, size = 255763, hashes = { sha256 = "c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4" } }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", upload-time = 2026-08-06T13:47:56Z, size = 224597, hashes = { sha256 = "5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b" } }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", upload-time = 2026-08-06T13:47:57Z, size = 225135, hashes = { sha256 = "6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd" } }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", upload-time = 2026-08-06T13:47:58Z, size = 224515, hashes = { sha256 = "67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f" } }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", upload-time = 2026-08-06T13:48:00Z, size = 222565, hashes = { sha256 = "c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921" } }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:48:02Z, size = 222936, hashes = { sha256 = "3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e" } }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:48:04Z, size = 253926, hashes = { sha256 = "5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36" } }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:48:05Z, size = 256523, hashes = { sha256 = "12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4" } }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:48:08Z, size = 257759, hashes = { sha256 = "349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c" } }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:48:09Z, size = 259890, hashes = { sha256 = "4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7" } }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:48:11Z, size = 254121, hashes = { sha256 = "d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25" } }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:48:13Z, size = 255891, hashes = { sha256 = "2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b" } }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:48:14Z, size = 253859, hashes = { sha256 = "8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78" } }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:48:16Z, size = 258011, hashes = { sha256 = "1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f" } }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:48:18Z, size = 253676, hashes = { sha256 = "337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d" } }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:48:20Z, size = 255453, hashes = { sha256 = "96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff" } }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", upload-time = 2026-08-06T13:48:21Z, size = 224605, hashes = { sha256 = "c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c" } }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", upload-time = 2026-08-06T13:48:23Z, size = 225148, hashes = { sha256 = "2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4" } }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", upload-time = 2026-08-06T13:48:25Z, size = 224536, hashes = { sha256 = "6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf" } }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", upload-time = 2026-08-06T13:48:26Z, size = 222608, hashes = { sha256 = "770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f" } }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:48:28Z, size = 222940, hashes = { sha256 = "d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c" } }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:48:29Z, size = 253985, hashes = { sha256 = "5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082" } }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:48:31Z, size = 256492, hashes = { sha256 = "ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac" } }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:48:33Z, size = 257837, hashes = { sha256 = "5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734" } }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:48:34Z, size = 260152, hashes = { sha256 = "474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d" } }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:48:36Z, size = 253978, hashes = { sha256 = "7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf" } }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:48:38Z, size = 255846, hashes = { sha256 = "39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b" } }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:48:39Z, size = 253808, hashes = { sha256 = "f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25" } }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:48:41Z, size = 258081, hashes = { sha256 = "93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303" } }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:48:43Z, size = 253624, hashes = { sha256 = "141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f" } }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:48:45Z, size = 255280, hashes = { sha256 = "81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5" } }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", upload-time = 2026-08-06T13:48:47Z, size = 224768, hashes = { sha256 = "7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7" } }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", upload-time = 2026-08-06T13:48:49Z, size = 225259, hashes = { sha256 = "f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425" } }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", upload-time = 2026-08-06T13:48:51Z, size = 224684, hashes = { sha256 = "9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8" } }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", upload-time = 2026-08-06T13:48:52Z, size = 223338, hashes = { sha256 = "7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8" } }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:48:54Z, size = 223609, hashes = { sha256 = "f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a" } }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:48:56Z, size = 264970, hashes = { sha256 = "46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b" } }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:48:58Z, size = 267088, hashes = { sha256 = "3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5" } }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:49:00Z, size = 269508, hashes = { sha256 = "3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba" } }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:49:02Z, size = 270629, hashes = { sha256 = "05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982" } }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:49:04Z, size = 264043, hashes = { sha256 = "226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c" } }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:49:05Z, size = 266963, hashes = { sha256 = "ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57" } }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:49:07Z, size = 264569, hashes = { sha256 = "8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26" } }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:49:09Z, size = 268299, hashes = { sha256 = "07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429" } }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:49:11Z, size = 263413, hashes = { sha256 = "a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017" } }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:49:13Z, size = 265725, hashes = { sha256 = "460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839" } }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", upload-time = 2026-08-06T13:49:15Z, size = 225079, hashes = { sha256 = "cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85" } }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", upload-time = 2026-08-06T13:49:17Z, size = 225911, hashes = { sha256 = "3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e" } }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", upload-time = 2026-08-06T13:49:19Z, size = 225219, hashes = { sha256 = "3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e" } }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", upload-time = 2026-08-06T13:49:21Z, size = 222604, hashes = { sha256 = "2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9" } }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:49:23Z, size = 222944, hashes = { sha256 = "e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a" } }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:49:25Z, size = 254050, hashes = { sha256 = "7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54" } }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:49:26Z, size = 256967, hashes = { sha256 = "d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb" } }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:49:28Z, size = 258587, hashes = { sha256 = "ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed" } }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:49:30Z, size = 260785, hashes = { sha256 = "a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce" } }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:49:32Z, size = 254545, hashes = { sha256 = "050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c" } }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:49:34Z, size = 256682, hashes = { sha256 = "d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced" } }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:49:36Z, size = 254560, hashes = { sha256 = "c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800" } }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:49:38Z, size = 258792, hashes = { sha256 = "1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3" } }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:49:39Z, size = 253968, hashes = { sha256 = "4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768" } }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:49:41Z, size = 255893, hashes = { sha256 = "1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753" } }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", upload-time = 2026-08-06T13:49:44Z, size = 224768, hashes = { sha256 = "43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2" } }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", upload-time = 2026-08-06T13:49:45Z, size = 225242, hashes = { sha256 = "be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809" } }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", upload-time = 2026-08-06T13:49:48Z, size = 224674, hashes = { sha256 = "def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d" } }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", upload-time = 2026-08-06T13:49:50Z, size = 223333, hashes = { sha256 = "c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d" } }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", upload-time = 2026-08-06T13:49:52Z, size = 223630, hashes = { sha256 = "2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26" } }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", upload-time = 2026-08-06T13:49:54Z, size = 264489, hashes = { sha256 = "4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645" } }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-08-06T13:49:56Z, size = 267567, hashes = { sha256 = "8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a" } }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-08-06T13:49:58Z, size = 270123, hashes = { sha256 = "050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624" } }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", upload-time = 2026-08-06T13:50:00Z, size = 271107, hashes = { sha256 = "1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa" } }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-08-06T13:50:03Z, size = 264955, hashes = { sha256 = "8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f" } }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", upload-time = 2026-08-06T13:50:05Z, size = 267949, hashes = { sha256 = "ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f" } }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", upload-time = 2026-08-06T13:50:07Z, size = 264421, hashes = { sha256 = "d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67" } }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", upload-time = 2026-08-06T13:50:09Z, size = 269121, hashes = { sha256 = "f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc" } }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", upload-time = 2026-08-06T13:50:11Z, size = 264565, hashes = { sha256 = "150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88" } }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", upload-time = 2026-08-06T13:50:13Z, size = 266522, hashes = { sha256 = "a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc" } }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", upload-time = 2026-08-06T13:50:15Z, size = 225068, hashes = { sha256 = "ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a" } }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", upload-time = 2026-08-06T13:50:17Z, size = 225895, hashes = { sha256 = "288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b" } }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", upload-time = 2026-08-06T13:50:19Z, size = 225213, hashes = { sha256 = "68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278" } }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", upload-time = 2026-08-06T13:50:22Z, size = 214332, hashes = { sha256 = "964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84" } }, ] [[packages]] name = "docker" -version = "7.1.0" +version = "7.2.0" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", upload-time = 2024-05-23T11:13:57Z, size = 117834, hashes = { sha256 = "ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c" } } +sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", upload-time = 2026-07-09T14:53:46Z, size = 118731, hashes = { sha256 = "cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", upload-time = 2024-05-23T11:13:55Z, size = 147774, hashes = { sha256 = "c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0" } }, + { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", upload-time = 2026-07-09T14:53:45Z, size = 148775, hashes = { sha256 = "a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f" } }, ] [[packages]] @@ -291,22 +347,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", upload-time = 2025-11-21T23:01:53Z, size = 16740, hashes = { sha256 = "a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598" } }, ] -[[packages]] -name = "execnet" -version = "2.1.2" -index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", upload-time = 2025-11-12T09:56:37Z, size = 166622, hashes = { sha256 = "63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd" } } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", upload-time = 2025-11-12T09:56:36Z, size = 40708, hashes = { sha256 = "67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec" } }, -] - [[packages]] name = "idna" -version = "3.15" +version = "3.19" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", upload-time = 2026-05-12T22:45:57Z, size = 199245, hashes = { sha256 = "ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc" } } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", upload-time = 2026-08-18T05:14:24Z, size = 215237, hashes = { sha256 = "5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", upload-time = 2026-05-12T22:45:55Z, size = 72340, hashes = { sha256 = "048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8" } }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", upload-time = 2026-08-18T05:14:22Z, size = 68550, hashes = { sha256 = "815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4" } }, ] [[packages]] @@ -320,32 +367,32 @@ wheels = [ [[packages]] name = "maturin" -version = "1.13.3" +version = "1.15.0" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/9c/1c/612d23d33ec21b9ae7ece7b3f0dd5f9dfd57b4009e9d2938165869ebd6ae/maturin-1.13.3.tar.gz", upload-time = 2026-05-11T07:43:39Z, size = 357934, hashes = { sha256 = "771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079" } } +sdist = { url = "https://files.pythonhosted.org/packages/b9/c8/22e5e21b2679c9bce6415ca578034ca2cc9316be0642ae21e051a2d5198c/maturin-1.15.0.tar.gz", upload-time = 2026-08-24T12:11:22Z, size = 385504, hashes = { sha256 = "94b26cc8e8aba61a5f2099715fe640e18c5f678e9a500408b38761263954228a" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/66/18c2aaac0b2a5dea9f1db5984ce83b905ad205cfc7c02d0091e707c0c2e7/maturin-1.13.3-py3-none-linux_armv6l.whl", upload-time = 2026-05-11T07:43:10Z, size = 10190971, hashes = { sha256 = "3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c" } }, - { url = "https://files.pythonhosted.org/packages/bc/71/26a988d092e4fd6a9523d46d44400a46cad7cdf3fd206ce702240c748aee/maturin-1.13.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", upload-time = 2026-05-11T07:43:36Z, size = 19716714, hashes = { sha256 = "53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323" } }, - { url = "https://files.pythonhosted.org/packages/82/5c/f3fd0e184255d9fc7e272c62af3dfa84c617b2577ef83af9ce615f5279cc/maturin-1.13.3-py3-none-macosx_10_12_x86_64.whl", upload-time = 2026-05-11T07:43:07Z, size = 10194726, hashes = { sha256 = "4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0" } }, - { url = "https://files.pythonhosted.org/packages/a9/e1/f4edb69fb647b77c4769a9bfd4d6fb62961e653d164bc277ecdffac3ab61/maturin-1.13.3-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", upload-time = 2026-05-11T07:43:40Z, size = 10172781, hashes = { sha256 = "a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11" } }, - { url = "https://files.pythonhosted.org/packages/c7/7d/a1be934690cdcc3c6609769ceaad322ab7501c2ee5bafcac1b14d609e403/maturin-1.13.3-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", upload-time = 2026-05-11T07:43:13Z, size = 10682670, hashes = { sha256 = "4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f" } }, - { url = "https://files.pythonhosted.org/packages/18/f5/372ae19b72ce8f6e37e5864ae4dc5b252ee9fce0619ccc3aa366aa3a7f97/maturin-1.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", upload-time = 2026-05-11T07:43:21Z, size = 10060363, hashes = { sha256 = "3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018" } }, - { url = "https://files.pythonhosted.org/packages/cb/5b/c68340cca09368af0df80965dfabed4234205a492a93da00793c7b9aae20/maturin-1.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", upload-time = 2026-05-11T07:43:33Z, size = 10017551, hashes = { sha256 = "1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3" } }, - { url = "https://files.pythonhosted.org/packages/28/1e/f90fb2b000bad9e6d850cd5afb88b2f1e2a279cfb4de02ea40078484690e/maturin-1.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", upload-time = 2026-05-11T07:43:26Z, size = 13301712, hashes = { sha256 = "c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a" } }, - { url = "https://files.pythonhosted.org/packages/be/58/1670f68a8f04ccd7b90df11047bd9a046585310e84e1967cc9849cd1c5a3/maturin-1.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2026-05-11T07:43:16Z, size = 10946765, hashes = { sha256 = "49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff" } }, - { url = "https://files.pythonhosted.org/packages/4b/ac/00c955c2ef134817b1a7bdaa76b0309e9c5291eb17d9ff88069eecd08bc2/maturin-1.13.3-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", upload-time = 2026-05-11T07:43:18Z, size = 10388661, hashes = { sha256 = "b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273" } }, - { url = "https://files.pythonhosted.org/packages/97/c6/cbf8a51dde19c19aeba0d9b075095a2effb9b31fd312b1aae3ac79f8aea2/maturin-1.13.3-py3-none-win32.whl", upload-time = 2026-05-11T07:43:23Z, size = 8901838, hashes = { sha256 = "0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b" } }, - { url = "https://files.pythonhosted.org/packages/a1/ff/c6a50a59dc8313097d43ac5f4d74df6a500c8cb62b0dc9e054f53e203a48/maturin-1.13.3-py3-none-win_amd64.whl", upload-time = 2026-05-11T07:43:29Z, size = 10340801, hashes = { sha256 = "def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6" } }, - { url = "https://files.pythonhosted.org/packages/6c/93/e32e79333f0902ba292b996f504f5f06be59587f7d02ab8d5ed1e3066445/maturin-1.13.3-py3-none-win_arm64.whl", upload-time = 2026-05-11T07:43:31Z, size = 9706562, hashes = { sha256 = "2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b" } }, + { url = "https://files.pythonhosted.org/packages/14/69/5c01b461044eb1f45ddcce006706eb88110c793cdb11c7ae0b5e08492e94/maturin-1.15.0-py3-none-linux_armv6l.whl", upload-time = 2026-08-24T12:10:53Z, size = 10206220, hashes = { sha256 = "6bf6dc62e22d4dcfd5a51244ff0d58975fa4979c48209fe84159617648956d82" } }, + { url = "https://files.pythonhosted.org/packages/eb/1f/2b431554e11687cdb1077e0cdadcc118c53f611086b3af00c8545a67c6a5/maturin-1.15.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", upload-time = 2026-08-24T12:10:56Z, size = 19416513, hashes = { sha256 = "cd35772633f489841132bc8e71d6fc7f842df30b9c05cd5cdf1ee1ddcb744cc7" } }, + { url = "https://files.pythonhosted.org/packages/51/36/e23a21cb34a648b711036b9b2fe1d4f3f4ee24f8db54215d73f1a9a3a3ec/maturin-1.15.0-py3-none-macosx_10_12_x86_64.whl", upload-time = 2026-08-24T12:10:58Z, size = 10014962, hashes = { sha256 = "c40b4eae7bf5ef1f4b1af8d623fe4105016f93578fb15b764e741d08ec3b92dd" } }, + { url = "https://files.pythonhosted.org/packages/8c/80/33b15cb2d8f30f12c807955e8f2fd775027692904e30ec0784744ce8cd83/maturin-1.15.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", upload-time = 2026-08-24T12:11:00Z, size = 10196223, hashes = { sha256 = "7eb066372f541f8eb4909c79c5d9bd0b9e8125980bdf1ec9e8aba23c6c8d6c55" } }, + { url = "https://files.pythonhosted.org/packages/fe/91/b495e19e2f5c503b540452b2039115e7b2363867e8c5ad4179eb752fa92c/maturin-1.15.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", upload-time = 2026-08-24T12:11:02Z, size = 10541186, hashes = { sha256 = "653020a63525bb224e5ab0adf02e17a2e08bc86dbea7fc1399c9a56d7529b99e" } }, + { url = "https://files.pythonhosted.org/packages/5d/2b/2abff58037188d852b124871b1f0d720e1c2bfb3d4f1b03d87c52cd66488/maturin-1.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", upload-time = 2026-08-24T12:11:05Z, size = 10083468, hashes = { sha256 = "0ebf9767892725083138e671c34482c660317a2f3d6a29fc0e0f34e9d8c99136" } }, + { url = "https://files.pythonhosted.org/packages/3f/07/b7e9f8be99a6627849e81ac7b6694876bce8f50a92995fe17e3cf2610f0a/maturin-1.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", upload-time = 2026-08-24T12:11:07Z, size = 10047786, hashes = { sha256 = "7ab7eebffd7b8debca2265985de4eaeb332141276d24b9560b5ad484d4b3add1" } }, + { url = "https://files.pythonhosted.org/packages/ca/ab/167e3cb7accee11b507dbe53e0e87aeccb376d44ae66284c96ee4df3a9fd/maturin-1.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", upload-time = 2026-08-24T12:11:09Z, size = 13315332, hashes = { sha256 = "126e12e618b4db42f68c779a56d41f82a390145ba36ac3f621d057eb34f5ad9d" } }, + { url = "https://files.pythonhosted.org/packages/14/4d/801379f646cbc6b00998e5289b0630a886be3a4ee4c75b6bc9b87478a7f1/maturin-1.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2026-08-24T12:11:11Z, size = 10807183, hashes = { sha256 = "4f9d33e6c3f9615c8caceecbbbd440f8eb25a3ddeb687077682cd5eca2e9ae15" } }, + { url = "https://files.pythonhosted.org/packages/89/27/2e612e1cbd1580e9e94d4722c227b5180dca27b32b955a34b79918aa1292/maturin-1.15.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", upload-time = 2026-08-24T12:11:14Z, size = 10413274, hashes = { sha256 = "bf29beddd0c6708f112db51d5275fc28b28b9e9c9c5faae387eaef662918b176" } }, + { url = "https://files.pythonhosted.org/packages/70/d8/202a7b4d75a51f20f84ec9ce3b7345b12b822207072164e2b1c6ef665125/maturin-1.15.0-py3-none-win32.whl", upload-time = 2026-08-24T12:11:16Z, size = 8928744, hashes = { sha256 = "da649988be98e87e009e51b1bf0d301b6a301bc0cecbdd60d40d8ba60748d1ca" } }, + { url = "https://files.pythonhosted.org/packages/40/dc/4e90da594986ba78dd3bc8a5921ecdcdb11085b22b02a412caab3b225601/maturin-1.15.0-py3-none-win_amd64.whl", upload-time = 2026-08-24T12:11:18Z, size = 10335085, hashes = { sha256 = "552c2be4afd43fe8d5c9f3ec8d4c4756d973b8dcbe94c14084390301f50243e1" } }, + { url = "https://files.pythonhosted.org/packages/8b/10/15d4314edf130955edf2dc237aa393a8a7c10f2b9b57b89fa2f61f915659/maturin-1.15.0-py3-none-win_arm64.whl", upload-time = 2026-08-24T12:11:20Z, size = 9713795, hashes = { sha256 = "c7dc0c66c78d3debdd9c5aa807e861fbcbf07f3505d34b125df74c03986b0f48" } }, ] [[packages]] name = "packaging" -version = "26.2" +version = "26.3" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", upload-time = 2026-04-24T20:15:23Z, size = 228134, hashes = { sha256 = "ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661" } } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", upload-time = 2026-08-04T18:15:28Z, size = 313412, hashes = { sha256 = "94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", upload-time = 2026-04-24T20:15:22Z, size = 100195, hashes = { sha256 = "5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" } }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", upload-time = 2026-08-04T18:15:27Z, size = 129956, hashes = { sha256 = "d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c" } }, ] [[packages]] @@ -359,55 +406,57 @@ wheels = [ [[packages]] name = "pygments" -version = "2.20.0" +version = "2.21.0" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", upload-time = 2026-03-29T13:29:33Z, size = 4955991, hashes = { sha256 = "6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", upload-time = 2026-08-17T08:02:48Z, size = 5005329, hashes = { sha256 = "610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", upload-time = 2026-03-29T13:29:30Z, size = 1231151, hashes = { sha256 = "81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" } }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", upload-time = 2026-08-17T08:02:44Z, size = 1250147, hashes = { sha256 = "2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9" } }, ] [[packages]] name = "pyrefly" -version = "1.0.0" +version = "1.2.0" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/9f/3a/9045b0097ac58979c7c30a4fa0e673db942d4adbc7b6d439bd54ae58c441/pyrefly-1.0.0.tar.gz", upload-time = 2026-05-12T20:12:46Z, size = 5677995, hashes = { sha256 = "5c2b810ffcebd84be71de5df1223651edee951653a66935c6f091e957c452455" } } +sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", upload-time = 2026-08-01T02:56:27Z, size = 6243654, hashes = { sha256 = "5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/c6/90788819bac9c61dd7bacba53b79f3c12d47ccbe5e51b3d6d89f2387e1d2/pyrefly-1.0.0-py3-none-macosx_10_12_x86_64.whl", upload-time = 2026-05-12T20:12:20Z, size = 13122950, hashes = { sha256 = "e355a0908555348ed4b9585ef25c76ff566673e345c866c325f1633f44d890b6" } }, - { url = "https://files.pythonhosted.org/packages/82/91/a3cf2a1e87d336eaa804a1e6fc93266faf6dc2a97eecdbc7eae289628022/pyrefly-1.0.0-py3-none-macosx_11_0_arm64.whl", upload-time = 2026-05-12T20:12:23Z, size = 12599494, hashes = { sha256 = "a7038efc3a40f8294edee339895633cf22db268c0d434cdbcbefc34f78a9ecc3" } }, - { url = "https://files.pythonhosted.org/packages/cd/ab/74d1e11e737e99b1c003ecc5d7d2e846c4ea1f328966bfdbbd0ac63fad0a/pyrefly-1.0.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2026-05-12T20:12:25Z, size = 12995507, hashes = { sha256 = "da331ca515ed1c08791da2b5f664cf9c1294c48fd802133262e7d5d51e0f4416" } }, - { url = "https://files.pythonhosted.org/packages/7c/ac/2df0899f8464c97e5d995f994c97c5cb5b0f58610432aa90d26d924e1db5/pyrefly-1.0.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2026-05-12T20:12:29Z, size = 13947693, hashes = { sha256 = "c74219d8f3e63cdaa5501a0b21d1c9d37011820f9606728d0ed06f09ae86a878" } }, - { url = "https://files.pythonhosted.org/packages/6b/3e/b247c24321e36f04b7d51f9ccf3df93e5009e4b29939524b36ec2e17dc2a/pyrefly-1.0.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2026-05-12T20:12:31Z, size = 13925803, hashes = { sha256 = "c0d05543b1bb6ee6d64149eb5d6b2fb15aa72d3962d6a97abca0afaca8b0c131" } }, - { url = "https://files.pythonhosted.org/packages/61/16/cfa2d61a4aa1e1f7bca48bb37acd01c6a09db4864b16a54f9587092765ff/pyrefly-1.0.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-05-12T20:12:35Z, size = 13470398, hashes = { sha256 = "1382d5b1fcdb49a4de9f34d112d2bddf290a78ff93ee8149492ad5f1077ddffc" } }, - { url = "https://files.pythonhosted.org/packages/cb/2b/6372c7dddb326223e24a46b17efd0d4bd7b4fe22c821e523157577eed2d2/pyrefly-1.0.0-py3-none-win32.whl", upload-time = 2026-05-12T20:12:38Z, size = 12222643, hashes = { sha256 = "aa8b5d0e47080e3202a2547b39f7a5a61d2c781c712b3b67884f745ca2c759d2" } }, - { url = "https://files.pythonhosted.org/packages/be/ad/1d23be700b6b2ddaeb362360c7145917a8edbbf7240ae428d40541772fce/pyrefly-1.0.0-py3-none-win_amd64.whl", upload-time = 2026-05-12T20:12:41Z, size = 13146369, hashes = { sha256 = "c8abcb0f2082e83c890375128f9cff4aa4d3f210b85eea7b3046c1ae764e77f5" } }, - { url = "https://files.pythonhosted.org/packages/8c/38/16589134f3012fd097a10dcc85771555f1a5fb76e04b682597180743af30/pyrefly-1.0.0-py3-none-win_arm64.whl", upload-time = 2026-05-12T20:12:43Z, size = 12538326, hashes = { sha256 = "d150fa9e40e8392832be81c3bcfc0497c146674ce4d0f8e04e1ec29e775ffb8c" } }, + { url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", upload-time = 2026-08-01T02:56:02Z, size = 14026305, hashes = { sha256 = "7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e" } }, + { url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", upload-time = 2026-08-01T02:56:04Z, size = 13463880, hashes = { sha256 = "756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d" } }, + { url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2026-08-01T02:56:07Z, size = 13907329, hashes = { sha256 = "e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d" } }, + { url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2026-08-01T02:56:09Z, size = 15039020, hashes = { sha256 = "5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd" } }, + { url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2026-08-01T02:56:11Z, size = 14986199, hashes = { sha256 = "25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a" } }, + { url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-08-01T02:56:14Z, size = 14393715, hashes = { sha256 = "90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1" } }, + { url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", upload-time = 2026-08-01T02:56:16Z, size = 13933008, hashes = { sha256 = "368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf" } }, + { url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", upload-time = 2026-08-01T02:56:18Z, size = 14431827, hashes = { sha256 = "d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe" } }, + { url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", upload-time = 2026-08-01T02:56:20Z, size = 13229447, hashes = { sha256 = "8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa" } }, + { url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", upload-time = 2026-08-01T02:56:23Z, size = 14087387, hashes = { sha256 = "8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170" } }, + { url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", upload-time = 2026-08-01T02:56:25Z, size = 13430873, hashes = { sha256 = "3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e" } }, ] [[packages]] name = "pytest" -version = "9.0.3" +version = "9.1.1" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", upload-time = 2026-04-07T17:16:18Z, size = 1572165, hashes = { sha256 = "b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c" } } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", upload-time = 2026-06-19T10:58:32Z, size = 1636369, hashes = { sha256 = "1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", upload-time = 2026-04-07T17:16:16Z, size = 375249, hashes = { sha256 = "2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9" } }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", upload-time = 2026-06-19T10:58:31Z, size = 386536, hashes = { sha256 = "37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c" } }, ] [[packages]] name = "pytest-asyncio" -version = "1.3.0" +version = "1.4.0" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", upload-time = 2025-11-10T16:07:47Z, size = 50087, hashes = { sha256 = "d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5" } } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", upload-time = 2026-05-26T09:56:04Z, size = 58514, hashes = { sha256 = "c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", upload-time = 2025-11-10T16:07:45Z, size = 15075, hashes = { sha256 = "611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5" } }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", upload-time = 2026-05-26T09:56:02Z, size = 16930, hashes = { sha256 = "933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1" } }, ] [[packages]] name = "pytest-cov" -version = "6.3.0" +version = "7.1.0" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", upload-time = 2025-09-06T15:40:14Z, size = 70398, hashes = { sha256 = "35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2" } } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", upload-time = 2026-03-21T20:11:16Z, size = 55592, hashes = { sha256 = "30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", upload-time = 2025-09-06T15:40:12Z, size = 25115, hashes = { sha256 = "440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749" } }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", upload-time = 2026-03-21T20:11:14Z, size = 22876, hashes = { sha256 = "a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678" } }, ] [[packages]] @@ -419,45 +468,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", upload-time = 2025-05-05T19:44:33Z, size = 14382, hashes = { sha256 = "c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2" } }, ] -[[packages]] -name = "pytest-xdist" -version = "3.8.0" -index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", upload-time = 2025-07-01T13:30:59Z, size = 88069, hashes = { sha256 = "7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1" } } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", upload-time = 2025-07-01T13:30:56Z, size = 46396, hashes = { sha256 = "202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88" } }, -] - [[packages]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.3" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", upload-time = 2026-03-01T16:00:26Z, size = 50135, hashes = { sha256 = "2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3" } } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", upload-time = 2026-08-16T16:54:54Z, size = 58945, hashes = { sha256 = "a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", upload-time = 2026-03-01T16:00:25Z, size = 22101, hashes = { sha256 = "1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a" } }, + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", upload-time = 2026-08-16T16:54:52Z, size = 22780, hashes = { sha256 = "904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9" } }, ] [[packages]] name = "pywin32" -version = "311" +version = "312" marker = "sys_platform == 'win32'" index = "https://pypi.org/simple" wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", upload-time = 2025-07-14T20:13:05Z, size = 8760432, hashes = { sha256 = "d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3" } }, - { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", upload-time = 2025-07-14T20:13:07Z, size = 9590103, hashes = { sha256 = "797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b" } }, - { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", upload-time = 2025-07-14T20:13:11Z, size = 8778557, hashes = { sha256 = "0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b" } }, - { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", upload-time = 2025-07-14T20:13:13Z, size = 8697031, hashes = { sha256 = "184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151" } }, - { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", upload-time = 2025-07-14T20:13:15Z, size = 9508308, hashes = { sha256 = "3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503" } }, - { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", upload-time = 2025-07-14T20:13:16Z, size = 8703930, hashes = { sha256 = "a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2" } }, - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", upload-time = 2025-07-14T20:13:20Z, size = 8706543, hashes = { sha256 = "750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31" } }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", upload-time = 2025-07-14T20:13:22Z, size = 9495040, hashes = { sha256 = "b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067" } }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", upload-time = 2025-07-14T20:13:24Z, size = 8710102, hashes = { sha256 = "e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852" } }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", upload-time = 2025-07-14T20:13:26Z, size = 8705700, hashes = { sha256 = "f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d" } }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", upload-time = 2025-07-14T20:13:28Z, size = 9494700, hashes = { sha256 = "718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d" } }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", upload-time = 2025-07-14T20:13:30Z, size = 8709318, hashes = { sha256 = "7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a" } }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", upload-time = 2025-07-14T20:13:32Z, size = 8840714, hashes = { sha256 = "b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee" } }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", upload-time = 2025-07-14T20:13:34Z, size = 9656800, hashes = { sha256 = "3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87" } }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", upload-time = 2025-07-14T20:13:36Z, size = 8932540, hashes = { sha256 = "a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42" } }, + { url = "https://files.pythonhosted.org/packages/fe/1b/9cfdeac80ee45bebbbcb31f1b7b99a0d81a1c72de48d837be984e0e88b1d/pywin32-312-cp310-cp310-win32.whl", upload-time = 2026-06-04T07:49:14Z, size = 6361387, hashes = { sha256 = "772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e" } }, + { url = "https://files.pythonhosted.org/packages/33/b1/7afc96d041d982c27bc2df6f853d43f01fd273e3d39d04be3647ddeb533d/pywin32-312-cp310-cp310-win_amd64.whl", upload-time = 2026-06-04T07:49:16Z, size = 6926780, hashes = { sha256 = "5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db" } }, + { url = "https://files.pythonhosted.org/packages/ce/3a/4140da9ad54108e517f4a16b2d83da3033e08662144623e1239587cb7db6/pywin32-312-cp310-cp310-win_arm64.whl", upload-time = 2026-06-04T07:49:18Z, size = 4307203, hashes = { sha256 = "3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd" } }, + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", upload-time = 2026-06-04T07:49:21Z, size = 6362659, hashes = { sha256 = "17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c" } }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", upload-time = 2026-06-04T07:49:23Z, size = 6928825, hashes = { sha256 = "d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a" } }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", upload-time = 2026-06-04T07:49:26Z, size = 6721875, hashes = { sha256 = "b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47" } }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", upload-time = 2026-06-04T07:49:28Z, size = 6371877, hashes = { sha256 = "dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b" } }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", upload-time = 2026-06-04T07:49:31Z, size = 6914841, hashes = { sha256 = "b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc" } }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", upload-time = 2026-06-04T07:49:34Z, size = 6727901, hashes = { sha256 = "6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950" } }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", upload-time = 2026-06-04T07:49:36Z, size = 6370184, hashes = { sha256 = "7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c" } }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", upload-time = 2026-06-04T07:49:38Z, size = 6914298, hashes = { sha256 = "c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9" } }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", upload-time = 2026-06-04T07:49:41Z, size = 6727640, hashes = { sha256 = "59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831" } }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", upload-time = 2026-06-04T07:49:43Z, size = 6468928, hashes = { sha256 = "a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b" } }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", upload-time = 2026-06-04T07:49:45Z, size = 7024157, hashes = { sha256 = "a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e" } }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", upload-time = 2026-06-04T07:49:47Z, size = 6839598, hashes = { sha256 = "9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa" } }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", upload-time = 2026-06-04T07:49:50Z, size = 6471159, hashes = { sha256 = "5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed" } }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", upload-time = 2026-06-04T07:49:54Z, size = 7025293, hashes = { sha256 = "c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5" } }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", upload-time = 2026-06-04T07:49:57Z, size = 6840337, hashes = { sha256 = "a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9" } }, ] [[packages]] @@ -471,36 +514,36 @@ wheels = [ [[packages]] name = "ruff" -version = "0.15.13" +version = "0.16.4" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", upload-time = 2026-05-14T13:44:37Z, size = 4678180, hashes = { sha256 = "f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7" } } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", upload-time = 2026-08-20T17:43:59Z, size = 4899731, hashes = { sha256 = "13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", upload-time = 2026-05-14T13:44:18Z, size = 10738279, hashes = { sha256 = "444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8" } }, - { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", upload-time = 2026-05-14T13:44:06Z, size = 11124798, hashes = { sha256 = "6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7" } }, - { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", upload-time = 2026-05-14T13:44:04Z, size = 10460761, hashes = { sha256 = "1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629" } }, - { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2026-05-14T13:44:25Z, size = 10804451, hashes = { sha256 = "9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5" } }, - { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", upload-time = 2026-05-14T13:44:08Z, size = 10534285, hashes = { sha256 = "bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22" } }, - { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2026-05-14T13:44:11Z, size = 11312063, hashes = { sha256 = "ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9" } }, - { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2026-05-14T13:44:01Z, size = 12183079, hashes = { sha256 = "2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55" } }, - { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2026-05-14T13:43:59Z, size = 11440833, hashes = { sha256 = "e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6" } }, - { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-05-14T13:44:27Z, size = 11434486, hashes = { sha256 = "cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca" } }, - { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", upload-time = 2026-05-14T13:44:13Z, size = 11385189, hashes = { sha256 = "768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd" } }, - { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", upload-time = 2026-05-14T13:43:56Z, size = 10781380, hashes = { sha256 = "fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6" } }, - { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", upload-time = 2026-05-14T13:44:20Z, size = 10540605, hashes = { sha256 = "8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51" } }, - { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", upload-time = 2026-05-14T13:44:16Z, size = 11036554, hashes = { sha256 = "7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2" } }, - { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", upload-time = 2026-05-14T13:44:22Z, size = 11528133, hashes = { sha256 = "f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b" } }, - { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", upload-time = 2026-05-14T13:44:35Z, size = 10721455, hashes = { sha256 = "4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41" } }, - { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", upload-time = 2026-05-14T13:44:30Z, size = 11900409, hashes = { sha256 = "7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4" } }, - { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", upload-time = 2026-05-14T13:44:33Z, size = 11179336, hashes = { sha256 = "2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21" } }, + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", upload-time = 2026-08-20T17:43:16Z, size = 10006909, hashes = { sha256 = "df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7" } }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", upload-time = 2026-08-20T17:43:19Z, size = 10240201, hashes = { sha256 = "0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604" } }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", upload-time = 2026-08-20T17:43:21Z, size = 9835122, hashes = { sha256 = "963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255" } }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", upload-time = 2026-08-20T17:43:24Z, size = 9977162, hashes = { sha256 = "32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade" } }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", upload-time = 2026-08-20T17:43:26Z, size = 9829789, hashes = { sha256 = "b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff" } }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", upload-time = 2026-08-20T17:43:29Z, size = 10527949, hashes = { sha256 = "7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80" } }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", upload-time = 2026-08-20T17:43:31Z, size = 11333695, hashes = { sha256 = "3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07" } }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", upload-time = 2026-08-20T17:43:34Z, size = 10727741, hashes = { sha256 = "e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7" } }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", upload-time = 2026-08-20T17:43:37Z, size = 10286522, hashes = { sha256 = "f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3" } }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", upload-time = 2026-08-20T17:43:39Z, size = 10584182, hashes = { sha256 = "6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454" } }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", upload-time = 2026-08-20T17:43:42Z, size = 10134195, hashes = { sha256 = "bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b" } }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", upload-time = 2026-08-20T17:43:44Z, size = 9825821, hashes = { sha256 = "b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d" } }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", upload-time = 2026-08-20T17:43:46Z, size = 10267658, hashes = { sha256 = "8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0" } }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", upload-time = 2026-08-20T17:43:49Z, size = 10697071, hashes = { sha256 = "fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d" } }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", upload-time = 2026-08-20T17:43:52Z, size = 10021687, hashes = { sha256 = "312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e" } }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", upload-time = 2026-08-20T17:43:54Z, size = 10567657, hashes = { sha256 = "05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c" } }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", upload-time = 2026-08-20T17:43:57Z, size = 10451579, hashes = { sha256 = "a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21" } }, ] [[packages]] name = "testcontainers" -version = "4.14.2" +version = "4.15.0" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", upload-time = 2026-03-18T05:19:16Z, size = 166841, hashes = { sha256 = "1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239" } } +sdist = { url = "https://files.pythonhosted.org/packages/4b/13/2cc466bddf26d0085f30a2b2bd56b7f8708b54a54db833eec97c5c69129b/testcontainers-4.15.0.tar.gz", upload-time = 2026-07-24T23:08:01Z, size = 95340, hashes = { sha256 = "085cde086337632e19002719460b7b80bbab2bdd51bb3ea04f77d0de96504706" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", upload-time = 2026-03-18T05:19:15Z, size = 125712, hashes = { sha256 = "0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68" } }, + { url = "https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl", upload-time = 2026-07-24T23:08:00Z, size = 160771, hashes = { sha256 = "8796c14e76604031ad39cf0ed3b8e9806283a1fbf5270965c2b1c594caa31b74" } }, ] [[packages]] @@ -560,11 +603,11 @@ wheels = [ [[packages]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", upload-time = 2025-08-25T13:49:26Z, size = 109391, hashes = { sha256 = "0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" } } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", upload-time = 2026-07-02T08:40:05Z, size = 113555, hashes = { sha256 = "dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", upload-time = 2025-08-25T13:49:24Z, size = 44614, hashes = { sha256 = "f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548" } }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", upload-time = 2026-07-02T08:40:04Z, size = 45571, hashes = { sha256 = "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8" } }, ] [[packages]] @@ -578,86 +621,86 @@ wheels = [ [[packages]] name = "wrapt" -version = "2.1.2" +version = "2.3.0" index = "https://pypi.org/simple" -sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", upload-time = 2026-03-06T02:53:25Z, size = 81678, hashes = { sha256 = "3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e" } } +sdist = { url = "https://files.pythonhosted.org/packages/2b/b0/c1f5a970721f06b85c0cd5142e0ff8fe067708abd779b0c4f4be7d61d09f/wrapt-2.3.0.tar.gz", upload-time = 2026-07-28T06:06:14Z, size = 131509, hashes = { sha256 = "681a2d0eefd721998f90642762b8e75c2159ec531b20ad5e437245ea7b06a107" } } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/d2/387594fb592d027366645f3d7cc9b4d7ca7be93845fbaba6d835a912ef3c/wrapt-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", upload-time = 2026-03-06T02:52:40Z, size = 60669, hashes = { sha256 = "4b7a86d99a14f76facb269dc148590c01aaf47584071809a70da30555228158c" } }, - { url = "https://files.pythonhosted.org/packages/c9/18/3f373935bc5509e7ac444c8026a56762e50c1183e7061797437ca96c12ce/wrapt-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", upload-time = 2026-03-06T02:54:21Z, size = 61603, hashes = { sha256 = "a819e39017f95bf7aede768f75915635aa8f671f2993c036991b8d3bfe8dbb6f" } }, - { url = "https://files.pythonhosted.org/packages/c2/7a/32758ca2853b07a887a4574b74e28843919103194bb47001a304e24af62f/wrapt-2.1.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-03-06T02:53:54Z, size = 113632, hashes = { sha256 = "5681123e60aed0e64c7d44f72bbf8b4ce45f79d81467e2c4c728629f5baf06eb" } }, - { url = "https://files.pythonhosted.org/packages/1d/d5/eeaa38f670d462e97d978b3b0d9ce06d5b91e54bebac6fbed867809216e7/wrapt-2.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-03-06T02:54:53Z, size = 115644, hashes = { sha256 = "2b8b28e97a44d21836259739ae76284e180b18abbb4dcfdff07a415cf1016c3e" } }, - { url = "https://files.pythonhosted.org/packages/e3/09/2a41506cb17affb0bdf9d5e2129c8c19e192b388c4c01d05e1b14db23c00/wrapt-2.1.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-03-06T02:54:43Z, size = 112016, hashes = { sha256 = "cef91c95a50596fcdc31397eb6955476f82ae8a3f5a8eabdc13611b60ee380ba" } }, - { url = "https://files.pythonhosted.org/packages/64/15/0e6c3f5e87caadc43db279724ee36979246d5194fa32fed489c73643ba59/wrapt-2.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", upload-time = 2026-03-06T02:54:29Z, size = 114823, hashes = { sha256 = "dad63212b168de8569b1c512f4eac4b57f2c6934b30df32d6ee9534a79f1493f" } }, - { url = "https://files.pythonhosted.org/packages/56/b2/0ad17c8248f4e57bedf44938c26ec3ee194715f812d2dbbd9d7ff4be6c06/wrapt-2.1.2-cp310-cp310-musllinux_1_2_riscv64.whl", upload-time = 2026-03-06T02:54:02Z, size = 111244, hashes = { sha256 = "d307aa6888d5efab2c1cde09843d48c843990be13069003184b67d426d145394" } }, - { url = "https://files.pythonhosted.org/packages/ff/04/bcdba98c26f2c6522c7c09a726d5d9229120163493620205b2f76bd13c01/wrapt-2.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", upload-time = 2026-03-06T02:54:12Z, size = 113307, hashes = { sha256 = "c87cf3f0c85e27b3ac7d9ad95da166bf8739ca215a8b171e8404a2d739897a45" } }, - { url = "https://files.pythonhosted.org/packages/0e/1b/5e2883c6bc14143924e465a6fc5a92d09eeabe35310842a481fb0581f832/wrapt-2.1.2-cp310-cp310-win32.whl", upload-time = 2026-03-06T02:54:26Z, size = 57986, hashes = { sha256 = "d1c5fea4f9fe3762e2b905fdd67df51e4be7a73b7674957af2d2ade71a5c075d" } }, - { url = "https://files.pythonhosted.org/packages/42/5a/4efc997bccadd3af5749c250b49412793bc41e13a83a486b2b54a33e240c/wrapt-2.1.2-cp310-cp310-win_amd64.whl", upload-time = 2026-03-06T02:54:18Z, size = 60336, hashes = { sha256 = "d8f7740e1af13dff2684e4d56fe604a7e04d6c94e737a60568d8d4238b9a0c71" } }, - { url = "https://files.pythonhosted.org/packages/c1/f5/a2bb833e20181b937e87c242645ed5d5aa9c373006b0467bfe1a35c727d0/wrapt-2.1.2-cp310-cp310-win_arm64.whl", upload-time = 2026-03-06T02:53:51Z, size = 58757, hashes = { sha256 = "1c6cc827c00dc839350155f316f1f8b4b0c370f52b6a19e782e2bda89600c7dc" } }, - { url = "https://files.pythonhosted.org/packages/c7/81/60c4471fce95afa5922ca09b88a25f03c93343f759aae0f31fb4412a85c7/wrapt-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", upload-time = 2026-03-06T02:52:58Z, size = 60666, hashes = { sha256 = "96159a0ee2b0277d44201c3b5be479a9979cf154e8c82fa5df49586a8e7679bb" } }, - { url = "https://files.pythonhosted.org/packages/6b/be/80e80e39e7cb90b006a0eaf11c73ac3a62bbfb3068469aec15cc0bc795de/wrapt-2.1.2-cp311-cp311-macosx_11_0_arm64.whl", upload-time = 2026-03-06T02:53:00Z, size = 61601, hashes = { sha256 = "98ba61833a77b747901e9012072f038795de7fc77849f1faa965464f3f87ff2d" } }, - { url = "https://files.pythonhosted.org/packages/b0/be/d7c88cd9293c859fc74b232abdc65a229bb953997995d6912fc85af18323/wrapt-2.1.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-03-06T02:52:44Z, size = 114057, hashes = { sha256 = "767c0dbbe76cae2a60dd2b235ac0c87c9cccf4898aef8062e57bead46b5f6894" } }, - { url = "https://files.pythonhosted.org/packages/ea/25/36c04602831a4d685d45a93b3abea61eca7fe35dab6c842d6f5d570ef94a/wrapt-2.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-03-06T02:54:56Z, size = 116099, hashes = { sha256 = "9c691a6bc752c0cc4711cc0c00896fcd0f116abc253609ef64ef930032821842" } }, - { url = "https://files.pythonhosted.org/packages/5c/4e/98a6eb417ef551dc277bec1253d5246b25003cf36fdf3913b65cb7657a56/wrapt-2.1.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-03-06T02:53:52Z, size = 112457, hashes = { sha256 = "f3b7d73012ea75aee5844de58c88f44cf62d0d62711e39da5a82824a7c4626a8" } }, - { url = "https://files.pythonhosted.org/packages/cb/a6/a6f7186a5297cad8ec53fd7578533b28f795fdf5372368c74bd7e6e9841c/wrapt-2.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", upload-time = 2026-03-06T02:53:32Z, size = 115351, hashes = { sha256 = "577dff354e7acd9d411eaf4bfe76b724c89c89c8fc9b7e127ee28c5f7bcb25b6" } }, - { url = "https://files.pythonhosted.org/packages/97/6f/06e66189e721dbebd5cf20e138acc4d1150288ce118462f2fcbff92d38db/wrapt-2.1.2-cp311-cp311-musllinux_1_2_riscv64.whl", upload-time = 2026-03-06T02:53:08Z, size = 111748, hashes = { sha256 = "3d7b6fd105f8b24e5bd23ccf41cb1d1099796524bcc6f7fbb8fe576c44befbc9" } }, - { url = "https://files.pythonhosted.org/packages/ef/43/4808b86f499a51370fbdbdfa6cb91e9b9169e762716456471b619fca7a70/wrapt-2.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", upload-time = 2026-03-06T02:53:02Z, size = 113783, hashes = { sha256 = "866abdbf4612e0b34764922ef8b1c5668867610a718d3053d59e24a5e5fcfc15" } }, - { url = "https://files.pythonhosted.org/packages/91/2c/a3f28b8fa7ac2cefa01cfcaca3471f9b0460608d012b693998cd61ef43df/wrapt-2.1.2-cp311-cp311-win32.whl", upload-time = 2026-03-06T02:53:27Z, size = 57977, hashes = { sha256 = "5a0a0a3a882393095573344075189eb2d566e0fd205a2b6414e9997b1b800a8b" } }, - { url = "https://files.pythonhosted.org/packages/3f/c3/2b1c7bd07a27b1db885a2fab469b707bdd35bddf30a113b4917a7e2139d2/wrapt-2.1.2-cp311-cp311-win_amd64.whl", upload-time = 2026-03-06T02:54:28Z, size = 60336, hashes = { sha256 = "64a07a71d2730ba56f11d1a4b91f7817dc79bc134c11516b75d1921a7c6fcda1" } }, - { url = "https://files.pythonhosted.org/packages/ec/5c/76ece7b401b088daa6503d6264dd80f9a727df3e6042802de9a223084ea2/wrapt-2.1.2-cp311-cp311-win_arm64.whl", upload-time = 2026-03-06T02:53:16Z, size = 58756, hashes = { sha256 = "b89f095fe98bc12107f82a9f7d570dc83a0870291aeb6b1d7a7d35575f55d98a" } }, - { url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", upload-time = 2026-03-06T02:52:45Z, size = 61255, hashes = { sha256 = "ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9" } }, - { url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", upload-time = 2026-03-06T02:53:48Z, size = 61848, hashes = { sha256 = "6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748" } }, - { url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-03-06T02:54:40Z, size = 121433, hashes = { sha256 = "c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e" } }, - { url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-03-06T02:53:26Z, size = 123013, hashes = { sha256 = "79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8" } }, - { url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-03-06T02:53:11Z, size = 117326, hashes = { sha256 = "f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c" } }, - { url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", upload-time = 2026-03-06T02:54:09Z, size = 121444, hashes = { sha256 = "e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c" } }, - { url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", upload-time = 2026-03-06T02:54:03Z, size = 116237, hashes = { sha256 = "6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1" } }, - { url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", upload-time = 2026-03-06T02:53:20Z, size = 120563, hashes = { sha256 = "7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2" } }, - { url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", upload-time = 2026-03-06T02:53:37Z, size = 58198, hashes = { sha256 = "eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0" } }, - { url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", upload-time = 2026-03-06T02:52:47Z, size = 60441, hashes = { sha256 = "1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63" } }, - { url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", upload-time = 2026-03-06T02:53:22Z, size = 58836, hashes = { sha256 = "c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf" } }, - { url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", upload-time = 2026-03-06T02:53:41Z, size = 61259, hashes = { sha256 = "787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b" } }, - { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", upload-time = 2026-03-06T02:52:48Z, size = 61851, hashes = { sha256 = "4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e" } }, - { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-03-06T02:54:14Z, size = 121446, hashes = { sha256 = "bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb" } }, - { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-03-06T02:54:10Z, size = 123056, hashes = { sha256 = "16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca" } }, - { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-03-06T02:53:03Z, size = 117359, hashes = { sha256 = "162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267" } }, - { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", upload-time = 2026-03-06T02:53:45Z, size = 121479, hashes = { sha256 = "f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f" } }, - { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", upload-time = 2026-03-06T02:54:46Z, size = 116271, hashes = { sha256 = "a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8" } }, - { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", upload-time = 2026-03-06T02:52:50Z, size = 120573, hashes = { sha256 = "6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413" } }, - { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", upload-time = 2026-03-06T02:53:47Z, size = 58205, hashes = { sha256 = "5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6" } }, - { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", upload-time = 2026-03-06T02:53:30Z, size = 60452, hashes = { sha256 = "f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1" } }, - { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", upload-time = 2026-03-06T02:52:52Z, size = 58842, hashes = { sha256 = "3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf" } }, - { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", upload-time = 2026-03-06T02:53:19Z, size = 63075, hashes = { sha256 = "0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b" } }, - { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", upload-time = 2026-03-06T02:54:33Z, size = 63719, hashes = { sha256 = "a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18" } }, - { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-03-06T02:52:42Z, size = 152643, hashes = { sha256 = "2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d" } }, - { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-03-06T02:54:25Z, size = 158805, hashes = { sha256 = "3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015" } }, - { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-03-06T02:53:57Z, size = 145990, hashes = { sha256 = "a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92" } }, - { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", upload-time = 2026-03-06T02:54:55Z, size = 155670, hashes = { sha256 = "ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf" } }, - { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", upload-time = 2026-03-06T02:54:24Z, size = 144357, hashes = { sha256 = "76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67" } }, - { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", upload-time = 2026-03-06T02:53:31Z, size = 150269, hashes = { sha256 = "c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a" } }, - { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", upload-time = 2026-03-06T02:54:15Z, size = 59894, hashes = { sha256 = "f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd" } }, - { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", upload-time = 2026-03-06T02:54:41Z, size = 63197, hashes = { sha256 = "84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f" } }, - { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", upload-time = 2026-03-06T02:54:48Z, size = 60363, hashes = { sha256 = "a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679" } }, - { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", upload-time = 2026-03-06T02:53:55Z, size = 61418, hashes = { sha256 = "1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9" } }, - { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", upload-time = 2026-03-06T02:52:53Z, size = 61914, hashes = { sha256 = "6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9" } }, - { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-03-06T02:54:30Z, size = 120417, hashes = { sha256 = "de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e" } }, - { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-03-06T02:54:51Z, size = 122797, hashes = { sha256 = "970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c" } }, - { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-03-06T02:54:37Z, size = 117350, hashes = { sha256 = "3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a" } }, - { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", upload-time = 2026-03-06T02:54:35Z, size = 121223, hashes = { sha256 = "57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90" } }, - { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", upload-time = 2026-03-06T02:54:19Z, size = 116287, hashes = { sha256 = "776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586" } }, - { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", upload-time = 2026-03-06T02:54:16Z, size = 119593, hashes = { sha256 = "fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19" } }, - { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", upload-time = 2026-03-06T02:53:06Z, size = 58631, hashes = { sha256 = "e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508" } }, - { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", upload-time = 2026-03-06T02:53:50Z, size = 60875, hashes = { sha256 = "467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04" } }, - { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", upload-time = 2026-03-06T02:53:59Z, size = 59164, hashes = { sha256 = "da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575" } }, - { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", upload-time = 2026-03-06T02:52:54Z, size = 63163, hashes = { sha256 = "62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb" } }, - { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", upload-time = 2026-03-06T02:52:56Z, size = 63723, hashes = { sha256 = "c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22" } }, - { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-03-06T02:53:23Z, size = 152652, hashes = { sha256 = "3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596" } }, - { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-03-06T02:53:35Z, size = 158807, hashes = { sha256 = "a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044" } }, - { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-03-06T02:52:57Z, size = 146061, hashes = { sha256 = "6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b" } }, - { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", upload-time = 2026-03-06T02:53:39Z, size = 155667, hashes = { sha256 = "4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf" } }, - { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", upload-time = 2026-03-06T02:54:22Z, size = 144392, hashes = { sha256 = "a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2" } }, - { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", upload-time = 2026-03-06T02:54:07Z, size = 150296, hashes = { sha256 = "3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3" } }, - { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", upload-time = 2026-03-06T02:54:00Z, size = 60539, hashes = { sha256 = "3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7" } }, - { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", upload-time = 2026-03-06T02:54:39Z, size = 63969, hashes = { sha256 = "08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5" } }, - { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", upload-time = 2026-03-06T02:53:14Z, size = 60554, hashes = { sha256 = "72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00" } }, - { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", upload-time = 2026-03-06T02:53:12Z, size = 43993, hashes = { sha256 = "b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8" } }, + { url = "https://files.pythonhosted.org/packages/40/31/5822ce37ca8820c2ed35a498c67c8b37960b9cee2ba437fd32849d0a234c/wrapt-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", upload-time = 2026-07-28T06:04:04Z, size = 81191, hashes = { sha256 = "0bb2797048db0956348cb3058c33bc4184614f13231389cfbccc16a5d32780a7" } }, + { url = "https://files.pythonhosted.org/packages/7a/5a/3c6117938be98754578ab83f5a40d7d0ea2cd2c487dc5cd6027ee7228229/wrapt-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", upload-time = 2026-07-28T06:04:07Z, size = 82255, hashes = { sha256 = "ce9f398f868d2b3b27aa2ea4de79645ef9077aeeac8dfc2814b0d542c6a2b87f" } }, + { url = "https://files.pythonhosted.org/packages/a5/0f/94ae724c5087eb6054c0d63febd7094947dcf302fe058e2e0488102a872b/wrapt-2.3.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-07-28T06:04:08Z, size = 155228, hashes = { sha256 = "ad71df7a04dd3497e9302e81f4a7c91bd401ea0e15a9df9029527900f94bee43" } }, + { url = "https://files.pythonhosted.org/packages/6c/21/1f780bba935dcf697c0c59de9be3a559bbb8e31a53ca3f25422023738432/wrapt-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-07-28T06:04:09Z, size = 157073, hashes = { sha256 = "fc82c2ccc8e234c844f5303d9f2984b346dcdd53e94823ce8420d2c75b4b9023" } }, + { url = "https://files.pythonhosted.org/packages/73/31/6c7799d7b6431fcd7e1b83245fb45258a2d2c3a2187fbaecb83572a72d7a/wrapt-2.3.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-07-28T06:04:10Z, size = 151594, hashes = { sha256 = "a6e19531ae33c508cea7d84a7edfda01fa86e51b8d1a93a77712c55e6e469152" } }, + { url = "https://files.pythonhosted.org/packages/ce/17/42d670dbfafd49076c6eb2b7d67633d7e1c968e39bfb11a135acb6fac67b/wrapt-2.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", upload-time = 2026-07-28T06:04:12Z, size = 156069, hashes = { sha256 = "df4ce31150bcd5d9f36f816aac3010ab4f4bf8672ac1d3b0ac7d539ec61c7c02" } }, + { url = "https://files.pythonhosted.org/packages/1f/d6/c66b4ba4eda49257c84d5c2df26118280f09ca7905aee20d0064db778d13/wrapt-2.3.0-cp310-cp310-musllinux_1_2_riscv64.whl", upload-time = 2026-07-28T06:04:13Z, size = 150930, hashes = { sha256 = "e2e692bc0d63f881cf7006730a56bd4e0c2fab5dc318466942805d692b166276" } }, + { url = "https://files.pythonhosted.org/packages/c0/f2/1a3b949c0322fb27396eafd1044328c1cb0400e0b32105d75a3cd03096e7/wrapt-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", upload-time = 2026-07-28T06:04:14Z, size = 154525, hashes = { sha256 = "c8388ba7faf5dbf9ee106bb70d66f257629b1bd98091123e19e8a4553a319199" } }, + { url = "https://files.pythonhosted.org/packages/12/65/147563a3dfa6e830c857b93b530ebd8c0cd9d540e5914aec8f9b12880c02/wrapt-2.3.0-cp310-cp310-win32.whl", upload-time = 2026-07-28T06:04:16Z, size = 77879, hashes = { sha256 = "e045ff75d7d94900fc32896ed93c45ce2d2cac28c9dead582ff9a5a49d446e35" } }, + { url = "https://files.pythonhosted.org/packages/c4/eb/921405b4dc55d4f8be4c700ef120539fdd75d5fdb50d83bd257171ee18e0/wrapt-2.3.0-cp310-cp310-win_amd64.whl", upload-time = 2026-07-28T06:04:17Z, size = 80733, hashes = { sha256 = "b4fc96b159af0a3e0faa72475a69d66292bea72a5bed1e1aca1bffbddc3cb2b0" } }, + { url = "https://files.pythonhosted.org/packages/b6/13/75947450c5bb57795fa86384721cd52c5c4deb0879022f309501a8a85d44/wrapt-2.3.0-cp310-cp310-win_arm64.whl", upload-time = 2026-07-28T06:04:18Z, size = 80199, hashes = { sha256 = "1236fa25173ca964c97422470482e9011b9e3c7ed0d75798b40b3da3b0e0e760" } }, + { url = "https://files.pythonhosted.org/packages/00/b8/9182e4c618a847be0baccb68e4602b070d0fa22c782cf058f4bc66b32709/wrapt-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", upload-time = 2026-07-28T06:04:20Z, size = 81427, hashes = { sha256 = "5ab559e1b2551d23d54db2a0001c6d73bad022a254639561c5f6c382a9d6c2fe" } }, + { url = "https://files.pythonhosted.org/packages/84/ca/613cefd9c5977366b1587e61c0b428176d382e6d75b454084c5e58503042/wrapt-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", upload-time = 2026-07-28T06:04:21Z, size = 82360, hashes = { sha256 = "bff9a671bc00709cab5a7f745c592b5671873449db0ee2a569af994f16b29a4d" } }, + { url = "https://files.pythonhosted.org/packages/71/71/4cd2151a236f44a6e2dd4ed8011838d7ba0be3d656c8bafdfc65a2ed1917/wrapt-2.3.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-07-28T06:04:22Z, size = 161700, hashes = { sha256 = "fc648a335d7e01adb3640b25f02fd0ea05886cf04d0af7f4ee902bc7b5e466e8" } }, + { url = "https://files.pythonhosted.org/packages/49/2c/bc508fee75eb2919ed69769800b09968e4aab16897f909a23f39c81e323f/wrapt-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-07-28T06:04:24Z, size = 162922, hashes = { sha256 = "d0077f3d65541925fa83002f967b22ad6550d24813ac64cb905f717194128d9c" } }, + { url = "https://files.pythonhosted.org/packages/4d/e5/04f34d38e66d857dfc2fc4088d60e70c0e422467822defa49b2b4a26e17b/wrapt-2.3.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-07-28T06:04:25Z, size = 156125, hashes = { sha256 = "9790ea25190a4e0fe4cdf4eeb868e9d75f8a024a70a5b6bf9c348a3a2b72e731" } }, + { url = "https://files.pythonhosted.org/packages/23/41/c35940ea1c423f129ebe4361db853bc80d4def6326242e1206fa15bf94f4/wrapt-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", upload-time = 2026-07-28T06:04:27Z, size = 162039, hashes = { sha256 = "816877aa749253149f9ecfd2635d4d948ecfa338e1a0311d187b1acb1bb8a3eb" } }, + { url = "https://files.pythonhosted.org/packages/0e/60/9bda34c3d7d182aa703fe35339ae0ed4c4dad5e5c587f93890143e1f87fb/wrapt-2.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", upload-time = 2026-07-28T06:04:28Z, size = 155110, hashes = { sha256 = "3d1c2c1b808600d2ea808e6360910a60ed5f409a4011655e10f9164ba0a414a6" } }, + { url = "https://files.pythonhosted.org/packages/e8/ba/60bfd9b1a751f4fcb2d603668fc272d651ccdd339a56acf8c40ad21a0293/wrapt-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", upload-time = 2026-07-28T06:04:29Z, size = 161089, hashes = { sha256 = "5ba1e5e08ddc46130e9682b2c249f2d1dd39bda9106ed4bd401b7519f18f41bd" } }, + { url = "https://files.pythonhosted.org/packages/0f/32/2bd358c6f4f1305c813479d1e9ba746bebdd794f4a20107ab2b3ee0cbd45/wrapt-2.3.0-cp311-cp311-win32.whl", upload-time = 2026-07-28T06:04:31Z, size = 78030, hashes = { sha256 = "45c9279b373d15649dfa2c2077cb3408ea1a6d3125afbdab9d6b809a66f68e14" } }, + { url = "https://files.pythonhosted.org/packages/4a/62/ecc969b13b141fef89b888c9760821cb01a86ac8fc953911592c8e1e1522/wrapt-2.3.0-cp311-cp311-win_amd64.whl", upload-time = 2026-07-28T06:04:32Z, size = 80944, hashes = { sha256 = "195b1842b4122fb54e3cd3dd5b2b4aa49302a5a61da901df0481f5c97aedde84" } }, + { url = "https://files.pythonhosted.org/packages/c4/3d/9278ada8a2b3f24372b630361e84e9a7de7abc3784634860c26d1c37785a/wrapt-2.3.0-cp311-cp311-win_arm64.whl", upload-time = 2026-07-28T06:04:33Z, size = 80074, hashes = { sha256 = "6db604ef0c67bdb2042ecdfd7b7f037cf09733557ca42360d1018285634f7b98" } }, + { url = "https://files.pythonhosted.org/packages/5b/4a/d17a0fad1bf1c5f2c887ff71fef75654141b0880bff71d157d955b5bec3a/wrapt-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", upload-time = 2026-07-28T06:04:35Z, size = 82139, hashes = { sha256 = "0a45ffae742ce91a16e11cb6c7cd71e7f9994f3cbd283b962ab093f5c6dcf525" } }, + { url = "https://files.pythonhosted.org/packages/6e/55/51b92daaf6defb57f4dc56bdcce985400f75c6984a03ca5e78ccac717028/wrapt-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", upload-time = 2026-07-28T06:04:36Z, size = 82723, hashes = { sha256 = "69e477046f2237ef0bc6547544ee73008dc764ca26eff44f09e976d221b34d5d" } }, + { url = "https://files.pythonhosted.org/packages/28/7f/cfd9bc4b1f5e424eeea83d0493e43f3b1b02707ce8e50c47945873982bd5/wrapt-2.3.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-07-28T06:04:37Z, size = 172381, hashes = { sha256 = "5d221a6e6ddd302b8397433184e96b59f259f50024b854db1c411a881586b6b8" } }, + { url = "https://files.pythonhosted.org/packages/cb/89/ff7814f6eb6856b479946117d1138a2fbb46cdb6b1f379db359056c69743/wrapt-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-07-28T06:04:38Z, size = 174120, hashes = { sha256 = "392158c9a7f2ab1b8699418bfc0fe6f83548788c418b27d7bf2019ad3405cebb" } }, + { url = "https://files.pythonhosted.org/packages/12/1e/8eded8615d39e3ce81f626937a3a87b280a2a86239a2bf14a4b4bb345034/wrapt-2.3.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-07-28T06:04:40Z, size = 163035, hashes = { sha256 = "e5301c35cf75655eb33498f2bd6ae8703ca19940e3167dc9cdf740c712a39c60" } }, + { url = "https://files.pythonhosted.org/packages/35/ea/a0af2d9da62897af2a055484920de05dade30d2ba2c0d65cbdea875d3d8b/wrapt-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", upload-time = 2026-07-28T06:04:41Z, size = 171887, hashes = { sha256 = "418f54bb09d1762db02c7009b4051149893af3153a87f92d70356703c11eea02" } }, + { url = "https://files.pythonhosted.org/packages/7e/dd/63cd4c864c65ef4906df64bd2d378f4a62b54f28063f282dfb3bf93caead/wrapt-2.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", upload-time = 2026-07-28T06:04:42Z, size = 161113, hashes = { sha256 = "1598becd30f8f2777d18564064eb4f4dbe1ab0e05a8f09786d0ef505ac782bf3" } }, + { url = "https://files.pythonhosted.org/packages/ca/ee/82f1fc9e431b5c2c5a6d201aa865dbeae3984c311c6d11a185f0c8367cf6/wrapt-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", upload-time = 2026-07-28T06:04:44Z, size = 170530, hashes = { sha256 = "3da470536bf9645143323dd41b32db55c6f4304ad382094c1a1da8a92061e10d" } }, + { url = "https://files.pythonhosted.org/packages/37/a5/5dc590e863a419930d988f8b7ca3e75a6befcfb10b6003b3a152f3d5f732/wrapt-2.3.0-cp312-cp312-win32.whl", upload-time = 2026-07-28T06:04:45Z, size = 78323, hashes = { sha256 = "fb8e2e6704a1e0b1b989546c69e2688371ef4a07fa5f61bde3eb6211186f5ac1" } }, + { url = "https://files.pythonhosted.org/packages/51/f9/4a6925a07951df56394f7e6ebe14f69f1c5ef9d87aa63e0839acf15aa63a/wrapt-2.3.0-cp312-cp312-win_amd64.whl", upload-time = 2026-07-28T06:04:47Z, size = 81180, hashes = { sha256 = "cdc021cb0b62471d6aac7f2bd92f3b4658073775f9ee7fcd325c511129e7bcc8" } }, + { url = "https://files.pythonhosted.org/packages/a8/4f/8b5de0395b2a72216751d41c9861df6facaeb611b619d8810ed2b3b23eb2/wrapt-2.3.0-cp312-cp312-win_arm64.whl", upload-time = 2026-07-28T06:04:48Z, size = 80155, hashes = { sha256 = "67bfe2485f50368c3fcd2275fc1fd100e350d601e0058921a7c82678a465aeab" } }, + { url = "https://files.pythonhosted.org/packages/8e/6e/0f88a072483e76b881e3fdcd6b6ffb4a5791002514fe541e72b1b73c859a/wrapt-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", upload-time = 2026-07-28T06:04:49Z, size = 81960, hashes = { sha256 = "0d3fb71e65b001adfc42684522eeccd9c21d8ba679945abc993439567b66e59f" } }, + { url = "https://files.pythonhosted.org/packages/d7/ff/b7e2776e7c294075eb712cc9ef573d1b818f393006d09787262b8fc871c4/wrapt-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", upload-time = 2026-07-28T06:04:50Z, size = 82435, hashes = { sha256 = "51a7a4181c1295774812271fbcd7c909df372bc25579d4ed9eb875caaf0ae86f" } }, + { url = "https://files.pythonhosted.org/packages/d8/90/343bb5d0f1f9669bc252a6073f085b4abf862511bd5c9c9eaec754341f1d/wrapt-2.3.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-07-28T06:04:52Z, size = 170350, hashes = { sha256 = "9045917809c63fdf7abe3a2ceaed3d670b8ee4500ddd9291192d30aeb34467c5" } }, + { url = "https://files.pythonhosted.org/packages/59/f8/13b79a392930bd0dd6b86cbfbfe1c40944110456e1dc6d809e5c46ece904/wrapt-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-07-28T06:04:53Z, size = 170022, hashes = { sha256 = "54ca1d5573f69b5fe1d74f1f65799c68015e82f685efec9fd8cfa40a094c44d0" } }, + { url = "https://files.pythonhosted.org/packages/b2/fc/4f1b6918f5290db959d6e0c07f77385d87cede29c39c9cf8f145e9c82954/wrapt-2.3.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-07-28T06:04:54Z, size = 161043, hashes = { sha256 = "242b60c21e30866e6a2fa606c612b47c553fa60c0eaeeeb7797fb842ac0ce609" } }, + { url = "https://files.pythonhosted.org/packages/01/e1/45d3cf74414780bdff6d0380467e003f6eb0f028b6c9403db868dbc7209c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", upload-time = 2026-07-28T06:04:56Z, size = 168576, hashes = { sha256 = "e3f3d7ec0a51fbfe00d3aef047641ff2c58b25565b4717fc1f90e050be01cba8" } }, + { url = "https://files.pythonhosted.org/packages/f3/73/2fa58dd97f191c997755e2c6d569a68f0c433db4e4b36099bdd7227b6cac/wrapt-2.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", upload-time = 2026-07-28T06:04:57Z, size = 159140, hashes = { sha256 = "261f53870cd4fb2bf38f9f972c56c728fd224cb7c65721307de59d9e7e6741ae" } }, + { url = "https://files.pythonhosted.org/packages/29/a8/08a56e2000a8816d449dcbad8c8b081697acbbd490821ceca0f9d8e8d20c/wrapt-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", upload-time = 2026-07-28T06:04:59Z, size = 169263, hashes = { sha256 = "8159ec0b0cb7608175eb150de94c19e34f4d47ac655f5ca9baf45df6b688ffd3" } }, + { url = "https://files.pythonhosted.org/packages/9e/d4/354e1725e35a73b2af4fa70a3e024c7a5d1bf1802dfb862dcb668aae0253/wrapt-2.3.0-cp313-cp313-win32.whl", upload-time = 2026-07-28T06:05:00Z, size = 78241, hashes = { sha256 = "10461884b3014fbfc8eb7d09a93c5f246363e6711d9d881f95eb8c27fdef049f" } }, + { url = "https://files.pythonhosted.org/packages/6c/7e/34c87fa2174848dfee820322aaa318bab08913998ccecc8d2f57b4ad4639/wrapt-2.3.0-cp313-cp313-win_amd64.whl", upload-time = 2026-07-28T06:05:01Z, size = 81113, hashes = { sha256 = "ac870cc97b73bb00ac353329e9559a4bebc47c4c86792ed9b23b58c15b6ad838" } }, + { url = "https://files.pythonhosted.org/packages/11/86/fcc9a530579e008c9478bb565a6cdfbfd33536660f069c8b91a6607c5050/wrapt-2.3.0-cp313-cp313-win_arm64.whl", upload-time = 2026-07-28T06:05:03Z, size = 80182, hashes = { sha256 = "a65e8db2b4e90c2e7ade931086351c98ef420bf7a94ee08c95ac8a3cbbc43579" } }, + { url = "https://files.pythonhosted.org/packages/96/50/3864848b95b28ef73e17551fc8dccbff2628a834f52cf26a57f9c419fb83/wrapt-2.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", upload-time = 2026-07-28T06:05:04Z, size = 83921, hashes = { sha256 = "fd1f2f557dd3491fe75905e578f4db967393d40d1a8f468edc4d40ac7f2d5944" } }, + { url = "https://files.pythonhosted.org/packages/3b/4c/3d1921a60c3e8c71c540ff136e6a47a1fbccf7f671e818394889f7871d9c/wrapt-2.3.0-cp313-cp313t-macosx_11_0_arm64.whl", upload-time = 2026-07-28T06:05:05Z, size = 84412, hashes = { sha256 = "9f5d2aec29dfc76c37e23897dee92766a3fd4f3bff3ae7fc9c6b4bf37d8c1360" } }, + { url = "https://files.pythonhosted.org/packages/fa/1a/4a796ff7adb26ada6d4b758c94d47a38320b085e7099afc088efbbcdb006/wrapt-2.3.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-07-28T06:05:07Z, size = 207168, hashes = { sha256 = "646d20d413ffcd1b0a2f700076e2d0252d872dcb7754860a73e45a59ea883614" } }, + { url = "https://files.pythonhosted.org/packages/1d/3e/d7777776806c579b761bac2f91721dda9f04c7a1b380213c5935cc750ae6/wrapt-2.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-07-28T06:05:08Z, size = 214351, hashes = { sha256 = "379f670f45b7bb8993edd9f6fc36c6cc65edb81cffa0b504be34acb0303fff0a" } }, + { url = "https://files.pythonhosted.org/packages/63/27/2d64d394df7bf181955b3bb562bf33c4492fb4be113f53071106d43ad8b5/wrapt-2.3.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-07-28T06:05:10Z, size = 199020, hashes = { sha256 = "6208f302f110295d64b22a7ac96500c791bf492dce4366e622e4912b077c9687" } }, + { url = "https://files.pythonhosted.org/packages/3e/3d/fb31d3db7d9834d265fb1a27a2adf0ddf51557c67458c97b22439ad6ae3d/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", upload-time = 2026-07-28T06:05:11Z, size = 209969, hashes = { sha256 = "ed635a9ca4f3a5a2b900c10c69e823373bc00ebc114b459383596d3487da3570" } }, + { url = "https://files.pythonhosted.org/packages/1f/d1/8724b5da582e62070dc9bf4d8bf1972f317297eefd7ba1f2b5c6393ccf6c/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", upload-time = 2026-07-28T06:05:13Z, size = 196324, hashes = { sha256 = "e3b9eaa742ae7a0aaaaad4ca4b69469d757af2d6e6663ef1dadc47adec0aeb41" } }, + { url = "https://files.pythonhosted.org/packages/0d/5c/3d9ef411149543016ee6bcf3af707f787cebd946527452b94bf122e9b7b4/wrapt-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", upload-time = 2026-07-28T06:05:15Z, size = 202610, hashes = { sha256 = "d0f7284f88f4833705132d06d3b425a43095c2cbd07c58166aac3ab646ba12a4" } }, + { url = "https://files.pythonhosted.org/packages/13/9b/4fc042ceb757866dd4a5fc057b3b736f2b360d3703ce9f830d83dc9226e0/wrapt-2.3.0-cp313-cp313t-win32.whl", upload-time = 2026-07-28T06:05:16Z, size = 79178, hashes = { sha256 = "7ebb274aba688b043429eb1500ff8a76ce0cb8ac0812ca3e301f06247b8722b3" } }, + { url = "https://files.pythonhosted.org/packages/6b/ff/b94878f8eed809ca042685276bcea9f24e8c2ca7c9653bb80bbb920a68a5/wrapt-2.3.0-cp313-cp313t-win_amd64.whl", upload-time = 2026-07-28T06:05:18Z, size = 82634, hashes = { sha256 = "c4bded758ad6f03b965830944a2f0bc5b2eb3767fe5a7310134315d1a6610e98" } }, + { url = "https://files.pythonhosted.org/packages/80/fb/663e1de5332a71685a729754312d327d4cada767c36e1c5a2db4c8de49e6/wrapt-2.3.0-cp313-cp313t-win_arm64.whl", upload-time = 2026-07-28T06:05:19Z, size = 81387, hashes = { sha256 = "d2cc64539da63e39ffb9c7ede849b6e8ddaaf7b3876b5cfb04efd85a5f3f4eb6" } }, + { url = "https://files.pythonhosted.org/packages/58/10/b073beaea89bc0d3670a75ff51139430a54b6af7ba7796507730634536dd/wrapt-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", upload-time = 2026-07-28T06:05:21Z, size = 81978, hashes = { sha256 = "ea52a0d0f08c584943d5764be0e84efa912c8da23c23e1e285ff2f5641c18fcc" } }, + { url = "https://files.pythonhosted.org/packages/b3/31/0916d9cebf848ed3f1a0c1888faee421747df77331e4db2bc527a9a85988/wrapt-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", upload-time = 2026-07-28T06:05:22Z, size = 82518, hashes = { sha256 = "fd85b0aa88efdb189d6ae2f35f4526943a8f091c38599c9c31478241c819e6a1" } }, + { url = "https://files.pythonhosted.org/packages/f5/73/31c1bf0f3384062751c2094dadb314916d70aa9b6bfd26d994b4a7b393fa/wrapt-2.3.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-07-28T06:05:23Z, size = 170187, hashes = { sha256 = "141ed6211286a9660d8d6702de598b43f0934b4f0eda16393f100a80f501d945" } }, + { url = "https://files.pythonhosted.org/packages/ed/25/fce087d54b79b8905f3c3c9dd5f454bbd8d8acb80b960c4a6aee5b4659b3/wrapt-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-07-28T06:05:25Z, size = 169288, hashes = { sha256 = "2e49885a62ec4ee854d1b9e6371fda6afd219917225752abf729a3f36d4df9a5" } }, + { url = "https://files.pythonhosted.org/packages/c7/30/0d09e6dddc6b7a7230ac77f50254b5980ab4fcd22976f72f8cc8a0404458/wrapt-2.3.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-07-28T06:05:27Z, size = 160932, hashes = { sha256 = "1d6159c9b2fefec02314e1332dbbbfaf960e369dfd26bcf7f8b258b5732065b3" } }, + { url = "https://files.pythonhosted.org/packages/2c/ca/0913af0d2ec0c43865d32d615f518fea66c13c5c930e489e9b0de248e9a8/wrapt-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", upload-time = 2026-07-28T06:05:28Z, size = 169017, hashes = { sha256 = "24da48596326ef8e448cfa837b454f638713d3531262375f00e5a9681682fc07" } }, + { url = "https://files.pythonhosted.org/packages/c3/f2/3d1e47ea81b822210f5df1bf942fd90780a75c055243d569b664529dea88/wrapt-2.3.0-cp314-cp314-musllinux_1_2_riscv64.whl", upload-time = 2026-07-28T06:05:30Z, size = 159065, hashes = { sha256 = "cd3a2edf0427013736b8127955cec62608c56e53ea47e82812ea32059cda407f" } }, + { url = "https://files.pythonhosted.org/packages/43/a5/ef2066ced8e5fca204e2b361e9708e36555b40949c583d997ea3b590817d/wrapt-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", upload-time = 2026-07-28T06:05:31Z, size = 168821, hashes = { sha256 = "4fa0df3bff4e7ce45759f33fd39335fe2f60477bb9ecf7b8aa41e7d07ee36a23" } }, + { url = "https://files.pythonhosted.org/packages/d5/e1/016104650d4e572fa91506eb396b3dd8efbccc9284fdc1c9479c3d21db28/wrapt-2.3.0-cp314-cp314-win32.whl", upload-time = 2026-07-28T06:05:33Z, size = 78700, hashes = { sha256 = "2935d5454b3f179a29b12cf390ee47246740ba2c3a7545b1b46ba31a5f2a4a0b" } }, + { url = "https://files.pythonhosted.org/packages/3d/97/6fdc20a9f2ca304748b3f0819cbf377d55260562777bf0b615431bc3c181/wrapt-2.3.0-cp314-cp314-win_amd64.whl", upload-time = 2026-07-28T06:05:34Z, size = 81422, hashes = { sha256 = "cc2cea812e5cb179a796b766747e7d3b21088760d8deb95676d482b8c8e6fa7d" } }, + { url = "https://files.pythonhosted.org/packages/5e/a4/9cbd53bf05746bea2c392af39cb052427a8ec95cbd494d930733d8f44681/wrapt-2.3.0-cp314-cp314-win_arm64.whl", upload-time = 2026-07-28T06:05:36Z, size = 80639, hashes = { sha256 = "22cc5c0a717bd4da87018ae0bffd4c19c6fb679d3ff357216ba566ab26c76cab" } }, + { url = "https://files.pythonhosted.org/packages/43/bb/6c5e4a0f66ea0d2b2dd267e8dd05a0014eea56840b3c8595d40b0a5d1f91/wrapt-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", upload-time = 2026-07-28T06:05:37Z, size = 84030, hashes = { sha256 = "a6b5984cd65dd639546f0eb4b8eacf1c31cb2fe9fb5c27bffe240987cdb2cf84" } }, + { url = "https://files.pythonhosted.org/packages/6a/eb/a1aedf03283bc9cbf8a1783995ddc54e3c5a86878f19002d2c428494f4c5/wrapt-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", upload-time = 2026-07-28T06:05:39Z, size = 84419, hashes = { sha256 = "c88abcf53daef80e01a75c7530e727fa6e2c1888fe83e3dcdba4c96216a1f5c7" } }, + { url = "https://files.pythonhosted.org/packages/63/61/50d511c0dc5105563849e86daa3e16ac7feef699f79fb05af45ea70107d5/wrapt-2.3.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", upload-time = 2026-07-28T06:05:40Z, size = 207171, hashes = { sha256 = "85de890ff968196e92dd1ae73a9fb8970495e7650a457b1c9ef0ac3dd550bce2" } }, + { url = "https://files.pythonhosted.org/packages/3f/59/9b538cf7795217e810699d16bc88b96a830d9b5c403eb2ec2db6b5f2ae81/wrapt-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = 2026-07-28T06:05:42Z, size = 214329, hashes = { sha256 = "50f416b74d092bb9f41b424e90dd457f365f7ba4b11de62a23679769a21bd85c" } }, + { url = "https://files.pythonhosted.org/packages/b3/28/9935d62b1499e5c8b3d191e99ba4eb31ca237a0b699142011a837e9dc7ea/wrapt-2.3.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", upload-time = 2026-07-28T06:05:43Z, size = 199079, hashes = { sha256 = "39febbee6d77301d31da6996b152ce52452da7c7ef72aba10c2fa976dff9c295" } }, + { url = "https://files.pythonhosted.org/packages/2b/01/4446b80fa2ffa47a3449b250d004ba1c1937f07f64a179608fec735df866/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", upload-time = 2026-07-28T06:05:45Z, size = 209992, hashes = { sha256 = "93513bec052c6cd987f9f580c3df068c8bc4ebae6543736be3ca7ec5959cafcd" } }, + { url = "https://files.pythonhosted.org/packages/d4/07/56f26c9f9979586a021e8148747004aba4498f49458c90b0502969b904e1/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_riscv64.whl", upload-time = 2026-07-28T06:05:47Z, size = 196334, hashes = { sha256 = "729126e667da34d251b8ebf8a45ef0c5ddadc21542b3d6e1abf4259ece6508df" } }, + { url = "https://files.pythonhosted.org/packages/8b/41/6d7bcc895b0f28b2250e10908f060687b9165429dcd7f22ddb3d4c031b74/wrapt-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", upload-time = 2026-07-28T06:05:49Z, size = 202644, hashes = { sha256 = "626b69db2021aa01671ec7bbc9740e558522bd44c18cf2ce69bf3d666a014109" } }, + { url = "https://files.pythonhosted.org/packages/cd/25/7860927edba06b758b8852a6f02e832be715563c67a6795d94350bc81099/wrapt-2.3.0-cp314-cp314t-win32.whl", upload-time = 2026-07-28T06:05:50Z, size = 79685, hashes = { sha256 = "629d73378082c00a8173031f9fb30a3ac6abbc894a5bfdfae71fabc60642d501" } }, + { url = "https://files.pythonhosted.org/packages/c4/0f/270bafe92fde3b069a39bc01e39ee79340895b335640df861d43d2a51885/wrapt-2.3.0-cp314-cp314t-win_amd64.whl", upload-time = 2026-07-28T06:05:52Z, size = 83104, hashes = { sha256 = "42869085687f0aefd57c0f636c3f9354f8ffb321a8ba9cb52d19beb796e561c5" } }, + { url = "https://files.pythonhosted.org/packages/55/b3/af176d79a8515a8a720eccdad9a96f6e31a30abf2865430c8c42adf2fd13/wrapt-2.3.0-cp314-cp314t-win_arm64.whl", upload-time = 2026-07-28T06:05:53Z, size = 81774, hashes = { sha256 = "b1e5aa486e269b00ed35e64771c7d0ab8096cfd2643405ca8cd60ebedc099a51" } }, + { url = "https://files.pythonhosted.org/packages/00/39/3daf9f47be208606586de4568ba6713db53ebc8fd7a575aea1fe57983b69/wrapt-2.3.0-py3-none-any.whl", upload-time = 2026-07-28T06:06:12Z, size = 61866, hashes = { sha256 = "d8c7ed08477429752b8c44991f40ad7838b18332a160698740a6bfbc10d998a2" } }, ] diff --git a/foreign/python/pyproject.toml b/foreign/python/pyproject.toml index 7a82108b6c..5f4258f714 100644 --- a/foreign/python/pyproject.toml +++ b/foreign/python/pyproject.toml @@ -16,13 +16,13 @@ # under the License. [build-system] -requires = ["maturin>=1.14.1,<2.0"] +requires = ["maturin>=1.15.0,<2.0"] build-backend = "maturin" [project] name = "apache-iggy" requires-python = ">=3.10" -version = "0.9.0.dev5" +version = "0.9.0" description = "Apache Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second." readme = "README.md" license = { file = "LICENSE" } @@ -102,7 +102,6 @@ testing = [ "pytest>=9.1.1,<10.0", "pytest-asyncio>=0.24.0,<2.0", "pytest-cov>=4.0,<8.0", - "pytest-xdist>=3.0,<4.0", "pytest-timeout>=2.0,<3.0", ] @@ -111,19 +110,18 @@ testing-docker = ["testcontainers>=4.15.0,<5.0"] # Development tools dev = [ - "maturin>=1.14.1,<2.0", + "maturin>=1.15.0,<2.0", "pyrefly>=1.2.0", "ruff>=0.1.0,<1.0", ] # All dependencies for full development setup all = [ - "maturin>=1.14.1,<2.0", + "maturin>=1.15.0,<2.0", "pyrefly>=1.2.0", "pytest>=9.1.1,<10.0", "pytest-asyncio>=0.24.0,<2.0", "pytest-cov>=4.0,<8.0", - "pytest-xdist>=3.0,<4.0", "pytest-timeout>=2.0,<3.0", "ruff>=0.1.0,<1.0", "testcontainers>=4.15.0,<5.0", diff --git a/foreign/python/src/bin/stub_gen.rs b/foreign/python/src/bin/stub_gen.rs index e9db76059d..1873a0f067 100644 --- a/foreign/python/src/bin/stub_gen.rs +++ b/foreign/python/src/bin/stub_gen.rs @@ -47,19 +47,16 @@ fn main() -> Result<()> { // `stub_info` is a function defined by `define_stub_info_gatherer!` macro. let stub = apache_iggy::client::stub_info()?; stub.generate()?; - let path = Path::new(file!()) - .parent() - .unwrap() - .parent() - .unwrap() - .parent() - .unwrap() - .join("apache_iggy.pyi"); + // Anchor on the manifest dir: `stub.generate()` writes to the crate root, so + // a cwd-relative path leaves the tracked stub without its license header. + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("apache_iggy.pyi"); let mut f = File::open(&path)?; - let mut content = LICENSE.as_bytes().to_owned(); - f.read_to_end(&mut content)?; + let mut content = LICENSE.to_owned(); + f.read_to_string(&mut content)?; + content = content.replacen("__all__ = [", "__all__ = [\n \"Durability\",", 1); + content.push_str("\nclass Durability(str, enum.Enum):\n REPLICATED = 'replicated'\n PERSISTED = 'persisted'\n"); let mut f = File::create(path)?; - f.write_all(content.as_slice())?; + f.write_all(content.as_bytes())?; Ok(()) } diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index f669a87484..4c5799c17f 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -19,7 +19,7 @@ use bytes::Bytes; use iggy::prelude::{ AutoCommit as RustAutoCommit, Consumer as RustConsumer, IggyClient as RustIggyClient, IggyExpiry as RustIggyExpiry, IggyMessage as RustMessage, MaxTopicSize as RustMaxTopicSize, - PollingStrategy as RustPollingStrategy, *, + Partitioning as RustPartitioning, PollingStrategy as RustPollingStrategy, *, }; use pyo3::PyRef; use pyo3::prelude::*; @@ -28,6 +28,7 @@ use pyo3_async_runtimes::tokio::future_into_py; use pyo3_stub_gen::define_stub_info_gatherer; use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; use std::collections::BTreeMap; +use std::fmt::Display; use std::str::FromStr; use std::sync::Arc; @@ -39,10 +40,13 @@ use crate::consumer::{ use crate::duration::{py_delta_to_iggy_duration, reject_zero}; use crate::identifier::PyIdentifier; use crate::options::OptionSpec as PyOptionSpec; +use crate::partitioning::PyPartitioning; use crate::permissions::Permissions as PyPermissions; +use crate::producer::{IggyProducer, ProducerMode, RetryInterval, u32_param as producer_u32_param}; use crate::receive_message::{PollingStrategy, ReceiveMessage}; use crate::send_message::{SendMessage, SendMessagesResponse as PySendMessagesResponse}; -use crate::stream::StreamDetails; +use crate::stats::Stats as PyStats; +use crate::stream::{Stream, StreamDetails}; use crate::topic::{IggyExpiry, MaxTopicSize, Topic, TopicDetails}; use crate::user::{ UserInfo as PyUserInfo, UserInfoDetails as PyUserInfoDetails, UserStatus as PyUserStatus, @@ -57,6 +61,15 @@ pub struct IggyClient { inner: Arc, } +/// Converts SDK errors to the RuntimeError exposed by the Python API. +fn to_runtime_error(error: E) -> PyErr { + PyErr::new::(error.to_string()) +} + +fn to_value_error(error: E) -> PyErr { + PyErr::new::(error.to_string()) +} + /// Resolves the shared `create_topic`/`update_topic` parameters, applying /// server defaults where the caller left them unset. fn resolve_topic_params( @@ -65,8 +78,7 @@ fn resolve_topic_params( max_topic_size: Option<&MaxTopicSize>, ) -> PyResult<(CompressionAlgorithm, RustIggyExpiry, RustMaxTopicSize)> { let compression_algorithm = match compression_algorithm { - Some(algo) => CompressionAlgorithm::from_str(&algo) - .map_err(|e| PyErr::new::(e.to_string()))?, + Some(algo) => CompressionAlgorithm::from_str(&algo).map_err(to_runtime_error)?, None => CompressionAlgorithm::default(), }; @@ -86,44 +98,69 @@ fn resolve_topic_params( #[gen_stub_pymethods] #[pymethods] impl IggyClient { - /// Constructs a new IggyClient from a TCP server address or a `TcpConfig`. - /// This initializes a new runtime for asynchronous operations. - /// Future versions might utilize asyncio for more Pythonic async. + /// Constructs a new IggyClient from a TCP server address, a `TcpConfig`, a + /// `QuicConfig`, an `HttpConfig`, or a `WebSocketConfig`. Construction is + /// synchronous; async methods return asyncio awaitables backed by the shared + /// Tokio runtime. /// /// Args: - /// conn: Either a `host:port` address, or a `TcpConfig` carrying the full - /// transport configuration. Defaults to `127.0.0.1:8090` with auto-login - /// disabled. A malformed address is reported differently by the two - /// forms: the string form raises `RuntimeError` here, while `TcpConfig` - /// raises `ValueError` when it is constructed, before it ever reaches - /// this call. Neither exception is a subclass of the other. + /// conn: A `host:port` address, a `TcpConfig`, a `QuicConfig`, an + /// `HttpConfig`, or a `WebSocketConfig`. Defaults to `127.0.0.1:8090` + /// over TCP with auto-login disabled. A malformed address is reported + /// differently depending on the form: the string form raises + /// `RuntimeError` here, while every config type raises `ValueError` + /// when it is constructed, before any of them reaches this call. Neither + /// exception is a subclass of the other. /// /// Raises: /// RuntimeError: If the address passed as a string is not a valid - /// `host:port` pair. + /// `host:port` pair, or if a `QuicConfig` client cannot bind its + /// local UDP socket (for example the port is already in use). #[new] #[pyo3(signature = (conn=None))] fn new( - #[gen_stub(override_type(type_repr = "TcpConfig | builtins.str | None"))] conn: Option< - PyClientConfig, - >, + #[gen_stub(override_type( + type_repr = "TcpConfig | QuicConfig | HttpConfig | WebSocketConfig | builtins.str | None" + ))] + conn: Option, ) -> PyResult { - let config = match conn { - Some(PyClientConfig::Config(config)) => config.client_config(), - Some(PyClientConfig::ServerAddress(server_address)) => Arc::new( - TcpClientConfigBuilder::new() - .with_server_address(server_address) - .build() - .map_err(|e| { - PyErr::new::(e.to_string()) - })?, + let wrapper = match conn { + Some(PyClientConfig::Tcp(config)) => ClientWrapper::Tcp( + TcpClient::create(config.client_config()).map_err(to_runtime_error)?, + ), + Some(PyClientConfig::ServerAddress(server_address)) => { + let config = Arc::new( + TcpClientConfigBuilder::new() + .with_server_address(server_address) + .build() + .map_err(to_runtime_error)?, + ); + ClientWrapper::Tcp(TcpClient::create(config).map_err(to_runtime_error)?) + } + Some(PyClientConfig::Quic(config)) => { + // `quinn::Endpoint::client` (invoked eagerly by `QuicClient::create`) looks + // up the current Tokio runtime via `Handle::try_current()` and fails with + // `CannotCreateEndpoint` if none is active. This method runs synchronously + // from Python without one, so enter the runtime pyo3-async-runtimes uses + // for our own async methods before building the endpoint. + let _guard = pyo3_async_runtimes::tokio::get_runtime().enter(); + ClientWrapper::Quic( + QuicClient::create(config.client_config()).map_err(to_runtime_error)?, + ) + } + Some(PyClientConfig::Http(config)) => ClientWrapper::Http( + HttpClient::create(config.client_config()).map_err(to_runtime_error)?, + ), + Some(PyClientConfig::WebSocket(config)) => ClientWrapper::WebSocket( + WebSocketClient::create(config.client_config()).map_err(to_runtime_error)?, + ), + None => ClientWrapper::Tcp( + TcpClient::create(Arc::new(TcpClientConfig::default())) + .map_err(to_runtime_error)?, ), - None => Arc::new(TcpClientConfig::default()), }; - let tcp_client = TcpClient::create(config) - .map_err(|e| PyErr::new::(e.to_string()))?; - Ok(IggyClient { - inner: Arc::new(RustIggyClient::new(ClientWrapper::Tcp(tcp_client))), + Ok(Self { + inner: Arc::new(RustIggyClient::new(wrapper)), }) } @@ -137,8 +174,13 @@ impl IggyClient { _cls: &Bound<'_, PyType>, connection_string: String, ) -> PyResult { - let client = RustIggyClient::from_connection_string(&connection_string) - .map_err(|e| PyErr::new::(e.to_string()))?; + // The QUIC transport builds its endpoint eagerly and needs a Tokio runtime context + // to do so (see the `QuicConfig` arm of `new()` above for details); entering it here + // is a no-op for the other transports since the protocol isn't known until the + // connection string is parsed. + let _guard = pyo3_async_runtimes::tokio::get_runtime().enter(); + let client = + RustIggyClient::from_connection_string(&connection_string).map_err(to_runtime_error)?; Ok(Self { inner: Arc::new(client), }) @@ -148,12 +190,31 @@ impl IggyClient { /// Raises `RuntimeError` if the connection fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn ping<'a>(&self, py: Python<'a>) -> PyResult> { + let inner = self.inner.clone(); + future_into_py( + py, + async move { inner.ping().await.map_err(to_runtime_error) }, + ) + } + + /// Get the statistics and details of the server and its running process. + /// + /// Requires an authenticated session whose user holds the `read_servers` + /// or `manage_servers` global permission. + /// + /// Returns: + /// An awaitable that resolves to `Stats`. + /// + /// Raises: + /// RuntimeError: If the client is not connected, the session is not + /// authenticated, the user lacks the permission, or the request + /// fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[Stats]", imports=("collections.abc")))] + fn get_stats<'a>(&self, py: Python<'a>) -> PyResult> { let inner = self.inner.clone(); future_into_py(py, async move { - inner - .ping() - .await - .map_err(|e| PyErr::new::(e.to_string())) + let stats = inner.get_stats().await.map_err(to_runtime_error)?; + Ok(PyStats::from(stats)) }) } @@ -182,7 +243,7 @@ impl IggyClient { let specs = inner .describe_options(scope) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(specs .into_iter() .map(PyOptionSpec::from) @@ -204,7 +265,7 @@ impl IggyClient { inner .login_user(&username, &password) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(()) }) } @@ -227,10 +288,7 @@ impl IggyClient { let inner = self.inner.clone(); future_into_py(py, async move { - let user = inner - .get_user(&user_id) - .await - .map_err(|e| PyErr::new::(e.to_string()))?; + let user = inner.get_user(&user_id).await.map_err(to_runtime_error)?; Ok(user.map(PyUserInfoDetails::from)) }) } @@ -247,10 +305,7 @@ impl IggyClient { let inner = self.inner.clone(); future_into_py(py, async move { - let users = inner - .get_users() - .await - .map_err(|e| PyErr::new::(e.to_string()))?; + let users = inner.get_users().await.map_err(to_runtime_error)?; Ok(users.into_iter().map(PyUserInfo::from).collect::>()) }) } @@ -288,7 +343,7 @@ impl IggyClient { let user = inner .create_user(&username, &password, status, permissions) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(PyUserInfoDetails::from(user)) }) } @@ -299,6 +354,9 @@ impl IggyClient { /// user_id: User identifier as `str | int`. /// username: New username as `str | None`; unchanged when `None`. /// status: New status as `UserStatus | None`; unchanged when `None`. + /// options: Reserved for future use. Additional option keys as + /// `dict[str, str] | None`, forwarded to the server. No user update + /// option key exists yet, so a current server rejects every key. /// /// Returns: /// An awaitable that resolves to `None` when the user is updated. @@ -306,7 +364,7 @@ impl IggyClient { /// Raises: /// ValueError: If a string identifier is invalid. /// RuntimeError: If the request fails. - #[pyo3(signature = (user_id, username=None, status=None))] + #[pyo3(signature = (user_id, username=None, status=None, options=None))] #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn update_user<'a>( &self, @@ -314,22 +372,21 @@ impl IggyClient { user_id: PyIdentifier, #[gen_stub(override_type(type_repr = "builtins.str | None"))] username: Option, #[gen_stub(override_type(type_repr = "UserStatus | None"))] status: Option, + #[gen_stub(override_type(type_repr = "builtins.dict[builtins.str, builtins.str] | None"))] + options: Option>, ) -> PyResult> { let user_id = Identifier::try_from(user_id)?; let status = status.map(UserStatus::from); + let update_options = UserUpdateOptions { + raw: options.unwrap_or_default(), + }; let inner = self.inner.clone(); future_into_py(py, async move { inner - .update_user( - &user_id, - username.as_deref(), - status, - // Users have no option keys yet. - &UserUpdateOptions::default(), - ) + .update_user(&user_id, username.as_deref(), status, &update_options) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(()) }) } @@ -354,7 +411,7 @@ impl IggyClient { inner .delete_user(&user_id) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(()) }) } @@ -392,7 +449,7 @@ impl IggyClient { inner .update_permissions(&user_id, permissions) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(()) }) } @@ -425,7 +482,7 @@ impl IggyClient { inner .change_password(&user_id, ¤t_password, &new_password) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(()) }) } @@ -442,24 +499,20 @@ impl IggyClient { let inner = self.inner.clone(); future_into_py(py, async move { - inner - .logout_user() - .await - .map_err(|e| PyErr::new::(e.to_string()))?; + inner.logout_user().await.map_err(to_runtime_error)?; Ok(()) }) } - /// Connects the IggyClient to its service. - /// Raises `RuntimeError` if the connection fails. + /// Connects the IggyClient to its service and starts the heartbeat task. + /// Raises `RuntimeError` if the connection fails. Over HTTP there is no + /// connection to establish, so only the heartbeat starts and this call + /// succeeds even against an unreachable server. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] fn connect<'a>(&self, py: Python<'a>) -> PyResult> { let inner = self.inner.clone(); future_into_py(py, async move { - inner - .connect() - .await - .map_err(|e| PyErr::new::(e.to_string()))?; + inner.connect().await.map_err(to_runtime_error)?; Ok(()) }) } @@ -471,10 +524,7 @@ impl IggyClient { fn create_stream<'a>(&self, py: Python<'a>, name: String) -> PyResult> { let inner = self.inner.clone(); future_into_py(py, async move { - inner - .create_stream(&name) - .await - .map_err(|e| PyErr::new::(e.to_string()))?; + inner.create_stream(&name).await.map_err(to_runtime_error)?; Ok(()) }) } @@ -495,22 +545,156 @@ impl IggyClient { let stream = inner .get_stream(&stream_id) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(stream.map(StreamDetails::from)) }) } + /// Return all streams. + /// + /// Returns: + /// A list of `Stream` summaries. + /// + /// Raises: + /// RuntimeError: If the client is not authenticated, the user lacks global + /// `read_streams` or `manage_streams` permission, or the request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[list[Stream]]", imports=("collections.abc")))] + fn get_streams<'a>(&self, py: Python<'a>) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + let streams = inner.get_streams().await.map_err(to_runtime_error)?; + Ok(streams.into_iter().map(Stream::from).collect::>()) + }) + } + + /// Rename a stream selected by name or numeric ID. + /// + /// `stream_id` accepts a stream name as `str` or numeric ID as `int`. A + /// decimal-only string is interpreted as a numeric ID. `name` must be unique + /// and contain between 1 and 255 UTF-8 bytes. Renaming a stream to its current + /// name succeeds without changing it. + /// + /// Args: + /// stream_id: Stream identifier as `str | int`. + /// name: New stream name as `str`. + /// options: Additional option keys as `dict[str, str] | None`, forwarded + /// to the server. Current server versions reject all stream update + /// option keys. + /// + /// Returns: + /// None. + /// + /// Raises: + /// TypeError: If `stream_id` is not `str` or an integer in + /// `0..=2**32 - 1`, or `name` is not `str`. + /// ValueError: If a string identifier is empty or exceeds 255 UTF-8 bytes. + /// RuntimeError: If the client is not authenticated, the user lacks global + /// `manage_streams` or per-stream `manage_stream` permission, the + /// stream does not exist, the new name is invalid or already used, or + /// the request fails. + #[pyo3(signature = (stream_id, name, options=None))] + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] + fn update_stream<'a>( + &self, + py: Python<'a>, + stream_id: PyIdentifier, + name: String, + #[gen_stub(override_type(type_repr = "builtins.dict[builtins.str, builtins.str] | None"))] + options: Option>, + ) -> PyResult> { + let stream_id = Identifier::try_from(stream_id)?; + let update_options = StreamUpdateOptions { + raw: options.unwrap_or_default(), + }; + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .update_stream(&stream_id, &name, &update_options) + .await + .map_err(to_runtime_error)?; + Ok(()) + }) + } + + /// Delete a stream selected by name or numeric ID. + /// + /// Deletion removes the stream and all of its topics, partitions, and messages. + /// `stream_id` accepts a stream name as `str` or numeric ID as `int`. A + /// decimal-only string is interpreted as a numeric ID. + /// + /// Returns: + /// None. + /// + /// Raises: + /// TypeError: If `stream_id` is not `str` or an integer in + /// `0..=2**32 - 1`. + /// ValueError: If a string identifier is empty or exceeds 255 UTF-8 bytes. + /// RuntimeError: If the client is not authenticated, the user lacks global + /// `manage_streams` or per-stream `manage_stream` permission, the + /// stream does not exist, or the request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] + fn delete_stream<'a>( + &self, + py: Python<'a>, + stream_id: PyIdentifier, + ) -> PyResult> { + let stream_id = Identifier::try_from(stream_id)?; + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .delete_stream(&stream_id) + .await + .map_err(to_runtime_error)?; + Ok(()) + }) + } + + /// Delete all messages from every topic in a stream. + /// + /// The stream, topics, and partitions remain available. Repeated purges of an + /// existing empty stream succeed. `stream_id` accepts a stream name as `str` + /// or numeric ID as `int`. A decimal-only string is interpreted as a numeric + /// ID. + /// + /// Returns: + /// None. + /// + /// Raises: + /// TypeError: If `stream_id` is not `str` or an integer in + /// `0..=2**32 - 1`. + /// ValueError: If a string identifier is empty or exceeds 255 UTF-8 bytes. + /// RuntimeError: If the client is not authenticated, the user lacks global + /// `manage_streams` or per-stream `manage_stream` permission, the + /// stream does not exist, or the request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] + fn purge_stream<'a>( + &self, + py: Python<'a>, + stream_id: PyIdentifier, + ) -> PyResult> { + let stream_id = Identifier::try_from(stream_id)?; + let inner = self.inner.clone(); + future_into_py(py, async move { + inner + .purge_stream(&stream_id) + .await + .map_err(to_runtime_error)?; + Ok(()) + }) + } + /// Creates a new topic with the given parameters. /// /// Args: /// stream: Stream identifier as `str | int`. /// name: Topic name as `str`. - /// partitions_count: Number of partitions as `int`. + /// partitions_count: Number of partitions as `int`, at most 1000. /// compression_algorithm: Compression algorithm as `str | None`. /// message_expiry: Message expiry as `IggyExpiry | None`. /// max_topic_size: Maximum topic size as `MaxTopicSize | None`. /// segment_size: Per-topic segment size in bytes as `int | None`. - /// enforce_fsync: Per-topic fsync enforcement as `bool | None`. + /// durability: Message completion policy, defaulting to replicated. + /// consumer_offset_durability: Independent offset policy, defaulting to replicated. /// messages_required_to_save: Message-count flush threshold as `int | None`. /// size_of_messages_required_to_save: Byte flush threshold as `int | None`. /// preallocate_segments: Reserve segment bytes on open as `bool | None`. @@ -527,7 +711,7 @@ impl IggyClient { /// ValueError: If `message_expiry` or `max_topic_size` is out of range. /// PyRuntimeError: If another argument is invalid or the request fails. #[pyo3( - signature = (stream, name, partitions_count, compression_algorithm = None, message_expiry = None, max_topic_size = None, segment_size = None, enforce_fsync = None, messages_required_to_save = None, size_of_messages_required_to_save = None, preallocate_segments = None, options = None) + signature = (stream, name, partitions_count, compression_algorithm = None, message_expiry = None, max_topic_size = None, segment_size = None, durability = None, consumer_offset_durability = None, messages_required_to_save = None, size_of_messages_required_to_save = None, preallocate_segments = None, options = None) )] #[allow(clippy::too_many_arguments)] #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] @@ -547,7 +731,11 @@ impl IggyClient { &MaxTopicSize, >, #[gen_stub(override_type(type_repr = "builtins.int | None"))] segment_size: Option, - #[gen_stub(override_type(type_repr = "builtins.bool | None"))] enforce_fsync: Option, + #[gen_stub(override_type(type_repr = "Durability | None"))] durability: Option< + &Bound<'_, PyAny>, + >, + #[gen_stub(override_type(type_repr = "Durability | None"))] + consumer_offset_durability: Option<&Bound<'_, PyAny>>, #[gen_stub(override_type(type_repr = "builtins.int | None"))] messages_required_to_save: Option, #[gen_stub(override_type(type_repr = "builtins.int | None"))] @@ -570,7 +758,11 @@ impl IggyClient { message_expiry: (expiry != RustIggyExpiry::ServerDefault).then_some(expiry), max_topic_size: (max_size != RustMaxTopicSize::ServerDefault).then_some(max_size), segment_size: segment_size.map(IggyByteSize::from), - enforce_fsync, + durability: crate::durability::Durability::try_from(durability)?.0, + consumer_offset_durability: crate::durability::Durability::try_from( + consumer_offset_durability, + )? + .0, messages_required_to_save, size_of_messages_required_to_save: size_of_messages_required_to_save .map(IggyByteSize::from), @@ -585,7 +777,7 @@ impl IggyClient { inner .create_topic(&stream, &name, &topic_options) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(()) }) } @@ -608,7 +800,7 @@ impl IggyClient { let topic = inner .get_topic(&stream_id, &topic_id) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(topic.map(TopicDetails::from)) }) } @@ -636,7 +828,7 @@ impl IggyClient { let topics = inner .get_topics(&stream_id) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(topics.into_iter().map(Topic::from).collect::>()) }) } @@ -690,10 +882,7 @@ impl IggyClient { // Absent stays absent: a key the caller did not pass is left alone // server-side rather than reset to a default. let compression_algorithm = compression_algorithm - .map(|algo| { - CompressionAlgorithm::from_str(&algo) - .map_err(|e| PyErr::new::(e.to_string())) - }) + .map(|algo| CompressionAlgorithm::from_str(&algo).map_err(to_runtime_error)) .transpose()?; let update_options = TopicUpdateOptions { compression_algorithm, @@ -710,7 +899,7 @@ impl IggyClient { inner .update_topic(&stream_id, &topic_id, &name, &update_options) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(()) }) } @@ -741,7 +930,7 @@ impl IggyClient { inner .delete_topic(&stream_id, &topic_id) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(()) }) } @@ -772,7 +961,93 @@ impl IggyClient { inner .purge_topic(&stream_id, &topic_id) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; + Ok(()) + }) + } + + /// Create partitions for a topic. New partition IDs continue from one past the + /// current highest ID; IDs removed by deletion can be reused. Existing consumer + /// groups are immediately rebalanced across all partitions, advancing their + /// generation and dropping pending revocations. + /// + /// Args: + /// stream_id: Stream identifier as `str | int`. + /// topic_id: Topic identifier as `str | int`. + /// partitions_count: Number of partitions to create as `int`, between 1 and + /// 1000 inclusive. + /// + /// Returns: + /// An awaitable that resolves to `None` when the partitions are committed; + /// storage materialization completes asynchronously. + /// + /// Raises: + /// ValueError: If an identifier is invalid. + /// OverflowError: If `partitions_count` is outside the unsigned 32-bit range. + /// RuntimeError: If the client is not authenticated, lacks global + /// `manage_streams` or `manage_topics`, per-stream `manage_stream` or + /// `manage_topics`, or per-topic `manage_topic` permission, or the + /// request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] + fn create_partitions<'a>( + &self, + py: Python<'a>, + stream_id: PyIdentifier, + topic_id: PyIdentifier, + partitions_count: u32, + ) -> PyResult> { + let stream_id = Identifier::try_from(stream_id)?; + let topic_id = Identifier::try_from(topic_id)?; + let inner = self.inner.clone(); + + future_into_py(py, async move { + inner + .create_partitions(&stream_id, &topic_id, partitions_count) + .await + .map_err(to_runtime_error)?; + Ok(()) + }) + } + + /// Delete the last partitions from a topic, including all messages stored in them. + /// Existing consumer groups are immediately rebalanced across the remaining + /// partitions, advancing their generation and dropping pending revocations. + /// + /// Args: + /// stream_id: Stream identifier as `str | int`. + /// topic_id: Topic identifier as `str | int`. + /// partitions_count: Number of partitions to delete as `int` from the end of + /// the topic; must be between 1 and 1000 inclusive and no greater than + /// its current count. + /// + /// Returns: + /// An awaitable that resolves to `None` when deletion is accepted; storage + /// teardown completes asynchronously. + /// + /// Raises: + /// ValueError: If an identifier is invalid. + /// OverflowError: If `partitions_count` is outside the unsigned 32-bit range. + /// RuntimeError: If the client is not authenticated, lacks global + /// `manage_streams` or `manage_topics`, per-stream `manage_stream` or + /// `manage_topics`, or per-topic `manage_topic` permission, or the + /// request fails. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[None]", imports=("collections.abc")))] + fn delete_partitions<'a>( + &self, + py: Python<'a>, + stream_id: PyIdentifier, + topic_id: PyIdentifier, + partitions_count: u32, + ) -> PyResult> { + let stream_id = Identifier::try_from(stream_id)?; + let topic_id = Identifier::try_from(topic_id)?; + let inner = self.inner.clone(); + + future_into_py(py, async move { + inner + .delete_partitions(&stream_id, &topic_id, partitions_count) + .await + .map_err(to_runtime_error)?; Ok(()) }) } @@ -806,7 +1081,7 @@ impl IggyClient { inner .create_consumer_group(&stream_id, &topic_id, &name) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(()) }) } @@ -842,7 +1117,7 @@ impl IggyClient { let group = inner .get_consumer_group(&stream_id, &topic_id, &group_id) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(group.map(PyConsumerGroupDetails::from)) }) } @@ -874,7 +1149,7 @@ impl IggyClient { let groups = inner .get_consumer_groups(&stream_id, &topic_id) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(groups .into_iter() .map(PyConsumerGroup::from) @@ -912,7 +1187,7 @@ impl IggyClient { inner .delete_consumer_group(&stream_id, &topic_id, &group_id) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(()) }) } @@ -950,7 +1225,7 @@ impl IggyClient { inner .join_consumer_group(&stream_id, &topic_id, &group_id) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(()) }) } @@ -990,23 +1265,41 @@ impl IggyClient { inner .leave_consumer_group(&stream_id, &topic_id, &group_id) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(()) }) } - /// Sends a list of messages to the specified topic. - /// Returns a SendMessagesResponse carrying the per-partition commit - /// confirmations, or a PyRuntimeError on failure. The confirmation list is - /// empty when the server reports no offsets, and the legacy server never - /// reports any. + /// Sends a batch of messages to a topic using the selected partitioning strategy. + /// + /// Args: + /// stream: Stream identifier as `str | int`. + /// topic: Topic identifier as `str | int`. + /// partitioning: A `Partitioning` strategy or an integer partition ID. + /// Use `Partitioning.balanced()`, `Partitioning.partition_id(id)`, or + /// `Partitioning.messages_key(key)`. An integer is shorthand for + /// `Partitioning.partition_id(id)`. + /// messages: Messages to send as `list[SendMessage]`. + /// + /// Returns: + /// An awaitable that resolves to `SendMessagesResponse`. Its confirmations + /// report the committed partition and batch base offset. The list is empty + /// when the server reports no offsets. + /// + /// Raises: + /// ValueError: If a string stream or topic identifier is invalid. + /// TypeError: If `partitioning` or `messages` has an unsupported type. + /// OverflowError: If a numeric stream, topic, or partition ID is outside + /// the supported unsigned 32-bit range. + /// RuntimeError: If the request fails. #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[SendMessagesResponse]", imports=("collections.abc")))] fn send_messages<'a>( &self, py: Python<'a>, stream: PyIdentifier, topic: PyIdentifier, - partitioning: u32, + #[gen_stub(override_type(type_repr = "Partitioning | builtins.int"))] + partitioning: PyPartitioning, #[gen_stub(override_type(type_repr = "list[SendMessage]"))] messages: &Bound<'_, PyList>, ) -> PyResult> { let messages: Vec = messages @@ -1023,18 +1316,123 @@ impl IggyClient { let stream = Identifier::try_from(stream)?; let topic = Identifier::try_from(topic)?; - let partitioning = Partitioning::partition_id(partitioning); + let partitioning = RustPartitioning::from(partitioning); let inner = self.inner.clone(); future_into_py(py, async move { let response = inner .send_messages(&stream, &topic, &partitioning, messages.as_mut()) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(PySendMessagesResponse::from(response)) }) } + /// Creates and initializes a high-level producer bound to a stream and topic. + /// + /// This is a Python port of the Rust high-level producer API. For detailed + /// producer semantics, see https://iggy.apache.org/docs/sdk/rust/high-level-sdk/. + /// `None` selects direct mode. `BackgroundProducerConfig` starts background + /// workers and makes successful sends mean queue acceptance rather than a + /// server commit. The returned producer is ready to send. + /// + /// Raises `ValueError` for invalid names or numeric ranges and `RuntimeError` + /// when stream/topic initialization fails. + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + stream, + topic, + partitioning=None, + mode=None, + create_stream_if_not_exists=true, + create_topic_if_not_exists=true, + topic_partitions_count=1, + topic_message_expiry=None, + topic_max_size=None, + send_retries=Some(3), + send_retry_interval=RetryInterval::default(), + ))] + #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[IggyProducer]", imports=("collections.abc")))] + fn producer<'a>( + &self, + py: Python<'a>, + stream: &str, + topic: &str, + #[gen_stub(override_type(type_repr = "Partitioning | None"))] partitioning: Option< + &crate::partitioning::Partitioning, + >, + #[gen_stub(override_type( + type_repr = "DirectProducerConfig | BackgroundProducerConfig | None" + ))] + mode: Option, + create_stream_if_not_exists: bool, + create_topic_if_not_exists: bool, + topic_partitions_count: i64, + #[gen_stub(override_type(type_repr = "IggyExpiry | None"))] topic_message_expiry: Option< + &IggyExpiry, + >, + #[gen_stub(override_type(type_repr = "MaxTopicSize | None"))] topic_max_size: Option< + &MaxTopicSize, + >, + send_retries: Option, + send_retry_interval: RetryInterval, + ) -> PyResult> { + let mode = mode.unwrap_or_default(); + + let topic_partitions_count = + producer_u32_param(topic_partitions_count, "topic_partitions_count")?; + let topic_message_expiry = topic_message_expiry + .map(RustIggyExpiry::try_from) + .transpose()? + .unwrap_or(RustIggyExpiry::ServerDefault); + let topic_max_size = topic_max_size + .map(RustMaxTopicSize::try_from) + .transpose()? + .unwrap_or(RustMaxTopicSize::ServerDefault); + let send_retries = send_retries + .map(|retries| producer_u32_param(retries, "send_retries")) + .transpose()? + .filter(|retries| *retries != 0); + let send_retry_interval = send_retry_interval.resolve()?; + + let mut builder = self + .inner + .producer(stream, topic) + .map_err(to_value_error)? + .send_retries(send_retries, send_retry_interval); + + builder = match mode { + ProducerMode::Direct(config) => builder.direct((&config).into()), + ProducerMode::Background(config) => builder.background((&config).try_into()?), + }; + + if let Some(partitioning) = partitioning { + builder = builder.partitioning(partitioning.inner.as_ref().clone()); + } + if create_stream_if_not_exists { + builder = builder.create_stream_if_not_exists(); + } else { + builder = builder.do_not_create_stream_if_not_exists(); + } + if create_topic_if_not_exists { + builder = builder.create_topic_if_not_exists( + topic_partitions_count, + topic_message_expiry, + topic_max_size, + ); + } else { + builder = builder.do_not_create_topic_if_not_exists(); + } + + future_into_py(py, async move { + // A background build starts Tokio worker tasks, so it must happen + // while this future is executing on the Rust runtime. + let producer = builder.build(); + producer.init().await.map_err(to_runtime_error)?; + Ok(IggyProducer::new(producer)) + }) + } + /// Polls for messages from the specified topic on behalf of the given consumer. /// Omitting `partition_id` reads partition 0 for a regular consumer, and /// polls the member's assigned partitions for a consumer group. @@ -1051,7 +1449,7 @@ impl IggyClient { polling_strategy: &PollingStrategy, count: u32, auto_commit: bool, - partition_id: Option, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] partition_id: Option, ) -> PyResult> { let consumer = RustConsumer::try_from(consumer)?; let stream = Identifier::try_from(stream)?; @@ -1072,7 +1470,7 @@ impl IggyClient { auto_commit, ) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; let partition_id = polled_messages.partition_id; let messages = polled_messages .messages @@ -1087,10 +1485,21 @@ impl IggyClient { } /// Creates a new consumer group consumer. + /// `partition_id` is ignored for a consumer group: the member reads the partitions + /// the server assigns to it. /// Returns the consumer or a RuntimeError on failure. Raises `ValueError` if /// `poll_interval`, `polling_retry_interval`, `init_retry_interval` or an /// `AutoCommit` interval is negative, or if any of those except `poll_interval` /// is zero. + /// + /// Consumer groups are not available over HTTP. With `auto_join_consumer_group` + /// left on, this call fails at the join with `Feature is unavailable`. + /// Turning it off is not a workaround: the join is skipped, but a group + /// member always polls without a partition, so the first poll fails with + /// the same error. Use `Consumer.Single(...)` with `poll_messages(...)` + /// instead - a `Consumer.Group(...)` poll with an explicit `partition_id` + /// does reach the server, but is served as an ordinary consumer named + /// after the group. #[allow(clippy::too_many_arguments)] #[pyo3(signature = ( name, @@ -1137,7 +1546,7 @@ impl IggyClient { let mut builder = self .inner .consumer_group(name, stream, topic) - .map_err(|e| PyErr::new::(e.to_string()))? + .map_err(to_runtime_error)? .without_encryptor() .partition(partition_id); @@ -1197,10 +1606,7 @@ impl IggyClient { let mut consumer = builder.build(); future_into_py(py, async move { - consumer - .init() - .await - .map_err(|e| PyErr::new::(e.to_string()))?; + consumer.init().await.map_err(to_runtime_error)?; let state = consumer.state(); let name = consumer.name().to_string(); let stream = PyIdentifier::try_from(consumer.stream())?; @@ -1241,7 +1647,7 @@ impl IggyClient { let response = inner .send_binary_request(code, Bytes::from(payload)) .await - .map_err(|e| PyErr::new::(e.to_string()))?; + .map_err(to_runtime_error)?; Ok(Python::attach(|py| PyBytes::new(py, &response).unbind())) }) } diff --git a/foreign/python/src/config.rs b/foreign/python/src/config.rs index ed6e2a14f0..e685d048c5 100644 --- a/foreign/python/src/config.rs +++ b/foreign/python/src/config.rs @@ -17,8 +17,14 @@ use iggy::prelude::{ AutoLogin as RustAutoLogin, Credentials as RustCredentials, + HttpClientConfig as RustHttpClientConfig, HttpClientConfigBuilder, + QuicClientConfig as RustQuicClientConfig, QuicClientConfigBuilder, + QuicClientReconnectionConfig as RustQuicClientReconnectionConfig, TcpClientConfig as RustTcpClientConfig, TcpClientConfigBuilder, TcpClientReconnectionConfig as RustTcpClientReconnectionConfig, + WebSocketClientConfig as RustWebSocketClientConfig, WebSocketClientConfigBuilder, + WebSocketClientReconnectionConfig as RustWebSocketClientReconnectionConfig, + WebSocketConfig as RustWebSocketFramingConfig, }; use pyo3::exceptions::PyValueError; use pyo3::prelude::*; @@ -26,10 +32,13 @@ use pyo3::types::PyDelta; use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; use pyo3_stub_gen::impl_stub_type; use secrecy::SecretString; +use std::fmt::Display; +use std::net::SocketAddr; use std::sync::Arc; use crate::duration::{ - duration_repr, iggy_duration_to_py_delta, py_delta_to_iggy_duration, reject_zero, + duration_repr, iggy_duration_to_py_delta, millis_repr, millis_to_py_delta, + py_delta_to_iggy_duration, py_delta_to_millis, reject_zero, }; /// The credentials replayed by the client every time it (re)connects. @@ -140,6 +149,8 @@ impl TcpReconnectionConfig { /// Raises: /// ValueError: If a duration is negative, if `max_retries` is outside the /// range of an unsigned 32-bit integer, or if `interval` is zero. + /// OverflowError: If `max_retries` does not fit a signed 64-bit integer, + /// raised by the underlying conversion before this constructor runs. #[new] #[pyo3(signature = (*, enabled=None, max_retries=None, interval=None, reestablish_after=None))] fn new( @@ -153,14 +164,7 @@ impl TcpReconnectionConfig { let defaults = RustTcpClientReconnectionConfig::default(); let enabled = enabled.unwrap_or(defaults.enabled); let max_retries = max_retries - .map(|max_retries| { - u32::try_from(max_retries).map_err(|_| { - PyValueError::new_err(format!( - "'max_retries' must be between 0 and {}", - u32::MAX - )) - }) - }) + .map(|max_retries| u32_param(max_retries, "max_retries")) .transpose()?; let interval = interval .as_ref() @@ -304,7 +308,7 @@ impl TcpConfig { } let mut inner = builder .build() - .map_err(|e| PyValueError::new_err(e.to_string()))?; + .map_err(|e| invalid_address("server_address", e))?; if let Some(auto_login) = auto_login { inner.auto_login = auto_login.inner; } @@ -408,16 +412,1148 @@ impl TcpConfig { } } +/// How the QUIC client reconnects after the connection to the server is lost. +#[gen_stub_pyclass] +#[pyclass(from_py_object)] +#[derive(Clone)] +pub struct QuicReconnectionConfig { + pub(crate) inner: RustQuicClientReconnectionConfig, +} + +#[gen_stub_pymethods] +#[pymethods] +impl QuicReconnectionConfig { + /// Constructs a reconnection policy. + /// + /// Args: + /// enabled: Whether to reconnect at all. Defaults to enabled. + /// max_retries: Redials of the configured server address after the first + /// attempt, or `None` for unlimited; `0` still makes that first + /// attempt. Unlike the TCP transport, QUIC redials the one address + /// it was configured with rather than walking a cluster roster, so + /// this counts dials. Defaults to unlimited, which means a call + /// awaited while the server is down never returns: `connect()` + /// waits inside the retry loop, as do `send_messages()` and + /// `poll_messages()` once auto-login is configured. Set a finite + /// number for request/reply style usage, so a call fails instead. + /// interval: Delay before each redial. Defaults to 1 second. + /// reestablish_after: Cooldown before redialing after a previously + /// successful connection, measured from when it was established, so + /// a session that outlived the interval is redialed at once. + /// Defaults to 5 seconds. + /// + /// Raises: + /// ValueError: If a duration is negative, if `max_retries` is outside the + /// range of an unsigned 32-bit integer, or if `interval` is zero. + /// OverflowError: If `max_retries` does not fit a signed 64-bit integer, + /// raised by the underlying conversion before this constructor runs. + #[new] + #[pyo3(signature = (*, enabled=None, max_retries=None, interval=None, reestablish_after=None))] + fn new( + #[gen_stub(override_type(type_repr = "builtins.bool | None"))] enabled: Option, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] max_retries: Option, + #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + interval: Option>, + #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + reestablish_after: Option>, + ) -> PyResult { + let defaults = RustQuicClientReconnectionConfig::default(); + let enabled = enabled.unwrap_or(defaults.enabled); + let max_retries = max_retries + .map(|max_retries| u32_param(max_retries, "max_retries")) + .transpose()?; + let interval = interval + .as_ref() + .map(py_delta_to_iggy_duration) + .transpose()? + .map(|interval| reject_zero(interval, "interval")) + .transpose()? + .unwrap_or(defaults.interval); + Ok(Self { + inner: RustQuicClientReconnectionConfig { + enabled, + max_retries, + interval, + reestablish_after: reestablish_after + .as_ref() + .map(py_delta_to_iggy_duration) + .transpose()? + .unwrap_or(defaults.reestablish_after), + }, + }) + } + + #[getter] + fn enabled(&self) -> bool { + self.inner.enabled + } + + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + #[getter] + fn max_retries(&self) -> Option { + self.inner.max_retries + } + + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn interval<'a>(&self, py: Python<'a>) -> PyResult> { + iggy_duration_to_py_delta(py, self.inner.interval.get()) + } + + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn reestablish_after<'a>(&self, py: Python<'a>) -> PyResult> { + iggy_duration_to_py_delta(py, self.inner.reestablish_after) + } + + fn __repr__(&self) -> String { + let max_retries = match self.inner.max_retries { + Some(max_retries) => max_retries.to_string(), + None => "None".to_owned(), + }; + format!( + "QuicReconnectionConfig(enabled={}, max_retries={max_retries}, interval={}, reestablish_after={})", + python_bool(self.inner.enabled), + duration_repr(self.inner.interval.get()), + duration_repr(self.inner.reestablish_after), + ) + } +} + +/// quinn clamps `TransportConfig::initial_mtu` up to this floor rather than +/// rejecting a smaller value, so `QuicConfig` rejects it instead: otherwise the +/// getter would read back a value that is not the one actually in effect. +const QUINN_MIN_INITIAL_MTU: u16 = 1200; + +/// Configuration for the QUIC transport, accepted by `IggyClient(...)`. +/// +/// Every field is keyword-only and optional. +#[gen_stub_pyclass] +#[pyclass(from_py_object)] +#[derive(Clone)] +pub struct QuicConfig { + inner: Arc, +} + +impl QuicConfig { + /// The configuration in the shape `QuicClient::create` expects. + pub(crate) fn client_config(&self) -> Arc { + self.inner.clone() + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl QuicConfig { + /// Constructs a QUIC configuration. + /// + /// Args: + /// server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8080`. + /// client_address: `host:port` to bind the local UDP socket to. Defaults to + /// `127.0.0.1:0`, which binds to any available port. That exact value, + /// passed or defaulted, binds `[::1]:0` instead when `server_address` + /// resolves to IPv6, so the socket in use may not be the address read + /// back here. Any other value binds as given. + /// server_name: Server name used for the QUIC/TLS handshake. Defaults to + /// `localhost`. + /// auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`. + /// reconnection: Reconnection policy. Defaults to `QuicReconnectionConfig()`. + /// heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds. + /// response_buffer_size: Size of the response buffer in bytes. Defaults to 10 MB. + /// max_concurrent_bidi_streams: Maximum number of concurrent bidirectional + /// streams. Defaults to 10,000. + /// datagram_send_buffer_size: Size of the datagram send buffer in bytes. + /// Defaults to 100,000. + /// initial_mtu: Initial MTU in bytes. Defaults to 1200. + /// send_window: Send window size in bytes. Defaults to 100,000. + /// receive_window: Receive window size in bytes. Defaults to 100,000. + /// keep_alive_interval: Interval between QUIC keep-alive pings, or a zero + /// duration to disable them. Defaults to 5 seconds. + /// max_idle_timeout: How long the connection tolerates silence before it is + /// considered dead, or a zero duration to use quinn's own default (30 + /// seconds) instead, since `configure()` skips the setter entirely when + /// zero. Defaults to 10 seconds. + /// validate_certificate: Whether to validate the server certificate. Defaults + /// to disabled; only the TCP transport validates by default. + /// + /// Raises: + /// ValueError: If `server_address` or `client_address` is not a valid + /// `host:port` pair, if a duration is negative, if + /// `heartbeat_interval` is zero, if `keep_alive_interval` or + /// `max_idle_timeout` is not a whole number of milliseconds, if + /// `initial_mtu` is below quinn's minimum of 1200, or if a numeric + /// field is outside the range of its underlying wire type. + /// OverflowError: If a numeric field does not fit a signed 64-bit integer, + /// raised by the underlying conversion before this constructor runs. + #[new] + #[pyo3(signature = ( + *, + server_address=None, + client_address=None, + server_name=None, + auto_login=None, + reconnection=None, + heartbeat_interval=None, + response_buffer_size=None, + max_concurrent_bidi_streams=None, + datagram_send_buffer_size=None, + initial_mtu=None, + send_window=None, + receive_window=None, + keep_alive_interval=None, + max_idle_timeout=None, + validate_certificate=None, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + #[gen_stub(override_type(type_repr = "builtins.str | None"))] server_address: Option< + String, + >, + #[gen_stub(override_type(type_repr = "builtins.str | None"))] client_address: Option< + String, + >, + #[gen_stub(override_type(type_repr = "builtins.str | None"))] server_name: Option, + #[gen_stub(override_type(type_repr = "AutoLogin | None"))] auto_login: Option, + #[gen_stub(override_type(type_repr = "QuicReconnectionConfig | None"))] + reconnection: Option, + #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + heartbeat_interval: Option>, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] response_buffer_size: Option< + i64, + >, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] + max_concurrent_bidi_streams: Option, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] + datagram_send_buffer_size: Option, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] initial_mtu: Option, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] send_window: Option, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] receive_window: Option, + #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + keep_alive_interval: Option>, + #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + max_idle_timeout: Option>, + #[gen_stub(override_type(type_repr = "builtins.bool | None"))] validate_certificate: Option< + bool, + >, + ) -> PyResult { + // The builder starts from `QuicClientConfig::default()`, and its `build()` + // trims and validates the server address whether or not one was set here. + let mut builder = QuicClientConfigBuilder::new(); + if let Some(server_address) = server_address { + builder = builder.with_server_address(server_address); + } + let mut inner = builder + .build() + .map_err(|e| invalid_address("server_address", e))?; + if let Some(client_address) = client_address { + // Trimmed like the server address, but otherwise kept verbatim rather + // than re-serialized from the parsed `SocketAddr`: `QuicClient::create` + // compares this against the literal default to decide whether to bind + // an IPv6 socket for an IPv6 server, and a rewritten string would not + // match. + let client_address = client_address.trim(); + client_address + .parse::() + .map_err(|e| invalid_address("client_address", e))?; + inner.client_address = client_address.to_owned(); + } + if let Some(server_name) = server_name { + inner.server_name = server_name; + } + if let Some(auto_login) = auto_login { + inner.auto_login = auto_login.inner; + } + if let Some(reconnection) = reconnection { + inner.reconnection = reconnection.inner; + } + if let Some(heartbeat_interval) = heartbeat_interval { + inner.heartbeat_interval = reject_zero( + py_delta_to_iggy_duration(&heartbeat_interval)?, + "heartbeat_interval", + )?; + } + if let Some(response_buffer_size) = response_buffer_size { + inner.response_buffer_size = u64_param(response_buffer_size, "response_buffer_size")?; + } + if let Some(max_concurrent_bidi_streams) = max_concurrent_bidi_streams { + inner.max_concurrent_bidi_streams = + varint_param(max_concurrent_bidi_streams, "max_concurrent_bidi_streams")?; + } + if let Some(datagram_send_buffer_size) = datagram_send_buffer_size { + inner.datagram_send_buffer_size = + u64_param(datagram_send_buffer_size, "datagram_send_buffer_size")?; + } + if let Some(initial_mtu) = initial_mtu { + let initial_mtu = u16_param(initial_mtu, "initial_mtu")?; + if initial_mtu < QUINN_MIN_INITIAL_MTU { + return Err(PyValueError::new_err(format!( + "'initial_mtu' must be at least {QUINN_MIN_INITIAL_MTU}; quinn silently \ + raises anything smaller to that floor, so the getter would no longer \ + match the value actually in effect" + ))); + } + inner.initial_mtu = initial_mtu; + } + if let Some(send_window) = send_window { + inner.send_window = u64_param(send_window, "send_window")?; + } + if let Some(receive_window) = receive_window { + inner.receive_window = varint_param(receive_window, "receive_window")?; + } + if let Some(keep_alive_interval) = keep_alive_interval { + inner.keep_alive_interval = + py_delta_to_millis(&keep_alive_interval, "keep_alive_interval")?; + } + if let Some(max_idle_timeout) = max_idle_timeout { + inner.max_idle_timeout = py_delta_to_millis(&max_idle_timeout, "max_idle_timeout")?; + } + if let Some(validate_certificate) = validate_certificate { + inner.validate_certificate = validate_certificate; + } + + Ok(Self { + inner: Arc::new(inner), + }) + } + + #[getter] + fn server_address(&self) -> String { + self.inner.server_address.clone() + } + + #[getter] + fn client_address(&self) -> String { + self.inner.client_address.clone() + } + + #[getter] + fn server_name(&self) -> String { + self.inner.server_name.clone() + } + + #[getter] + fn auto_login(&self) -> AutoLogin { + AutoLogin { + inner: self.inner.auto_login.clone(), + } + } + + #[getter] + fn reconnection(&self) -> QuicReconnectionConfig { + QuicReconnectionConfig { + inner: self.inner.reconnection.clone(), + } + } + + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn heartbeat_interval<'a>(&self, py: Python<'a>) -> PyResult> { + iggy_duration_to_py_delta(py, self.inner.heartbeat_interval.get()) + } + + #[gen_stub(override_return_type(type_repr = "builtins.int"))] + #[getter] + fn response_buffer_size(&self) -> u64 { + self.inner.response_buffer_size + } + + #[gen_stub(override_return_type(type_repr = "builtins.int"))] + #[getter] + fn max_concurrent_bidi_streams(&self) -> u64 { + self.inner.max_concurrent_bidi_streams + } + + #[gen_stub(override_return_type(type_repr = "builtins.int"))] + #[getter] + fn datagram_send_buffer_size(&self) -> u64 { + self.inner.datagram_send_buffer_size + } + + #[gen_stub(override_return_type(type_repr = "builtins.int"))] + #[getter] + fn initial_mtu(&self) -> u16 { + self.inner.initial_mtu + } + + #[gen_stub(override_return_type(type_repr = "builtins.int"))] + #[getter] + fn send_window(&self) -> u64 { + self.inner.send_window + } + + #[gen_stub(override_return_type(type_repr = "builtins.int"))] + #[getter] + fn receive_window(&self) -> u64 { + self.inner.receive_window + } + + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn keep_alive_interval<'a>(&self, py: Python<'a>) -> PyResult> { + millis_to_py_delta(py, self.inner.keep_alive_interval) + } + + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn max_idle_timeout<'a>(&self, py: Python<'a>) -> PyResult> { + millis_to_py_delta(py, self.inner.max_idle_timeout) + } + + #[getter] + fn validate_certificate(&self) -> bool { + self.inner.validate_certificate + } + + fn __repr__(&self) -> String { + format!( + "QuicConfig(server_address={:?}, client_address={:?}, server_name={:?}, auto_login={}, reconnection={}, heartbeat_interval={}, response_buffer_size={}, max_concurrent_bidi_streams={}, datagram_send_buffer_size={}, initial_mtu={}, send_window={}, receive_window={}, keep_alive_interval={}, max_idle_timeout={}, validate_certificate={})", + self.inner.server_address, + self.inner.client_address, + self.inner.server_name, + self.auto_login().__repr__(), + self.reconnection().__repr__(), + duration_repr(self.inner.heartbeat_interval.get()), + self.inner.response_buffer_size, + self.inner.max_concurrent_bidi_streams, + self.inner.datagram_send_buffer_size, + self.inner.initial_mtu, + self.inner.send_window, + self.inner.receive_window, + millis_repr(self.inner.keep_alive_interval), + millis_repr(self.inner.max_idle_timeout), + python_bool(self.inner.validate_certificate), + ) + } +} + +/// Configuration for the HTTP transport, accepted by `IggyClient(...)`. +/// +/// Every field is keyword-only and optional. +/// +/// There is no `AutoLogin` and no reconnection policy, and `connect()` does not +/// dial: it only starts the heartbeat, so `login_user(...)` has to follow it. +/// +/// HTTP is single-consumer only. `consumer_group(...)` fails with +/// `Feature is unavailable`, and so does a `Consumer.Group(...)` poll unless it +/// names an explicit `partition_id`. With one, the consumer kind is not carried +/// on the HTTP wire, so the poll is served as an ordinary consumer named after +/// the group, with no membership, no partition assignment, and no rebalancing +/// behind it. Pass `Consumer.Single(...)` explicitly. +#[gen_stub_pyclass] +#[pyclass(from_py_object)] +#[derive(Clone)] +pub struct HttpConfig { + inner: Arc, +} + +impl HttpConfig { + /// The configuration in the shape `HttpClient::create` expects. + pub(crate) fn client_config(&self) -> Arc { + self.inner.clone() + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl HttpConfig { + /// Constructs an HTTP configuration. + /// + /// Args: + /// api_url: Base URL of the Iggy HTTP API, as `scheme://host[:port]` + /// only - no path, query, fragment, or credentials. Defaults to + /// `http://127.0.0.1:3000`. + /// retries: Number of retries to perform on transient errors, each one + /// replaying the full request (including its body) via automatic + /// middleware. Defaults to 3. Delivery is therefore at-least-once: + /// if the original request actually committed but its response + /// was lost (e.g. to a timeout), a retried call applies the same + /// operation again. Set to 0 to disable automatic replay and match + /// the other transports, which surface the failure instead of + /// silently resending. + /// jwt: JWT token for A2A (Agent-to-Agent) authentication. Defaults to + /// `None`. Stored trimmed, since a token read from a file carries a + /// trailing newline that the `Authorization` header value rejects. + /// Rejected if empty or whitespace-only: accepting it would make + /// `has_jwt` report `True` while every call still fails + /// `Unauthenticated`. + /// heartbeat_interval: Interval between the client's liveness probes + /// (a bare `GET /ping`). Defaults to 5 seconds. Unlike TCP/QUIC, + /// HTTP has no persistent connection or session for this to keep + /// alive; it only proves the server is reachable. + /// + /// Raises: + /// ValueError: If `api_url` is not a valid URL, if `retries` is outside + /// the range of an unsigned 32-bit integer, if `jwt` is empty or + /// whitespace-only, if a duration is negative, or if + /// `heartbeat_interval` is zero. + /// OverflowError: If `retries` does not fit a signed 64-bit integer, + /// raised by the underlying conversion before this constructor runs. + #[new] + #[pyo3(signature = (*, api_url=None, retries=None, jwt=None, heartbeat_interval=None))] + fn new( + #[gen_stub(override_type(type_repr = "builtins.str | None"))] api_url: Option, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] retries: Option, + #[gen_stub(override_type(type_repr = "builtins.str | None"))] jwt: Option, + #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + heartbeat_interval: Option>, + ) -> PyResult { + // The builder starts from `HttpClientConfig::default()`, and its `build()` + // trims and validates the API URL whether or not one was set here. + let mut builder = HttpClientConfigBuilder::new(); + if let Some(api_url) = api_url { + builder = builder.with_api_url(api_url); + } + let mut inner = builder + .build() + .map_err(|e| PyValueError::new_err(format!("'api_url' is not a valid URL: {e}")))?; + if let Some(retries) = retries { + inner.retries = u32_param(retries, "retries")?; + } + if let Some(jwt) = jwt { + let jwt = jwt.trim(); + if jwt.is_empty() { + return Err(PyValueError::new_err( + "'jwt' must not be empty or whitespace-only", + )); + } + inner.jwt = Some(jwt.to_owned()); + } + if let Some(heartbeat_interval) = heartbeat_interval { + inner.heartbeat_interval = reject_zero( + py_delta_to_iggy_duration(&heartbeat_interval)?, + "heartbeat_interval", + )?; + } + + Ok(Self { + inner: Arc::new(inner), + }) + } + + #[getter] + fn api_url(&self) -> String { + self.inner.api_url.clone() + } + + #[getter] + fn retries(&self) -> u32 { + self.inner.retries + } + + /// Whether a JWT is configured, without exposing the token itself. + #[getter] + fn has_jwt(&self) -> bool { + self.inner.jwt.is_some() + } + + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn heartbeat_interval<'a>(&self, py: Python<'a>) -> PyResult> { + iggy_duration_to_py_delta(py, self.inner.heartbeat_interval.get()) + } + + fn __repr__(&self) -> String { + let jwt = if self.inner.jwt.is_some() { + "..." + } else { + "None" + }; + format!( + "HttpConfig(api_url={:?}, retries={}, jwt={jwt}, heartbeat_interval={})", + self.inner.api_url, + self.inner.retries, + duration_repr(self.inner.heartbeat_interval.get()), + ) + } +} + +/// How the WebSocket client reconnects after the connection to the server is lost. +#[gen_stub_pyclass] +#[pyclass(from_py_object)] +#[derive(Clone)] +pub struct WebSocketReconnectionConfig { + pub(crate) inner: RustWebSocketClientReconnectionConfig, +} + +#[gen_stub_pymethods] +#[pymethods] +impl WebSocketReconnectionConfig { + /// Constructs a reconnection policy. + /// + /// Args: + /// enabled: Whether to reconnect at all. Defaults to enabled. + /// max_retries: Redials of the configured server address after the first + /// attempt, or `None` for unlimited; `0` still makes that first + /// attempt. Unlike the TCP transport, WebSocket redials the one + /// address it was configured with rather than walking a cluster + /// roster, so this counts dials. Defaults to unlimited, which means + /// a call awaited while the server is down never returns: + /// `connect()` waits inside the retry loop, as do `send_messages()` + /// and `poll_messages()` once auto-login is configured. Set a finite + /// number for request/reply style usage, so a call fails instead. + /// interval: Delay before each redial. Defaults to 1 second. + /// reestablish_after: Cooldown before redialing after a previously + /// successful connection, measured from when it was established, so + /// a session that outlived the interval is redialed at once. Applied + /// from the first redial onward, not to the initial connect. + /// Defaults to 5 seconds. + /// + /// Raises: + /// ValueError: If a duration is negative, if `max_retries` is outside the + /// range of an unsigned 32-bit integer, or if `interval` is zero. + /// OverflowError: If `max_retries` does not fit a signed 64-bit integer, + /// raised by the underlying conversion before this constructor runs. + #[new] + #[pyo3(signature = (*, enabled=None, max_retries=None, interval=None, reestablish_after=None))] + fn new( + #[gen_stub(override_type(type_repr = "builtins.bool | None"))] enabled: Option, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] max_retries: Option, + #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + interval: Option>, + #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + reestablish_after: Option>, + ) -> PyResult { + let defaults = RustWebSocketClientReconnectionConfig::default(); + let enabled = enabled.unwrap_or(defaults.enabled); + let max_retries = max_retries + .map(|max_retries| u32_param(max_retries, "max_retries")) + .transpose()?; + let interval = interval + .as_ref() + .map(py_delta_to_iggy_duration) + .transpose()? + .map(|interval| reject_zero(interval, "interval")) + .transpose()? + .unwrap_or(defaults.interval); + Ok(Self { + inner: RustWebSocketClientReconnectionConfig { + enabled, + max_retries, + interval, + reestablish_after: reestablish_after + .as_ref() + .map(py_delta_to_iggy_duration) + .transpose()? + .unwrap_or(defaults.reestablish_after), + }, + }) + } + + #[getter] + fn enabled(&self) -> bool { + self.inner.enabled + } + + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + #[getter] + fn max_retries(&self) -> Option { + self.inner.max_retries + } + + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn interval<'a>(&self, py: Python<'a>) -> PyResult> { + iggy_duration_to_py_delta(py, self.inner.interval.get()) + } + + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn reestablish_after<'a>(&self, py: Python<'a>) -> PyResult> { + iggy_duration_to_py_delta(py, self.inner.reestablish_after) + } + + fn __repr__(&self) -> String { + let max_retries = match self.inner.max_retries { + Some(max_retries) => max_retries.to_string(), + None => "None".to_owned(), + }; + format!( + "WebSocketReconnectionConfig(enabled={}, max_retries={max_retries}, interval={}, reestablish_after={})", + python_bool(self.inner.enabled), + duration_repr(self.inner.interval.get()), + duration_repr(self.inner.reestablish_after), + ) + } +} + +/// Frame- and buffer-level options passed through to the underlying WebSocket +/// implementation, accepted by `WebSocketConfig`'s `framing` argument. +/// +/// Every field is keyword-only and optional; unset fields fall back to the +/// underlying WebSocket library's own defaults. +#[gen_stub_pyclass] +#[pyclass(from_py_object)] +#[derive(Clone)] +pub struct WebSocketFramingConfig { + pub(crate) inner: RustWebSocketFramingConfig, +} + +#[gen_stub_pymethods] +#[pymethods] +impl WebSocketFramingConfig { + /// Constructs a WebSocket framing configuration. + /// + /// Args: + /// read_buffer_size: Read buffer size in bytes. Defaults to 128 KiB. + /// write_buffer_size: Write buffer size in bytes. Defaults to 128 KiB. + /// max_write_buffer_size: Maximum write buffer size in bytes. Defaults to + /// unbounded, which reads back as the largest value a pointer-sized + /// unsigned integer holds rather than as `None`. + /// max_message_size: Maximum message size in bytes, or an explicit `None` + /// to lift the limit entirely. Omitting the argument is not the same + /// as passing `None`: it keeps the underlying default of 64 MiB. + /// Lifting the limit lets a peer queue an arbitrarily large message + /// in memory, so prefer a finite value. + /// max_frame_size: Maximum frame size in bytes, or an explicit `None` to + /// lift the limit entirely. Omitting the argument keeps the + /// underlying default of 16 MiB, with the same caveat as + /// `max_message_size`. + /// accept_unmasked_frames: Whether to accept unmasked frames. Defaults to + /// `False`; clients should typically keep this off for RFC compliance. + /// + /// Raises: + /// ValueError: If a numeric field is outside the range of a pointer-sized + /// unsigned integer, or if `max_write_buffer_size` does not come out + /// greater than `write_buffer_size`. tungstenite enforces the same + /// invariant with an `assert!` at connect time, which would otherwise + /// surface as an unrecoverable Rust panic instead of a `ValueError`. + /// OverflowError: If a numeric field does not fit a signed 128-bit integer, + /// raised by the underlying conversion before this constructor runs. + #[new] + #[pyo3(signature = ( + *, + read_buffer_size=None, + write_buffer_size=None, + max_write_buffer_size=None, + max_message_size=64 << 20, + max_frame_size=16 << 20, + accept_unmasked_frames=None, + ))] + fn new( + #[gen_stub(override_type(type_repr = "builtins.int | None"))] read_buffer_size: Option< + i128, + >, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] write_buffer_size: Option< + i128, + >, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] max_write_buffer_size: Option< + i128, + >, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] max_message_size: Option< + i128, + >, + #[gen_stub(override_type(type_repr = "builtins.int | None"))] max_frame_size: Option, + #[gen_stub(override_type(type_repr = "builtins.bool | None"))] + accept_unmasked_frames: Option, + ) -> PyResult { + let mut inner = RustWebSocketFramingConfig::default(); + if let Some(read_buffer_size) = read_buffer_size { + inner.read_buffer_size = Some(usize_param(read_buffer_size, "read_buffer_size")?); + } + if let Some(write_buffer_size) = write_buffer_size { + inner.write_buffer_size = Some(usize_param(write_buffer_size, "write_buffer_size")?); + } + if let Some(max_write_buffer_size) = max_write_buffer_size { + inner.max_write_buffer_size = + Some(usize_param(max_write_buffer_size, "max_write_buffer_size")?); + } + // Assigned unconditionally, unlike the buffer sizes above: `None` here + // means "no limit", and pyo3 cannot tell an omitted argument from an + // explicit `None` on its own. The signature defaults carry the + // underlying limits instead, so omission lands on `Some(default)` and + // only an explicit `None` reaches this as `None`. + inner.max_message_size = max_message_size + .map(|max_message_size| usize_param(max_message_size, "max_message_size")) + .transpose()?; + inner.max_frame_size = max_frame_size + .map(|max_frame_size| usize_param(max_frame_size, "max_frame_size")) + .transpose()?; + if let Some(accept_unmasked_frames) = accept_unmasked_frames { + inner.accept_unmasked_frames = accept_unmasked_frames; + } + if let (Some(write_buffer_size), Some(max_write_buffer_size)) = + (inner.write_buffer_size, inner.max_write_buffer_size) + && max_write_buffer_size <= write_buffer_size + { + return Err(PyValueError::new_err(format!( + "'max_write_buffer_size' ({max_write_buffer_size}) must be greater than \ + 'write_buffer_size' ({write_buffer_size})" + ))); + } + + Ok(Self { inner }) + } + + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + #[getter] + fn read_buffer_size(&self) -> Option { + self.inner.read_buffer_size + } + + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + #[getter] + fn write_buffer_size(&self) -> Option { + self.inner.write_buffer_size + } + + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + #[getter] + fn max_write_buffer_size(&self) -> Option { + self.inner.max_write_buffer_size + } + + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + #[getter] + fn max_message_size(&self) -> Option { + self.inner.max_message_size + } + + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + #[getter] + fn max_frame_size(&self) -> Option { + self.inner.max_frame_size + } + + #[getter] + fn accept_unmasked_frames(&self) -> bool { + self.inner.accept_unmasked_frames + } + + fn __repr__(&self) -> String { + let optional_usize = |value: Option| match value { + Some(value) => value.to_string(), + None => "None".to_owned(), + }; + format!( + "WebSocketFramingConfig(read_buffer_size={}, write_buffer_size={}, max_write_buffer_size={}, max_message_size={}, max_frame_size={}, accept_unmasked_frames={})", + optional_usize(self.inner.read_buffer_size), + optional_usize(self.inner.write_buffer_size), + optional_usize(self.inner.max_write_buffer_size), + optional_usize(self.inner.max_message_size), + optional_usize(self.inner.max_frame_size), + python_bool(self.inner.accept_unmasked_frames), + ) + } +} + +/// Configuration for the WebSocket transport, accepted by `IggyClient(...)`. +/// +/// Every field is keyword-only and optional. +#[gen_stub_pyclass] +#[pyclass(from_py_object)] +#[derive(Clone)] +pub struct WebSocketConfig { + inner: Arc, +} + +impl WebSocketConfig { + /// The configuration in the shape `WebSocketClient::create` expects. + pub(crate) fn client_config(&self) -> Arc { + self.inner.clone() + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl WebSocketConfig { + /// Constructs a WebSocket configuration. + /// + /// Args: + /// server_address: `host:port` of the Iggy server. Defaults to `127.0.0.1:8092`. + /// auto_login: Credentials replayed on every connect. Defaults to `AutoLogin.disabled()`. + /// reconnection: Reconnection policy. Defaults to `WebSocketReconnectionConfig()`. + /// heartbeat_interval: Interval of heartbeats sent by the client. Defaults to 5 seconds. + /// framing: Frame- and buffer-level options. Defaults to `WebSocketFramingConfig()`. + /// tls_enabled: Whether to connect over TLS. Defaults to disabled. + /// tls_domain: Domain to validate the certificate against. Defaults to + /// `localhost`. Empty means it is taken from the IP `server_address` + /// resolves to. + /// tls_ca_file: Path to the CA file for TLS. Read only when `tls_enabled` + /// and `tls_validate_certificate` are both on; with either one off it + /// is kept but never consulted, so pairing it with + /// `tls_validate_certificate=False` pins nothing. + /// tls_validate_certificate: Whether to validate the server certificate. + /// Defaults to `False`; only the TCP transport validates by default. + /// Disabling this accepts any certificate the server presents, + /// including self-signed and mismatched ones, and takes precedence + /// over `tls_ca_file`. + /// + /// Raises: + /// ValueError: If `server_address` is not a valid `host:port` pair, if a + /// duration is negative, or if `heartbeat_interval` is zero. + #[new] + #[pyo3(signature = ( + *, + server_address=None, + auto_login=None, + reconnection=None, + heartbeat_interval=None, + framing=None, + tls_enabled=None, + tls_domain=None, + tls_ca_file=None, + tls_validate_certificate=None, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + #[gen_stub(override_type(type_repr = "builtins.str | None"))] server_address: Option< + String, + >, + #[gen_stub(override_type(type_repr = "AutoLogin | None"))] auto_login: Option, + #[gen_stub(override_type(type_repr = "WebSocketReconnectionConfig | None"))] + reconnection: Option, + #[gen_stub(override_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + heartbeat_interval: Option>, + #[gen_stub(override_type(type_repr = "WebSocketFramingConfig | None"))] framing: Option< + WebSocketFramingConfig, + >, + #[gen_stub(override_type(type_repr = "builtins.bool | None"))] tls_enabled: Option, + #[gen_stub(override_type(type_repr = "builtins.str | None"))] tls_domain: Option, + #[gen_stub(override_type(type_repr = "builtins.str | None"))] tls_ca_file: Option, + #[gen_stub(override_type(type_repr = "builtins.bool | None"))] + tls_validate_certificate: Option, + ) -> PyResult { + // The builder starts from `WebSocketClientConfig::default()`, and its + // `build()` trims and validates the address whether or not one was set here. + let mut builder = WebSocketClientConfigBuilder::new(); + if let Some(server_address) = server_address { + builder = builder.with_server_address(server_address); + } + let mut inner = builder + .build() + .map_err(|e| invalid_address("server_address", e))?; + if let Some(auto_login) = auto_login { + inner.auto_login = auto_login.inner; + } + if let Some(reconnection) = reconnection { + inner.reconnection = reconnection.inner; + } + if let Some(heartbeat_interval) = heartbeat_interval { + inner.heartbeat_interval = reject_zero( + py_delta_to_iggy_duration(&heartbeat_interval)?, + "heartbeat_interval", + )?; + } + if let Some(framing) = framing { + inner.ws_config = framing.inner; + } + if let Some(tls_enabled) = tls_enabled { + inner.tls_enabled = tls_enabled; + } + if let Some(tls_domain) = tls_domain { + inner.tls_domain = tls_domain; + } + if tls_ca_file.is_some() { + inner.tls_ca_file = tls_ca_file; + } + if let Some(tls_validate_certificate) = tls_validate_certificate { + inner.tls_validate_certificate = tls_validate_certificate; + } + + Ok(Self { + inner: Arc::new(inner), + }) + } + + #[getter] + fn server_address(&self) -> String { + self.inner.server_address.clone() + } + + #[getter] + fn auto_login(&self) -> AutoLogin { + AutoLogin { + inner: self.inner.auto_login.clone(), + } + } + + #[getter] + fn reconnection(&self) -> WebSocketReconnectionConfig { + WebSocketReconnectionConfig { + inner: self.inner.reconnection.clone(), + } + } + + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn heartbeat_interval<'a>(&self, py: Python<'a>) -> PyResult> { + iggy_duration_to_py_delta(py, self.inner.heartbeat_interval.get()) + } + + #[getter] + fn framing(&self) -> WebSocketFramingConfig { + WebSocketFramingConfig { + inner: self.inner.ws_config.clone(), + } + } + + #[getter] + fn tls_enabled(&self) -> bool { + self.inner.tls_enabled + } + + #[getter] + fn tls_domain(&self) -> String { + self.inner.tls_domain.clone() + } + + #[gen_stub(override_return_type(type_repr = "builtins.str | None"))] + #[getter] + fn tls_ca_file(&self) -> Option { + self.inner.tls_ca_file.clone() + } + + #[getter] + fn tls_validate_certificate(&self) -> bool { + self.inner.tls_validate_certificate + } + + fn __repr__(&self) -> String { + let tls_ca_file = match &self.inner.tls_ca_file { + Some(tls_ca_file) => format!("{tls_ca_file:?}"), + None => "None".to_owned(), + }; + format!( + "WebSocketConfig(server_address={:?}, auto_login={}, reconnection={}, heartbeat_interval={}, framing={}, tls_enabled={}, tls_domain={:?}, tls_ca_file={tls_ca_file}, tls_validate_certificate={})", + self.inner.server_address, + self.auto_login().__repr__(), + self.reconnection().__repr__(), + duration_repr(self.inner.heartbeat_interval.get()), + self.framing().__repr__(), + python_bool(self.inner.tls_enabled), + self.inner.tls_domain, + python_bool(self.inner.tls_validate_certificate), + ) + } +} + fn python_bool(value: bool) -> &'static str { if value { "True" } else { "False" } } -/// What `IggyClient(...)` accepts: a bare `host:port` or a full `TcpConfig`. +/// Rejects an address that is not a valid `host:port`, naming the argument it +/// came from: neither the builder's error nor `SocketAddr`'s mentions which one. +fn invalid_address(parameter: &str, error: impl Display) -> PyErr { + PyValueError::new_err(format!("'{parameter}' is not a valid 'host:port': {error}")) +} + +/// Converts a Python int to the unsigned 32-bit integer `max_retries`/`retries` +/// expect, naming the parameter in the error so a caller can tell which +/// argument was out of range. A value too large even for `i64` still raises +/// pyo3's own unnamed `OverflowError` before this ever runs. +fn u32_param(value: i64, parameter: &str) -> PyResult { + u32::try_from(value).map_err(|_| { + PyValueError::new_err(format!("'{parameter}' must be between 0 and {}", u32::MAX)) + }) +} + +/// Converts a Python int to the unsigned 64-bit integer a QUIC transport +/// field expects, naming the parameter in the error so a caller can tell +/// which argument was out of range. The bound in the message is `i64::MAX` +/// rather than `u64::MAX` because pyo3 extracts the argument as an `i64` +/// first: anything above that never reaches here, raising `OverflowError` +/// on the way in. Every one of these fields is a buffer or window size, so +/// the unreachable half of the range has no practical use. +fn u64_param(value: i64, parameter: &str) -> PyResult { + u64::try_from(value).map_err(|_| { + PyValueError::new_err(format!("'{parameter}' must be between 0 and {}", i64::MAX)) + }) +} + +/// Converts a Python int to the unsigned 16-bit integer `initial_mtu` expects. +fn u16_param(value: i64, parameter: &str) -> PyResult { + u16::try_from(value).map_err(|_| { + PyValueError::new_err(format!("'{parameter}' must be between 0 and {}", u16::MAX)) + }) +} + +/// Converts a Python int to a `u64` that also fits `quinn::VarInt` (max +/// `2^62 - 1`), which `max_concurrent_bidi_streams` and `receive_window` are +/// narrowed into when the connection is configured. A `u64` in range for +/// `u64::MAX` but not `VarInt::MAX` would otherwise only fail there, as an +/// opaque `RuntimeError` instead of a `ValueError` naming the argument. +fn varint_param(value: i64, parameter: &str) -> PyResult { + const VARINT_MAX: u64 = (1u64 << 62) - 1; + let value = u64_param(value, parameter)?; + if value > VARINT_MAX { + return Err(PyValueError::new_err(format!( + "'{parameter}' must be between 0 and {VARINT_MAX}" + ))); + } + Ok(value) +} + +/// Converts a Python int to the unsigned pointer-sized integer a WebSocket +/// framing field expects, naming the parameter in the error so a caller can +/// tell which argument was out of range. Extracted as an `i128` rather than an +/// `i64` so the whole `usize` range survives the way in: `max_write_buffer_size` +/// defaults to `usize::MAX`, which an `i64` parameter would refuse to take back +/// with an unnamed `OverflowError`, breaking `eval(repr(config))`. +fn usize_param(value: i128, parameter: &str) -> PyResult { + usize::try_from(value).map_err(|_| { + PyValueError::new_err(format!( + "'{parameter}' must be between 0 and {}", + usize::MAX + )) + }) +} + +/// What `IggyClient(...)` accepts: a bare `host:port`, a full `TcpConfig`, a +/// `QuicConfig` for the QUIC transport, an `HttpConfig` for the HTTP transport, +/// or a `WebSocketConfig` for the WebSocket transport. #[derive(FromPyObject)] pub enum PyClientConfig { #[pyo3(transparent)] - Config(TcpConfig), + Tcp(TcpConfig), + #[pyo3(transparent)] + Quic(QuicConfig), + #[pyo3(transparent)] + Http(HttpConfig), + #[pyo3(transparent)] + WebSocket(WebSocketConfig), #[pyo3(transparent, annotation = "str")] ServerAddress(String), } -impl_stub_type!(PyClientConfig = TcpConfig | String); +impl_stub_type!(PyClientConfig = TcpConfig | QuicConfig | HttpConfig | WebSocketConfig | String); + +#[cfg(test)] +mod tests { + use super::*; + + /// Mirrors the literal in `WebSocketFramingConfig::new`'s signature. + const DEFAULT_MAX_MESSAGE_SIZE: usize = 64 << 20; + + /// Mirrors the literal in `WebSocketFramingConfig::new`'s signature. + const DEFAULT_MAX_FRAME_SIZE: usize = 16 << 20; + + /// The signature defaults have to be literals for the generated stub to stay + /// valid Python, so nothing but this test stops them drifting from the SDK + /// (and so from tungstenite) on a dependency bump. + #[test] + fn defaults_should_match_the_sdk() { + let defaults = RustWebSocketFramingConfig::default(); + + assert_eq!( + defaults.max_message_size, + Some(DEFAULT_MAX_MESSAGE_SIZE), + "'max_message_size' drifted from the SDK, update the literal in \ + WebSocketFramingConfig::new's signature too" + ); + assert_eq!( + defaults.max_frame_size, + Some(DEFAULT_MAX_FRAME_SIZE), + "'max_frame_size' drifted from the SDK, update the literal in \ + WebSocketFramingConfig::new's signature too" + ); + assert!( + defaults.write_buffer_size.is_some(), + "'write_buffer_size' lost its default, so the write buffer invariant \ + check in WebSocketFramingConfig::new would stop running" + ); + assert!( + defaults.max_write_buffer_size.is_some(), + "'max_write_buffer_size' lost its default, so the write buffer \ + invariant check in WebSocketFramingConfig::new would stop running" + ); + } +} diff --git a/foreign/python/src/durability.rs b/foreign/python/src/durability.rs new file mode 100644 index 0000000000..9cbd1cb75f --- /dev/null +++ b/foreign/python/src/durability.rs @@ -0,0 +1,58 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use iggy::prelude::Durability as RustDurability; +use pyo3::{ + exceptions::PyTypeError, + prelude::*, + types::{PyDict, PyString}, +}; + +pub struct Durability(pub RustDurability); + +impl Durability { + pub fn register(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> { + let kwargs = PyDict::new(py); + kwargs.set_item("type", py.get_type::())?; + kwargs.set_item("module", "apache_iggy")?; + let members = [("REPLICATED", "replicated"), ("PERSISTED", "persisted")]; + let enumeration = py + .import("enum")? + .getattr("Enum")? + .call(("Durability", members), Some(&kwargs))?; + module.add("Durability", enumeration) + } +} + +impl TryFrom>> for Durability { + type Error = PyErr; + + fn try_from(value: Option<&Bound<'_, PyAny>>) -> PyResult { + let Some(value) = value else { + return Ok(Self(RustDurability::Replicated)); + }; + let class = value.py().import("apache_iggy")?.getattr("Durability")?; + if !value.is_instance(&class)? { + return Err(PyTypeError::new_err("Expected Durability")); + } + let token: String = value.getattr("value")?.extract()?; + token + .parse() + .map(Self) + .map_err(|_| PyTypeError::new_err("Invalid durability")) + } +} diff --git a/foreign/python/src/duration.rs b/foreign/python/src/duration.rs index 1d448ad254..776df6604d 100644 --- a/foreign/python/src/duration.rs +++ b/foreign/python/src/duration.rs @@ -40,6 +40,34 @@ pub fn iggy_duration_to_py_delta( duration.get_duration().into_pyobject(py) } +/// Converts a Python timedelta to milliseconds, for fields the Rust SDK +/// stores as a raw millisecond count rather than an `IggyDuration` (e.g. +/// QUIC's `keep_alive_interval`/`max_idle_timeout`). Anything finer than a +/// millisecond is rejected rather than truncated, so the getter always reads +/// back the duration that is actually in effect. Zero is accepted for both of +/// those fields as a magic value (disables the keep-alive, or falls back to +/// quinn's own default), so a non-zero duration below 1ms is rejected with its +/// own message rather than collapsing into it. +pub fn py_delta_to_millis(delta: &Py, parameter: &str) -> PyResult { + let duration = py_delta_to_iggy_duration(delta)?.get_duration(); + if !duration.is_zero() && duration.as_millis() == 0 { + return Err(PyValueError::new_err(format!( + "'{parameter}' is non-zero but rounds down to 0ms; use a duration of at least 1ms, or exactly zero" + ))); + } + if duration.subsec_nanos() % 1_000_000 != 0 { + return Err(PyValueError::new_err(format!( + "'{parameter}' must be a whole number of milliseconds; anything finer is dropped by the QUIC transport, which stores it as a millisecond count" + ))); + } + Ok(duration.as_millis() as u64) +} + +/// The inverse of `py_delta_to_millis`. +pub fn millis_to_py_delta(py: Python<'_>, millis: u64) -> PyResult> { + Duration::from_millis(millis).into_pyobject(py) +} + /// Renders a duration the way it would be written in Python, so that a `__repr__` /// built from it can be pasted back into a constructor. pub fn duration_repr(duration: IggyDuration) -> String { @@ -53,6 +81,11 @@ pub fn duration_repr(duration: IggyDuration) -> String { } } +/// The `duration_repr` equivalent for a raw millisecond count. +pub fn millis_repr(millis: u64) -> String { + duration_repr(IggyDuration::new(Duration::from_millis(millis))) +} + /// Converts a duration for parameters that pace a loop, where zero means an /// unthrottled loop rather than "disabled". pub fn reject_zero(duration: IggyDuration, parameter: &str) -> PyResult { diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs index 5f2e128264..6462b786d0 100644 --- a/foreign/python/src/lib.rs +++ b/foreign/python/src/lib.rs @@ -18,50 +18,81 @@ pub mod client; mod config; mod consumer; +mod durability; mod duration; mod identifier; mod options; +mod partitioning; mod permissions; +mod producer; mod receive_message; mod send_message; +mod stats; mod stream; mod topic; mod user; mod user_headers; use client::IggyClient; -use config::{AutoLogin, TcpConfig, TcpReconnectionConfig}; +use config::{ + AutoLogin, HttpConfig, QuicConfig, QuicReconnectionConfig, TcpConfig, TcpReconnectionConfig, + WebSocketConfig, WebSocketFramingConfig, WebSocketReconnectionConfig, +}; use consumer::{ AutoCommit, AutoCommitAfter, AutoCommitWhen, Consumer, ConsumerGroup, ConsumerGroupDetails, ConsumerGroupMember, IggyConsumer, ReceiveMessageIterator, }; use options::OptionSpec; +use partitioning::Partitioning; use permissions::{GlobalPermissions, Permissions, StreamPermissions, TopicPermissions}; +use producer::{ + BackgroundProducerConfig, BackpressureMode, DirectProducerConfig, IggyProducer, + ProducerSendError, ProducerSharding, +}; use pyo3::prelude::*; use receive_message::{PollingStrategy, ReceiveMessage}; use send_message::{SendMessage, SendMessagesConfirmation, SendMessagesResponse}; -use stream::StreamDetails; +use stats::{CacheMetrics, CacheMetricsKey, Stats}; +use stream::{Stream, StreamDetails}; use topic::{IggyExpiry, MaxTopicSize, Partition, Topic, TopicDetails}; use user::{UserInfo, UserInfoDetails, UserStatus}; use user_headers::{HeaderKey, HeaderValue, UserHeaders}; /// Python client for Apache Iggy, the persistent message streaming platform. #[pymodule] -fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { +fn apache_iggy(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { + durability::Durability::register(py, m)?; m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/foreign/python/src/options.rs b/foreign/python/src/options.rs index c3eee8cdd6..a0d4fa8095 100644 --- a/foreign/python/src/options.rs +++ b/foreign/python/src/options.rs @@ -62,7 +62,9 @@ impl OptionSpec { /// /// The same type message user headers use, so the usual accessors read it; /// options ride that codec. + #[gen_stub(override_return_type(type_repr = "HeaderValue | None"))] #[getter] + #[gen_stub(override_return_type(type_repr = "HeaderValue | None"))] pub fn default_value<'a>(&self, py: Python<'a>) -> PyResult>> { if self.inner.default_value.is_empty() { return Ok(None); diff --git a/foreign/python/src/partitioning.rs b/foreign/python/src/partitioning.rs new file mode 100644 index 0000000000..bb345cf511 --- /dev/null +++ b/foreign/python/src/partitioning.rs @@ -0,0 +1,122 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use std::sync::Arc; + +use iggy::prelude::Partitioning as RustPartitioning; +use pyo3::{exceptions::PyValueError, prelude::*, types::PyBytes}; +use pyo3_stub_gen::{ + derive::{gen_stub_pyclass, gen_stub_pymethods}, + impl_stub_type, +}; + +/// Defines how a batch of messages is assigned to a topic partition. +#[derive(Clone)] +#[pyclass(from_py_object)] +#[gen_stub_pyclass] +pub struct Partitioning { + pub(crate) inner: Arc, +} + +#[gen_stub_pymethods] +#[pymethods] +impl Partitioning { + /// Routes the batch to one partition selected by round-robin. + #[staticmethod] + pub fn balanced() -> Self { + Self { + inner: Arc::new(RustPartitioning::balanced()), + } + } + + /// Routes the batch to the specified partition. + /// + /// `partition_id` must be between 0 and `2**32 - 1`. The topic must contain + /// that partition when the batch is sent. + /// + /// Raises: + /// TypeError: If `partition_id` is not an integer. + /// OverflowError: If `partition_id` is outside the supported unsigned + /// 32-bit range. + #[staticmethod] + pub fn partition_id(partition_id: u32) -> Self { + Self { + inner: Arc::new(RustPartitioning::partition_id(partition_id)), + } + } + + /// Routes the batch to one partition selected by hashing `key`. + /// + /// `key` may be `str` or `bytes`. Strings are encoded as UTF-8; the encoded + /// key must contain between 1 and 255 bytes. + /// + /// Raises: + /// ValueError: If the encoded key is empty or exceeds 255 bytes. + /// TypeError: If `key` is not `str` or `bytes`. + #[staticmethod] + pub fn messages_key(py: Python<'_>, key: PyMessagesKey) -> PyResult { + let key = match key { + PyMessagesKey::String(key) => key.into_bytes(), + PyMessagesKey::Bytes(key) => key.extract::>(py)?, + }; + let inner = RustPartitioning::messages_key(&key) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + Ok(Self { + inner: Arc::new(inner), + }) + } +} + +#[derive(FromPyObject)] +pub enum PyMessagesKey { + #[pyo3(transparent, annotation = "str")] + String(String), + #[pyo3(transparent, annotation = "bytes")] + Bytes(Py), +} +impl_stub_type!(PyMessagesKey = String | PyBytes); + +#[derive(FromPyObject)] +pub(crate) enum PyPartitioning { + #[pyo3(transparent, annotation = "Partitioning")] + Strategy(Partitioning), + #[pyo3(transparent, annotation = "int")] + PartitionId(u32), +} +impl_stub_type!(PyPartitioning = Partitioning | isize); + +impl From for RustPartitioning { + fn from(partitioning: PyPartitioning) -> Self { + match partitioning { + PyPartitioning::Strategy(partitioning) => partitioning.inner.as_ref().clone(), + PyPartitioning::PartitionId(partition_id) => Self::partition_id(partition_id), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clone_shares_the_rust_partitioning() { + let partitioning = Partitioning::balanced(); + let cloned = partitioning.clone(); + + assert!(Arc::ptr_eq(&partitioning.inner, &cloned.inner)); + } +} diff --git a/foreign/python/src/producer.rs b/foreign/python/src/producer.rs new file mode 100644 index 0000000000..c51e34d2fa --- /dev/null +++ b/foreign/python/src/producer.rs @@ -0,0 +1,1307 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use std::{str::FromStr, sync::Arc}; + +use iggy::clients::producer_config::BackpressureMode as RustBackpressureMode; +use iggy::prelude::{ + BackgroundConfig as RustBackgroundConfig, BalancedSharding, DirectConfig as RustDirectConfig, + Identifier, IggyByteSize, IggyDuration, IggyError, IggyMessage as RustIggyMessage, + IggyProducer as RustIggyProducer, OrderedSharding, + SendMessagesConfirmationResponse as RustSendMessagesConfirmationResponse, Sharding, +}; +use pyo3::IntoPyObjectExt; +use pyo3::conversion::FromPyObject; +use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyAny, PyDelta, PyInt, PyList, PyString}; +use pyo3_async_runtimes::tokio::future_into_py; +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_enum, gen_stub_pymethods}; +use pyo3_stub_gen::{PyStubType, TypeInfo}; +use tokio::sync::{RwLock, Semaphore}; + +use crate::duration::{duration_repr, iggy_duration_to_py_delta, py_delta_to_iggy_duration}; +use crate::partitioning::Partitioning; +use crate::send_message::{SendMessage, SendMessagesConfirmation, SendMessagesResponse}; + +const DEFAULT_BACKGROUND_NUM_SHARDS: usize = 1; +const DEFAULT_BACKGROUND_BATCH_SIZE: usize = 1024 * 1024; +const DEFAULT_BACKGROUND_BATCH_LENGTH: usize = 1_000; +const DEFAULT_BACKGROUND_MAX_BUFFER_SIZE: u64 = 32 * 1024 * 1024; +const DEFAULT_BACKGROUND_MAX_IN_FLIGHT: usize = 1; + +/// Configuration for a producer that sends from the calling task. +#[derive(Clone)] +#[gen_stub_pyclass] +#[pyclass(frozen, from_py_object)] +pub struct DirectProducerConfig { + pub(crate) inner: RustDirectConfig, +} + +impl Default for DirectProducerConfig { + fn default() -> Self { + Self { + inner: RustDirectConfig::builder().build(), + } + } +} + +impl From<&DirectProducerConfig> for RustDirectConfig { + fn from(config: &DirectProducerConfig) -> Self { + config.inner.clone() + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl DirectProducerConfig { + /// Constructs direct-producer batching and pacing configuration. + #[new] + #[pyo3(signature = (*, batch_length=1000, linger_time=DefaultDuration::default()))] + fn new(batch_length: i64, linger_time: DefaultDuration) -> PyResult { + let batch_length = u32_param(batch_length, "batch_length")?; + let linger_time = linger_time.resolve(IggyDuration::from(0))?; + if linger_time.get_duration().as_micros() > u128::from(u64::MAX) { + return Err(PyValueError::new_err(format!( + "'linger_time' must not exceed {} microseconds", + u64::MAX + ))); + } + Ok(Self { + inner: RustDirectConfig::builder() + .batch_length(batch_length) + .linger_time(linger_time) + .build(), + }) + } + + /// Maximum number of messages sent in one request. + /// A value of zero uses the internal limit of 1,000,000 messages. + #[getter] + fn batch_length(&self) -> u32 { + self.inner.batch_length + } + + /// Minimum gap requested between sequential direct sends. + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn linger_time<'py>(&self, py: Python<'py>) -> PyResult> { + iggy_duration_to_py_delta(py, self.inner.linger_time) + } + + fn __repr__(&self) -> String { + format!( + "DirectProducerConfig(batch_length={}, linger_time={})", + self.inner.batch_length, + duration_repr(self.inner.linger_time) + ) + } +} + +/// How a background producer distributes sends among its workers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[gen_stub_pyclass_enum] +#[pyclass(eq, from_py_object, rename_all = "UPPERCASE")] +pub enum ProducerSharding { + Ordered, + Balanced, +} + +impl From for Box { + fn from(sharding: ProducerSharding) -> Self { + match sharding { + ProducerSharding::Ordered => Box::new(OrderedSharding), + ProducerSharding::Balanced => Box::new(BalancedSharding::default()), + } + } +} + +/// What a background send does when the producer buffer is full. +#[derive(Debug, Clone, PartialEq, Eq)] +#[gen_stub_pyclass] +#[pyclass(eq, frozen, from_py_object)] +pub struct BackpressureMode { + kind: BackpressureKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum BackpressureKind { + Block, + BlockWithTimeout(IggyDuration), + FailImmediately, +} + +impl From<&BackpressureMode> for RustBackpressureMode { + fn from(mode: &BackpressureMode) -> Self { + match mode.kind { + BackpressureKind::Block => Self::Block, + BackpressureKind::BlockWithTimeout(timeout) => Self::BlockWithTimeout(timeout), + BackpressureKind::FailImmediately => Self::FailImmediately, + } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl BackpressureMode { + /// Wait indefinitely for buffer capacity. + #[staticmethod] + fn block() -> Self { + Self { + kind: BackpressureKind::Block, + } + } + + /// Wait up to `timeout` for buffer capacity. + #[staticmethod] + fn block_with_timeout( + #[gen_stub(override_type(type_repr = "datetime.timedelta", imports=("datetime")))] + timeout: Py, + ) -> PyResult { + Ok(Self { + kind: BackpressureKind::BlockWithTimeout(py_delta_to_iggy_duration(&timeout)?), + }) + } + + /// Fail immediately when the producer buffer is full. + #[staticmethod] + fn fail_immediately() -> Self { + Self { + kind: BackpressureKind::FailImmediately, + } + } + + /// The configured timeout, or `None` for modes without one. + #[gen_stub(override_return_type(type_repr = "datetime.timedelta | None", imports=("datetime")))] + #[getter] + fn timeout<'py>(&self, py: Python<'py>) -> PyResult>> { + match self.kind { + BackpressureKind::BlockWithTimeout(timeout) => { + iggy_duration_to_py_delta(py, timeout).map(Some) + } + BackpressureKind::Block | BackpressureKind::FailImmediately => Ok(None), + } + } + + fn __repr__(&self) -> String { + match self.kind { + BackpressureKind::Block => "BackpressureMode.block()".to_owned(), + BackpressureKind::BlockWithTimeout(timeout) => format!( + "BackpressureMode.block_with_timeout({})", + duration_repr(timeout) + ), + BackpressureKind::FailImmediately => "BackpressureMode.fail_immediately()".to_owned(), + } + } +} + +/// Immutable configuration for a producer that queues sends on background workers. +/// +/// For detailed background-producer semantics, see +/// https://iggy.apache.org/docs/sdk/rust/high-level-sdk/. +#[derive(Clone)] +#[gen_stub_pyclass] +#[pyclass(frozen, from_py_object)] +pub struct BackgroundProducerConfig { + num_shards: usize, + linger_time: IggyDuration, + batch_size: usize, + batch_length: usize, + max_buffer_size: IggyByteSize, + failure_mode: BackpressureMode, + max_in_flight: usize, + sharding: ProducerSharding, +} + +impl Default for BackgroundProducerConfig { + fn default() -> Self { + Self { + num_shards: DEFAULT_BACKGROUND_NUM_SHARDS, + linger_time: IggyDuration::from(1_000), + batch_size: DEFAULT_BACKGROUND_BATCH_SIZE, + batch_length: DEFAULT_BACKGROUND_BATCH_LENGTH, + max_buffer_size: IggyByteSize::from(DEFAULT_BACKGROUND_MAX_BUFFER_SIZE), + failure_mode: BackpressureMode::block(), + max_in_flight: DEFAULT_BACKGROUND_MAX_IN_FLIGHT, + sharding: ProducerSharding::Ordered, + } + } +} + +impl TryFrom<&BackgroundProducerConfig> for RustBackgroundConfig { + type Error = PyErr; + + fn try_from(config: &BackgroundProducerConfig) -> PyResult { + let max_buffer_size = config.max_buffer_size.as_bytes_u64(); + if max_buffer_size != 0 && max_buffer_size > Semaphore::MAX_PERMITS as u64 { + return Err(PyValueError::new_err(format!( + "'max_buffer_size' must not exceed {}", + Semaphore::MAX_PERMITS + ))); + } + if config.max_in_flight != 0 && config.max_in_flight > Semaphore::MAX_PERMITS { + return Err(PyValueError::new_err(format!( + "'max_in_flight' must not exceed {}", + Semaphore::MAX_PERMITS + ))); + } + + Ok(RustBackgroundConfig::builder() + .num_shards(config.num_shards) + .linger_time(config.linger_time) + .batch_size(config.batch_size) + .batch_length(config.batch_length) + .max_buffer_size(config.max_buffer_size) + .failure_mode((&config.failure_mode).into()) + .max_in_flight(config.max_in_flight) + .sharding(config.sharding.into()) + .build()) + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl BackgroundProducerConfig { + /// Constructs background batching, capacity, backpressure, and sharding configuration. + #[new] + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = ( + *, + num_shards=1, + linger_time=DefaultDuration::one_millisecond(), + batch_size=1048576, + batch_length=1000, + max_buffer_size=33554432, + failure_mode=DefaultBackpressureMode::default(), + max_in_flight=1, + sharding=DefaultProducerSharding::default(), + ))] + fn new( + num_shards: i128, + linger_time: DefaultDuration, + batch_size: i128, + batch_length: i128, + max_buffer_size: i128, + failure_mode: DefaultBackpressureMode, + max_in_flight: i128, + sharding: DefaultProducerSharding, + ) -> PyResult { + let linger_time = linger_time.resolve(IggyDuration::from(1_000))?; + Ok(Self { + num_shards: usize_param(num_shards, "num_shards")?, + linger_time, + batch_size: usize_param(batch_size, "batch_size")?, + batch_length: usize_param(batch_length, "batch_length")?, + max_buffer_size: IggyByteSize::from(u64_param(max_buffer_size, "max_buffer_size")?), + failure_mode: failure_mode.resolve(), + max_in_flight: usize_param(max_in_flight, "max_in_flight")?, + sharding: sharding.resolve(), + }) + } + + /// Number of background worker shards, each with its own queue. + /// A value of zero is treated as one shard. + #[getter] + fn num_shards(&self) -> usize { + self.num_shards + } + + /// Maximum time a worker holds a non-empty buffer before flushing it. + /// A zero duration flushes as soon as the worker receives a send. + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + #[getter] + fn linger_time<'py>(&self, py: Python<'py>) -> PyResult> { + iggy_duration_to_py_delta(py, self.linger_time) + } + + /// Per-worker flush threshold in buffered bytes. + /// A value of zero disables this threshold. + #[getter] + fn batch_size(&self) -> usize { + self.batch_size + } + + /// Per-worker flush threshold in queued sends, not individual messages. + /// A value of zero disables this threshold. + #[getter] + fn batch_length(&self) -> usize { + self.batch_length + } + + /// Maximum bytes buffered or in flight across all worker shards. + /// A value of zero makes the byte budget unlimited. + #[getter] + fn max_buffer_size(&self) -> u64 { + self.max_buffer_size.as_bytes_u64() + } + + /// Behavior when `max_buffer_size` is exhausted. + #[getter] + fn failure_mode(&self) -> BackpressureMode { + self.failure_mode.clone() + } + + /// Maximum number of requests written concurrently across all workers. + /// A value of zero uses the runtime's maximum semaphore permit count. + #[getter] + fn max_in_flight(&self) -> usize { + self.max_in_flight + } + + /// Strategy used to assign each send to a worker shard. + /// Ordered sharding preserves per-destination dispatch order, while balanced + /// sharding distributes sends round-robin and may reorder them. + #[getter] + fn sharding(&self) -> ProducerSharding { + self.sharding + } + + fn __repr__(&self) -> String { + let sharding = match self.sharding { + ProducerSharding::Ordered => "ProducerSharding.ORDERED", + ProducerSharding::Balanced => "ProducerSharding.BALANCED", + }; + format!( + "BackgroundProducerConfig(num_shards={}, linger_time={}, batch_size={}, batch_length={}, max_buffer_size={}, failure_mode={}, max_in_flight={}, sharding={sharding})", + self.num_shards, + duration_repr(self.linger_time), + self.batch_size, + self.batch_length, + self.max_buffer_size.as_bytes_u64(), + self.failure_mode.__repr__(), + self.max_in_flight, + ) + } +} + +/// A direct producer error that preserves partial-send recovery state. +#[gen_stub_pyclass] +#[pyclass(frozen, extends=PyRuntimeError)] +pub struct ProducerSendError { + cause: String, + failed: Arc>, + committed: Arc>, +} + +#[gen_stub_pymethods] +#[pymethods] +impl ProducerSendError { + /// The underlying Iggy error message. + #[getter] + fn cause(&self) -> &str { + &self.cause + } + + /// Messages without a usable confirmation after the failure. + /// An encryptor can leave these messages encrypted, so do not submit them + /// to the same producer without restoring their original payloads. + #[getter] + fn failed(&self) -> Vec { + self.failed + .iter() + .map(SendMessage::clone_from_rust) + .collect() + } + + /// Confirmations returned for chunks committed before the failure. + #[getter] + fn committed(&self) -> Vec { + self.committed + .iter() + .map(SendMessagesConfirmation::from) + .collect() + } +} + +impl ProducerSendError { + fn new_err( + cause: IggyError, + failed: Arc>, + committed: Arc>, + ) -> PyErr { + Python::attach(|py| { + let cause = cause.to_string(); + let message = format!("Producer send failed: {cause}"); + let cause_error = PyRuntimeError::new_err(cause.clone()); + let instance = match Bound::new( + py, + Self { + cause, + failed, + committed, + }, + ) { + Ok(instance) => instance, + Err(error) => return error, + }; + if let Err(error) = instance.setattr("args", (message,)) { + return error; + } + let error = PyErr::from_value(instance.into_any()); + error.set_cause(py, Some(cause_error)); + error + }) + } +} + +/// Python port of the Rust high-level producer API, bound to one stream and topic. +/// +/// For detailed producer semantics, see +/// https://iggy.apache.org/docs/sdk/rust/high-level-sdk/. +/// +/// Direct sends complete after the server responds and contain commit confirmations. +/// Background sends complete once accepted by a worker and contain no confirmations. +/// Always use the async context manager or call `shutdown()` explicitly; dropping a +/// background producer can lose accepted buffered messages. +#[derive(Clone)] +#[gen_stub_pyclass] +#[pyclass(from_py_object)] +pub struct IggyProducer { + inner: Arc>>, +} + +impl IggyProducer { + pub(crate) fn new(producer: RustIggyProducer) -> Self { + Self { + inner: Arc::new(RwLock::new(Some(producer))), + } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl IggyProducer { + /// Sends a batch to the producer's bound stream and topic. + /// In background mode, success means accepted into the dispatcher and the + /// returned confirmation list is empty. + #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[SendMessagesResponse]", imports=("collections.abc")))] + fn send<'py>( + &self, + py: Python<'py>, + #[gen_stub(override_type(type_repr = "list[SendMessage]"))] messages: &Bound<'_, PyList>, + ) -> PyResult> { + let messages = extract_messages(messages)?; + let inner = self.inner.clone(); + future_into_py(py, async move { + // Every send keeps a read guard for its full future. Concurrent sends + // remain possible while shutdown cannot consume an active producer. + let producer = inner.read().await; + let producer = producer + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("producer has been shut down"))?; + producer + .send(messages) + .await + .map(SendMessagesResponse::from) + .map_err(to_send_error) + }) + } + + /// Sends one message to the producer's bound stream and topic. + /// It has the same mode-dependent completion semantics as `send()`. + #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[SendMessagesResponse]", imports=("collections.abc")))] + fn send_one<'py>( + &self, + py: Python<'py>, + message: PyRef<'_, SendMessage>, + ) -> PyResult> { + let message = clone_rust_message(&message); + let inner = self.inner.clone(); + future_into_py(py, async move { + let producer = inner.read().await; + let producer = producer + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("producer has been shut down"))?; + producer + .send_one(message) + .await + .map(SendMessagesResponse::from) + .map_err(to_send_error) + }) + } + + /// Sends a batch with an optional per-call partitioning override. + /// It has the same mode-dependent completion semantics as `send()`. + #[pyo3(signature = (messages, partitioning=None))] + #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[SendMessagesResponse]", imports=("collections.abc")))] + fn send_with_partitioning<'py>( + &self, + py: Python<'py>, + #[gen_stub(override_type(type_repr = "list[SendMessage]"))] messages: &Bound<'_, PyList>, + #[gen_stub(override_type(type_repr = "Partitioning | None"))] partitioning: Option< + &Partitioning, + >, + ) -> PyResult> { + let messages = extract_messages(messages)?; + let partitioning = partitioning.map(|value| value.inner.clone()); + let inner = self.inner.clone(); + future_into_py(py, async move { + let producer = inner.read().await; + let producer = producer + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("producer has been shut down"))?; + producer + .send_with_partitioning(messages, partitioning) + .await + .map(SendMessagesResponse::from) + .map_err(to_send_error) + }) + } + + /// Sends a batch to another existing stream and topic. + /// It has the same mode-dependent completion semantics as `send()` and does + /// not create the alternate destination. + #[pyo3(signature = (stream, topic, messages, partitioning=None))] + #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[SendMessagesResponse]", imports=("collections.abc")))] + fn send_to<'py>( + &self, + py: Python<'py>, + #[gen_stub(override_type(type_repr = "builtins.str | builtins.int"))] stream: &Bound< + '_, + PyAny, + >, + #[gen_stub(override_type(type_repr = "builtins.str | builtins.int"))] topic: &Bound< + '_, + PyAny, + >, + #[gen_stub(override_type(type_repr = "list[SendMessage]"))] messages: &Bound<'_, PyList>, + #[gen_stub(override_type(type_repr = "Partitioning | None"))] partitioning: Option< + &Partitioning, + >, + ) -> PyResult> { + let stream = Arc::new(extract_send_to_identifier(stream, "stream")?); + let topic = Arc::new(extract_send_to_identifier(topic, "topic")?); + let messages = extract_messages(messages)?; + let partitioning = partitioning.map(|value| value.inner.clone()); + let inner = self.inner.clone(); + future_into_py(py, async move { + let producer = inner.read().await; + let producer = producer + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("producer has been shut down"))?; + producer + .send_to(stream, topic, messages, partitioning) + .await + .map(SendMessagesResponse::from) + .map_err(to_send_error) + }) + } + + #[gen_stub(skip)] + fn _is_send_active(&self) -> bool { + self.inner.try_write().is_err() + } + + /// Waits for active sends and closes the producer. Repeated calls are safe. + /// Background shutdown flushes every accepted buffered message before returning. + #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[None]", imports=("collections.abc")))] + fn shutdown<'py>(&self, py: Python<'py>) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + shutdown(inner).await?; + Ok(Python::attach(|py| py.None())) + }) + } + + #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[IggyProducer]", imports=("collections.abc")))] + fn __aenter__<'py>(this: Py, py: Python<'py>) -> PyResult> { + future_into_py(py, async move { Ok(this) }) + } + + #[gen_stub(override_return_type(type_repr = "collections.abc.Awaitable[builtins.bool]", imports=("collections.abc")))] + fn __aexit__<'py>( + &self, + py: Python<'py>, + _exc_type: &Bound<'_, PyAny>, + _exc_value: &Bound<'_, PyAny>, + _traceback: &Bound<'_, PyAny>, + ) -> PyResult> { + let inner = self.inner.clone(); + future_into_py(py, async move { + shutdown(inner).await?; + Ok(false) + }) + } +} + +#[derive(Clone, FromPyObject)] +pub(crate) enum ProducerMode { + #[pyo3(transparent)] + Direct(DirectProducerConfig), + #[pyo3(transparent)] + Background(BackgroundProducerConfig), +} + +impl Default for ProducerMode { + fn default() -> Self { + Self::Direct(DirectProducerConfig::default()) + } +} + +#[derive(FromPyObject)] +#[pyo3(transparent)] +struct DefaultBackpressureMode(BackpressureMode); + +impl Default for DefaultBackpressureMode { + fn default() -> Self { + Self(BackpressureMode::block()) + } +} + +impl DefaultBackpressureMode { + fn resolve(self) -> BackpressureMode { + self.0 + } +} + +impl PyStubType for DefaultBackpressureMode { + fn type_output() -> TypeInfo { + BackpressureMode::type_output() + } + + fn type_input() -> TypeInfo { + BackpressureMode::type_input() + } +} + +impl<'py> IntoPyObject<'py> for DefaultBackpressureMode { + type Target = PyAny; + type Output = Bound<'py, PyAny>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> Result { + Ok(opaque_stub_default(py)) + } +} + +#[derive(FromPyObject)] +#[pyo3(transparent)] +struct DefaultProducerSharding(ProducerSharding); + +impl Default for DefaultProducerSharding { + fn default() -> Self { + Self(ProducerSharding::Ordered) + } +} + +impl DefaultProducerSharding { + fn resolve(self) -> ProducerSharding { + self.0 + } +} + +impl PyStubType for DefaultProducerSharding { + fn type_output() -> TypeInfo { + ProducerSharding::type_output() + } + + fn type_input() -> TypeInfo { + ProducerSharding::type_input() + } +} + +impl<'py> IntoPyObject<'py> for DefaultProducerSharding { + type Target = PyAny; + type Output = Bound<'py, PyAny>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> Result { + Ok(opaque_stub_default(py)) + } +} + +#[derive(Default)] +pub(crate) enum RetryInterval { + #[default] + Omitted, + Disabled, + Duration(Py), +} + +impl<'a, 'py> FromPyObject<'a, 'py> for RetryInterval { + type Error = PyErr; + + fn extract(object: Borrowed<'a, 'py, PyAny>) -> PyResult { + if object.is_none() { + Ok(Self::Disabled) + } else { + Ok(Self::Duration(object.extract::>()?)) + } + } +} + +impl PyStubType for RetryInterval { + fn type_output() -> TypeInfo { + ::type_output() | TypeInfo::none() + } + + fn type_input() -> TypeInfo { + ::type_input() | TypeInfo::none() + } +} + +impl<'py> IntoPyObject<'py> for RetryInterval { + type Target = PyAny; + type Output = Bound<'py, PyAny>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> Result { + match self { + Self::Omitted => std::time::Duration::from_secs(1).into_bound_py_any(py), + Self::Disabled => Ok(py.None().into_bound(py)), + Self::Duration(duration) => Ok(duration.into_bound(py).into_any()), + } + } +} + +impl RetryInterval { + pub(crate) fn resolve(self) -> PyResult> { + match self { + Self::Omitted => Ok(Some(iggy::prelude::NonZeroIggyDuration::ONE_SECOND)), + Self::Disabled => Ok(None), + Self::Duration(duration) => py_delta_to_iggy_duration(&duration).and_then(|duration| { + iggy::prelude::NonZeroIggyDuration::try_from(duration) + .map(Some) + .map_err(|_| PyValueError::new_err("'send_retry_interval' must not be zero")) + }), + } + } +} + +#[derive(Default)] +enum DefaultDuration { + #[default] + Zero, + OneMillisecond, + Value(Py), +} + +impl DefaultDuration { + fn one_millisecond() -> Self { + Self::OneMillisecond + } +} + +impl PyStubType for DefaultDuration { + fn type_output() -> TypeInfo { + timedelta_type_info() + } + + fn type_input() -> TypeInfo { + timedelta_type_info() + } +} + +impl<'py> IntoPyObject<'py> for DefaultDuration { + type Target = PyDelta; + type Output = Bound<'py, PyDelta>; + type Error = PyErr; + + fn into_pyobject(self, py: Python<'py>) -> Result { + match self { + Self::Zero => std::time::Duration::ZERO.into_pyobject(py), + Self::OneMillisecond => std::time::Duration::from_millis(1).into_pyobject(py), + Self::Value(duration) => Ok(duration.into_bound(py)), + } + } +} + +impl<'a, 'py> FromPyObject<'a, 'py> for DefaultDuration { + type Error = PyErr; + + fn extract(object: Borrowed<'a, 'py, PyAny>) -> PyResult { + Ok(Self::Value(object.extract::>()?)) + } +} + +impl DefaultDuration { + fn resolve(self, default: IggyDuration) -> PyResult { + match self { + Self::Value(duration) => py_delta_to_iggy_duration(&duration), + Self::Zero | Self::OneMillisecond => Ok(default), + } + } +} + +fn timedelta_type_info() -> TypeInfo { + let mut type_info = ::type_input(); + type_info.source_module = None; + type_info +} + +fn opaque_stub_default(py: Python<'_>) -> Bound<'_, PyAny> { + // The stub generator renders objects without a stable Python expression as + // `...`. An actual Ellipsis is rendered as `Ellipsis`, which type checkers + // reject as a default for these public configuration types. + py.None().into_bound(py).get_type().into_any() +} + +async fn shutdown(inner: Arc>>) -> PyResult<()> { + // Exclusive access must span consuming shutdown so another close waits for + // completion and no send can observe a producer being closed underneath it. + let mut producer = inner.write().await; + if let Some(producer) = producer.take() { + producer.shutdown().await; + } + Ok(()) +} + +fn extract_messages(messages: &Bound<'_, PyList>) -> PyResult> { + messages + .iter() + .map(|item| { + let message = item.extract::>()?; + Ok(clone_rust_message(&message)) + }) + .collect() +} + +fn extract_send_to_identifier(value: &Bound<'_, PyAny>, parameter: &str) -> PyResult { + if let Ok(value) = value.cast::() { + return Identifier::from_str(value.to_str()?) + .map_err(|error| PyValueError::new_err(error.to_string())); + } + if value.is_instance_of::() { + let value = value.extract::()?; + return Identifier::numeric(value) + .map_err(|error| PyValueError::new_err(error.to_string())); + } + Err(PyTypeError::new_err(format!( + "'{parameter}' must be a string or an integer" + ))) +} + +fn clone_rust_message(message: &SendMessage) -> RustIggyMessage { + message.clone().inner +} + +fn to_send_error(error: IggyError) -> PyErr { + match error { + IggyError::ProducerSendFailed { + cause, + failed, + committed, + .. + } => ProducerSendError::new_err(*cause, failed, committed), + error => PyRuntimeError::new_err(error.to_string()), + } +} + +pub(crate) fn u32_param(value: i64, parameter: &str) -> PyResult { + u32::try_from(value).map_err(|_| { + PyValueError::new_err(format!("'{parameter}' must be between 0 and {}", u32::MAX)) + }) +} + +fn usize_param(value: i128, parameter: &str) -> PyResult { + usize::try_from(value).map_err(|_| { + PyValueError::new_err(format!( + "'{parameter}' must be between 0 and {}", + usize::MAX + )) + }) +} + +fn u64_param(value: i128, parameter: &str) -> PyResult { + u64::try_from(value).map_err(|_| { + PyValueError::new_err(format!("'{parameter}' must be between 0 and {}", u64::MAX)) + }) +} + +#[cfg(test)] +mod tests { + use std::future::Future; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + use iggy::clients::producer::ProducerCoreBackend; + use iggy::clients::producer_dispatcher::ProducerDispatcher; + use pyo3::exceptions::{PyOverflowError, PyRuntimeError, PyTypeError, PyValueError}; + + use super::*; + + #[derive(Debug)] + struct ConcurrencyTrackingBackend { + active: Arc, + maximum: Arc, + } + + impl ProducerCoreBackend for ConcurrencyTrackingBackend { + fn send_internal( + &self, + _stream: &Identifier, + _topic: &Identifier, + _messages: Vec, + _partitioning: Option>, + ) -> impl Future> + Send + { + let active = self.active.clone(); + let maximum = self.maximum.clone(); + async move { + let active_now = active.fetch_add(1, Ordering::SeqCst) + 1; + maximum.fetch_max(active_now, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(50)).await; + active.fetch_sub(1, Ordering::SeqCst); + Ok(iggy::prelude::SendMessagesResponse { + confirmations: Vec::new(), + }) + } + } + } + + #[test] + fn direct_defaults_match_rust() { + let python = DirectProducerConfig::default(); + let rust = RustDirectConfig::builder().build(); + + assert_eq!(python.inner.batch_length, rust.batch_length); + assert_eq!(python.inner.linger_time, rust.linger_time); + } + + #[test] + fn direct_linger_rejects_values_above_u64_microseconds() { + Python::initialize(); + Python::attach(|py| { + let duration = PyDelta::new(py, 999_999_999, 0, 0, false).unwrap().unbind(); + let error = DirectProducerConfig::new(1_000, DefaultDuration::Value(duration)) + .err() + .unwrap(); + + assert!(error.is_instance_of::(py)); + assert!(error.to_string().contains("linger_time")); + }); + } + + #[test] + fn producer_send_error_preserves_recovery_state() { + Python::initialize(); + Python::attach(|py| { + let cause = IggyError::CannotSendMessagesDueToClientDisconnection; + let cause_message = cause.to_string(); + let error = to_send_error(IggyError::ProducerSendFailed { + cause: Box::new(cause), + failed: Arc::new(vec![RustIggyMessage::from_str("failed").unwrap()]), + committed: Arc::new(vec![RustSendMessagesConfirmationResponse { + stream_id: 1, + topic_id: 2, + partition_id: 3, + base_offset: 4, + }]), + stream_name: "stream".to_owned(), + topic_name: "topic".to_owned(), + }); + + assert!(error.is_instance_of::(py)); + assert!(error.is_instance_of::(py)); + assert_eq!( + error + .value(py) + .getattr("cause") + .unwrap() + .extract::() + .unwrap(), + cause_message + ); + + let failed = error + .value(py) + .getattr("failed") + .unwrap() + .extract::>>() + .unwrap(); + assert_eq!(failed.len(), 1); + assert_eq!(failed[0].borrow(py).inner.payload.as_ref(), b"failed"); + + let committed = error + .value(py) + .getattr("committed") + .unwrap() + .extract::>>() + .unwrap(); + assert_eq!(committed.len(), 1); + assert_eq!(committed[0].borrow(py).inner.base_offset, 4); + + let source = error.cause(py).unwrap(); + assert!(source.is_instance_of::(py)); + assert_eq!( + source.value(py).str().unwrap().to_str().unwrap(), + cause_message + ); + }); + } + + #[test] + fn background_defaults_match_rust() { + let python = BackgroundProducerConfig::default(); + let rust = RustBackgroundConfig::builder().build(); + + assert_eq!(python.num_shards, rust.num_shards); + assert_eq!(python.linger_time, rust.linger_time); + assert_eq!(python.batch_size, rust.batch_size); + assert_eq!(python.batch_length, rust.batch_length); + assert_eq!(python.max_buffer_size, rust.max_buffer_size); + assert!(matches!(rust.failure_mode, RustBackpressureMode::Block)); + assert_eq!(python.max_in_flight, rust.max_in_flight); + assert!(matches!(python.sharding, ProducerSharding::Ordered)); + assert_eq!(format!("{:?}", rust.sharding), "OrderedSharding"); + } + + #[test] + fn background_configuration_converts_every_field_to_rust() { + let python = BackgroundProducerConfig { + num_shards: 4, + linger_time: IggyDuration::from(2_000), + batch_size: 2_048, + batch_length: 32, + max_buffer_size: IggyByteSize::from(8_192), + failure_mode: BackpressureMode::fail_immediately(), + max_in_flight: 3, + sharding: ProducerSharding::Balanced, + }; + let rust = RustBackgroundConfig::try_from(&python).unwrap(); + + assert_eq!(rust.num_shards, 4); + assert_eq!(rust.linger_time, IggyDuration::from(2_000)); + assert_eq!(rust.batch_size, 2_048); + assert_eq!(rust.batch_length, 32); + assert_eq!(rust.max_buffer_size.as_bytes_u64(), 8_192); + assert!(matches!( + rust.failure_mode, + RustBackpressureMode::FailImmediately + )); + assert_eq!(rust.max_in_flight, 3); + assert_eq!( + format!("{:?}", rust.sharding), + "BalancedSharding { counter: 0 }" + ); + } + + #[test] + fn backpressure_modes_convert_to_rust() { + let block = RustBackpressureMode::from(&BackpressureMode::block()); + let timeout = RustBackpressureMode::from(&BackpressureMode { + kind: BackpressureKind::BlockWithTimeout(IggyDuration::from(250_000)), + }); + let immediate = RustBackpressureMode::from(&BackpressureMode::fail_immediately()); + + assert!(matches!(block, RustBackpressureMode::Block)); + assert!(matches!( + timeout, + RustBackpressureMode::BlockWithTimeout(value) + if value == IggyDuration::from(250_000) + )); + assert!(matches!(immediate, RustBackpressureMode::FailImmediately)); + } + + #[test] + fn sharding_modes_convert_to_the_selected_rust_strategy() { + let messages = vec![RustIggyMessage::from_str("message").unwrap()]; + let stream = Identifier::from_str("stream").unwrap(); + let topic = Identifier::from_str("topic").unwrap(); + + let ordered = RustBackgroundConfig::try_from(&BackgroundProducerConfig { + num_shards: 4, + ..BackgroundProducerConfig::default() + }) + .unwrap(); + let first = ordered.sharding.pick_shard(4, &messages, &stream, &topic); + let second = ordered.sharding.pick_shard(4, &messages, &stream, &topic); + assert_eq!(first, second); + + let balanced = RustBackgroundConfig::try_from(&BackgroundProducerConfig { + num_shards: 3, + sharding: ProducerSharding::Balanced, + ..BackgroundProducerConfig::default() + }) + .unwrap(); + let picked = (0..4) + .map(|_| balanced.sharding.pick_shard(3, &messages, &stream, &topic)) + .collect::>(); + assert_eq!(picked, vec![0, 1, 2, 0]); + } + + #[test] + fn background_conversion_rejects_values_that_would_panic_semaphore() { + let invalid_max_buffer = BackgroundProducerConfig { + max_buffer_size: IggyByteSize::from(Semaphore::MAX_PERMITS as u64 + 1), + ..BackgroundProducerConfig::default() + }; + let invalid_max_in_flight = BackgroundProducerConfig { + max_in_flight: Semaphore::MAX_PERMITS + 1, + ..BackgroundProducerConfig::default() + }; + + assert!(RustBackgroundConfig::try_from(&invalid_max_buffer).is_err()); + assert!(RustBackgroundConfig::try_from(&invalid_max_in_flight).is_err()); + } + + #[tokio::test] + async fn converted_max_in_flight_limits_concurrent_background_writes() { + let active = Arc::new(AtomicUsize::new(0)); + let maximum = Arc::new(AtomicUsize::new(0)); + let backend = Arc::new(ConcurrencyTrackingBackend { + active, + maximum: maximum.clone(), + }); + let config = RustBackgroundConfig::try_from(&BackgroundProducerConfig { + num_shards: 4, + linger_time: IggyDuration::from(0), + batch_size: 0, + batch_length: 1, + max_buffer_size: IggyByteSize::from(0), + failure_mode: BackpressureMode::block(), + max_in_flight: 2, + sharding: ProducerSharding::Balanced, + }) + .unwrap(); + let dispatcher = ProducerDispatcher::new(backend, config); + let stream = Arc::new(Identifier::numeric(1).unwrap()); + let topic = Arc::new(Identifier::numeric(1).unwrap()); + + for index in 0..4 { + dispatcher + .dispatch( + vec![RustIggyMessage::from_str(&index.to_string()).unwrap()], + stream.clone(), + topic.clone(), + None, + ) + .await + .unwrap(); + } + dispatcher.shutdown().await; + + assert_eq!(maximum.load(Ordering::SeqCst), 2); + } + + #[test] + fn direct_batch_length_rejects_values_outside_u32() { + assert!(u32_param(-1, "batch_length").is_err()); + assert!(u32_param(i64::from(u32::MAX) + 1, "batch_length").is_err()); + assert_eq!( + u32_param(i64::from(u32::MAX), "batch_length").unwrap(), + u32::MAX + ); + } + + #[test] + fn send_to_identifier_preserves_python_error_categories() { + Python::initialize(); + Python::attach(|py| { + let string = PyString::new(py, "stream"); + let numeric = 7_u32.into_pyobject(py).unwrap(); + let negative = (-1_i64).into_pyobject(py).unwrap(); + let too_large = (u64::from(u32::MAX) + 1).into_pyobject(py).unwrap(); + let wrong_type = py.None().into_bound(py); + + assert_eq!( + extract_send_to_identifier(string.as_any(), "stream") + .unwrap() + .get_string_value() + .unwrap(), + "stream" + ); + assert_eq!( + extract_send_to_identifier(numeric.as_any(), "stream") + .unwrap() + .get_u32_value() + .unwrap(), + 7 + ); + + let negative = extract_send_to_identifier(negative.as_any(), "stream").unwrap_err(); + let too_large = extract_send_to_identifier(too_large.as_any(), "stream").unwrap_err(); + let wrong_type = extract_send_to_identifier(&wrong_type, "stream").unwrap_err(); + + assert!(negative.is_instance_of::(py)); + assert!(too_large.is_instance_of::(py)); + assert!(wrong_type.is_instance_of::(py)); + }); + } + + #[test] + fn representations_name_public_python_constructors() { + let direct = DirectProducerConfig::default(); + let background = BackgroundProducerConfig::default(); + + assert_eq!( + direct.__repr__(), + "DirectProducerConfig(batch_length=1000, linger_time=datetime.timedelta(seconds=0))" + ); + assert_eq!( + BackpressureMode::block().__repr__(), + "BackpressureMode.block()" + ); + assert_eq!( + BackpressureMode::fail_immediately().__repr__(), + "BackpressureMode.fail_immediately()" + ); + assert!( + background + .__repr__() + .ends_with("sharding=ProducerSharding.ORDERED)") + ); + } + + #[test] + fn numeric_getters_return_configured_values() { + let background = BackgroundProducerConfig::default(); + + assert_eq!(background.num_shards(), 1); + assert_eq!(background.batch_size(), 1_048_576); + assert_eq!(background.batch_length(), 1_000); + assert_eq!(background.max_buffer_size(), 33_554_432); + assert_eq!(background.max_in_flight(), 1); + assert_eq!(background.sharding(), ProducerSharding::Ordered); + assert_eq!(background.failure_mode(), BackpressureMode::block()); + } + + #[test] + fn numeric_validation_uses_python_semantic_ranges() { + assert!(usize_param(-1, "num_shards").is_err()); + assert!(u64_param(-1, "max_buffer_size").is_err()); + assert_eq!( + usize_param(usize::MAX as i128, "num_shards").unwrap(), + usize::MAX + ); + assert_eq!( + u64_param(u64::MAX as i128, "max_buffer_size").unwrap(), + u64::MAX + ); + } + + #[test] + fn direct_default_constants_match_builder() { + let rust = RustDirectConfig::builder().build(); + assert_eq!(1_000, rust.batch_length); + assert_eq!(IggyDuration::from(0), rust.linger_time); + } + + #[test] + fn background_default_constants_match_builder() { + let rust = RustBackgroundConfig::builder().build(); + assert_eq!(DEFAULT_BACKGROUND_NUM_SHARDS, rust.num_shards); + assert_eq!(IggyDuration::from(1_000), rust.linger_time); + assert_eq!(DEFAULT_BACKGROUND_BATCH_SIZE, rust.batch_size); + assert_eq!(DEFAULT_BACKGROUND_BATCH_LENGTH, rust.batch_length); + assert_eq!( + DEFAULT_BACKGROUND_MAX_BUFFER_SIZE, + rust.max_buffer_size.as_bytes_u64() + ); + assert_eq!(DEFAULT_BACKGROUND_MAX_IN_FLIGHT, rust.max_in_flight); + } +} diff --git a/foreign/python/src/send_message.rs b/foreign/python/src/send_message.rs index bfa3cdd671..454fbb2acf 100644 --- a/foreign/python/src/send_message.rs +++ b/foreign/python/src/send_message.rs @@ -38,20 +38,26 @@ pub struct SendMessage { impl Clone for SendMessage { fn clone(&self) -> Self { + Self::clone_from_rust(&self.inner) + } +} + +impl SendMessage { + pub(crate) fn clone_from_rust(message: &RustIggyMessage) -> Self { Self { inner: RustIggyMessage { header: IggyMessageHeader { - checksum: self.inner.header.checksum, - id: self.inner.header.id, - offset: self.inner.header.offset, - timestamp: self.inner.header.timestamp, - origin_timestamp: self.inner.header.origin_timestamp, - user_headers_length: self.inner.header.user_headers_length, - payload_length: self.inner.header.payload_length, - reserved: self.inner.header.reserved, + checksum: message.header.checksum, + id: message.header.id, + offset: message.header.offset, + timestamp: message.header.timestamp, + origin_timestamp: message.header.origin_timestamp, + user_headers_length: message.header.user_headers_length, + payload_length: message.header.payload_length, + reserved: message.header.reserved, }, - payload: self.inner.payload.clone(), - user_headers: self.inner.user_headers.clone(), + payload: message.payload.clone(), + user_headers: message.user_headers.clone(), }, } } @@ -146,12 +152,8 @@ impl SendMessagesConfirmation { /// at-least-once, so an earlier retry may already have committed these /// messages at a lower offset. /// - /// A batch is confirmed once it is committed in memory, not once it is - /// fsynced. A crash-restart can stamp a later batch with an offset a client - /// has already recorded. - /// - /// The legacy server confirms nothing, so its confirmation list is empty - /// and this value is never reached. + /// Confirmation follows VSR quorum commit. A topic with persisted message + /// durability also waits for recoverable stable-storage copies on the quorum. #[getter] pub fn base_offset(&self) -> u64 { self.inner.base_offset @@ -176,15 +178,13 @@ impl From for SendMessagesResponse { impl SendMessagesResponse { /// Gets the commit confirmations, one per partition the batch was written to. /// - /// The list is empty when the server reports no offsets, and the legacy - /// server never reports any, so branch on it being empty rather than - /// indexing into it. + /// The list is empty when the server reports no offsets, so check whether + /// it is empty before indexing into it. /// /// A reported `base_offset` never implies uniqueness, because delivery is /// at-least-once and an earlier retry may already have committed the same - /// messages at a lower offset. A batch is confirmed once it is committed in - /// memory, not once it is fsynced. A crash-restart can stamp a later batch - /// with an offset a client has already recorded. + /// messages at a lower offset. Confirmation follows the topic's message + /// durability policy: quorum commit, plus stable storage for persisted topics. #[getter] pub fn confirmations(&self) -> Vec { self.inner diff --git a/foreign/python/src/stats.rs b/foreign/python/src/stats.rs new file mode 100644 index 0000000000..af48425a0d --- /dev/null +++ b/foreign/python/src/stats.rs @@ -0,0 +1,449 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::duration::iggy_duration_to_py_delta; +use iggy::prelude::{ + CacheMetrics as RustCacheMetrics, CacheMetricsKey as RustCacheMetricsKey, Stats as RustStats, +}; +use pyo3::prelude::*; +use pyo3::types::{PyDelta, PyDict}; +use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; + +/// Key identifying the partition a `CacheMetrics` entry belongs to. +/// +/// Hashable and comparable, so it can key the `Stats.cache_metrics` dict. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[gen_stub_pyclass] +#[pyclass(eq, frozen, hash, skip_from_py_object)] +pub struct CacheMetricsKey { + /// The unique identifier (numeric) of the stream. + #[pyo3(get)] + pub stream_id: u32, + /// The unique identifier (numeric) of the topic within the stream. + #[pyo3(get)] + pub topic_id: u32, + /// The unique identifier (numeric) of the partition within the topic. + #[pyo3(get)] + pub partition_id: u32, +} + +impl From<&RustCacheMetricsKey> for CacheMetricsKey { + fn from(key: &RustCacheMetricsKey) -> Self { + Self { + stream_id: key.stream_id, + topic_id: key.topic_id, + partition_id: key.partition_id, + } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl CacheMetricsKey { + #[new] + fn new(stream_id: u32, topic_id: u32, partition_id: u32) -> Self { + Self { + stream_id, + topic_id, + partition_id, + } + } + + fn __repr__(&self) -> String { + format!( + "CacheMetricsKey(stream_id={}, topic_id={}, partition_id={})", + self.stream_id, self.topic_id, self.partition_id + ) + } +} + +/// Cache metrics for a specific partition. +#[gen_stub_pyclass] +#[pyclass] +pub struct CacheMetrics { + /// Number of cache hits. + #[pyo3(get)] + pub hits: u64, + /// Number of cache misses. + #[pyo3(get)] + pub misses: u64, + /// Hit ratio (hits / (hits + misses)). + #[pyo3(get)] + pub hit_ratio: f32, +} + +impl From<&RustCacheMetrics> for CacheMetrics { + fn from(metrics: &RustCacheMetrics) -> Self { + Self { + hits: metrics.hits, + misses: metrics.misses, + hit_ratio: metrics.hit_ratio, + } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl CacheMetrics { + fn __repr__(&self) -> String { + format!( + "CacheMetrics(hits={}, misses={}, hit_ratio={})", + self.hits, self.misses, self.hit_ratio + ) + } +} + +/// The statistics and details of the server and its running process. +/// +/// The fields are gathered from several sources while the request is served +/// (metadata counters, a process probe, a disk probe), so they are not an +/// atomic snapshot of one instant. +#[gen_stub_pyclass] +#[pyclass] +pub struct Stats { + pub(crate) inner: RustStats, +} + +impl From for Stats { + fn from(stats: RustStats) -> Self { + Self { inner: stats } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl Stats { + /// The unique identifier of the server process. + #[getter] + pub fn process_id(&self) -> u32 { + self.inner.process_id + } + + /// The CPU usage of the server process, in percent summed over the cores + /// it ran on, so it exceeds 100 whenever the process uses more than one + /// core. + /// + /// Measured as a delta since the previous `get_stats` served by the same + /// server shard, so the first sample a shard serves is 0. + #[getter] + pub fn cpu_usage(&self) -> f32 { + self.inner.cpu_usage + } + + /// The total CPU usage of the system, in percent averaged over the cores + /// the server may run on when confined by an affinity/cpuset mask (over + /// every host core otherwise), so it stays within 0-100. + /// + /// Same per-shard delta sampling as `cpu_usage`: the first sample a shard + /// serves is 0. + #[getter] + pub fn total_cpu_usage(&self) -> f32 { + self.inner.total_cpu_usage + } + + /// The memory usage of the server process, in bytes. + #[getter] + pub fn memory_usage(&self) -> u64 { + self.inner.memory_usage.as_bytes_u64() + } + + /// The total memory of the system, in bytes, or the effective cgroup memory + /// limit when the server runs inside a memory-capped cgroup (container, + /// systemd slice). + #[getter] + pub fn total_memory(&self) -> u64 { + self.inner.total_memory.as_bytes_u64() + } + + /// The available memory of the system, in bytes, scoped to the cgroup + /// limit when one applies. + #[getter] + pub fn available_memory(&self) -> u64 { + self.inner.available_memory.as_bytes_u64() + } + + /// The run time of the server process, with whole-second precision. + #[getter] + #[gen_stub(override_return_type(type_repr = "datetime.timedelta", imports=("datetime")))] + pub fn run_time<'a>(&self, py: Python<'a>) -> PyResult> { + iggy_duration_to_py_delta(py, self.inner.run_time) + } + + /// The start time of the server process, in microseconds since the Unix + /// epoch, with whole-second precision. + #[getter] + pub fn start_time(&self) -> u64 { + self.inner.start_time.as_micros() + } + + /// The total number of bytes read. + #[getter] + pub fn read_bytes(&self) -> u64 { + self.inner.read_bytes.as_bytes_u64() + } + + /// The total number of bytes written. + #[getter] + pub fn written_bytes(&self) -> u64 { + self.inner.written_bytes.as_bytes_u64() + } + + /// The total size of the messages, in bytes. + #[getter] + pub fn messages_size_bytes(&self) -> u64 { + self.inner.messages_size_bytes.as_bytes_u64() + } + + /// The total number of streams. + #[getter] + pub fn streams_count(&self) -> u32 { + self.inner.streams_count + } + + /// The total number of topics. + #[getter] + pub fn topics_count(&self) -> u32 { + self.inner.topics_count + } + + /// The total number of partitions. + #[getter] + pub fn partitions_count(&self) -> u32 { + self.inner.partitions_count + } + + /// The total number of segments. + #[getter] + pub fn segments_count(&self) -> u32 { + self.inner.segments_count + } + + /// The total number of messages. + #[getter] + pub fn messages_count(&self) -> u64 { + self.inner.messages_count + } + + /// The total number of connected clients. + #[getter] + pub fn clients_count(&self) -> u32 { + self.inner.clients_count + } + + /// The total number of consumer groups. + #[getter] + pub fn consumer_groups_count(&self) -> u32 { + self.inner.consumer_groups_count + } + + /// The name of the host the server runs on. + #[getter] + pub fn hostname(&self) -> String { + self.inner.hostname.clone() + } + + /// The name of the operating system. + #[getter] + pub fn os_name(&self) -> String { + self.inner.os_name.clone() + } + + /// The version of the operating system. + #[getter] + pub fn os_version(&self) -> String { + self.inner.os_version.clone() + } + + /// The version of the kernel. + #[getter] + pub fn kernel_version(&self) -> String { + self.inner.kernel_version.clone() + } + + /// The version of the Iggy server. + #[getter] + pub fn iggy_server_version(&self) -> String { + self.inner.iggy_server_version.clone() + } + + /// The numeric semantic version of the Iggy server, or `None` when unknown. + /// E.g. 1.2.3 -> 1002003 (major * 1000000 + minor * 1000 + patch). + #[getter] + #[gen_stub(override_return_type(type_repr = "builtins.int | None"))] + pub fn iggy_server_semver(&self) -> Option { + self.inner.iggy_server_semver + } + + /// Cache metrics per partition. + /// + /// Current servers do not populate this and reply with an empty map. Each + /// access builds a fresh dict, so mutating the returned dict does not + /// change the stats. + #[getter] + #[gen_stub(override_return_type(type_repr = "builtins.dict[CacheMetricsKey, CacheMetrics]"))] + pub fn cache_metrics<'a>(&self, py: Python<'a>) -> PyResult> { + let dict = PyDict::new(py); + for (key, metrics) in &self.inner.cache_metrics { + dict.set_item(CacheMetricsKey::from(key), CacheMetrics::from(metrics))?; + } + Ok(dict) + } + + /// The number of threads in the server process. + #[getter] + pub fn threads_count(&self) -> u32 { + self.inner.threads_count + } + + /// The available (free) disk space for the data directory, in bytes. + /// + /// 0 when the server does not know its data directory or the disk probe + /// fails. + #[getter] + pub fn free_disk_space(&self) -> u64 { + self.inner.free_disk_space.as_bytes_u64() + } + + /// The total disk space for the data directory, in bytes. + /// + /// 0 when the server does not know its data directory or the disk probe + /// fails. + #[getter] + pub fn total_disk_space(&self) -> u64 { + self.inner.total_disk_space.as_bytes_u64() + } + + fn __repr__(&self) -> String { + format!( + "Stats(hostname='{}', iggy_server_version='{}', streams_count={}, \ + topics_count={}, partitions_count={}, messages_count={}, clients_count={})", + self.inner.hostname, + self.inner.iggy_server_version, + self.inner.streams_count, + self.inner.topics_count, + self.inner.partitions_count, + self.inner.messages_count, + self.inner.clients_count + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use iggy::prelude::{IggyByteSize, IggyDuration, IggyTimestamp}; + use std::collections::HashMap; + + /// Two entries sharing a stream and topic, so a key collision in the + /// `HashMap` -> `PyDict` conversion drops one of them. + fn cache_metrics_entries() -> HashMap { + HashMap::from([ + ( + RustCacheMetricsKey { + stream_id: 1, + topic_id: 2, + partition_id: 3, + }, + RustCacheMetrics { + hits: 7, + misses: 3, + hit_ratio: 0.7, + }, + ), + ( + RustCacheMetricsKey { + stream_id: 1, + topic_id: 2, + partition_id: 4, + }, + RustCacheMetrics { + hits: 0, + misses: 5, + hit_ratio: 0.0, + }, + ), + ]) + } + + fn rust_stats(iggy_server_semver: Option) -> RustStats { + RustStats { + process_id: 1, + cpu_usage: 0.0, + total_cpu_usage: 0.0, + memory_usage: IggyByteSize::default(), + total_memory: IggyByteSize::default(), + available_memory: IggyByteSize::default(), + run_time: IggyDuration::default(), + start_time: IggyTimestamp::default(), + read_bytes: IggyByteSize::default(), + written_bytes: IggyByteSize::default(), + messages_size_bytes: IggyByteSize::default(), + streams_count: 0, + topics_count: 0, + partitions_count: 0, + segments_count: 0, + messages_count: 0, + clients_count: 0, + consumer_groups_count: 0, + hostname: String::new(), + os_name: String::new(), + os_version: String::new(), + kernel_version: String::new(), + iggy_server_version: String::new(), + iggy_server_semver, + cache_metrics: cache_metrics_entries(), + threads_count: 0, + free_disk_space: IggyByteSize::default(), + total_disk_space: IggyByteSize::default(), + } + } + + #[test] + fn given_stats_when_converting_should_preserve_semver_option() { + Python::initialize(); + + assert_eq!(Stats::from(rust_stats(None)).iggy_server_semver(), None); + assert_eq!( + Stats::from(rust_stats(Some(1_002_003))).iggy_server_semver(), + Some(1_002_003) + ); + } + + #[test] + fn given_populated_cache_metrics_when_reading_should_key_every_entry() { + Python::initialize(); + + let stats = Stats::from(rust_stats(None)); + + Python::attach(|py| { + let dict = stats.cache_metrics(py).expect("build cache metrics dict"); + assert_eq!(dict.len(), cache_metrics_entries().len()); + + let entry = dict + .get_item(CacheMetricsKey::new(1, 2, 4)) + .expect("look up cache metrics entry") + .expect("entry for an independently built key"); + assert_eq!( + entry + .getattr("misses") + .and_then(|misses| misses.extract::()) + .expect("read misses"), + 5 + ); + }); + } +} diff --git a/foreign/python/src/stream.rs b/foreign/python/src/stream.rs index 7343d2c53f..d5f6a8e444 100644 --- a/foreign/python/src/stream.rs +++ b/foreign/python/src/stream.rs @@ -15,10 +15,12 @@ // specific language governing permissions and limitations // under the License. -use iggy::prelude::StreamDetails as RustStreamDetails; +use iggy::prelude::{Stream as RustStream, StreamDetails as RustStreamDetails}; use pyo3::prelude::*; use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; +use crate::topic::Topic; + #[pyclass] #[gen_stub_pyclass] pub struct StreamDetails { @@ -36,21 +38,95 @@ impl From for StreamDetails { #[gen_stub_pymethods] #[pymethods] impl StreamDetails { + /// Stream creation time as Unix time in microseconds. + #[getter] + pub fn created_at(&self) -> u64 { + self.inner.created_at.as_micros() + } + + #[getter] + pub fn id(&self) -> u32 { + self.inner.id + } + + #[getter] + pub fn name(&self) -> String { + self.inner.name.to_string() + } + + /// Current stored stream size in bytes. + #[getter] + pub fn size(&self) -> u64 { + self.inner.size.as_bytes_u64() + } + + #[getter] + pub fn messages_count(&self) -> u64 { + self.inner.messages_count + } + + #[getter] + pub fn topics_count(&self) -> u32 { + self.inner.topics_count + } + + /// Returns the topics in the stream. + #[getter] + pub fn topics(&self) -> Vec { + self.inner.topics.iter().map(Topic::from).collect() + } +} + +/// Summary information returned by `IggyClient.get_streams()`. +/// +/// `created_at` is Unix time in microseconds. `size` is the stream's current +/// stored size in bytes. +#[gen_stub_pyclass] +#[pyclass] +pub struct Stream { + pub(crate) inner: RustStream, +} + +impl From for Stream { + fn from(stream: RustStream) -> Self { + Self { inner: stream } + } +} + +#[gen_stub_pymethods] +#[pymethods] +impl Stream { + /// Numeric stream identifier. #[getter] pub fn id(&self) -> u32 { self.inner.id } + /// Stream creation time as Unix time in microseconds. + #[getter] + pub fn created_at(&self) -> u64 { + self.inner.created_at.as_micros() + } + + /// Unique stream name. #[getter] pub fn name(&self) -> String { self.inner.name.to_string() } + /// Current stored stream size in bytes. + #[getter] + pub fn size(&self) -> u64 { + self.inner.size.as_bytes_u64() + } + + /// Total messages across all topics in the stream. #[getter] pub fn messages_count(&self) -> u64 { self.inner.messages_count } + /// Number of topics in the stream. #[getter] pub fn topics_count(&self) -> u32 { self.inner.topics_count diff --git a/foreign/python/src/topic.rs b/foreign/python/src/topic.rs index e5f86facd8..fe2be86d61 100644 --- a/foreign/python/src/topic.rs +++ b/foreign/python/src/topic.rs @@ -185,6 +185,25 @@ impl From for Topic { } } +impl From<&RustTopic> for Topic { + fn from(topic: &RustTopic) -> Self { + Self { + inner: RustTopic { + id: topic.id, + created_at: topic.created_at, + name: topic.name.clone(), + size: topic.size, + message_expiry: topic.message_expiry, + compression_algorithm: topic.compression_algorithm, + max_topic_size: topic.max_topic_size, + messages_count: topic.messages_count, + partitions_count: topic.partitions_count, + options: topic.options.clone(), + }, + } + } +} + #[gen_stub_pymethods] #[pymethods] impl Topic { @@ -254,7 +273,7 @@ impl Topic { /// Options admission resolved for the keys the client did not send. /// - /// Same shape as [`Self::options`]. These would have resolved differently + /// Same shape as `options`. These would have resolved differently /// under another server configuration. #[getter] pub fn derived_options<'a>(&self, py: Python<'a>) -> PyResult> { @@ -345,7 +364,7 @@ impl TopicDetails { /// Options admission resolved for the keys the client did not send. /// - /// Same shape as [`Self::options`]. These would have resolved differently + /// Same shape as `options`. These would have resolved differently /// under another server configuration. #[getter] pub fn derived_options<'a>(&self, py: Python<'a>) -> PyResult> { diff --git a/foreign/python/tests/conftest.py b/foreign/python/tests/conftest.py index 3ab97065f5..ac9acdec4f 100644 --- a/foreign/python/tests/conftest.py +++ b/foreign/python/tests/conftest.py @@ -22,13 +22,12 @@ and connecting to Iggy servers in various configurations. """ -# TODO(slbotbm): Create text fixture for clean up after -# delete_stream() has been implemented. - import asyncio +import contextlib import os import secrets import string +from collections.abc import AsyncGenerator from pathlib import Path import pytest @@ -108,6 +107,28 @@ def make_name( return make_name +@pytest.fixture(scope="function", autouse=True) +async def cleanup_streams( + request: pytest.FixtureRequest, +) -> AsyncGenerator[None, None]: + """Delete streams created by the current test.""" + if "iggy_client" not in request.fixturenames: + yield + return + + client: IggyClient = request.getfixturevalue("iggy_client") + existing_streams = { + (stream.id, stream.created_at) for stream in await client.get_streams() + } + + yield + + for stream in await client.get_streams(): + if (stream.id, stream.created_at) not in existing_streams: + with contextlib.suppress(RuntimeError): + await client.delete_stream(stream.id) + + @pytest.fixture(scope="session", autouse=True) def configure_asyncio(): """Configure asyncio settings for tests.""" diff --git a/foreign/python/tests/test_client_config.py b/foreign/python/tests/test_client_config.py index 26d9f4430e..7e80fe0659 100644 --- a/foreign/python/tests/test_client_config.py +++ b/foreign/python/tests/test_client_config.py @@ -257,8 +257,8 @@ def test_repr_shows_every_field_as_python(self): ["", "127.0.0.1", "127.0.0.1:not-a-port", "127.0.0.1:70000", "::1:8090"], ) def test_invalid_server_address_is_rejected(self, invalid_address: str): - """Test that a malformed address fails at construction, not at connect.""" - with pytest.raises(ValueError): + """Test that a malformed address fails at construction, naming itself.""" + with pytest.raises(ValueError, match="server_address"): TcpConfig(server_address=invalid_address) def test_negative_heartbeat_interval_is_rejected(self): diff --git a/foreign/python/tests/test_connectivity.py b/foreign/python/tests/test_connectivity.py index 69516d74c1..3b4230705d 100644 --- a/foreign/python/tests/test_connectivity.py +++ b/foreign/python/tests/test_connectivity.py @@ -40,6 +40,7 @@ async def test_client_not_none(self, iggy_client: IggyClient): "iggy+http://iggy:iggy@127.0.0.1:3000?heartbeat_interval=5s&retries=3", "iggy+ws://iggy:iggy@127.0.0.1:8092", "iggy+ws://iggy:iggy@127.0.0.1:8092?heartbeat_interval=5s&reconnection_retries=3&reconnection_interval=1s&reestablish_after=5s&read_buffer_size=4096&write_buffer_size=4096&max_write_buffer_size=8192&max_message_size=16384&max_frame_size=16384&accept_unmasked_frames=false&tls_domain=localhost&tls_ca_file=unused.pem&tls_validate_certificate=false&tls=false", + "iggy+quic://iggy:iggy@127.0.0.1:8080?reconnection_max_retries=0", ], ) @pytest.mark.asyncio @@ -77,7 +78,6 @@ async def test_valid_connection_string(self, connection_string: str): "iggy+tcp://iggy:iggy@{host}:{port}?invalid_option=value", "Invalid connection string", ), - ("iggy+quic://iggy:iggy@127.0.0.1:8080", "Cannot create endpoint"), ], ) def test_invalid_connection_string(self, invalid_value: str, expected_error: str): diff --git a/foreign/python/tests/test_http_config.py b/foreign/python/tests/test_http_config.py new file mode 100644 index 0000000000..d2237aa35a --- /dev/null +++ b/foreign/python/tests/test_http_config.py @@ -0,0 +1,363 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Tests for the HTTP client configuration surface. + +`HttpConfig` mirrors the Rust SDK's `HttpClientConfig` the same way +`TcpConfig` does, so most of these assert that a value set from Python +survives to the getters and that unset fields fall back to the Rust +defaults. Unlike TCP there is no `AutoLogin` or reconnection policy to +configure. +""" + +import ast +import json +import urllib.request +from datetime import timedelta + +import pytest + +from apache_iggy import Consumer, HttpConfig, IggyClient, PollingStrategy +from apache_iggy import SendMessage as Message + +from .utils import get_http_server_config, wait_for_ping + + +@pytest.mark.unit +class TestHttpConfig: + """Test the transport configuration.""" + + def test_defaults_match_the_rust_sdk(self): + """Test that an unconfigured transport matches the Rust SDK defaults.""" + config = HttpConfig() + + assert config.api_url == "http://127.0.0.1:3000" + assert config.retries == 3 + assert config.has_jwt is False + assert config.heartbeat_interval == timedelta(seconds=5) + + def test_every_field_round_trips(self): + """Test that each configured field is readable back unchanged.""" + config = HttpConfig( + api_url="http://127.0.0.1:3001", + retries=5, + jwt="a-token", + heartbeat_interval=timedelta(seconds=15), + ) + + assert config.api_url == "http://127.0.0.1:3001" + assert config.retries == 5 + assert config.has_jwt is True + assert config.heartbeat_interval == timedelta(seconds=15) + + def test_arguments_are_keyword_only(self): + """Test that the API URL cannot be passed positionally.""" + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-count + HttpConfig("http://127.0.0.1:3000") + + def test_repr_hides_the_jwt(self): + """Test that the JWT does not leak through repr but still parses as Python.""" + config = HttpConfig(jwt="a-secret-token") + + printed = repr(config) + + assert "a-secret-token" not in printed + ast.parse(printed) + + def test_repr_shows_every_field_as_python(self): + """Test that repr covers the configured fields and parses as Python. + + `heartbeat_interval` is included: its repr is built from a duration, + the one format-fragile field here, and `ast.parse` alone would not + catch a regression that renders it as something other than a + `datetime.timedelta` call. + """ + config = HttpConfig( + api_url="http://127.0.0.1:3001", + retries=5, + heartbeat_interval=timedelta(seconds=15), + ) + + printed = repr(config) + + assert 'api_url="http://127.0.0.1:3001"' in printed + assert "retries=5" in printed + assert "heartbeat_interval=datetime.timedelta(seconds=15)" in printed + ast.parse(printed) + + @pytest.mark.parametrize( + "invalid_url", + [ + "", + "not-a-url", + "http://127.0.0.1:0", + "http://127.0.0.1:3000/iggy", + "http://user:pass@127.0.0.1:3000", + ], + ) + def test_invalid_api_url_is_rejected(self, invalid_url: str): + """Test that a malformed API URL fails at construction, not at connect. + + Only `scheme://host[:port]` is accepted: a path, query, fragment, or + embedded credentials are all rejected, not just a missing/zero port. + """ + with pytest.raises(ValueError, match="api_url"): + HttpConfig(api_url=invalid_url) + + @pytest.mark.parametrize("bad_jwt", ["", " ", "\t"]) + def test_empty_or_whitespace_jwt_is_rejected(self, bad_jwt: str): + """Test that an empty or whitespace-only JWT fails at construction. + + Accepting it would make `has_jwt` report `True` while every call + still fails `Unauthenticated`, since the stored token is blank. + """ + with pytest.raises(ValueError, match="jwt"): + HttpConfig(jwt=bad_jwt) + + @pytest.mark.parametrize("out_of_range", [-1, 2**32]) + def test_out_of_range_retries_is_rejected(self, out_of_range: int): + """Test that a retry count outside the wire range names the argument. + + The conversion pyo3 does on its own raises OverflowError, which is not a + ValueError and so escapes the handler a caller wraps construction in. + """ + with pytest.raises(ValueError, match="retries"): + HttpConfig(retries=out_of_range) + + @pytest.mark.parametrize( + "negative", + [timedelta(microseconds=-1), timedelta(seconds=-1), timedelta(days=-1)], + ) + def test_negative_heartbeat_interval_is_rejected(self, negative: timedelta): + """Test that a negative heartbeat interval fails at construction.""" + with pytest.raises(ValueError, match="negative"): + HttpConfig(heartbeat_interval=negative) + + def test_zero_heartbeat_interval_is_rejected(self): + """Test that a zero heartbeat interval fails at construction. + + Nothing downstream reads zero as "disabled"; it heartbeats in a + continuous loop for as long as the client lives. + """ + with pytest.raises(ValueError, match=r"heartbeat_interval.*must not be zero"): + HttpConfig(heartbeat_interval=timedelta(0)) + + def test_maximum_heartbeat_interval_round_trips(self): + """Test that the largest timedelta survives the duration conversion. + + The repr is asserted in seconds rather than days: it is rendered from + a microsecond count, so the maximum comes back as a whole-second + `timedelta` instead of the `days=` form it was constructed with. + """ + maximum = timedelta(days=999_999_999) + + config = HttpConfig(heartbeat_interval=maximum) + + printed = repr(config) + + assert config.heartbeat_interval == maximum + assert ( + "heartbeat_interval=datetime.timedelta(seconds=86399999913600)" in printed + ) + ast.parse(printed) + + +@pytest.mark.unit +class TestHttpClientConstruction: + """Test that `IggyClient(...)` builds an HTTP client from an `HttpConfig`.""" + + @pytest.mark.asyncio + async def test_accepts_a_config(self): + """Test that the resulting client is actually HTTP, not silently TCP. + + `IggyClient(...)` is not None for either union arm, so that alone + never pinned the transport, and a bare `RuntimeError` does not either: + a client that regressed to the TCP arm raises too, just after hanging + until the pytest timeout. `Invalid HTTP request` is the HTTP + transport's own send failure, so matching it pins the transport. + `retries=0` keeps the failure immediate instead of working through + the default retry/backoff first. + """ + client = IggyClient(HttpConfig(api_url="http://127.0.0.1:1", retries=0)) + + with pytest.raises(RuntimeError, match="Invalid HTTP request"): + await client.ping() + + def test_accepts_the_default_config(self): + """Test that an explicit default `HttpConfig` is accepted.""" + assert IggyClient(HttpConfig()) is not None + + @pytest.mark.asyncio + async def test_without_a_jwt_a_privileged_call_is_unauthenticated(self): + """Test that a privileged call fails when no token is configured. + + `HttpClient` seeds its access token from `jwt`, so with none configured + and no `login_user()` it stays empty and the client rejects the call + itself. The dead port is what proves that: nothing is dialled, so the + failure cannot be the server's. + """ + client = IggyClient(HttpConfig(api_url="http://127.0.0.1:1", retries=0)) + await client.connect() + + with pytest.raises(RuntimeError, match="Unauthenticated"): + await client.create_stream("never-created") + + +@pytest.mark.integration +class TestHttpConfigAgainstServer: + """Test that a client built from `HttpConfig` actually connects.""" + + @pytest.mark.asyncio + async def test_client_connects_and_pings(self): + """Test that a client built with a custom config reaches the server.""" + host, port = get_http_server_config() + + client = IggyClient(HttpConfig(api_url=f"http://{host}:{port}")) + await client.connect() + await wait_for_ping(client) + + @pytest.mark.asyncio + async def test_client_sends_and_polls_a_message(self, unique_name): + """Test a full round trip: login, create stream/topic, send, poll. + + This is the part `test_client_connects_and_pings` above does not + cover: that a client built from `HttpConfig` can carry a real + workload, not just answer a ping. + """ + host, port = get_http_server_config() + stream_name = unique_name() + topic_name = unique_name() + payload = f"payload-{unique_name()}" + + client = IggyClient(HttpConfig(api_url=f"http://{host}:{port}")) + await client.connect() + await wait_for_ping(client) + await client.login_user("iggy", "iggy") + + await client.create_stream(stream_name) + await client.create_topic( + stream=stream_name, name=topic_name, partitions_count=1 + ) + await client.send_messages( + stream=stream_name, + topic=topic_name, + partitioning=0, + messages=[Message(payload)], + ) + + polled_messages = await client.poll_messages( + stream=stream_name, + topic=topic_name, + consumer=Consumer.Single("http-round-trip"), + partition_id=0, + polling_strategy=PollingStrategy.First(), + count=1, + auto_commit=True, + ) + + assert [message.payload().decode() for message in polled_messages] == [payload] + + @pytest.mark.asyncio + async def test_jwt_config_actually_authenticates(self, unique_name): + """Test that a JWT passed to `HttpConfig` reaches `access_token`. + + `has_jwt` only proves a token is configured, not that it works. + `/users/login` is unauthenticated, so a token minted out-of-band via + stdlib `urllib` (bypassing `HttpConfig` and `login_user()` entirely) + proves the client actually authenticates with the token it was given. + + The trailing newline is deliberate, and is the only coverage of the + trim in `HttpConfig::new`: it reproduces a token read from a file, and + untrimmed it builds a `Bearer \\n` header value that + `HeaderValue` rejects, failing every call with `Invalid HTTP request`. + Do not remove it. + """ + host, port = get_http_server_config() + api_url = f"http://{host}:{port}" + + request = urllib.request.Request( # noqa: S310 + f"{api_url}/users/login", + data=json.dumps({"username": "iggy", "password": "iggy"}).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request) as response: # noqa: S310 + identity = json.loads(response.read()) + token = identity["access_token"]["token"] + + client = IggyClient(HttpConfig(api_url=api_url, jwt=f"{token}\n")) + await client.connect() + await wait_for_ping(client) + + stream_name = unique_name() + await client.create_stream(stream_name) + assert await client.get_stream(stream_name) is not None + + @pytest.mark.asyncio + async def test_wrong_jwt_is_rejected_by_the_server(self, unique_name): + """Test that a JWT the server cannot decode fails the call, not connect. + + `connect()` does not dial over HTTP, so a bad token can only surface + later. `wait_for_ping` runs first because ping needs no credentials: + it proves the server is reachable, which pins the failure below on the + token rather than on a missing listener. The server answers an + undecodable token with 401, which the HTTP client maps back to + `Unauthenticated`. + """ + host, port = get_http_server_config() + + client = IggyClient( + HttpConfig(api_url=f"http://{host}:{port}", jwt="not-a-real-token") + ) + await client.connect() + await wait_for_ping(client) + + with pytest.raises(RuntimeError, match="Unauthenticated"): + await client.create_stream(unique_name()) + + @pytest.mark.asyncio + async def test_consumer_group_is_rejected(self, unique_name): + """Test that a consumer group fails loudly over HTTP, not silently. + + `join_consumer_group` answers `Feature is unavailable` over HTTP, and + `consumer_group(...)` awaits that join before returning, so the + failure surfaces at construction. `auto_join_consumer_group=False` + is not a way around it: the join is skipped, but the first poll + raises the same error, because a group member is pinned to no + partition and a consumer-group poll without one is rejected + client-side. + """ + host, port = get_http_server_config() + stream_name = unique_name() + topic_name = unique_name() + + client = IggyClient(HttpConfig(api_url=f"http://{host}:{port}")) + await client.connect() + await wait_for_ping(client) + await client.login_user("iggy", "iggy") + + await client.create_stream(stream_name) + await client.create_topic( + stream=stream_name, name=topic_name, partitions_count=1 + ) + + with pytest.raises(RuntimeError, match="Feature is unavailable"): + await client.consumer_group( + name=unique_name(), stream=stream_name, topic=topic_name + ) diff --git a/foreign/python/tests/test_message_operations.py b/foreign/python/tests/test_message_operations.py index bbf5b1d0e8..af5924c4dc 100644 --- a/foreign/python/tests/test_message_operations.py +++ b/foreign/python/tests/test_message_operations.py @@ -25,12 +25,54 @@ HeaderKey, HeaderValue, IggyClient, + Partitioning, PollingStrategy, UserHeaders, ) from apache_iggy import SendMessage as Message +class TestPartitioning: + """Test message partitioning strategy construction.""" + + @pytest.mark.unit + def test_balanced_and_partition_id_strategies(self): + assert isinstance(Partitioning.balanced(), Partitioning) + assert isinstance(Partitioning.partition_id(1), Partitioning) + assert isinstance(Partitioning.partition_id(2**32 - 1), Partitioning) + + @pytest.mark.unit + @pytest.mark.parametrize("key", [b"customer-42", "customer-42", "客户-42"]) + def test_messages_key_accepts_bytes_and_strings(self, key): + assert isinstance(Partitioning.messages_key(key), Partitioning) + + @pytest.mark.unit + @pytest.mark.parametrize("key", [b"a" * 255, "a" * 255, "界" * 85]) + def test_messages_key_accepts_255_bytes(self, key): + assert isinstance(Partitioning.messages_key(key), Partitioning) + + @pytest.mark.unit + @pytest.mark.parametrize("key", [b"", "", b"a" * 256, "a" * 256, "界" * 86]) + def test_messages_key_rejects_invalid_encoded_length(self, key): + with pytest.raises(ValueError): + Partitioning.messages_key(key) + + @pytest.mark.unit + @pytest.mark.parametrize("partition_id", [-1, 2**32]) + def test_partition_id_rejects_values_outside_u32(self, partition_id): + with pytest.raises(OverflowError): + Partitioning.partition_id(partition_id) + + @pytest.mark.unit + def test_partitioning_rejects_invalid_types(self): + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + Partitioning.partition_id("0") + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + Partitioning.messages_key(1) + + class TestMessageOperations: """Test message sending, polling, and processing.""" @@ -112,6 +154,101 @@ async def test_send_messages_reports_committed_confirmation( assert confirmation.partition_id == partition_id assert confirmation.base_offset == polled_messages[0].offset() + @pytest.mark.asyncio + async def test_send_messages_with_partition_id_strategy( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name() + topic_name = unique_name() + partition_id = 2 + + await iggy_client.create_stream(stream_name) + await iggy_client.create_topic( + stream=stream_name, name=topic_name, partitions_count=3 + ) + + response = await iggy_client.send_messages( + stream=stream_name, + topic=topic_name, + partitioning=Partitioning.partition_id(partition_id), + messages=[Message("fixed partition 1"), Message("fixed partition 2")], + ) + + assert len(response.confirmations) == 1 + assert response.confirmations[0].partition_id == partition_id + + with pytest.raises(RuntimeError): + await iggy_client.send_messages( + stream=stream_name, + topic=topic_name, + partitioning=Partitioning.partition_id(3), + messages=[Message("missing partition")], + ) + + @pytest.mark.asyncio + async def test_send_messages_with_balanced_strategy( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name() + topic_name = unique_name() + partitions_count = 3 + + await iggy_client.create_stream(stream_name) + await iggy_client.create_topic( + stream=stream_name, + name=topic_name, + partitions_count=partitions_count, + ) + + responses = [ + await iggy_client.send_messages( + stream=stream_name, + topic=topic_name, + partitioning=Partitioning.balanced(), + messages=[Message(f"balanced {index}")], + ) + for index in range(partitions_count) + ] + + assert all(len(response.confirmations) == 1 for response in responses) + assert ( + len({response.confirmations[0].partition_id for response in responses}) + == partitions_count + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize("key", [b"customer-42", "customer-42"]) + async def test_send_messages_with_same_key_uses_same_partition( + self, iggy_client: IggyClient, unique_name, key + ): + stream_name = unique_name() + topic_name = unique_name() + + await iggy_client.create_stream(stream_name) + await iggy_client.create_topic( + stream=stream_name, name=topic_name, partitions_count=3 + ) + partitioning = Partitioning.messages_key(key) + + first = await iggy_client.send_messages( + stream=stream_name, + topic=topic_name, + partitioning=partitioning, + messages=[Message("first")], + ) + second = await iggy_client.send_messages( + stream=stream_name, + topic=topic_name, + partitioning=partitioning, + messages=[Message("second")], + ) + + assert len(first.confirmations) == 1 + assert len(second.confirmations) == 1 + assert ( + first.confirmations[0].partition_id == second.confirmations[0].partition_id + ) + @pytest.mark.asyncio async def test_send_and_poll_messages_as_bytes( self, iggy_client: IggyClient, unique_name diff --git a/foreign/python/tests/test_partition.py b/foreign/python/tests/test_partition.py new file mode 100644 index 0000000000..193b3cb42a --- /dev/null +++ b/foreign/python/tests/test_partition.py @@ -0,0 +1,228 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import asyncio + +import pytest + +from apache_iggy import ( + IggyClient, + Permissions, + SendMessage, + StreamPermissions, + TopicPermissions, +) + +from .utils import login_fresh_client, unique_credentials + + +async def _create_topic(iggy_client: IggyClient, unique_name): + stream_name = unique_name() + topic_name = unique_name() + + await iggy_client.create_stream(stream_name) + await iggy_client.create_topic( + stream=stream_name, name=topic_name, partitions_count=2 + ) + return stream_name, topic_name + + +async def _wait_for_messages(iggy_client, stream_id, topic_id, expected): + for _ in range(100): + topic = await iggy_client.get_topic(stream_id, topic_id) + if topic is not None and topic.messages_count == expected: + return topic + await asyncio.sleep(0.01) + raise AssertionError(f"topic messages_count did not reach {expected}") + + +class TestPartitionManagement: + @pytest.mark.asyncio + @pytest.mark.parametrize("numeric_ids", [False, True]) + async def test_create_and_delete_partitions( + self, iggy_client: IggyClient, unique_name, numeric_ids: bool + ): + stream_name, topic_name = await _create_topic(iggy_client, unique_name) + stream = await iggy_client.get_stream(stream_name) + assert stream is not None + topic = await iggy_client.get_topic(stream.id, topic_name) + assert topic is not None + stream_id = stream.id if numeric_ids else stream_name + topic_id = topic.id if numeric_ids else topic_name + + await iggy_client.create_partitions(stream_id, topic_id, 2) + created = await iggy_client.get_topic(stream_id, topic_id) + assert created is not None + assert created.partitions_count == 4 + assert [partition.id for partition in created.partitions] == [0, 1, 2, 3] + + await iggy_client.delete_partitions(stream_id, topic_id, 2) + deleted = await iggy_client.get_topic(stream_id, topic_id) + assert deleted is not None + assert deleted.partitions_count == 2 + assert [partition.id for partition in deleted.partitions] == [0, 1] + + @pytest.mark.asyncio + async def test_delete_partitions_rolls_back_stats( + self, iggy_client: IggyClient, unique_name + ): + stream_name, topic_name = await _create_topic(iggy_client, unique_name) + await iggy_client.create_partitions(stream_name, topic_name, 2) + await iggy_client.send_messages( + stream_name, topic_name, 0, [SendMessage("retained")] + ) + await iggy_client.send_messages( + stream_name, topic_name, 3, [SendMessage("deleted")] + ) + await _wait_for_messages(iggy_client, stream_name, topic_name, 2) + + await iggy_client.delete_partitions(stream_name, topic_name, 2) + deleted = await _wait_for_messages(iggy_client, stream_name, topic_name, 1) + assert [partition.id for partition in deleted.partitions] == [0, 1] + assert deleted.partitions[0].messages_count == 1 + + @pytest.mark.asyncio + @pytest.mark.parametrize("method", ["create_partitions", "delete_partitions"]) + @pytest.mark.parametrize("partitions_count", [0, 1001]) + async def test_partition_management_rejects_invalid_count( + self, + iggy_client: IggyClient, + unique_name, + method: str, + partitions_count: int, + ): + stream_name, topic_name = await _create_topic(iggy_client, unique_name) + + # Zero shares the legacy TooManyPartitions code with an over-limit count. + with pytest.raises(RuntimeError, match="Too many partitions"): + await getattr(iggy_client, method)( + stream_name, topic_name, partitions_count + ) + + @pytest.mark.asyncio + async def test_delete_partitions_rejects_count_larger_than_topic( + self, iggy_client: IggyClient, unique_name + ): + stream_name, topic_name = await _create_topic(iggy_client, unique_name) + + with pytest.raises(RuntimeError, match="Invalid partitions count"): + await iggy_client.delete_partitions(stream_name, topic_name, 3) + + @pytest.mark.asyncio + @pytest.mark.parametrize("method", ["create_partitions", "delete_partitions"]) + @pytest.mark.parametrize("missing", ["stream", "topic"]) + async def test_partition_management_rejects_missing_stream_or_topic( + self, iggy_client: IggyClient, unique_name, method: str, missing: str + ): + stream_name, topic_name = await _create_topic(iggy_client, unique_name) + missing_name = unique_name() + stream_id = missing_name if missing == "stream" else stream_name + topic_id = missing_name if missing == "topic" else topic_name + + with pytest.raises(RuntimeError, match=r"was not found\."): + await getattr(iggy_client, method)(stream_id, topic_id, 1) + + @pytest.mark.asyncio + @pytest.mark.parametrize("method", ["create_partitions", "delete_partitions"]) + async def test_partition_management_rejects_invalid_identifier( + self, iggy_client: IggyClient, unique_name, method: str + ): + _, topic_name = await _create_topic(iggy_client, unique_name) + + with pytest.raises(ValueError): + await getattr(iggy_client, method)("", topic_name, 1) + + @pytest.mark.asyncio + @pytest.mark.parametrize("method", ["create_partitions", "delete_partitions"]) + @pytest.mark.parametrize("partitions_count", [-1, 2**32]) + async def test_partition_management_rejects_out_of_range_python_integer( + self, + iggy_client: IggyClient, + unique_name, + method: str, + partitions_count: int, + ): + stream_name, topic_name = await _create_topic(iggy_client, unique_name) + + with pytest.raises(OverflowError): + await getattr(iggy_client, method)( + stream_name, topic_name, partitions_count + ) + + @pytest.mark.asyncio + async def test_delete_partitions_accepts_deleting_all_partitions( + self, iggy_client: IggyClient, unique_name + ): + stream_name, topic_name = await _create_topic(iggy_client, unique_name) + + await iggy_client.delete_partitions(stream_name, topic_name, 2) + topic = await iggy_client.get_topic(stream_name, topic_name) + assert topic is not None + assert topic.partitions_count == 0 + assert topic.partitions == [] + + @pytest.mark.asyncio + async def test_partition_management_requires_scoped_manage_topic( + self, iggy_client: IggyClient, unique_name + ): + stream_name, topic_name = await _create_topic(iggy_client, unique_name) + other_topic_name = unique_name() + await iggy_client.create_topic( + stream_name, other_topic_name, partitions_count=2 + ) + stream = await iggy_client.get_stream(stream_name) + assert stream is not None + topic = await iggy_client.get_topic(stream.id, topic_name) + other_topic = await iggy_client.get_topic(stream.id, other_topic_name) + assert topic is not None + assert other_topic is not None + + denied_username, denied_password = unique_credentials(unique_name) + denied_user = await iggy_client.create_user(denied_username, denied_password) + denied = await login_fresh_client(denied_username, denied_password) + for method in ("create_partitions", "delete_partitions"): + with pytest.raises(RuntimeError, match="Unauthorized"): + await getattr(denied, method)(stream.id, topic.id, 1) + unchanged = await iggy_client.get_topic(stream.id, topic.id) + assert unchanged is not None + assert unchanged.partitions_count == 2 + + allowed_username, allowed_password = unique_credentials(unique_name) + allowed_user = await iggy_client.create_user( + allowed_username, + allowed_password, + permissions=Permissions( + streams={ + stream.id: StreamPermissions( + topics={topic.id: TopicPermissions(manage_topic=True)} + ) + } + ), + ) + allowed = await login_fresh_client(allowed_username, allowed_password) + await allowed.create_partitions(stream.id, topic.id, 1) + await allowed.delete_partitions(stream.id, topic.id, 1) + for method in ("create_partitions", "delete_partitions"): + with pytest.raises(RuntimeError, match="Unauthorized"): + await getattr(allowed, method)(stream.id, other_topic.id, 1) + scoped = await iggy_client.get_topic(stream.id, topic.id) + untouched = await iggy_client.get_topic(stream.id, other_topic.id) + assert scoped is not None and scoped.partitions_count == 2 + assert untouched is not None and untouched.partitions_count == 2 + + await iggy_client.delete_user(denied_user.id) + await iggy_client.delete_user(allowed_user.id) diff --git a/foreign/python/tests/test_producer.py b/foreign/python/tests/test_producer.py new file mode 100644 index 0000000000..418b7631ea --- /dev/null +++ b/foreign/python/tests/test_producer.py @@ -0,0 +1,1608 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the high-level producer API and both send modes.""" + +import ast +import asyncio +import contextlib +import os +import subprocess +import sys +import time +from datetime import timedelta +from pathlib import Path + +import pytest + +from apache_iggy import ( + BackgroundProducerConfig, + BackpressureMode, + Consumer, + DirectProducerConfig, + IggyClient, + IggyExpiry, + IggyProducer, + MaxTopicSize, + Partitioning, + PollingStrategy, + ProducerSendError, + ProducerSharding, + SendMessage, + SendMessagesResponse, +) + +from .utils import wait_for_server + +REPOSITORY_ROOT = Path(__file__).resolve().parents[3] +SERVER_BINARY = ( + REPOSITORY_ROOT + / "target" + / "debug" + / ("iggy-server.exe" if sys.platform == "win32" else "iggy-server") +) + + +def spawn_restartable_server( + data_path: Path, + tcp_address: str, +) -> subprocess.Popen[bytes]: + data_path.mkdir(parents=True, exist_ok=True) + environment = os.environ.copy() + # Python test connection settings use the IGGY_SERVER_ prefix, but the + # server rejects them as unknown configuration overrides. + for name in tuple(environment): + if name.startswith("IGGY_SERVER_"): + del environment[name] + environment.update( + { + "IGGY_PATH": str(data_path), + "IGGY_TCP_ADDRESS": tcp_address, + "IGGY_HTTP_ENABLED": "false", + "IGGY_QUIC_ENABLED": "false", + "IGGY_WEBSOCKET_ENABLED": "false", + "IGGY_LOGGING_LEVEL": "error", + } + ) + with (data_path / "server.log").open("ab") as server_log: + return subprocess.Popen( # noqa: S603 + [str(SERVER_BINARY), "--with-default-root-credentials"], + cwd=REPOSITORY_ROOT, + env=environment, + stdout=server_log, + stderr=subprocess.STDOUT, + ) + + +async def stop_restartable_server(process: subprocess.Popen[bytes]) -> None: + if process.poll() is not None: + return + + process.terminate() + try: + await asyncio.to_thread(process.wait, 10) + except subprocess.TimeoutExpired: + process.kill() + await asyncio.to_thread(process.wait) + + +async def discover_server_address( + data_path: Path, + process: subprocess.Popen[bytes], + timeout: float = 15, +) -> tuple[str, int]: + config_path = data_path / "runtime" / "current_config.toml" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + server_log = (data_path / "server.log").read_text(errors="replace") + raise RuntimeError( + f"Restartable Iggy server exited with code {process.returncode}:\n" + f"{server_log[-4_000:]}" + ) + if config_path.exists(): + in_tcp_section = False + for line in config_path.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("["): + in_tcp_section = stripped == "[tcp]" + elif in_tcp_section and stripped.startswith("address = "): + address = stripped.split('"', 2)[1] + host, port = address.rsplit(":", 1) + return host, int(port) + await asyncio.sleep(0.05) + + raise TimeoutError("Restartable Iggy server did not publish its TCP address") + + +async def poll_payloads( + client: IggyClient, + stream: str, + topic: str, + *, + partition_id: int = 0, +) -> list[str]: + messages = await client.poll_messages( + stream=stream, + topic=topic, + consumer=Consumer.Single(1), + partition_id=partition_id, + polling_strategy=PollingStrategy.First(), + count=100, + auto_commit=False, + ) + return [message.payload().decode("utf-8") for message in messages] + + +async def wait_for_payloads( + client: IggyClient, + stream: str, + topic: str, + expected: list[str], + *, + partition_id: int = 0, + timeout: float = 2, +) -> list[str]: + deadline = time.monotonic() + timeout + payloads: list[str] = [] + while time.monotonic() < deadline: + payloads = await poll_payloads( + client, + stream, + topic, + partition_id=partition_id, + ) + if payloads == expected: + return payloads + await asyncio.sleep(0.01) + raise AssertionError( + f"Timed out waiting for payloads {expected!r}; last payloads were {payloads!r}" + ) + + +@pytest.mark.unit +class TestDirectProducerConfig: + """Test direct-mode configuration independently of a server.""" + + def test_defaults_match_the_rust_sdk(self): + config = DirectProducerConfig() + + assert config.batch_length == 1_000 + assert config.linger_time == timedelta(0) + + def test_fields_are_keyword_only_and_round_trip(self): + config = DirectProducerConfig( + batch_length=25, + linger_time=timedelta(milliseconds=125), + ) + + assert config.batch_length == 25 + assert config.linger_time == timedelta(milliseconds=125) + + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-count + DirectProducerConfig(25, timedelta(milliseconds=125)) + + def test_zero_batch_length_retains_the_rust_sentinel(self): + assert DirectProducerConfig(batch_length=0).batch_length == 0 + + def test_largest_batch_length_round_trips(self): + assert DirectProducerConfig(batch_length=2**32 - 1).batch_length == 2**32 - 1 + + @pytest.mark.parametrize("batch_length", [-1, 2**32]) + def test_batch_length_outside_u32_is_rejected(self, batch_length: int): + with pytest.raises(ValueError, match="batch_length"): + DirectProducerConfig(batch_length=batch_length) + + def test_batch_length_binding_overflow_is_distinct(self): + with pytest.raises(OverflowError): + DirectProducerConfig(batch_length=2**63) + + @pytest.mark.parametrize( + "linger_time", + [timedelta(microseconds=-1), timedelta(seconds=-1)], + ) + def test_negative_linger_is_rejected(self, linger_time: timedelta): + with pytest.raises(ValueError, match="negative"): + DirectProducerConfig(linger_time=linger_time) + + def test_linger_above_u64_microseconds_is_rejected(self): + with pytest.raises(ValueError, match="linger_time"): + DirectProducerConfig(linger_time=timedelta(days=999_999_999)) + + def test_wrong_field_types_are_rejected(self): + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + DirectProducerConfig(batch_length="1000") + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + DirectProducerConfig(linger_time=1) + + def test_repr_contains_pasteable_python_values(self): + config = DirectProducerConfig( + batch_length=25, + linger_time=timedelta(milliseconds=125), + ) + + printed = repr(config) + + assert "batch_length=25" in printed + assert "linger_time=datetime.timedelta(microseconds=125000)" in printed + ast.parse(printed) + + +@pytest.mark.unit +class TestBackgroundProducerSurface: + """Test the background configuration model independently of a server.""" + + def test_sharding_values_are_public_and_distinct(self): + assert ProducerSharding.ORDERED != ProducerSharding.BALANCED + assert repr(ProducerSharding.ORDERED) == "ProducerSharding.ORDERED" + assert repr(ProducerSharding.BALANCED) == "ProducerSharding.BALANCED" + + def test_backpressure_constructors_and_getters(self): + blocking = BackpressureMode.block() + timed = BackpressureMode.block_with_timeout(timedelta(milliseconds=250)) + immediate = BackpressureMode.fail_immediately() + + assert blocking.timeout is None + assert timed.timeout == timedelta(milliseconds=250) + assert immediate.timeout is None + assert repr(blocking) == "BackpressureMode.block()" + assert repr(timed) == ( + "BackpressureMode.block_with_timeout(" + "datetime.timedelta(microseconds=250000))" + ) + assert repr(immediate) == "BackpressureMode.fail_immediately()" + + def test_backpressure_timeout_validation(self): + zero = BackpressureMode.block_with_timeout(timedelta(0)) + assert zero.timeout == timedelta(0) + + with pytest.raises(ValueError, match="negative"): + BackpressureMode.block_with_timeout(timedelta(microseconds=-1)) + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + BackpressureMode.block_with_timeout(1) + + def test_defaults_match_the_rust_sdk(self): + config = BackgroundProducerConfig() + + assert config.num_shards == 1 + assert config.linger_time == timedelta(milliseconds=1) + assert config.batch_size == 1024 * 1024 + assert config.batch_length == 1_000 + assert config.max_buffer_size == 32 * 1024 * 1024 + assert repr(config.failure_mode) == "BackpressureMode.block()" + assert config.max_in_flight == 1 + assert config.sharding == ProducerSharding.ORDERED + + def test_every_field_round_trips(self): + failure_mode = BackpressureMode.block_with_timeout(timedelta(milliseconds=500)) + config = BackgroundProducerConfig( + num_shards=4, + linger_time=timedelta(milliseconds=10), + batch_size=256 * 1024, + batch_length=50, + max_buffer_size=8 * 1024 * 1024, + failure_mode=failure_mode, + max_in_flight=3, + sharding=ProducerSharding.BALANCED, + ) + + assert config.num_shards == 4 + assert config.linger_time == timedelta(milliseconds=10) + assert config.batch_size == 256 * 1024 + assert config.batch_length == 50 + assert config.max_buffer_size == 8 * 1024 * 1024 + assert repr(config.failure_mode) == repr(failure_mode) + assert config.max_in_flight == 3 + assert config.sharding == ProducerSharding.BALANCED + + def test_fields_are_keyword_only_and_read_only(self): + config = BackgroundProducerConfig() + + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-count + BackgroundProducerConfig(2) + with pytest.raises(AttributeError): + # pyrefly: ignore # read-only + config.num_shards = 2 + + @pytest.mark.parametrize( + "field", + [ + "num_shards", + "batch_size", + "batch_length", + "max_buffer_size", + "max_in_flight", + ], + ) + def test_unsigned_fields_reject_negative_values(self, field: str): + with pytest.raises(ValueError, match=field): + # pyrefly: ignore # bad-argument-type + BackgroundProducerConfig(**{field: -1}) + + @pytest.mark.parametrize( + "field", + [ + "num_shards", + "batch_size", + "batch_length", + "max_buffer_size", + "max_in_flight", + ], + ) + def test_unsigned_fields_reject_wrong_types(self, field: str): + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + BackgroundProducerConfig(**{field: "1"}) + + def test_max_buffer_size_checks_semantic_range_before_binding_overflow(self): + maximum = 2**64 - 1 + + assert ( + BackgroundProducerConfig(max_buffer_size=maximum).max_buffer_size == maximum + ) + with pytest.raises(ValueError, match="max_buffer_size"): + BackgroundProducerConfig(max_buffer_size=2**64) + with pytest.raises(OverflowError): + BackgroundProducerConfig(max_buffer_size=2**127) + + @pytest.mark.parametrize("value", [0, 1]) + def test_zero_and_one_are_preserved_for_threshold_fields(self, value: int): + config = BackgroundProducerConfig( + num_shards=value, + batch_size=value, + batch_length=value, + max_buffer_size=value, + max_in_flight=value, + ) + + assert config.num_shards == value + assert config.batch_size == value + assert config.batch_length == value + assert config.max_buffer_size == value + assert config.max_in_flight == value + + def test_wrong_variant_and_duration_types_are_rejected(self): + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + BackgroundProducerConfig(linger_time=1) + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + BackgroundProducerConfig(failure_mode="block") + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + BackgroundProducerConfig(sharding="ordered") + + def test_negative_linger_is_rejected(self): + with pytest.raises(ValueError, match="negative"): + BackgroundProducerConfig(linger_time=timedelta(microseconds=-1)) + + def test_repr_contains_every_public_field(self): + config = BackgroundProducerConfig( + num_shards=2, + linger_time=timedelta(milliseconds=5), + batch_size=4_096, + batch_length=8, + max_buffer_size=65_536, + failure_mode=BackpressureMode.fail_immediately(), + max_in_flight=2, + sharding=ProducerSharding.BALANCED, + ) + + printed = repr(config) + + for expected in [ + "num_shards=2", + "linger_time=datetime.timedelta(microseconds=5000)", + "batch_size=4096", + "batch_length=8", + "max_buffer_size=65536", + "failure_mode=BackpressureMode.fail_immediately()", + "max_in_flight=2", + "sharding=ProducerSharding.BALANCED", + ]: + assert expected in printed + ast.parse(printed) + + +class TestProducerCreation: + """Test initialization and producer-owned resource creation.""" + + @pytest.mark.asyncio + async def test_default_producer_creates_resources_and_is_immediately_usable( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name() + topic_name = unique_name() + + producer = await iggy_client.producer(stream_name, topic_name) + try: + response = await producer.send_one(SendMessage("ready")) + + assert isinstance(producer, IggyProducer) + assert isinstance(response, SendMessagesResponse) + assert len(response.confirmations) == 1 + assert await iggy_client.get_stream(stream_name) is not None + assert await iggy_client.get_topic(stream_name, topic_name) is not None + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_none_mode_uses_the_default_direct_mode( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer( + unique_name(), + unique_name(), + mode=None, + ) + try: + response = await producer.send_one(SendMessage("default mode")) + assert len(response.confirmations) == 1 + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_existing_resources_work_when_creation_is_disabled( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name() + topic_name = unique_name() + await iggy_client.create_stream(stream_name) + await iggy_client.create_topic(stream_name, topic_name, 1) + + producer = await iggy_client.producer( + stream_name, + topic_name, + create_stream_if_not_exists=False, + create_topic_if_not_exists=False, + ) + try: + response = await producer.send_one(SendMessage("existing")) + assert len(response.confirmations) == 1 + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_missing_stream_fails_without_returning_a_producer( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name() + topic_name = unique_name() + not_returned = object() + result = not_returned + + with pytest.raises(RuntimeError): + result = await iggy_client.producer( + stream_name, + topic_name, + create_stream_if_not_exists=False, + ) + + assert result is not_returned + assert await iggy_client.get_stream(stream_name) is None + + @pytest.mark.asyncio + async def test_missing_topic_fails_after_creating_the_bound_stream( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name() + topic_name = unique_name() + not_returned = object() + result = not_returned + + with pytest.raises(RuntimeError): + result = await iggy_client.producer( + stream_name, + topic_name, + create_topic_if_not_exists=False, + ) + + assert result is not_returned + assert await iggy_client.get_stream(stream_name) is not None + assert await iggy_client.get_topic(stream_name, topic_name) is None + + @pytest.mark.asyncio + async def test_created_topic_uses_requested_topology_and_retention( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name() + topic_name = unique_name() + expiry = timedelta(minutes=15) + maximum_size = 2_000_000_000 + + producer = await iggy_client.producer( + stream_name, + topic_name, + topic_partitions_count=3, + topic_message_expiry=IggyExpiry.ExpireDuration(expiry), + topic_max_size=MaxTopicSize.Custom(maximum_size), + ) + try: + topic = await iggy_client.get_topic(stream_name, topic_name) + + assert topic is not None + assert topic.partitions_count == 3 + assert isinstance(topic.message_expiry, IggyExpiry.ExpireDuration) + assert topic.message_expiry.duration == expiry + assert isinstance(topic.max_topic_size, MaxTopicSize.Custom) + assert topic.max_topic_size.bytes == maximum_size + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_background_producer_is_initialized_and_immediately_usable( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name("background-", min_bytes=20, max_bytes=20) + topic_name = unique_name("topic-", min_bytes=20, max_bytes=20) + producer = await iggy_client.producer( + stream_name, + topic_name, + mode=BackgroundProducerConfig(), + ) + try: + response = await producer.send_one(SendMessage("ready")) + + assert isinstance(producer, IggyProducer) + assert response.confirmations == [] + finally: + await producer.shutdown() + + assert await wait_for_payloads( + iggy_client, stream_name, topic_name, ["ready"] + ) == ["ready"] + + +class TestProducerSends: + """Test all direct send operations and partitioning fallbacks.""" + + @pytest.mark.asyncio + async def test_send_and_send_one_return_confirmations( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer(unique_name(), unique_name()) + try: + batch = await producer.send([SendMessage("first"), SendMessage("second")]) + single = await producer.send_one(SendMessage("third")) + + assert isinstance(batch, SendMessagesResponse) + assert isinstance(single, SendMessagesResponse) + assert len(batch.confirmations) == 1 + assert len(single.confirmations) == 1 + assert single.confirmations[0].base_offset == 2 + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_default_producer_partitioning_is_balanced( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer( + unique_name(), + unique_name(), + topic_partitions_count=3, + ) + try: + responses = [ + await producer.send_one(SendMessage(str(index))) for index in range(3) + ] + + assert { + response.confirmations[0].partition_id for response in responses + } == {0, 1, 2} + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_per_call_partitioning_overrides_and_falls_back( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer( + unique_name(), + unique_name(), + partitioning=Partitioning.partition_id(2), + topic_partitions_count=3, + ) + try: + configured = await producer.send([SendMessage("configured")]) + overridden = await producer.send_with_partitioning( + [SendMessage("override")], Partitioning.partition_id(0) + ) + omitted = await producer.send_with_partitioning([SendMessage("omitted")]) + explicit_none = await producer.send_with_partitioning( + [SendMessage("none")], None + ) + + assert configured.confirmations[0].partition_id == 2 + assert overridden.confirmations[0].partition_id == 0 + assert omitted.confirmations[0].partition_id == 2 + assert explicit_none.confirmations[0].partition_id == 2 + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_send_to_accepts_string_and_numeric_identifiers( + self, iggy_client: IggyClient, unique_name + ): + destination_stream = unique_name() + destination_topic = unique_name() + await iggy_client.create_stream(destination_stream) + await iggy_client.create_topic(destination_stream, destination_topic, 2) + stream = await iggy_client.get_stream(destination_stream) + topic = await iggy_client.get_topic(destination_stream, destination_topic) + assert stream is not None + assert topic is not None + + producer = await iggy_client.producer( + unique_name(), + unique_name(), + partitioning=Partitioning.partition_id(1), + ) + try: + by_name = await producer.send_to( + destination_stream, + destination_topic, + [SendMessage("names")], + ) + by_id = await producer.send_to( + stream.id, + topic.id, + [SendMessage("ids")], + None, + ) + + assert by_name.confirmations[0].stream_id == stream.id + assert by_name.confirmations[0].topic_id == topic.id + assert by_name.confirmations[0].partition_id == 1 + assert by_id.confirmations[0].stream_id == stream.id + assert by_id.confirmations[0].topic_id == topic.id + assert by_id.confirmations[0].partition_id == 1 + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_empty_batches_are_successful_no_ops_before_shutdown( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name() + topic_name = unique_name() + producer = await iggy_client.producer(stream_name, topic_name) + try: + responses = [ + await producer.send([]), + await producer.send_with_partitioning([]), + await producer.send_to(stream_name, topic_name, []), + ] + + assert all( + isinstance(response, SendMessagesResponse) for response in responses + ) + assert all(response.confirmations == [] for response in responses) + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_batch_length_splits_direct_requests( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer( + unique_name(), + unique_name(), + mode=DirectProducerConfig(batch_length=2), + ) + try: + response = await producer.send( + [SendMessage(str(index)) for index in range(5)] + ) + + assert len(response.confirmations) == 3 + assert [ + confirmation.base_offset for confirmation in response.confirmations + ] == [0, 2, 4] + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_linger_paces_sequential_direct_sends( + self, iggy_client: IggyClient, unique_name + ): + linger = timedelta(milliseconds=150) + producer = await iggy_client.producer( + unique_name(), + unique_name(), + mode=DirectProducerConfig(linger_time=linger), + ) + try: + await producer.send_one(SendMessage("first")) + started_at = time.monotonic() + await producer.send_one(SendMessage("second")) + elapsed = time.monotonic() - started_at + + assert elapsed >= 0.1 + finally: + await producer.shutdown() + + +class TestBackgroundProducerSends: + """Test background acceptance, batching, routing, and graceful flush.""" + + @pytest.mark.asyncio + async def test_all_send_methods_accept_messages_without_confirmations( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name("bg-stream-", min_bytes=20, max_bytes=20) + topic_name = unique_name("bg-topic-", min_bytes=20, max_bytes=20) + destination_stream = unique_name("to-stream-", min_bytes=20, max_bytes=20) + destination_topic = unique_name("to-topic-", min_bytes=20, max_bytes=20) + await iggy_client.create_stream(destination_stream) + await iggy_client.create_topic(destination_stream, destination_topic, 1) + + producer = await iggy_client.producer( + stream_name, + topic_name, + partitioning=Partitioning.partition_id(1), + mode=BackgroundProducerConfig( + num_shards=2, + linger_time=timedelta(seconds=60), + batch_size=0, + batch_length=0, + max_in_flight=2, + sharding=ProducerSharding.BALANCED, + ), + topic_partitions_count=3, + ) + try: + responses = [ + await producer.send( + [SendMessage("batch-one"), SendMessage("batch-two")] + ), + await producer.send_one(SendMessage("single")), + await producer.send_with_partitioning( + [SendMessage("override")], Partitioning.partition_id(2) + ), + await producer.send_to( + destination_stream, + destination_topic, + [SendMessage("send-to")], + Partitioning.partition_id(0), + ), + ] + + assert all( + isinstance(response, SendMessagesResponse) for response in responses + ) + assert all(response.confirmations == [] for response in responses) + finally: + await producer.shutdown() + + payloads = await poll_payloads( + iggy_client, stream_name, topic_name, partition_id=1 + ) + assert sorted(payloads) == ["batch-one", "batch-two", "single"] + assert await poll_payloads( + iggy_client, stream_name, topic_name, partition_id=2 + ) == ["override"] + assert await poll_payloads( + iggy_client, destination_stream, destination_topic + ) == ["send-to"] + + @pytest.mark.asyncio + async def test_batch_length_flushes_after_the_configured_number_of_sends( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name("length-", min_bytes=12, max_bytes=12) + topic_name = unique_name("topic-", min_bytes=12, max_bytes=12) + producer = await iggy_client.producer( + stream_name, + topic_name, + mode=BackgroundProducerConfig( + linger_time=timedelta(seconds=5), + batch_size=0, + batch_length=2, + ), + ) + try: + await producer.send([SendMessage("first-a"), SendMessage("first-b")]) + await asyncio.sleep(0.05) + assert await poll_payloads(iggy_client, stream_name, topic_name) == [] + + await producer.send_one(SendMessage("second-send")) + assert await wait_for_payloads( + iggy_client, + stream_name, + topic_name, + ["first-a", "first-b", "second-send"], + ) == ["first-a", "first-b", "second-send"] + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_batch_size_flushes_after_buffered_bytes_reach_the_threshold( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name("size-", min_bytes=12, max_bytes=12) + topic_name = unique_name("topic-", min_bytes=12, max_bytes=12) + first = "a" * 50 + second = "b" * 50 + producer = await iggy_client.producer( + stream_name, + topic_name, + mode=BackgroundProducerConfig( + linger_time=timedelta(seconds=5), + batch_size=200, + batch_length=0, + ), + ) + try: + # With two 12-byte identifiers and the 64-byte message header, + # either send is below 200 reported bytes while both exceed it. + await producer.send_one(SendMessage(first)) + await asyncio.sleep(0.05) + assert await poll_payloads(iggy_client, stream_name, topic_name) == [] + + await producer.send_one(SendMessage(second)) + assert await wait_for_payloads( + iggy_client, stream_name, topic_name, [first, second] + ) == [first, second] + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_linger_time_flushes_a_non_empty_buffer( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name("linger-", min_bytes=12, max_bytes=12) + topic_name = unique_name("topic-", min_bytes=12, max_bytes=12) + producer = await iggy_client.producer( + stream_name, + topic_name, + mode=BackgroundProducerConfig( + linger_time=timedelta(milliseconds=200), + batch_size=0, + batch_length=0, + ), + ) + try: + await producer.send_one(SendMessage("linger")) + await asyncio.sleep(0.05) + assert await poll_payloads(iggy_client, stream_name, topic_name) == [] + assert await wait_for_payloads( + iggy_client, stream_name, topic_name, ["linger"] + ) == ["linger"] + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_zero_sentinels_use_one_shard_and_unlimited_capacity( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name("zero-", min_bytes=12, max_bytes=12) + topic_name = unique_name("topic-", min_bytes=12, max_bytes=12) + producer = await iggy_client.producer( + stream_name, + topic_name, + mode=BackgroundProducerConfig( + num_shards=0, + linger_time=timedelta(0), + batch_size=0, + batch_length=0, + max_buffer_size=0, + max_in_flight=0, + ), + ) + try: + response = await producer.send_one(SendMessage("zero sentinels")) + assert response.confirmations == [] + finally: + await producer.shutdown() + + assert await poll_payloads(iggy_client, stream_name, topic_name) == [ + "zero sentinels" + ] + + @pytest.mark.asyncio + async def test_ordered_sharding_preserves_order_for_one_destination( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name("ordered-", min_bytes=16, max_bytes=16) + topic_name = unique_name("topic-", min_bytes=16, max_bytes=16) + expected = [str(index) for index in range(30)] + producer = await iggy_client.producer( + stream_name, + topic_name, + mode=BackgroundProducerConfig( + num_shards=4, + linger_time=timedelta(0), + batch_size=0, + batch_length=1, + max_in_flight=4, + sharding=ProducerSharding.ORDERED, + ), + ) + try: + for payload in expected: + await producer.send_one(SendMessage(payload)) + finally: + await producer.shutdown() + + assert await poll_payloads(iggy_client, stream_name, topic_name) == expected + + +class TestBackgroundProducerBackpressure: + """Test each byte-budget backpressure policy through the Python API.""" + + @staticmethod + def config( + failure_mode: BackpressureMode, + *, + linger_time: timedelta, + ) -> BackgroundProducerConfig: + return BackgroundProducerConfig( + linger_time=linger_time, + batch_size=0, + batch_length=0, + max_buffer_size=250, + failure_mode=failure_mode, + ) + + @pytest.mark.asyncio + async def test_fail_immediately_rejects_a_send_when_the_buffer_is_full( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer( + unique_name("fail-", min_bytes=12, max_bytes=12), + unique_name("topic-", min_bytes=12, max_bytes=12), + mode=self.config( + BackpressureMode.fail_immediately(), + linger_time=timedelta(seconds=5), + ), + ) + try: + assert (await producer.send_one(SendMessage("a" * 128))).confirmations == [] + with pytest.raises(RuntimeError, match="(?i)buffer|overflow"): + await producer.send_one(SendMessage("b" * 128)) + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_block_with_timeout_waits_then_reports_a_timeout( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer( + unique_name("timeout-", min_bytes=16, max_bytes=16), + unique_name("topic-", min_bytes=16, max_bytes=16), + mode=self.config( + BackpressureMode.block_with_timeout(timedelta(milliseconds=100)), + linger_time=timedelta(seconds=5), + ), + ) + try: + await producer.send_one(SendMessage("a" * 128)) + started_at = time.monotonic() + with pytest.raises(RuntimeError, match="(?i)timeout"): + await producer.send_one(SendMessage("b" * 128)) + elapsed = time.monotonic() - started_at + + assert 0.05 <= elapsed < 1 + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_block_waits_until_a_previous_batch_releases_capacity( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer( + unique_name("block-", min_bytes=12, max_bytes=12), + unique_name("topic-", min_bytes=12, max_bytes=12), + mode=self.config( + BackpressureMode.block(), + linger_time=timedelta(milliseconds=200), + ), + ) + try: + await producer.send_one(SendMessage("a" * 128)) + started_at = time.monotonic() + response = await producer.send_one(SendMessage("b" * 128)) + elapsed = time.monotonic() - started_at + + assert response.confirmations == [] + assert elapsed >= 0.1 + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_batch_larger_than_the_total_budget_fails_without_blocking( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer( + unique_name("oversize-", min_bytes=16, max_bytes=16), + unique_name("topic-", min_bytes=16, max_bytes=16), + mode=BackgroundProducerConfig( + linger_time=timedelta(seconds=5), + batch_size=0, + batch_length=0, + max_buffer_size=100, + failure_mode=BackpressureMode.block(), + ), + ) + try: + with pytest.raises(RuntimeError, match="(?i)buffer|overflow"): + await asyncio.wait_for( + producer.send_one(SendMessage("larger than the budget")), + timeout=1, + ) + finally: + await producer.shutdown() + + +class TestProducerLifecycle: + """Test producer ownership, shutdown, and asynchronous context management.""" + + @pytest.mark.asyncio + async def test_linger_sends_run_concurrently_under_shared_lifecycle_access( + self, iggy_client: IggyClient, unique_name + ): + linger_seconds = 2 + producer = await iggy_client.producer( + unique_name(), + unique_name(), + mode=DirectProducerConfig(linger_time=timedelta(seconds=linger_seconds)), + ) + try: + await producer.send_one(SendMessage("prime linger")) + started_at = time.monotonic() + responses = await asyncio.gather( + producer.send_one(SendMessage("concurrent one")), + producer.send_one(SendMessage("concurrent two")), + ) + elapsed = time.monotonic() - started_at + + assert len(responses) == 2 + assert all(len(response.confirmations) == 1 for response in responses) + # Both reads wait against the same timestamp. A mutex around the + # producer would make the second wait through another full linger. + assert 1.2 <= elapsed < 3.6 + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_shutdown_is_sequentially_and_concurrently_idempotent( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer(unique_name(), unique_name()) + + assert await asyncio.gather(producer.shutdown(), producer.shutdown()) == [ + None, + None, + ] + assert await producer.shutdown() is None + + @pytest.mark.asyncio + async def test_background_shutdown_is_idempotent_and_flushes_accepted_messages( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name("shutdown-", min_bytes=20, max_bytes=20) + topic_name = unique_name("topic-", min_bytes=20, max_bytes=20) + producer = await iggy_client.producer( + stream_name, + topic_name, + mode=BackgroundProducerConfig( + linger_time=timedelta(seconds=60), + batch_size=0, + batch_length=0, + ), + ) + await producer.send([SendMessage("one"), SendMessage("two")]) + + assert await asyncio.gather(producer.shutdown(), producer.shutdown()) == [ + None, + None, + ] + assert await producer.shutdown() is None + assert await poll_payloads(iggy_client, stream_name, topic_name) == [ + "one", + "two", + ] + + @pytest.mark.asyncio + async def test_every_send_rejects_use_after_shutdown( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name() + topic_name = unique_name() + producer = await iggy_client.producer(stream_name, topic_name) + await producer.shutdown() + + calls = [ + producer.send([]), + producer.send_one(SendMessage("closed")), + producer.send_with_partitioning([], None), + producer.send_to(stream_name, topic_name, [], None), + ] + for call in calls: + with pytest.raises(RuntimeError, match="closed|shut down"): + await call + + @pytest.mark.asyncio + async def test_shutdown_waits_for_a_send_holding_lifecycle_access( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer( + unique_name(), + unique_name(), + mode=DirectProducerConfig(linger_time=timedelta(milliseconds=150)), + ) + await producer.send_one(SendMessage("prime linger")) + + sending = asyncio.ensure_future(producer.send_one(SendMessage("in flight"))) + + async def wait_for_active_send(): + # pyrefly: ignore # missing-attribute + while not producer._is_send_active(): + await asyncio.sleep(0) + + await asyncio.wait_for(wait_for_active_send(), timeout=1) + shutting_down = asyncio.ensure_future(producer.shutdown()) + + response, shutdown_result = await asyncio.wait_for( + asyncio.gather(sending, shutting_down), timeout=2 + ) + assert len(response.confirmations) == 1 + assert shutdown_result is None + with pytest.raises(RuntimeError, match="closed|shut down"): + await producer.send_one(SendMessage("too late")) + + @pytest.mark.asyncio + async def test_background_send_racing_shutdown_completes_without_deadlock( + self, iggy_client: IggyClient, unique_name + ): + stream_name = unique_name("race-", min_bytes=12, max_bytes=12) + topic_name = unique_name("topic-", min_bytes=12, max_bytes=12) + producer = await iggy_client.producer( + stream_name, + topic_name, + mode=BackgroundProducerConfig( + linger_time=timedelta(milliseconds=200), + batch_size=0, + batch_length=0, + max_buffer_size=250, + failure_mode=BackpressureMode.block(), + ), + ) + await producer.send_one(SendMessage("a" * 128)) + sending = asyncio.ensure_future(producer.send_one(SendMessage("b" * 128))) + + async def wait_for_blocked_send(): + # pyrefly: ignore # missing-attribute + while not producer._is_send_active(): + await asyncio.sleep(0) + + await asyncio.wait_for(wait_for_blocked_send(), timeout=1) + shutting_down = asyncio.ensure_future(producer.shutdown()) + response, shutdown_result = await asyncio.wait_for( + asyncio.gather(sending, shutting_down), timeout=2 + ) + + assert response.confirmations == [] + assert shutdown_result is None + assert await poll_payloads(iggy_client, stream_name, topic_name) == [ + "a" * 128, + "b" * 128, + ] + + @pytest.mark.asyncio + async def test_async_context_manager_returns_self_and_closes_normally( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer(unique_name(), unique_name()) + + async with producer as entered: + assert entered is producer + assert ( + len((await entered.send_one(SendMessage("inside"))).confirmations) == 1 + ) + + with pytest.raises(RuntimeError, match="closed|shut down"): + await producer.send_one(SendMessage("outside")) + + @pytest.mark.asyncio + async def test_async_context_manager_closes_on_exception_without_suppressing_it( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer(unique_name(), unique_name()) + + with pytest.raises(LookupError, match="application failure"): + async with producer: + raise LookupError("application failure") + + with pytest.raises(RuntimeError, match="closed|shut down"): + await producer.send_one(SendMessage("outside")) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "raises_inside", [False, True], ids=["normal", "exception"] + ) + async def test_background_context_manager_flushes_on_every_exit( + self, iggy_client: IggyClient, unique_name, raises_inside: bool + ): + stream_name = unique_name("context-", min_bytes=20, max_bytes=20) + topic_name = unique_name("topic-", min_bytes=20, max_bytes=20) + producer = await iggy_client.producer( + stream_name, + topic_name, + mode=BackgroundProducerConfig( + linger_time=timedelta(seconds=60), + batch_size=0, + batch_length=0, + ), + ) + + async def use_context(): + async with producer: + await producer.send_one(SendMessage("buffered")) + if raises_inside: + raise LookupError("application failure") + + if raises_inside: + with pytest.raises(LookupError, match="application failure"): + await use_context() + else: + await use_context() + + assert await poll_payloads(iggy_client, stream_name, topic_name) == ["buffered"] + with pytest.raises(RuntimeError, match="closed|shut down"): + await producer.send_one(SendMessage("outside")) + + +class TestProducerValidationAndRetries: + """Test stable error categories and observable retry policy behavior.""" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("kwargs", "expected_exception"), + [ + ({"partitioning": 0}, TypeError), + ({"mode": object()}, TypeError), + ({"topic_partitions_count": -1}, ValueError), + ({"topic_partitions_count": 2**32}, ValueError), + ({"topic_partitions_count": 2**63}, OverflowError), + ({"topic_message_expiry": timedelta(seconds=1)}, TypeError), + ({"topic_max_size": 1000}, TypeError), + ({"send_retries": -1}, ValueError), + ({"send_retries": 2**32}, ValueError), + ({"send_retries": 2**63}, OverflowError), + ({"send_retry_interval": timedelta(0)}, ValueError), + ({"send_retry_interval": timedelta(microseconds=-1)}, ValueError), + ({"send_retry_interval": 1}, TypeError), + ], + ) + async def test_producer_configuration_errors_have_stable_categories( + self, + iggy_client: IggyClient, + unique_name, + kwargs: dict, + expected_exception: type[Exception], + ): + with pytest.raises(expected_exception): + await iggy_client.producer(unique_name(), unique_name(), **kwargs) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "mode", + [ + BackgroundProducerConfig(max_buffer_size=2**64 - 1), + BackgroundProducerConfig(max_in_flight=sys.maxsize), + ], + ids=["max-buffer-size", "max-in-flight"], + ) + async def test_background_limits_that_would_panic_rust_are_value_errors( + self, + iggy_client: IggyClient, + unique_name, + mode: BackgroundProducerConfig, + ): + with pytest.raises(ValueError, match="must not exceed"): + await iggy_client.producer( + unique_name(), + unique_name(), + mode=mode, + ) + + @pytest.mark.asyncio + async def test_bound_destination_requires_names( + self, iggy_client: IggyClient, unique_name + ): + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + await iggy_client.producer(1, unique_name()) + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + await iggy_client.producer(unique_name(), 1) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("stream", "topic"), + [ + ("", "topic"), + ("stream", ""), + ("x" * 256, "topic"), + ("stream", "x" * 256), + ], + ) + async def test_bound_destination_rejects_invalid_names_as_value_errors( + self, iggy_client: IggyClient, stream: str, topic: str + ): + with pytest.raises(ValueError): + await iggy_client.producer(stream, topic) + + @pytest.mark.asyncio + async def test_send_arguments_reject_low_level_shorthands_and_wrong_types( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer(unique_name(), unique_name()) + try: + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + await producer.send((SendMessage("tuple"),)) + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + await producer.send(["payload"]) + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + await producer.send_one("payload") + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + await producer.send_with_partitioning([SendMessage("integer")], 0) + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-type + await producer.send_to(object(), "topic", [SendMessage("bad stream")]) + finally: + await producer.shutdown() + + @pytest.mark.asyncio + @pytest.mark.parametrize("identifier", [-1, 2**32, 2**63]) + async def test_send_to_identifier_overflow_is_not_folded_into_type_error( + self, iggy_client: IggyClient, unique_name, identifier: int + ): + producer = await iggy_client.producer(unique_name(), unique_name()) + try: + for stream, topic in [(identifier, "topic"), ("stream", identifier)]: + with pytest.raises(OverflowError): + await producer.send_to( + stream, + topic, + [SendMessage("outside u32")], + ) + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_server_send_errors_preserve_recovery_state( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer( + unique_name(), + unique_name(), + send_retries=0, + ) + try: + with pytest.raises(ProducerSendError) as raised: + await producer.send_with_partitioning( + [SendMessage("missing partition")], + Partitioning.partition_id(1), + ) + + error = raised.value + assert isinstance(error, RuntimeError) + assert error.cause + assert isinstance(error.__cause__, RuntimeError) + assert str(error.__cause__) == error.cause + assert len(error.failed) == 1 + assert isinstance(error.failed[0], SendMessage) + assert error.committed == [] + finally: + await producer.shutdown() + + @pytest.mark.asyncio + @pytest.mark.parametrize("send_retries", [None, 0]) + async def test_none_and_zero_disable_retries( + self, iggy_client: IggyClient, unique_name, send_retries: int | None + ): + producer = await iggy_client.producer( + unique_name(), + unique_name(), + send_retries=send_retries, + send_retry_interval=timedelta(seconds=5), + ) + try: + with pytest.raises(RuntimeError): + await asyncio.wait_for( + producer.send_to( + unique_name(), unique_name(), [SendMessage("single attempt")] + ), + timeout=1, + ) + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_explicit_none_interval_retries_without_delay( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer( + unique_name(), + unique_name(), + send_retries=10, + send_retry_interval=None, + ) + try: + with pytest.raises(RuntimeError): + await asyncio.wait_for( + producer.send_to( + unique_name(), unique_name(), [SendMessage("immediate retries")] + ), + timeout=1, + ) + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_custom_retry_interval_paces_later_retries( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer( + unique_name(), + unique_name(), + send_retries=2, + send_retry_interval=timedelta(milliseconds=150), + ) + try: + started_at = time.monotonic() + with pytest.raises(RuntimeError): + await producer.send_to( + unique_name(), unique_name(), [SendMessage("paced retries")] + ) + elapsed = time.monotonic() - started_at + + assert elapsed >= 0.1 + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_default_retry_policy_retains_the_rust_interval( + self, iggy_client: IggyClient, unique_name + ): + producer = await iggy_client.producer(unique_name(), unique_name()) + try: + started_at = time.monotonic() + with pytest.raises(RuntimeError): + await asyncio.wait_for( + producer.send_to( + unique_name(), unique_name(), [SendMessage("default retries")] + ), + timeout=6, + ) + elapsed = time.monotonic() - started_at + + # The first retry is immediate. Three retries with the one-second + # default wait for two later interval ticks. + assert elapsed >= 1.5 + finally: + await producer.shutdown() + + @pytest.mark.asyncio + async def test_background_write_retries_until_a_destination_appears( + self, iggy_client: IggyClient, unique_name + ): + destination_stream = unique_name("retry-stream-", min_bytes=24, max_bytes=24) + destination_topic = unique_name("retry-topic-", min_bytes=24, max_bytes=24) + producer = await iggy_client.producer( + unique_name("bound-stream-", min_bytes=24, max_bytes=24), + unique_name("bound-topic-", min_bytes=24, max_bytes=24), + mode=BackgroundProducerConfig( + linger_time=timedelta(0), + batch_length=1, + ), + send_retries=10, + send_retry_interval=timedelta(milliseconds=100), + ) + try: + response = await producer.send_to( + destination_stream, + destination_topic, + [SendMessage("eventual destination")], + ) + assert response.confirmations == [] + + # The first retry is immediate. Create the destination before a + # later interval tick so the worker can recover asynchronously. + await asyncio.sleep(0.03) + await iggy_client.create_stream(destination_stream) + await iggy_client.create_topic(destination_stream, destination_topic, 1) + finally: + await producer.shutdown() + + assert await wait_for_payloads( + iggy_client, + destination_stream, + destination_topic, + ["eventual destination"], + ) == ["eventual destination"] + + @pytest.mark.asyncio + async def test_background_write_reconnects_after_server_restart( + self, + tmp_path: Path, + unique_name, + ): + data_path = tmp_path / "restartable-server" + server = spawn_restartable_server(data_path, "127.0.0.1:0") + producer: IggyProducer | None = None + + try: + host, port = await discover_server_address(data_path, server) + await asyncio.to_thread(wait_for_server, host, port, 15, 1) + client = IggyClient.from_connection_string( + f"iggy+tcp://iggy:iggy@{host}:{port}" + "?reconnection_interval=100ms&reestablish_after=0" + ) + await client.connect() + + stream = unique_name("reconnect-stream-", min_bytes=28, max_bytes=28) + topic = unique_name("reconnect-topic-", min_bytes=28, max_bytes=28) + producer = await client.producer( + stream, + topic, + mode=BackgroundProducerConfig( + linger_time=timedelta(0), + batch_length=1, + ), + send_retries=20, + send_retry_interval=timedelta(milliseconds=100), + ) + + await stop_restartable_server(server) + server = None + accepted = await producer.send_one(SendMessage("after restart")) + assert accepted.confirmations == [] + + server = spawn_restartable_server(data_path, f"{host}:{port}") + await asyncio.to_thread(wait_for_server, host, port, 15, 1) + await asyncio.wait_for(producer.shutdown(), timeout=15) + producer = None + + assert await wait_for_payloads( + client, + stream, + topic, + ["after restart"], + timeout=10, + ) == ["after restart"] + finally: + if producer is not None: + if server is None: + server = spawn_restartable_server(data_path, f"{host}:{port}") + await asyncio.to_thread(wait_for_server, host, port, 15, 1) + with contextlib.suppress(RuntimeError, TimeoutError): + await asyncio.wait_for(producer.shutdown(), timeout=5) + if server is not None: + await stop_restartable_server(server) diff --git a/foreign/python/tests/test_quic_config.py b/foreign/python/tests/test_quic_config.py new file mode 100644 index 0000000000..eb4089a154 --- /dev/null +++ b/foreign/python/tests/test_quic_config.py @@ -0,0 +1,494 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Tests for the QUIC client configuration surface. + +`QuicConfig` and `QuicReconnectionConfig` mirror the Rust SDK types the same +way `TcpConfig`/`TcpReconnectionConfig` do, so most of these assert that a +value set from Python survives to the getters and that unset fields fall +back to the Rust defaults. `AutoLogin` is transport-agnostic and already +covered by `test_client_config.py`. +""" + +import ast +import socket +from collections.abc import Callable +from datetime import timedelta + +import pytest + +from apache_iggy import AutoLogin, IggyClient, QuicConfig, QuicReconnectionConfig + +from .utils import get_quic_server_config, wait_for_ping + + +@pytest.mark.unit +class TestQuicReconnectionConfig: + """Test the reconnection policy.""" + + def test_defaults_match_the_rust_sdk(self): + """Test that an unconfigured policy reconnects forever, one second apart.""" + reconnection = QuicReconnectionConfig() + + assert reconnection.enabled is True + assert reconnection.max_retries is None + assert reconnection.interval == timedelta(seconds=1) + assert reconnection.reestablish_after == timedelta(seconds=5) + + def test_every_field_round_trips(self): + """Test that each configured field is readable back unchanged.""" + reconnection = QuicReconnectionConfig( + enabled=False, + max_retries=10, + interval=timedelta(milliseconds=250), + reestablish_after=timedelta(seconds=30), + ) + + assert reconnection.enabled is False + assert reconnection.max_retries == 10 + assert reconnection.interval == timedelta(milliseconds=250) + assert reconnection.reestablish_after == timedelta(seconds=30) + + def test_arguments_are_keyword_only(self): + """Test that the adjacent flags cannot be passed positionally.""" + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-count + QuicReconnectionConfig(True) + + @pytest.mark.parametrize( + "construct", + [ + lambda duration: QuicReconnectionConfig(interval=duration), + lambda duration: QuicReconnectionConfig(reestablish_after=duration), + ], + ids=["interval", "reestablish_after"], + ) + @pytest.mark.parametrize( + "negative", + [timedelta(microseconds=-1), timedelta(seconds=-1), timedelta(days=-1)], + ) + def test_negative_duration_is_rejected( + self, + construct: Callable[[timedelta], QuicReconnectionConfig], + negative: timedelta, + ): + """Test that a negative duration fails at construction, not at connect.""" + with pytest.raises(ValueError, match="negative"): + construct(negative) + + @pytest.mark.parametrize("out_of_range", [-1, 2**32]) + def test_out_of_range_max_retries_is_rejected(self, out_of_range: int): + """Test that a retry count outside the wire range names the argument. + + The conversion pyo3 does on its own raises OverflowError, which is not a + ValueError and so escapes the handler a caller wraps construction in. + """ + with pytest.raises(ValueError, match="max_retries"): + QuicReconnectionConfig(max_retries=out_of_range) + + def test_zero_reestablish_after_is_allowed(self): + """Test that a zero cooldown is legal and readable back.""" + reconnection = QuicReconnectionConfig(reestablish_after=timedelta(0)) + + assert reconnection.reestablish_after == timedelta(0) + + @pytest.mark.parametrize( + "kwargs", + [ + {}, + {"max_retries": 5}, + {"enabled": False}, + ], + ids=["unlimited_retries", "bounded_retries", "reconnection_disabled"], + ) + def test_zero_interval_is_rejected(self, kwargs: dict): + """Test that a zero interval fails whatever the retry policy is. + + The interval is a delay between passes, so zero reconnects in a + continuous loop. + """ + with pytest.raises(ValueError, match=r"interval.*must not be zero"): + QuicReconnectionConfig(interval=timedelta(0), **kwargs) + + def test_very_long_interval_round_trips(self): + """Test that an interval beyond 68 years survives the i32 boundary.""" + reconnection = QuicReconnectionConfig(interval=timedelta(days=30_000)) + + assert reconnection.interval == timedelta(days=30_000) + + def test_maximum_interval_round_trips(self): + """Test that the largest timedelta survives the day conversion. + + The repr is asserted in seconds rather than days: it is rendered from + a microsecond count, so the maximum comes back as a whole-second + `timedelta` instead of the `days=` form it was constructed with. + """ + maximum = timedelta(days=999_999_999) + + reconnection = QuicReconnectionConfig(interval=maximum) + + printed = repr(reconnection) + + assert reconnection.interval == maximum + assert "interval=datetime.timedelta(seconds=86399999913600)" in printed + ast.parse(printed) + + +@pytest.mark.unit +class TestQuicConfig: + """Test the transport configuration.""" + + def test_defaults_match_the_rust_sdk(self): + """Test that an unconfigured transport matches the Rust SDK defaults.""" + config = QuicConfig() + + assert config.server_address == "127.0.0.1:8080" + assert config.client_address == "127.0.0.1:0" + assert config.server_name == "localhost" + assert config.auto_login.enabled is False + assert config.reconnection.enabled is True + assert config.heartbeat_interval == timedelta(seconds=5) + assert config.response_buffer_size == 10_000_000 + assert config.max_concurrent_bidi_streams == 10_000 + assert config.datagram_send_buffer_size == 100_000 + assert config.initial_mtu == 1200 + assert config.send_window == 100_000 + assert config.receive_window == 100_000 + assert config.keep_alive_interval == timedelta(milliseconds=5000) + assert config.max_idle_timeout == timedelta(milliseconds=10_000) + assert config.validate_certificate is False + + def test_every_field_round_trips(self): + """Test that each configured field is readable back unchanged.""" + config = QuicConfig( + server_address="127.0.0.1:8081", + client_address="127.0.0.1:9000", + server_name="example.com", + auto_login=AutoLogin.username_password("iggy", "iggy"), + reconnection=QuicReconnectionConfig(max_retries=3), + heartbeat_interval=timedelta(seconds=15), + response_buffer_size=5_000_000, + max_concurrent_bidi_streams=500, + datagram_send_buffer_size=50_000, + initial_mtu=1400, + send_window=200_000, + receive_window=200_000, + keep_alive_interval=timedelta(seconds=2), + max_idle_timeout=timedelta(seconds=20), + validate_certificate=True, + ) + + assert config.server_address == "127.0.0.1:8081" + assert config.client_address == "127.0.0.1:9000" + assert config.server_name == "example.com" + assert config.auto_login.username == "iggy" + assert config.reconnection.max_retries == 3 + assert config.heartbeat_interval == timedelta(seconds=15) + assert config.response_buffer_size == 5_000_000 + assert config.max_concurrent_bidi_streams == 500 + assert config.datagram_send_buffer_size == 50_000 + assert config.initial_mtu == 1400 + assert config.send_window == 200_000 + assert config.receive_window == 200_000 + assert config.keep_alive_interval == timedelta(seconds=2) + assert config.max_idle_timeout == timedelta(seconds=20) + assert config.validate_certificate is True + + def test_arguments_are_keyword_only(self): + """Test that the address cannot be passed positionally.""" + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-count + QuicConfig("127.0.0.1:8080") + + def test_repr_hides_the_password(self): + """Test that the password does not leak through repr.""" + config = QuicConfig(auto_login=AutoLogin.username_password("iggy", "secret")) + + assert "secret" not in repr(config) + + def test_repr_shows_every_field_as_python(self): + """Test that repr covers the configured fields and parses as Python. + + The three string fields are asserted too: `ast.parse` alone still + passes on a repr that dropped one from the format string. + """ + config = QuicConfig( + server_address="127.0.0.1:8081", + client_address="127.0.0.1:9000", + server_name="example.com", + heartbeat_interval=timedelta(seconds=15), + keep_alive_interval=timedelta(seconds=2), + max_idle_timeout=timedelta(seconds=20), + validate_certificate=True, + ) + + printed = repr(config) + + assert 'server_address="127.0.0.1:8081"' in printed + assert 'client_address="127.0.0.1:9000"' in printed + assert 'server_name="example.com"' in printed + assert "validate_certificate=True" in printed + assert "heartbeat_interval=datetime.timedelta(seconds=15)" in printed + assert "keep_alive_interval=datetime.timedelta(seconds=2)" in printed + assert "max_idle_timeout=datetime.timedelta(seconds=20)" in printed + ast.parse(printed) + + @pytest.mark.parametrize( + "invalid_address", + ["", "127.0.0.1", "127.0.0.1:not-a-port", "127.0.0.1:70000", "::1:8080"], + ) + def test_invalid_server_address_is_rejected(self, invalid_address: str): + """Test that a malformed address fails at construction, naming itself.""" + with pytest.raises(ValueError, match="server_address"): + QuicConfig(server_address=invalid_address) + + @pytest.mark.parametrize( + "invalid_address", + ["", "127.0.0.1", "127.0.0.1:not-a-port", "127.0.0.1:70000", "localhost:0"], + ) + def test_invalid_client_address_is_rejected(self, invalid_address: str): + """Test that a malformed bind address fails at construction. + + `QuicClient::create` parses this as a `SocketAddr`, so a hostname is + rejected alongside the malformed forms: without the eager check the + failure would surface as a `RuntimeError` from `IggyClient(...)` + instead, which is not a `ValueError` and so escapes the handler a + caller wraps construction in. + """ + with pytest.raises(ValueError, match="client_address"): + QuicConfig(client_address=invalid_address) + + def test_surrounding_whitespace_in_the_addresses_is_trimmed(self): + """Test that both addresses tolerate whitespace, like HTTP's `api_url`. + + `client_address` is stored as the string `QuicClient::create` compares + against the literal default to pick an IPv6 bind address, so storing + the trimmed form is what keeps a padded default matching that sentinel. + """ + config = QuicConfig( + server_address=" 127.0.0.1:8080 ", client_address=" 127.0.0.1:0 " + ) + + assert config.server_address == "127.0.0.1:8080" + assert config.client_address == "127.0.0.1:0" + + def test_negative_heartbeat_interval_is_rejected(self): + """Test that a negative heartbeat interval fails at construction.""" + with pytest.raises(ValueError, match="negative"): + QuicConfig(heartbeat_interval=timedelta(seconds=-3)) + + def test_zero_heartbeat_interval_is_rejected(self): + """Test that a zero heartbeat interval fails at construction. + + Nothing downstream reads zero as "disabled"; it heartbeats in a + continuous loop for as long as the client lives. + """ + with pytest.raises(ValueError, match=r"heartbeat_interval.*must not be zero"): + QuicConfig(heartbeat_interval=timedelta(0)) + + @pytest.mark.parametrize( + ("field", "out_of_range"), + [ + ("response_buffer_size", -1), + ("max_concurrent_bidi_streams", -1), + ("datagram_send_buffer_size", -1), + ("send_window", -1), + ("receive_window", -1), + ("initial_mtu", -1), + ("initial_mtu", 2**16), + ("max_concurrent_bidi_streams", 2**62), + ("receive_window", 2**62), + ], + ) + def test_out_of_range_numeric_field_is_rejected( + self, field: str, out_of_range: int + ): + """Test that a numeric field outside its wire type's range names itself. + + `max_concurrent_bidi_streams` and `receive_window` fit `u64`, but + quinn narrows them further into a `VarInt` (max `2**62 - 1`), so + `2**62` fits the wire type and must still be rejected. + """ + with pytest.raises(ValueError, match=field): + # pyrefly: ignore # bad-argument-type + QuicConfig(**{field: out_of_range}) + + @pytest.mark.parametrize("field", ["keep_alive_interval", "max_idle_timeout"]) + def test_duration_rounding_down_to_zero_millis_is_rejected(self, field: str): + """Test that a non-zero sub-millisecond duration names itself. + + Both fields are raw millisecond counts to the Rust SDK where zero is a + magic value (disables the keep-alive, or falls back to quinn's own + default), so a duration that rounds down to zero would silently mean + something other than what was asked for. + """ + with pytest.raises(ValueError, match=rf"{field}.*rounds down to 0ms"): + # pyrefly: ignore # bad-argument-type + QuicConfig(**{field: timedelta(microseconds=500)}) + + @pytest.mark.parametrize("field", ["keep_alive_interval", "max_idle_timeout"]) + def test_sub_millisecond_precision_is_rejected(self, field: str): + """Test that a duration with a sub-millisecond remainder is refused. + + The Rust SDK stores both fields as a millisecond count, so the + remainder would be dropped and the getter would read back a different + duration than the one that was passed in. + """ + with pytest.raises(ValueError, match=rf"{field}.*whole number of milliseconds"): + # pyrefly: ignore # bad-argument-type + QuicConfig(**{field: timedelta(milliseconds=1, microseconds=500)}) + + @pytest.mark.parametrize("field", ["keep_alive_interval", "max_idle_timeout"]) + def test_exact_zero_duration_is_allowed(self, field: str): + """Test that an exact zero duration is still legal for these fields.""" + # pyrefly: ignore # bad-argument-type + config = QuicConfig(**{field: timedelta(0)}) + + assert getattr(config, field) == timedelta(0) + + @pytest.mark.parametrize("field", ["keep_alive_interval", "max_idle_timeout"]) + def test_whole_millisecond_duration_is_allowed(self, field: str): + """Test that a duration that is not a whole number of seconds round-trips. + + Every other duration these fields accept here is second-aligned, so a + check tightened to whole seconds would otherwise pass the suite. + """ + # pyrefly: ignore # bad-argument-type + config = QuicConfig(**{field: timedelta(milliseconds=1500)}) + + assert getattr(config, field) == timedelta(milliseconds=1500) + + def test_initial_mtu_below_quinns_minimum_is_rejected(self): + """Test that an initial_mtu below 1200 fails at construction. + + quinn silently raises anything smaller to that floor instead of + rejecting it, so accepting it here would let the getter read back a + value that is not the one actually in effect on the connection. + """ + with pytest.raises(ValueError, match="initial_mtu"): + QuicConfig(initial_mtu=1199) + + def test_initial_mtu_at_quinns_minimum_is_allowed(self): + """Test that exactly 1200, quinn's own floor, is accepted.""" + config = QuicConfig(initial_mtu=1200) + + assert config.initial_mtu == 1200 + + +@pytest.mark.unit +class TestQuicClientConstruction: + """Test that `IggyClient(...)` accepts a `QuicConfig`.""" + + def test_accepts_a_config(self): + """Test that the resulting client is actually QUIC, not silently TCP. + + `IggyClient(...)` is not None for either union arm, so that alone never + pinned the transport. `client_address` is a QUIC-only field that + `QuicClient::create` binds eagerly here, so a port already held fails + the bind synchronously with `Cannot create endpoint`, with no server + and no privileged port involved. A client that regressed to the TCP arm + has no such field and would construct fine, and no other error maps to + that message. + + The held port has to be a real one: an unroutable address would only + fail the bind while `net.ipv4.ip_nonlocal_bind` is 0, and a host + running keepalived or a container setting it alone would bind + successfully and assert nothing. Neither the socket below nor quinn + sets `SO_REUSEADDR`, so the second bind is EADDRINUSE regardless. + """ + # A bindable client_address of its own, so the failure below is the + # collision and not the field being set at all. + assert IggyClient(QuicConfig(client_address="127.0.0.1:0")) is not None + + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as held: + held.bind(("127.0.0.1", 0)) + port = held.getsockname()[1] + + with pytest.raises(RuntimeError, match="Cannot create endpoint"): + IggyClient(QuicConfig(client_address=f"127.0.0.1:{port}")) + + def test_accepts_the_default_config(self): + """Test that an explicit default `QuicConfig` is accepted.""" + assert IggyClient(QuicConfig()) is not None + + +@pytest.mark.integration +class TestAutoLoginAgainstServer: + """Test that configured credentials are actually replayed on connect.""" + + @pytest.mark.asyncio + async def test_auto_login_authenticates_without_login_user(self, unique_name): + """Test that a privileged call succeeds without a manual login_user().""" + host, port = get_quic_server_config() + + client = IggyClient( + QuicConfig( + server_address=f"{host}:{port}", + auto_login=AutoLogin.username_password("iggy", "iggy"), + # The default reconnection policy retries forever: a missing + # listener would stall this test for the full 30s pytest + # timeout instead of failing fast. + reconnection=QuicReconnectionConfig(enabled=False), + ) + ) + await client.connect() + await wait_for_ping(client) + + stream_name = unique_name() + await client.create_stream(stream_name) + assert await client.get_stream(stream_name) is not None + + @pytest.mark.asyncio + async def test_without_auto_login_a_privileged_call_is_unauthenticated( + self, unique_name + ): + """Test that the same call fails when no credentials are configured.""" + host, port = get_quic_server_config() + + client = IggyClient( + QuicConfig( + server_address=f"{host}:{port}", + # The default reconnection policy retries forever: a missing + # listener would stall this test for the full 30s pytest + # timeout instead of failing fast. + reconnection=QuicReconnectionConfig(enabled=False), + ) + ) + await client.connect() + await wait_for_ping(client) + + with pytest.raises(RuntimeError): + await client.create_stream(unique_name()) + + @pytest.mark.asyncio + async def test_wrong_auto_login_credentials_fail(self): + """Test that bad configured credentials surface as a connect failure.""" + host, port = get_quic_server_config() + + client = IggyClient( + QuicConfig( + server_address=f"{host}:{port}", + auto_login=AutoLogin.username_password("iggy", "invalid-password"), + reconnection=QuicReconnectionConfig(enabled=False), + ) + ) + + # A bare RuntimeError would also match "Cannot establish connection", + # which is what a missing listener raises on this reconnection policy. + with pytest.raises(RuntimeError, match="Invalid credentials"): + await client.connect() diff --git a/foreign/python/tests/test_stats.py b/foreign/python/tests/test_stats.py new file mode 100644 index 0000000000..2b4423cb8a --- /dev/null +++ b/foreign/python/tests/test_stats.py @@ -0,0 +1,250 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import datetime + +import pytest + +from apache_iggy import CacheMetricsKey, GlobalPermissions, IggyClient, Permissions +from apache_iggy import SendMessage as Message + +from .utils import ( + get_server_config, + login_fresh_client, + unique_credentials, + wait_for_server, +) + +# Fields that describe the server process itself and must not change between +# two calls within one server run. +PROCESS_IDENTITY_FIELDS = ( + "process_id", + "start_time", + "hostname", + "os_name", + "os_version", + "kernel_version", + "iggy_server_version", + "iggy_server_semver", +) + + +class TestStats: + """Test server statistics retrieval.""" + + @pytest.mark.asyncio + async def test_get_stats(self, iggy_client: IggyClient, unique_name): + """Sending messages moves the server counts reported by get_stats.""" + stats_before = await iggy_client.get_stats() + + stream_name = unique_name() + topic_name = unique_name() + await iggy_client.create_stream(stream_name) + await iggy_client.create_topic( + stream=stream_name, name=topic_name, partitions_count=1 + ) + await iggy_client.send_messages( + stream=stream_name, + topic=topic_name, + partitioning=0, + messages=[Message(f"stats message {i}") for i in range(3)], + ) + + stats = await iggy_client.get_stats() + + # `>=` rather than exact equality: the counters are server-global, so + # concurrently running tests (e.g. under pytest-xdist) may bump them too. + assert stats.streams_count >= stats_before.streams_count + 1 + assert stats.topics_count >= stats_before.topics_count + 1 + assert stats.partitions_count >= stats_before.partitions_count + 1 + assert stats.messages_count >= stats_before.messages_count + 3 + assert stats.messages_size_bytes > stats_before.messages_size_bytes + assert stats.clients_count >= 1 + + assert stats.iggy_server_version + assert stats.hostname + assert stats.os_name + assert stats.os_version + assert stats.kernel_version + assert stats.process_id > 0 + # sysinfo cannot enumerate a process's threads on macOS, so a server + # running there reports 0. + if stats.os_name != "Darwin": + assert stats.threads_count > 0 + assert stats.start_time > 0 + assert stats.total_memory > 0 + assert stats.available_memory <= stats.total_memory + assert stats.total_disk_space > 0 + assert stats.free_disk_space <= stats.total_disk_space + + assert isinstance(stats.run_time, datetime.timedelta) + assert stats.run_time >= stats_before.run_time + for field in PROCESS_IDENTITY_FIELDS: + assert getattr(stats, field) == getattr(stats_before, field) + + assert f"streams_count={stats.streams_count}" in repr(stats) + assert stats.hostname in repr(stats) + + @pytest.mark.asyncio + async def test_get_stats_reflects_topology( + self, iggy_client: IggyClient, unique_name + ): + """Streams, topics, partitions, consumer groups and a second client show + up in the counters, and deleting the streams brings them back down.""" + stats_before = await iggy_client.get_stats() + + streams = [unique_name() for _ in range(2)] + topics_per_stream = 2 + partitions_per_topic = 3 + topics = [] + for stream_name in streams: + await iggy_client.create_stream(stream_name) + for _ in range(topics_per_stream): + topic_name = unique_name() + await iggy_client.create_topic( + stream=stream_name, + name=topic_name, + partitions_count=partitions_per_topic, + ) + await iggy_client.create_consumer_group( + stream_name, topic_name, unique_name() + ) + topics.append((stream_name, topic_name)) + # Bound only to keep a second connection open until the test ends. + _second_client = await login_fresh_client("iggy", "iggy") + + topics_created = len(topics) + partitions_created = topics_created * partitions_per_topic + + stats = await iggy_client.get_stats() + + assert stats.streams_count >= stats_before.streams_count + len(streams) + assert stats.topics_count >= stats_before.topics_count + topics_created + assert ( + stats.partitions_count >= stats_before.partitions_count + partitions_created + ) + # Every new partition opens with one segment. + assert stats.segments_count >= stats_before.segments_count + partitions_created + assert ( + stats.consumer_groups_count + >= stats_before.consumer_groups_count + topics_created + ) + # Both `iggy_client` and `_second_client` are connected at this point, so + # the cross-shard total covers them regardless of what else the server + # reaped in between. + assert stats.clients_count >= 2 + + # The SDK has no delete_stream, so the streams stay behind and only the + # topic-level counters are expected to drop. The baseline is re-read + # right before the deletes to keep the window in which a concurrent + # test can bump the server-global counters as small as possible. + stats_before_delete = await iggy_client.get_stats() + for stream_name, topic_name in topics: + await iggy_client.delete_topic(stream_name, topic_name) + + stats_after = await iggy_client.get_stats() + + assert ( + stats_after.topics_count + <= stats_before_delete.topics_count - topics_created + ) + assert ( + stats_after.partitions_count + <= stats_before_delete.partitions_count - partitions_created + ) + assert ( + stats_after.segments_count + <= stats_before_delete.segments_count - partitions_created + ) + assert ( + stats_after.consumer_groups_count + <= stats_before_delete.consumer_groups_count - topics_created + ) + + @pytest.mark.asyncio + async def test_get_stats_cache_metrics_dict(self, iggy_client: IggyClient): + """cache_metrics is a dict the server currently leaves empty.""" + stats = await iggy_client.get_stats() + + assert stats.cache_metrics == {} + + @pytest.mark.unit + def test_cache_metrics_key_is_constructible_and_hashable(self): + """A key built in Python can address a cache_metrics dict entry.""" + key = CacheMetricsKey(stream_id=1, topic_id=2, partition_id=3) + + assert key.stream_id == 1 + assert key.topic_id == 2 + assert key.partition_id == 3 + assert repr(key) == "CacheMetricsKey(stream_id=1, topic_id=2, partition_id=3)" + + equal_key = CacheMetricsKey(stream_id=1, topic_id=2, partition_id=3) + other_key = CacheMetricsKey(stream_id=1, topic_id=2, partition_id=4) + assert key == equal_key + assert key != other_key + assert hash(key) == hash(equal_key) + + # An equal key constructed independently hits the same dict slot. + metrics_by_key = {key: "metrics"} + assert metrics_by_key[equal_key] == "metrics" + assert other_key not in metrics_by_key + + @pytest.mark.asyncio + async def test_get_stats_requires_connection_and_auth(self): + """get_stats fails before connecting, before login, and after logout.""" + host, port = get_server_config() + wait_for_server(host, port) + + client = IggyClient(f"{host}:{port}") + with pytest.raises(RuntimeError, match="Not connected"): + await client.get_stats() + + await client.connect() + with pytest.raises(RuntimeError, match="Unauthenticated"): + await client.get_stats() + + await client.login_user("iggy", "iggy") + await client.get_stats() + + await client.logout_user() + with pytest.raises(RuntimeError, match="Unauthenticated"): + await client.get_stats() + + @pytest.mark.asyncio + @pytest.mark.parametrize("flag", ["read_servers", "manage_servers"]) + async def test_get_stats_requires_server_permission( + self, iggy_client: IggyClient, unique_name, flag + ): + """A user without read_servers or manage_servers is denied; either grants.""" + username, password = unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + try: + denied_client = await login_fresh_client(username, password) + with pytest.raises(RuntimeError, match="Unauthorized"): + await denied_client.get_stats() + + await iggy_client.update_permissions( + created.id, + Permissions(global_permissions=GlobalPermissions(**{flag: True})), + ) + + granted_client = await login_fresh_client(username, password) + stats = await granted_client.get_stats() + assert stats.process_id > 0 + finally: + await iggy_client.delete_user(created.id) diff --git a/foreign/python/tests/test_stream.py b/foreign/python/tests/test_stream.py index 5e71a543a4..c6e6bdc75d 100644 --- a/foreign/python/tests/test_stream.py +++ b/foreign/python/tests/test_stream.py @@ -17,7 +17,7 @@ import pytest -from apache_iggy import IggyClient +from apache_iggy import IggyClient, SendMessage from .utils import get_server_config, wait_for_ping, wait_for_server @@ -62,7 +62,9 @@ async def test_create_and_get_stream( stream = await iggy_client.get_stream(stream_name) assert stream is not None + assert stream.created_at > 0 assert stream.name == stream_name + assert stream.size == 0 assert stream.topics_count == 0 @pytest.mark.asyncio @@ -203,3 +205,443 @@ async def test_create_stream_before_login_fails(self, unique_name): with pytest.raises(RuntimeError): await client.create_stream(unique_name()) + + +class TestGetStreams: + """Test listing streams via get_streams.""" + + @pytest.mark.asyncio + async def test_get_streams_returns_created_streams( + self, iggy_client: IggyClient, unique_name + ): + """Test get_streams returns every stream created during the test.""" + # Stream IDs can be reused after deletion, so creation order does not + # imply numeric ID order. The client fixture is session-scoped, so + # other tests may have created streams; assert on the ones created + # here instead of the full server view. + created = [unique_name(f"z{index}") for index in range(3, 0, -1)] + for name in created: + await iggy_client.create_stream(name) + + streams = await iggy_client.get_streams() + created_names = set(created) + mine = [stream for stream in streams if stream.name in created_names] + + assert {stream.name for stream in mine} == created_names + assert [stream.id for stream in mine] == sorted(stream.id for stream in mine) + assert all(stream.created_at > 0 for stream in mine) + assert all(stream.size == 0 for stream in mine) + assert all(stream.messages_count == 0 for stream in mine) + assert all(stream.topics_count == 0 for stream in mine) + + @pytest.mark.asyncio + async def test_get_streams_reflects_topic_count( + self, iggy_client: IggyClient, unique_name + ): + """Test get_streams reports the topic count for a listed stream.""" + stream_name = unique_name() + topic_name = unique_name() + + await iggy_client.create_stream(stream_name) + await iggy_client.create_topic( + stream=stream_name, name=topic_name, partitions_count=1 + ) + + streams = await iggy_client.get_streams() + listed = next( + (stream for stream in streams if stream.name == stream_name), None + ) + assert listed is not None + assert listed.topics_count == 1 + + stream = await iggy_client.get_stream(stream_name) + assert stream is not None + assert [topic.name for topic in stream.topics] == [topic_name] + + @pytest.mark.asyncio + async def test_get_streams_returns_same_result_when_called_repeatedly( + self, iggy_client: IggyClient, unique_name + ): + """Test repeated get_streams calls return an identically ordered view.""" + await iggy_client.create_stream(unique_name()) + + first = await iggy_client.get_streams() + second = await iggy_client.get_streams() + assert [stream.id for stream in first] == [stream.id for stream in second] + assert [stream.name for stream in first] == [stream.name for stream in second] + + @pytest.mark.asyncio + async def test_get_streams_requires_connection_and_auth(self): + """Test get_streams fails both before connecting and before logging in.""" + host, port = get_server_config() + wait_for_server(host, port) + + client = IggyClient(f"{host}:{port}") + with pytest.raises(RuntimeError): + await client.get_streams() + + await client.connect() + with pytest.raises(RuntimeError): + await client.get_streams() + + +class TestUpdateStream: + """Test updating streams via update_stream.""" + + @pytest.mark.asyncio + async def test_update_stream_renames_stream( + self, iggy_client: IggyClient, unique_name + ): + """Test update_stream renames a stream; old name no longer resolves.""" + stream_name = unique_name() + new_name = unique_name() + + await iggy_client.create_stream(stream_name) + before = await iggy_client.get_stream(stream_name) + assert before is not None + + await iggy_client.update_stream(stream_id=stream_name, name=new_name) + + renamed = await iggy_client.get_stream(new_name) + assert renamed is not None + assert renamed.name == new_name + assert renamed.id == before.id + + old = await iggy_client.get_stream(stream_name) + assert old is None + + @pytest.mark.asyncio + async def test_update_stream_by_numeric_id( + self, iggy_client: IggyClient, unique_name + ): + """Test update_stream accepts a numeric stream id.""" + stream_name = unique_name() + new_name = unique_name() + + await iggy_client.create_stream(stream_name) + stream = await iggy_client.get_stream(stream_name) + assert stream is not None + + await iggy_client.update_stream(stream_id=stream.id, name=new_name) + + renamed = await iggy_client.get_stream(new_name) + assert renamed is not None + assert renamed.name == new_name + + @pytest.mark.asyncio + async def test_update_stream_forwards_options( + self, iggy_client: IggyClient, unique_name + ): + """Test update_stream forwards option keys to the server.""" + stream_name = unique_name() + await iggy_client.create_stream(stream_name) + + with pytest.raises(RuntimeError): + await iggy_client.update_stream( + stream_id=stream_name, + name=stream_name, + options={"unknown": "value"}, + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "new_name", + [ + pytest.param("a", id="one-byte"), + pytest.param("a" * 255, id="255-byte-ascii"), + pytest.param(("é" * 127) + "a", id="255-byte-utf8"), + ], + ) + async def test_update_stream_accepts_name_boundaries( + self, iggy_client: IggyClient, unique_name, new_name: str + ): + """Test update_stream accepts names at the UTF-8 byte boundaries.""" + stream_name = unique_name() + await iggy_client.create_stream(stream_name) + + await iggy_client.update_stream(stream_id=stream_name, name=new_name) + renamed = await iggy_client.get_stream(new_name) + assert renamed is not None + assert renamed.name == new_name + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "new_name", + [ + pytest.param("", id="empty"), + pytest.param("a" * 256, id="256-byte-ascii"), + pytest.param("é" * 128, id="256-byte-utf8"), + pytest.param(("😀" * 63) + "aaaa", id="256-byte-four-byte-utf8"), + ], + ) + async def test_update_stream_rejects_invalid_name_boundaries( + self, iggy_client: IggyClient, unique_name, new_name: str + ): + """Test update_stream rejects empty and 256-byte names.""" + stream_name = unique_name() + await iggy_client.create_stream(stream_name) + + with pytest.raises(RuntimeError): + await iggy_client.update_stream(stream_id=stream_name, name=new_name) + + @pytest.mark.asyncio + async def test_update_stream_applies_repeated_updates( + self, iggy_client: IggyClient, unique_name + ): + """Test successive update_stream calls each take effect.""" + stream_name = unique_name() + first_rename = unique_name() + second_rename = unique_name() + + await iggy_client.create_stream(stream_name) + + await iggy_client.update_stream(stream_id=stream_name, name=first_rename) + after_first = await iggy_client.get_stream(first_rename) + assert after_first is not None + assert after_first.name == first_rename + assert await iggy_client.get_stream(stream_name) is None + + await iggy_client.update_stream(stream_id=first_rename, name=second_rename) + after_second = await iggy_client.get_stream(second_rename) + assert after_second is not None + assert after_second.name == second_rename + assert await iggy_client.get_stream(first_rename) is None + + @pytest.mark.asyncio + async def test_update_nonexistent_stream_fails( + self, iggy_client: IggyClient, unique_name + ): + """Test update_stream raises for a non-existent stream.""" + with pytest.raises(RuntimeError): + await iggy_client.update_stream(stream_id=unique_name(), name=unique_name()) + + @pytest.mark.asyncio + async def test_update_stream_to_existing_name_fails_and_current_name_is_a_noop( + self, iggy_client: IggyClient, unique_name + ): + """Test update_stream rejects conflicts and preserves a self-rename.""" + first_stream = unique_name() + second_stream = unique_name() + + await iggy_client.create_stream(first_stream) + await iggy_client.create_stream(second_stream) + streams = await iggy_client.get_streams() + before = next(stream for stream in streams if stream.name == second_stream) + before_metadata = ( + before.id, + before.created_at, + before.name, + before.size, + before.messages_count, + before.topics_count, + ) + + with pytest.raises(RuntimeError): + await iggy_client.update_stream(stream_id=second_stream, name=first_stream) + + await iggy_client.update_stream(stream_id=second_stream, name=second_stream) + + streams = await iggy_client.get_streams() + after = next(stream for stream in streams if stream.id == before.id) + assert ( + after.id, + after.created_at, + after.name, + after.size, + after.messages_count, + after.topics_count, + ) == before_metadata + + @pytest.mark.asyncio + async def test_update_stream_requires_connection_and_auth(self, unique_name): + """Test update_stream fails both before connecting and before logging in.""" + host, port = get_server_config() + wait_for_server(host, port) + + client = IggyClient(f"{host}:{port}") + with pytest.raises(RuntimeError): + await client.update_stream(stream_id=unique_name(), name=unique_name()) + + await client.connect() + with pytest.raises(RuntimeError): + await client.update_stream(stream_id=unique_name(), name=unique_name()) + + +class TestDeleteStream: + """Test deleting streams via delete_stream.""" + + @pytest.mark.asyncio + async def test_delete_stream_removes_stream( + self, iggy_client: IggyClient, unique_name + ): + """Test delete_stream removes the stream so it no longer resolves.""" + stream_name = unique_name() + + await iggy_client.create_stream(stream_name) + + await iggy_client.delete_stream(stream_name) + + assert await iggy_client.get_stream(stream_name) is None + + @pytest.mark.asyncio + async def test_delete_stream_by_numeric_id( + self, iggy_client: IggyClient, unique_name + ): + """Test delete_stream accepts a numeric stream id.""" + stream_name = unique_name() + + await iggy_client.create_stream(stream_name) + stream = await iggy_client.get_stream(stream_name) + assert stream is not None + + await iggy_client.delete_stream(stream.id) + + assert await iggy_client.get_stream(stream_name) is None + + @pytest.mark.asyncio + async def test_delete_stream_leaves_other_streams( + self, iggy_client: IggyClient, unique_name + ): + """Test delete_stream removes only the targeted stream.""" + stream_to_delete = unique_name() + stream_to_keep = unique_name() + + await iggy_client.create_stream(stream_to_delete) + await iggy_client.create_stream(stream_to_keep) + + await iggy_client.delete_stream(stream_to_delete) + + assert await iggy_client.get_stream(stream_to_delete) is None + kept = await iggy_client.get_stream(stream_to_keep) + assert kept is not None + assert kept.name == stream_to_keep + + @pytest.mark.asyncio + async def test_delete_nonexistent_stream_fails( + self, iggy_client: IggyClient, unique_name + ): + """Test delete_stream raises for a non-existent stream.""" + with pytest.raises(RuntimeError): + await iggy_client.delete_stream(unique_name()) + + @pytest.mark.asyncio + async def test_delete_stream_twice_fails_second_time( + self, iggy_client: IggyClient, unique_name + ): + """Test deleting an already-deleted stream raises on the second call.""" + stream_name = unique_name() + + await iggy_client.create_stream(stream_name) + + await iggy_client.delete_stream(stream_name) + with pytest.raises(RuntimeError): + await iggy_client.delete_stream(stream_name) + + @pytest.mark.asyncio + async def test_delete_stream_requires_connection_and_auth(self, unique_name): + """Test delete_stream fails both before connecting and before logging in.""" + host, port = get_server_config() + wait_for_server(host, port) + + client = IggyClient(f"{host}:{port}") + with pytest.raises(RuntimeError): + await client.delete_stream(unique_name()) + + await client.connect() + with pytest.raises(RuntimeError): + await client.delete_stream(unique_name()) + + +class TestPurgeStream: + """Test purging stream messages via purge_stream.""" + + @pytest.mark.asyncio + async def test_purge_stream_clears_messages_but_keeps_stream( + self, iggy_client: IggyClient, unique_name + ): + """Test purge_stream empties the stream while leaving it in place.""" + stream_name = unique_name() + topic_name = unique_name() + + await iggy_client.create_stream(stream_name) + await iggy_client.create_topic( + stream=stream_name, name=topic_name, partitions_count=1 + ) + + messages = [SendMessage(f"payload-{index}") for index in range(5)] + await iggy_client.send_messages(stream_name, topic_name, 0, messages) + + before = await iggy_client.get_stream(stream_name) + assert before is not None + assert before.messages_count == 5 + + await iggy_client.purge_stream(stream_name) + + after = await iggy_client.get_stream(stream_name) + # Purging clears messages only; the stream itself survives (purge is + # not delete) and keeps its identity and topics. + assert after is not None + assert after.messages_count == 0 + assert after.id == before.id + assert after.name == before.name + assert after.topics_count == before.topics_count + + @pytest.mark.asyncio + async def test_purge_empty_stream_succeeds( + self, iggy_client: IggyClient, unique_name + ): + """Test purge_stream is a no-op on a stream with no messages.""" + stream_name = unique_name() + + await iggy_client.create_stream(stream_name) + + await iggy_client.purge_stream(stream_name) + + stream = await iggy_client.get_stream(stream_name) + assert stream is not None + assert stream.messages_count == 0 + + @pytest.mark.asyncio + async def test_purge_stream_is_idempotent_when_called_repeatedly( + self, iggy_client: IggyClient, unique_name + ): + """Test purge_stream succeeds when called repeatedly on the same stream.""" + stream_name = unique_name() + topic_name = unique_name() + + await iggy_client.create_stream(stream_name) + await iggy_client.create_topic( + stream=stream_name, name=topic_name, partitions_count=1 + ) + + messages = [SendMessage(f"payload-{index}") for index in range(5)] + await iggy_client.send_messages(stream_name, topic_name, 0, messages) + + await iggy_client.purge_stream(stream_name) + await iggy_client.purge_stream(stream_name) + + stream = await iggy_client.get_stream(stream_name) + assert stream is not None + assert stream.messages_count == 0 + + @pytest.mark.asyncio + async def test_purge_nonexistent_stream_fails( + self, iggy_client: IggyClient, unique_name + ): + """Test purge_stream raises for a non-existent stream.""" + with pytest.raises(RuntimeError): + await iggy_client.purge_stream(unique_name()) + + @pytest.mark.asyncio + async def test_purge_stream_requires_connection_and_auth(self, unique_name): + """Test purge_stream fails both before connecting and before logging in.""" + host, port = get_server_config() + wait_for_server(host, port) + + client = IggyClient(f"{host}:{port}") + with pytest.raises(RuntimeError): + await client.purge_stream(unique_name()) + + await client.connect() + with pytest.raises(RuntimeError): + await client.purge_stream(unique_name()) diff --git a/foreign/python/tests/test_tls.py b/foreign/python/tests/test_tls.py index f9be943797..f1daf889cf 100644 --- a/foreign/python/tests/test_tls.py +++ b/foreign/python/tests/test_tls.py @@ -66,6 +66,7 @@ def tls_container(): .with_env("IGGY_TCP_TLS_CERT_FILE", "/app/certs/iggy_cert.pem") .with_env("IGGY_TCP_TLS_KEY_FILE", "/app/certs/iggy_key.pem") .with_env("IGGY_TCP_ADDRESS", f"0.0.0.0:{CONTAINER_TCP_PORT}") + .with_env("IGGY_NODE_ADVERTISED_ADDRESS", "127.0.0.1") .with_volume_mapping(CERTS_DIR, "/app/certs", "ro") .with_kwargs(privileged=True) ) diff --git a/foreign/python/tests/test_topic.py b/foreign/python/tests/test_topic.py index f942956220..7390cb3aa2 100644 --- a/foreign/python/tests/test_topic.py +++ b/foreign/python/tests/test_topic.py @@ -19,7 +19,14 @@ import pytest -from apache_iggy import HeaderValue, IggyClient, IggyExpiry, MaxTopicSize, SendMessage +from apache_iggy import ( + Durability, + HeaderValue, + IggyClient, + IggyExpiry, + MaxTopicSize, + SendMessage, +) from .utils import ( get_server_config, @@ -1380,7 +1387,8 @@ async def test_topic_options_round_trip(self, iggy_client: IggyClient, unique_na stream=stream_name, name=topic_name, partitions_count=1, - options={"enforce_fsync": "true", "segment_size": "128 MiB"}, + durability=Durability.PERSISTED, + options={"segment_size": "128 MiB"}, ) topic = await iggy_client.get_topic(stream_name, topic_name) @@ -1388,17 +1396,17 @@ async def test_topic_options_round_trip(self, iggy_client: IggyClient, unique_na # Options come back through the same typed dict message user headers # use, so the scalar helper reads them the same way. explicit = topic.options.to_scalar_dict() - assert explicit["enforce_fsync"] is True + assert explicit["durability"] == "persisted" assert explicit["segment_size"] == 128 * 1024 * 1024 # Keys the client left alone are resolved by admission and reported # separately, so an operator can tell chosen from defaulted. derived = topic.derived_options.to_scalar_dict() assert "max_topic_size" in derived - assert "enforce_fsync" not in derived + assert "durability" not in derived topics = await iggy_client.get_topics(stream_name) listed = next(entry for entry in topics if entry.name == topic_name) - assert listed.options.to_scalar_dict()["enforce_fsync"] is True + assert listed.options.to_scalar_dict()["durability"] == "persisted" @pytest.mark.asyncio async def test_update_topic_options_reach_the_server( @@ -1443,7 +1451,8 @@ async def test_describe_options_lists_the_topic_catalog( by_key = {spec.key: spec for spec in specs} assert "segment_size" in by_key - assert "enforce_fsync" in by_key + assert "durability" in by_key + assert "consumer_offset_durability" in by_key segment_size = by_key["segment_size"] assert segment_size.kind == "uint64" # The default is the same HeaderValue type message headers carry, so it diff --git a/foreign/python/tests/test_user.py b/foreign/python/tests/test_user.py index a15b714b1d..f6a328ca1f 100644 --- a/foreign/python/tests/test_user.py +++ b/foreign/python/tests/test_user.py @@ -361,6 +361,42 @@ async def test_update_username_and_status_together( await iggy_client.delete_user(created.id) + @pytest.mark.asyncio + async def test_update_user_with_empty_options_succeeds( + self, iggy_client: IggyClient, unique_name + ): + """Test update_user accepts an empty options map.""" + username, password = unique_credentials(unique_name) + new_username = unique_name(max_bytes=MAX_USERNAME_BYTES) + created = await iggy_client.create_user(username, password) + + await iggy_client.update_user(created.id, username=new_username, options={}) + + user = await iggy_client.get_user(created.id) + assert user is not None + assert user.username == new_username + + await iggy_client.delete_user(created.id) + + @pytest.mark.asyncio + async def test_update_user_forwards_options( + self, iggy_client: IggyClient, unique_name + ): + """Test update_user forwards option keys to the server.""" + username, password = unique_credentials(unique_name) + created = await iggy_client.create_user(username, password) + + with pytest.raises(RuntimeError) as rejection: + await iggy_client.update_user( + created.id, + options={"unknown": "value"}, + ) + + # Binary transports carry the code alone, so the key itself is empty. + assert "Unsupported option key" in str(rejection.value) + + await iggy_client.delete_user(created.id) + @pytest.mark.asyncio async def test_update_user_with_no_fields_is_a_noop( self, iggy_client: IggyClient, unique_name diff --git a/foreign/python/tests/test_websocket_config.py b/foreign/python/tests/test_websocket_config.py new file mode 100644 index 0000000000..555e758fa3 --- /dev/null +++ b/foreign/python/tests/test_websocket_config.py @@ -0,0 +1,522 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +""" +Tests for the WebSocket client configuration surface. + +`WebSocketConfig` and `WebSocketReconnectionConfig` mirror the Rust SDK +types the same way `TcpConfig`/`TcpReconnectionConfig` do, so most of these +assert that a value set from Python survives to the getters and that unset +fields fall back to the Rust defaults. `WebSocketFramingConfig` is new: it +wraps the tungstenite frame- and buffer-level options the Rust SDK nests +under `ws_config`, exposed here as a `framing` argument rather than flat +kwargs, mirroring the Rust struct's own nesting. `AutoLogin` is +transport-agnostic and already covered by `test_client_config.py`. +""" + +import ast +import socket +import sys +import threading +from collections.abc import Callable +from datetime import timedelta + +import pytest + +from apache_iggy import ( + AutoLogin, + IggyClient, + WebSocketConfig, + WebSocketFramingConfig, + WebSocketReconnectionConfig, +) + +from .utils import get_websocket_server_config, wait_for_ping, wait_for_server + +# tungstenite's own `WebSocketConfig::default()` values, mirrored here so a +# dependency bump that moves one fails loudly instead of silently redefining the +# Python surface. +TUNGSTENITE_READ_BUFFER_SIZE = 128 * 1024 +TUNGSTENITE_WRITE_BUFFER_SIZE = 128 * 1024 +# `usize::MAX`, tungstenite's "unbounded" write buffer. +TUNGSTENITE_MAX_WRITE_BUFFER_SIZE = sys.maxsize * 2 + 1 +TUNGSTENITE_MAX_MESSAGE_SIZE = 64 << 20 +TUNGSTENITE_MAX_FRAME_SIZE = 16 << 20 + +# The accept thread only has to come back from a connection the client already +# dialed, so anything beyond this is a hang, not slowness. +ACCEPT_JOIN_TIMEOUT_SECONDS = 5.0 + + +def _accept_and_close(listener: socket.socket) -> None: + """Accept one connection and drop it, failing any WebSocket handshake.""" + try: + connection, _ = listener.accept() + except OSError: + return + connection.close() + + +@pytest.mark.unit +class TestWebSocketReconnectionConfig: + """Test the reconnection policy.""" + + def test_defaults_match_the_rust_sdk(self): + """Test that an unconfigured policy reconnects forever, one second apart.""" + reconnection = WebSocketReconnectionConfig() + + assert reconnection.enabled is True + assert reconnection.max_retries is None + assert reconnection.interval == timedelta(seconds=1) + assert reconnection.reestablish_after == timedelta(seconds=5) + + def test_every_field_round_trips(self): + """Test that each configured field is readable back unchanged.""" + reconnection = WebSocketReconnectionConfig( + enabled=False, + max_retries=10, + interval=timedelta(milliseconds=250), + reestablish_after=timedelta(seconds=30), + ) + + assert reconnection.enabled is False + assert reconnection.max_retries == 10 + assert reconnection.interval == timedelta(milliseconds=250) + assert reconnection.reestablish_after == timedelta(seconds=30) + + def test_arguments_are_keyword_only(self): + """Test that the adjacent flags cannot be passed positionally.""" + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-count + WebSocketReconnectionConfig(True) + + @pytest.mark.parametrize( + "construct", + [ + lambda duration: WebSocketReconnectionConfig(interval=duration), + lambda duration: WebSocketReconnectionConfig(reestablish_after=duration), + ], + ids=["interval", "reestablish_after"], + ) + @pytest.mark.parametrize( + "negative", + [timedelta(microseconds=-1), timedelta(seconds=-1), timedelta(days=-1)], + ) + def test_negative_duration_is_rejected( + self, + construct: Callable[[timedelta], WebSocketReconnectionConfig], + negative: timedelta, + ): + """Test that a negative duration fails at construction, not at connect.""" + with pytest.raises(ValueError, match="negative"): + construct(negative) + + @pytest.mark.parametrize("out_of_range", [-1, 2**32]) + def test_out_of_range_max_retries_is_rejected(self, out_of_range: int): + """Test that a retry count outside the wire range names the argument. + + The conversion pyo3 does on its own raises OverflowError, which is not a + ValueError and so escapes the handler a caller wraps construction in. + """ + with pytest.raises(ValueError, match="max_retries"): + WebSocketReconnectionConfig(max_retries=out_of_range) + + def test_zero_reestablish_after_is_allowed(self): + """Test that a zero cooldown is legal and readable back.""" + reconnection = WebSocketReconnectionConfig(reestablish_after=timedelta(0)) + + assert reconnection.reestablish_after == timedelta(0) + + @pytest.mark.parametrize( + "kwargs", + [ + {}, + {"max_retries": 5}, + {"enabled": False}, + ], + ids=["unlimited_retries", "bounded_retries", "reconnection_disabled"], + ) + def test_zero_interval_is_rejected(self, kwargs: dict): + """Test that a zero interval fails whatever the retry policy is. + + The interval is a delay between passes, so zero reconnects in a + continuous loop. + """ + with pytest.raises(ValueError, match="zero"): + WebSocketReconnectionConfig(interval=timedelta(0), **kwargs) + + def test_very_long_interval_round_trips(self): + """Test that an interval beyond 68 years survives the i32 boundary.""" + reconnection = WebSocketReconnectionConfig(interval=timedelta(days=30_000)) + + assert reconnection.interval == timedelta(days=30_000) + + def test_maximum_interval_round_trips(self): + """Test that the largest timedelta survives the day conversion.""" + reconnection = WebSocketReconnectionConfig(interval=timedelta(days=999_999_999)) + + assert reconnection.interval == timedelta(days=999_999_999) + + +@pytest.mark.unit +class TestWebSocketFramingConfig: + """Test the frame- and buffer-level options.""" + + def test_defaults_match_tungstenite(self): + """Test that unconfigured sizes fall back to the tungstenite defaults. + + Every size defaults to a concrete value; an omitted argument keeps that + default, while an explicit `None` clears the limit. See + `test_explicit_none_clears_the_limit`. + """ + framing = WebSocketFramingConfig() + + assert framing.read_buffer_size == TUNGSTENITE_READ_BUFFER_SIZE + assert framing.write_buffer_size == TUNGSTENITE_WRITE_BUFFER_SIZE + assert framing.max_write_buffer_size == TUNGSTENITE_MAX_WRITE_BUFFER_SIZE + assert framing.max_message_size == TUNGSTENITE_MAX_MESSAGE_SIZE + assert framing.max_frame_size == TUNGSTENITE_MAX_FRAME_SIZE + assert framing.accept_unmasked_frames is False + + def test_every_field_round_trips(self): + """Test that each configured field is readable back unchanged.""" + framing = WebSocketFramingConfig( + read_buffer_size=4096, + write_buffer_size=4096, + max_write_buffer_size=8192, + max_message_size=16384, + max_frame_size=16384, + accept_unmasked_frames=True, + ) + + assert framing.read_buffer_size == 4096 + assert framing.write_buffer_size == 4096 + assert framing.max_write_buffer_size == 8192 + assert framing.max_message_size == 16384 + assert framing.max_frame_size == 16384 + assert framing.accept_unmasked_frames is True + + @pytest.mark.parametrize("field", ["max_message_size", "max_frame_size"]) + def test_explicit_none_clears_the_limit(self, field: str): + """Test that an explicit `None` lifts the size limit. + + An omitted argument and an explicit `None` both reach Rust as + `Option::None`, so without a sentinel the constructor cannot tell them + apart and a caller asking for no limit would silently keep the default. + """ + framing = WebSocketFramingConfig(**{field: None}) + + assert getattr(framing, field) is None + + @pytest.mark.parametrize("field", ["max_message_size", "max_frame_size"]) + def test_omitting_the_argument_keeps_the_default_limit(self, field: str): + """Test that omitting the argument is not the same as passing `None`.""" + framing = WebSocketFramingConfig() + + assert getattr(framing, field) is not None + + def test_clearing_one_limit_leaves_the_other_alone(self): + """Test that the two sentinels are independent.""" + framing = WebSocketFramingConfig(max_message_size=None) + + assert framing.max_message_size is None + assert framing.max_frame_size is not None + + def test_arguments_are_keyword_only(self): + """Test that the first field cannot be passed positionally.""" + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-count + WebSocketFramingConfig(4096) + + def test_repr_shows_every_field_as_python(self): + """Test that repr covers every field and parses as Python.""" + framing = WebSocketFramingConfig( + read_buffer_size=4096, + accept_unmasked_frames=True, + ) + + printed = repr(framing) + + assert "read_buffer_size=4096" in printed + assert "accept_unmasked_frames=True" in printed + ast.parse(printed) + + def test_default_repr_is_constructible(self): + """Test that the default repr survives being fed back to the constructor. + + `max_write_buffer_size` defaults to `usize::MAX`, so this only holds + because the constructor takes the sizes as `i128`. An `i64` parameter + rejects its own default with an `OverflowError`, which `ast.parse` + alone would not catch. + """ + printed = repr(WebSocketFramingConfig()) + + assert repr(eval(printed)) == printed # noqa: S307 + + @pytest.mark.parametrize( + "field", + [ + "read_buffer_size", + "write_buffer_size", + "max_write_buffer_size", + "max_message_size", + "max_frame_size", + ], + ) + def test_negative_size_is_rejected(self, field: str): + """Test that a negative size names the argument that caused it.""" + with pytest.raises(ValueError, match=field): + # pyrefly: ignore # bad-argument-type + WebSocketFramingConfig(**{field: -1}) + + def test_max_write_buffer_size_not_greater_than_write_buffer_size_is_rejected(self): + """Test that a non-increasing write buffer pair fails at construction. + + tungstenite enforces `max_write_buffer_size > write_buffer_size` with an + `assert!` when the connection is established, which would otherwise + surface as an unrecoverable Rust panic instead of a catchable error. + """ + with pytest.raises(ValueError, match="max_write_buffer_size"): + WebSocketFramingConfig(write_buffer_size=1000, max_write_buffer_size=1000) + + def test_max_write_buffer_size_below_the_default_write_buffer_size_is_rejected( + self, + ): + """Test that the invariant is checked against the default too. + + Setting only `max_write_buffer_size` below the untouched default + `write_buffer_size` (128 KiB) must fail the same way as setting both. + """ + with pytest.raises(ValueError, match="max_write_buffer_size"): + WebSocketFramingConfig(max_write_buffer_size=1000) + + +@pytest.mark.unit +class TestWebSocketConfig: + """Test the transport configuration.""" + + def test_defaults_match_the_rust_sdk(self): + """Test that an unconfigured transport matches the Rust SDK defaults.""" + config = WebSocketConfig() + + assert config.server_address == "127.0.0.1:8092" + assert config.auto_login.enabled is False + assert config.reconnection.enabled is True + assert config.heartbeat_interval == timedelta(seconds=5) + assert config.tls_enabled is False + assert config.tls_domain == "localhost" + assert config.tls_ca_file is None + # Unlike TCP and QUIC, WebSocket does not validate the server + # certificate by default. + assert config.tls_validate_certificate is False + + def test_every_field_round_trips(self): + """Test that each configured field is readable back unchanged.""" + config = WebSocketConfig( + server_address="127.0.0.1:8093", + auto_login=AutoLogin.username_password("iggy", "iggy"), + reconnection=WebSocketReconnectionConfig(max_retries=3), + heartbeat_interval=timedelta(seconds=15), + framing=WebSocketFramingConfig(read_buffer_size=4096), + tls_enabled=True, + tls_domain="example.com", + tls_ca_file="ca.pem", + tls_validate_certificate=True, + ) + + assert config.server_address == "127.0.0.1:8093" + assert config.auto_login.username == "iggy" + assert config.reconnection.max_retries == 3 + assert config.heartbeat_interval == timedelta(seconds=15) + assert config.framing.read_buffer_size == 4096 + assert config.tls_enabled is True + assert config.tls_domain == "example.com" + assert config.tls_ca_file == "ca.pem" + assert config.tls_validate_certificate is True + + def test_arguments_are_keyword_only(self): + """Test that the address cannot be passed positionally.""" + with pytest.raises(TypeError): + # pyrefly: ignore # bad-argument-count + WebSocketConfig("127.0.0.1:8092") + + def test_repr_hides_the_password(self): + """Test that the password does not leak through repr.""" + config = WebSocketConfig( + auto_login=AutoLogin.username_password("iggy", "secret") + ) + + assert "secret" not in repr(config) + + def test_repr_shows_every_field_as_python(self): + """Test that repr covers the TLS fields and parses as Python.""" + config = WebSocketConfig( + heartbeat_interval=timedelta(seconds=15), + tls_enabled=True, + tls_domain="example.com", + tls_ca_file="ca.pem", + tls_validate_certificate=True, + ) + + printed = repr(config) + + assert 'tls_domain="example.com"' in printed + assert 'tls_ca_file="ca.pem"' in printed + assert "tls_validate_certificate=True" in printed + assert "heartbeat_interval=datetime.timedelta(seconds=15)" in printed + ast.parse(printed) + + @pytest.mark.parametrize( + "invalid_address", + ["", "127.0.0.1", "127.0.0.1:not-a-port", "127.0.0.1:70000", "::1:8092"], + ) + def test_invalid_server_address_is_rejected(self, invalid_address: str): + """Test that a malformed address fails at construction, naming itself.""" + with pytest.raises(ValueError, match="server_address"): + WebSocketConfig(server_address=invalid_address) + + def test_negative_heartbeat_interval_is_rejected(self): + """Test that a negative heartbeat interval fails at construction.""" + with pytest.raises(ValueError, match="negative"): + WebSocketConfig(heartbeat_interval=timedelta(seconds=-3)) + + def test_zero_heartbeat_interval_is_rejected(self): + """Test that a zero heartbeat interval fails at construction. + + Nothing downstream reads zero as "disabled"; it heartbeats in a + continuous loop for as long as the client lives. + """ + with pytest.raises(ValueError, match="zero"): + WebSocketConfig(heartbeat_interval=timedelta(0)) + + +@pytest.mark.unit +class TestWebSocketClientConstruction: + """Test that `IggyClient(...)` accepts a `WebSocketConfig`.""" + + @pytest.mark.asyncio + async def test_accepts_a_config(self): + """Test that the resulting client is actually WebSocket, not silently TCP. + + `IggyClient(...)` is not None for either union arm, so that alone never + pinned the transport, and neither does the error text: both arms report + a failed dial as `Cannot establish connection`. What separates them is + the outcome against a plain TCP listener. WebSocket has to complete an + HTTP upgrade handshake, which the listener below refuses by closing at + once, while a client that regressed to the TCP arm needs nothing beyond + the accepted socket and would connect. So the raise itself is the proof. + + The listener is a real socket on an ephemeral loopback port, so nothing + here depends on a sysctl, a privileged port, or a running server. + `max_retries=0` keeps the failure immediate instead of retrying + forever, which is the default. + + The accept thread is joined before the block ends so it is out of + `accept()` before the listener closes under it. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + port = listener.getsockname()[1] + accept_thread = threading.Thread( + target=lambda: _accept_and_close(listener), daemon=True + ) + accept_thread.start() + + client = IggyClient( + WebSocketConfig( + server_address=f"127.0.0.1:{port}", + reconnection=WebSocketReconnectionConfig(max_retries=0), + ) + ) + + with pytest.raises(RuntimeError, match="Cannot establish connection"): + await client.connect() + + accept_thread.join(timeout=ACCEPT_JOIN_TIMEOUT_SECONDS) + + def test_accepts_the_default_config(self): + """Test that an explicit default `WebSocketConfig` is accepted.""" + assert IggyClient(WebSocketConfig()) is not None + + +@pytest.mark.integration +class TestAutoLoginAgainstServer: + """Test that configured credentials are actually replayed on connect.""" + + @pytest.mark.asyncio + async def test_auto_login_authenticates_without_login_user(self, unique_name): + """Test that a privileged call succeeds without a manual login_user().""" + host, port = get_websocket_server_config() + wait_for_server(host, port) + + client = IggyClient( + WebSocketConfig( + server_address=f"{host}:{port}", + auto_login=AutoLogin.username_password("iggy", "iggy"), + # The default reconnection policy retries forever: a missing + # listener would hang this test until the CI timeout instead + # of failing. + reconnection=WebSocketReconnectionConfig(enabled=False), + ) + ) + await client.connect() + await wait_for_ping(client) + + stream_name = unique_name() + await client.create_stream(stream_name) + assert await client.get_stream(stream_name) is not None + + @pytest.mark.asyncio + async def test_without_auto_login_a_privileged_call_is_unauthenticated( + self, unique_name + ): + """Test that the same call fails when no credentials are configured.""" + host, port = get_websocket_server_config() + wait_for_server(host, port) + + client = IggyClient( + WebSocketConfig( + server_address=f"{host}:{port}", + # The default reconnection policy retries forever: a missing + # listener would hang this test until the CI timeout instead + # of failing. + reconnection=WebSocketReconnectionConfig(enabled=False), + ) + ) + await client.connect() + await wait_for_ping(client) + + with pytest.raises(RuntimeError): + await client.create_stream(unique_name()) + + @pytest.mark.asyncio + async def test_wrong_auto_login_credentials_fail(self): + """Test that bad configured credentials surface as a connect failure.""" + host, port = get_websocket_server_config() + wait_for_server(host, port) + + client = IggyClient( + WebSocketConfig( + server_address=f"{host}:{port}", + auto_login=AutoLogin.username_password("iggy", "invalid-password"), + reconnection=WebSocketReconnectionConfig(enabled=False), + ) + ) + + with pytest.raises(RuntimeError): + await client.connect() diff --git a/foreign/python/tests/utils.py b/foreign/python/tests/utils.py index b37a53831e..b005dcabea 100644 --- a/foreign/python/tests/utils.py +++ b/foreign/python/tests/utils.py @@ -32,16 +32,25 @@ MIN_PASSWORD_BYTES = 3 MAX_PASSWORD_BYTES = 100 +DEFAULT_TCP_PORT = 8090 +DEFAULT_QUIC_PORT = 8080 +DEFAULT_HTTP_PORT = 3000 +DEFAULT_WEBSOCKET_PORT = 8092 -def get_server_config() -> tuple[str, int]: + +def get_transport_config(port_env_var: str, default_port: int) -> tuple[str, int]: """ - Get server configuration from environment variables or defaults. + Get transport-specific server configuration from environment variables or defaults. + + Args: + port_env_var: Name of the environment variable holding the port. + default_port: Port to use if the environment variable is not set. Returns: tuple: (host, port) for the Iggy server """ host = os.environ.get("IGGY_SERVER_HOST", "127.0.0.1") - port = int(os.environ.get("IGGY_SERVER_TCP_PORT", "8090")) + port = int(os.environ.get(port_env_var, str(default_port))) # Convert hostname to IP address for the Rust client if host not in ("127.0.0.1", "localhost"): @@ -58,6 +67,46 @@ def get_server_config() -> tuple[str, int]: return host, port +def get_server_config() -> tuple[str, int]: + """ + Get TCP server configuration from environment variables or defaults. + + Returns: + tuple: (host, port) for the Iggy server + """ + return get_transport_config("IGGY_SERVER_TCP_PORT", DEFAULT_TCP_PORT) + + +def get_quic_server_config() -> tuple[str, int]: + """ + Get QUIC server configuration from environment variables or defaults. + + Returns: + tuple: (host, port) for the Iggy server + """ + return get_transport_config("IGGY_SERVER_QUIC_PORT", DEFAULT_QUIC_PORT) + + +def get_http_server_config() -> tuple[str, int]: + """ + Get HTTP server configuration from environment variables or defaults. + + Returns: + tuple: (host, port) for the Iggy HTTP API + """ + return get_transport_config("IGGY_SERVER_HTTP_PORT", DEFAULT_HTTP_PORT) + + +def get_websocket_server_config() -> tuple[str, int]: + """ + Get WebSocket server configuration from environment variables or defaults. + + Returns: + tuple: (host, port) for the Iggy server + """ + return get_transport_config("IGGY_SERVER_WS_PORT", DEFAULT_WEBSOCKET_PORT) + + def wait_for_server(host: str, port: int, timeout: int = 60, interval: int = 2) -> None: """ Wait for the server to become available. diff --git a/foreign/python/uv.lock b/foreign/python/uv.lock index 963b58acf9..7b70b78107 100644 --- a/foreign/python/uv.lock +++ b/foreign/python/uv.lock @@ -8,7 +8,7 @@ exclude-newer-span = "P7D" [[package]] name = "apache-iggy" -version = "0.9.0.dev5" +version = "0.9.0" source = { editable = "." } [package.optional-dependencies] @@ -19,7 +19,6 @@ all = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, - { name = "pytest-xdist" }, { name = "ruff" }, { name = "testcontainers" }, ] @@ -33,7 +32,6 @@ testing = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-timeout" }, - { name = "pytest-xdist" }, ] testing-docker = [ { name = "testcontainers" }, @@ -41,8 +39,8 @@ testing-docker = [ [package.metadata] requires-dist = [ - { name = "maturin", marker = "extra == 'all'", specifier = ">=1.14.1,<2.0" }, - { name = "maturin", marker = "extra == 'dev'", specifier = ">=1.14.1,<2.0" }, + { name = "maturin", marker = "extra == 'all'", specifier = ">=1.15.0,<2.0" }, + { name = "maturin", marker = "extra == 'dev'", specifier = ">=1.15.0,<2.0" }, { name = "pyrefly", marker = "extra == 'all'", specifier = ">=1.2.0" }, { name = "pyrefly", marker = "extra == 'dev'", specifier = ">=1.2.0" }, { name = "pytest", marker = "extra == 'all'", specifier = ">=9.1.1,<10.0" }, @@ -53,8 +51,6 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'testing'", specifier = ">=4.0,<8.0" }, { name = "pytest-timeout", marker = "extra == 'all'", specifier = ">=2.0,<3.0" }, { name = "pytest-timeout", marker = "extra == 'testing'", specifier = ">=2.0,<3.0" }, - { name = "pytest-xdist", marker = "extra == 'all'", specifier = ">=3.0,<4.0" }, - { name = "pytest-xdist", marker = "extra == 'testing'", specifier = ">=3.0,<4.0" }, { name = "ruff", marker = "extra == 'all'", specifier = ">=0.1.0,<1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0,<1.0" }, { name = "testcontainers", marker = "extra == 'all'", specifier = ">=4.15.0,<5.0" }, @@ -82,165 +78,165 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.5.0" +version = "3.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cb/31/4971872b3ed8715346231fb6eb4da8fcba65a4143c189db151ee28a2812b/charset_normalizer-3.5.0.tar.gz", hash = "sha256:49bd5feb59b0bf3cbf6ebcf4352e371c95b9da9bacd4449f8b64d0ad2c10a26e", size = 169295, upload-time = "2026-08-12T14:35:31.624Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/8e/1e9a03657c2f12960c67b05e0550059787d4c8fc3fae822672f4fe7f40c5/charset_normalizer-3.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2478bd3b2ead3962a484fb802891be40d10049fb74f83e09cb4463fad023fea", size = 349795, upload-time = "2026-08-12T14:31:47.546Z" }, - { url = "https://files.pythonhosted.org/packages/76/e5/a42c8884ec0321c56124a5b43c135efe75260dad4f70ea0ffba4dcac85ec/charset_normalizer-3.5.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cdded069549b5eae3d5d9bb6c2e5bb4fe83f9b81863e2a193cd747bf197aebb", size = 250677, upload-time = "2026-08-12T14:31:48.918Z" }, - { url = "https://files.pythonhosted.org/packages/5d/21/d94021b005d2e660fd13634eecfe4ad378b0d0bbb45134809aed42b94a99/charset_normalizer-3.5.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aff38231e3171c578b2c449a01afa44e9ff40844597a32873da102394f63d28e", size = 239868, upload-time = "2026-08-12T14:31:50.107Z" }, - { url = "https://files.pythonhosted.org/packages/47/d0/8b9b07e41501bfcdfe380b826481033ca2eb5fa4b5507eb64dbc4cba9885/charset_normalizer-3.5.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4346a693c08b1d0cfc0e3325bfb0ecd4322fb1a6904d68cf416f8da5e981b234", size = 280688, upload-time = "2026-08-12T14:31:51.182Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/b83b3469e596adff778bf3c8ca8f705822c1c3c0ada8477a0d00113bfa59/charset_normalizer-3.5.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b787efadba00f5da6fe89513bfbe3852d52ca3a448fdec165765cb3b44a80248", size = 277133, upload-time = "2026-08-12T14:31:52.362Z" }, - { url = "https://files.pythonhosted.org/packages/0c/aa/a080c4facaab4ece837558649eb6e2c760867d1c0ea52220cbbc4e89dcaa/charset_normalizer-3.5.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:143792a43e06dc3b27fc891948406e251502dc19ff9216cd80182b79131be5c5", size = 261492, upload-time = "2026-08-12T14:31:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/f9/24/e23bcc9d4bc897bd620bc39c6b6052574f5ef93b85b50b175dd427a7dc07/charset_normalizer-3.5.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d74bcf1cdd8ac8267fb216473ce6b112efa07b163536288094541415084d131c", size = 259739, upload-time = "2026-08-12T14:31:54.86Z" }, - { url = "https://files.pythonhosted.org/packages/ef/85/f4c391c807f67c6483d02c02d28683e2fe229403728369c0cd33f61e4292/charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbe543d957213fc9a3db4979a8e171b7aa7504c1d737029defdb03a6095a38", size = 252120, upload-time = "2026-08-12T14:31:56.227Z" }, - { url = "https://files.pythonhosted.org/packages/4e/cb/dcf5aa14e24270613fafa2f02b3979820c244610410e5508696ca96bd0d2/charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3587d94b5c9f05c2dc4c3f3d47aba6375ff141a21adae3051d8d4d53e8a937c0", size = 241622, upload-time = "2026-08-12T14:31:57.54Z" }, - { url = "https://files.pythonhosted.org/packages/83/a9/decd1c1cc01ccbfce66845096e3945397e4ede354f31cc5cb28fc6ca601b/charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a7cb4cd266bd85613367fb85a30cfbf6fe6349919e87e18ca8dba584951bfb8a", size = 280745, upload-time = "2026-08-12T14:31:58.91Z" }, - { url = "https://files.pythonhosted.org/packages/8b/47/420ddefc04f089e4497b3bcbadea37586477713300c13204fb4a7c736071/charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ffdd7ac514301d0a67f7c23b9f2b431ef909a3c3dd6c3766668d0a6f5900c94e", size = 258669, upload-time = "2026-08-12T14:32:00.069Z" }, - { url = "https://files.pythonhosted.org/packages/58/73/01d5173097a5f0a13417e77db9ba3aea24de54421344c8e0c4973bf8cf21/charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3684ebbdffd51329ac44245d1d227d90b965797aa1a8abd026568a1f6ae88811", size = 277344, upload-time = "2026-08-12T14:32:01.211Z" }, - { url = "https://files.pythonhosted.org/packages/41/4a/300ef2598da520744aca7c49370e48732cab1f04de5f784f0e4f0e9e1d11/charset_normalizer-3.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8b8788f114845c01f2b520e0b91ea58d143276cfc0483aa943e815f7b9555c15", size = 263440, upload-time = "2026-08-12T14:32:02.421Z" }, - { url = "https://files.pythonhosted.org/packages/be/5e/78dd366df9ae4810039c0f799ec47d325a5cb76e92bb707ca9039b7c2306/charset_normalizer-3.5.0-cp310-cp310-win32.whl", hash = "sha256:9a1d9b13e5e394e13e3c316f0d910d100b17681ff59797f30da1dba032061296", size = 181680, upload-time = "2026-08-12T14:32:03.932Z" }, - { url = "https://files.pythonhosted.org/packages/67/d6/455982837da317ce47cafe35260f446f218f5843090b77a59ee69aa0ad94/charset_normalizer-3.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a54587f93f2e289f8faf25b35c997d4cc75cf677485ac6f50c985715989f99c", size = 205665, upload-time = "2026-08-12T14:32:05.045Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e5/5b285d50d4fc82111c18ea4fff8b5e1916a257b98dcd5cd0abf1c165473b/charset_normalizer-3.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:38a395079f229a631dece74e24c69c1f612536dd51f345a7d6a98abe2d3e047a", size = 184409, upload-time = "2026-08-12T14:32:06.503Z" }, - { url = "https://files.pythonhosted.org/packages/50/42/71e4e3bfe59202feef062c68487f54c6adf501cfbe087ecd93e3cd597fea/charset_normalizer-3.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e46a37ea7fcf9ae01d71b2e5ece19f1565987f3e308394b829197cbefc061f92", size = 349110, upload-time = "2026-08-12T14:32:07.832Z" }, - { url = "https://files.pythonhosted.org/packages/0f/dd/fd3386d0fbd358d3b5c7a2fa5bf312afe6159b04fafeb67d39fa971d7448/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cdfed4d7a59333c8220c67dd3be4e7a6c887b67453a64394022dcc919570add", size = 250773, upload-time = "2026-08-12T14:32:09.113Z" }, - { url = "https://files.pythonhosted.org/packages/17/ad/4901a66d6d3b17f1096725d7e50266132c16555aa6a70047fe1cf262b4b2/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9491f594859b68052edebd69e05fb045055a713b57a67974e6c1553b4e503c39", size = 240229, upload-time = "2026-08-12T14:32:10.43Z" }, - { url = "https://files.pythonhosted.org/packages/43/b7/1d790e0425e0f4c99e9b89a3956a94a6d2d0c6f01b2a5eca93d8f082d5ac/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:420b19411959eec115063229536788e6b32d0a7fa907d6b940317919120d702d", size = 280757, upload-time = "2026-08-12T14:32:11.628Z" }, - { url = "https://files.pythonhosted.org/packages/44/bb/4b8c8086c67636e52d6354ad17697f54a00b40041ca53dc765737e21709b/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a565303d118ea3b94a4b6c076bf568069726be414e43b06d58f7070b076ce11d", size = 276174, upload-time = "2026-08-12T14:32:12.808Z" }, - { url = "https://files.pythonhosted.org/packages/25/fa/690a11924c40c766d258d2b74d817cc5efe7bbcfceeedc5c0f35256d7524/charset_normalizer-3.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:815f143a91983ba3041bba066e492ae3c42de523fb1c699685a1abf3313b7d1b", size = 261808, upload-time = "2026-08-12T14:32:14.138Z" }, - { url = "https://files.pythonhosted.org/packages/53/2d/4a6945eff0c8f684e3f5b7b978644ab18ba9198da060dbaa1d9206bc6cc9/charset_normalizer-3.5.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c5e981a5ac8641381efe6f0029467500661616a530d27bc6eedfe45f840599f8", size = 259922, upload-time = "2026-08-12T14:32:15.485Z" }, - { url = "https://files.pythonhosted.org/packages/60/4d/10ac7e07bbf7ea569effeb9524e32f345f7e643800b20d538fb4706eab4e/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1a573e1e428f93908e79e04b349717f400e720f2f82285f0aaaf3ee0ff7f4c79", size = 252315, upload-time = "2026-08-12T14:32:16.764Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/1ddacf7aa7da12097f229a6a4e71a70200937a621b3617f6dc819fb99a66/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:32e6d56dd825205f81e5c45bcebb4df6a11fb2bbf4969a01ef156d6ced90c224", size = 240600, upload-time = "2026-08-12T14:32:17.956Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/293444d48c8d86fe51552262117134a9fc666d68cbbbc9b8c1b2b35a29be/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:30ae26a1adcd943690dcbbc47f28be762bae9e08ad7442b78c86b1c0dd5a626c", size = 280788, upload-time = "2026-08-12T14:32:19.1Z" }, - { url = "https://files.pythonhosted.org/packages/12/ed/0e34e40584f51eda38d4a5daf25fd8586366347efd4b2470dbf64710e778/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f51a19dc52197a20218b05ec5336d0c6b3b09935f838724722032c8d45dc91a", size = 258425, upload-time = "2026-08-12T14:32:20.207Z" }, - { url = "https://files.pythonhosted.org/packages/72/b2/c1b1c27f6f0ef35b21a8bdb592854bbf7f219629db3477f74c0b1380e0ed/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e44bc2780516b3df986d6fe33103c7080cd9dcd5576fe3cb4b0f64309c8f22b", size = 277437, upload-time = "2026-08-12T14:32:21.345Z" }, - { url = "https://files.pythonhosted.org/packages/b2/bd/a1b7d959a37847675adb2f5978f81703306dd78df4fefb82ee5a1cc5e37f/charset_normalizer-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7faa47b56070b3dd6f4898ed28528843ab130d53266cb9948d9b1f3bb1a5c5e8", size = 262947, upload-time = "2026-08-12T14:32:22.613Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fd/a76a2d7e639bfc6a9c869371e34f28231eaca7d7126ec19895aa38eedb51/charset_normalizer-3.5.0-cp311-cp311-win32.whl", hash = "sha256:830c04a49998b5ed58c8b642c65b7b26419397f52392a64121ba9fd0e95e7f9f", size = 181313, upload-time = "2026-08-12T14:32:23.736Z" }, - { url = "https://files.pythonhosted.org/packages/97/84/6fc03e802578df41a2ab9b6a1f26657fb92e32285a603ad5852a4a4f68c1/charset_normalizer-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:8cb9b6892b53bd6d11fa4cde3dbee020b1f0b6656be1fbaa1ec0d4324a7839db", size = 206197, upload-time = "2026-08-12T14:32:24.938Z" }, - { url = "https://files.pythonhosted.org/packages/0a/27/7208360ff1901607359869fcc45ca0989d597f3e30777ba30c7254170587/charset_normalizer-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:2403b489c103e9a18c835863fc6dd54361355c8291d4cafdb37492b683440b9b", size = 184925, upload-time = "2026-08-12T14:32:26.123Z" }, - { url = "https://files.pythonhosted.org/packages/6d/3c/045ea64ea5a550870dd8ab60b2242870328d53f17d2be593b4f9f3121474/charset_normalizer-3.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:98820e1ceb25c6df7a80c4fd8efa59cb121f99bc7c4c1693ad94a2caff5b311d", size = 343861, upload-time = "2026-08-12T14:32:27.254Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1b/7502be709db899d5b4801509829188b3a5a10969411da9c846115a5f1b70/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:608553f476fca509537e804c4a71f5eb166ce63b75141f89c2c686ce1aa36956", size = 237550, upload-time = "2026-08-12T14:32:28.385Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ce/66392661375148d9455c17bee25509a54e28c39969e34befa48ec8777936/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6753de11eef42f1c321b26d682957d92c7f7bbce6530f34bbe0f9291dd37cc6f", size = 229673, upload-time = "2026-08-12T14:32:29.668Z" }, - { url = "https://files.pythonhosted.org/packages/6a/64/f58c32a8d4ecf55b82ee61ee9aa6a664d4afcd36c72feb4c926fd6fe9af8/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f76dc0a47f94cb9b69d86f01e477f4b0371ca70208b9ccea7e063c41eed9046", size = 260768, upload-time = "2026-08-12T14:32:30.891Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ea/36c90e59a96386174377e855479ec154221ef001e96637e0b23be92489c4/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c387c6bf91b4774e359a48a179e2872b8e8bf741e4fde06ba8d1665eb9a4760a", size = 257880, upload-time = "2026-08-12T14:32:32.175Z" }, - { url = "https://files.pythonhosted.org/packages/23/35/5b85772eb82528ef22ba29487ad544a7049dfd27f35b1a5a55dbc0843048/charset_normalizer-3.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14f6904a3cf870abf044df3a8c4924ac6c8ef77e9896586fd37e73ae96cff2af", size = 247547, upload-time = "2026-08-12T14:32:33.495Z" }, - { url = "https://files.pythonhosted.org/packages/b6/14/ba11a99c2a22ab04c2d5383a700b378cb463a78ab15f36444cabc10cd671/charset_normalizer-3.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cce46dd29d73e135e8087b96eb62a4aca6d69391b7f97808c6588ebed3178f3", size = 243326, upload-time = "2026-08-12T14:32:34.794Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4a/cadba3f2400b45aa1d62a4ae0298bf58a3b30b1158baf15c338c7ce5b601/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b476cdb63df22da2b91837593380be3ddbe406f36c506c1c91d80e7196b66288", size = 238820, upload-time = "2026-08-12T14:32:35.984Z" }, - { url = "https://files.pythonhosted.org/packages/47/41/d5188b9342d75b72c2b05d3ee373f01a691397e770f001ee05e3b37925f5/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1f56ce84b317ef2a59d7d3461891c7597c79247d2192bb8114c68a1a1debfcc0", size = 231661, upload-time = "2026-08-12T14:32:37.191Z" }, - { url = "https://files.pythonhosted.org/packages/13/5f/df38fa972c4e945c3d8cee2bc4e610613af522fd359c7dc7a74c419f0278/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9ce0f885239357379d92fd9a5fddbe20f0e30e0527c29ba69f8e99eeb1304a76", size = 261459, upload-time = "2026-08-12T14:32:38.369Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d7/043ff7720067a3beee05523465ac9c1c846c68b7884930dd483f72ee5ab6/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:96ae7ab5d8155fde927aa0864fbc8ba3cc4fde6d41ab0c7cea9d6012b4978603", size = 242300, upload-time = "2026-08-12T14:32:39.505Z" }, - { url = "https://files.pythonhosted.org/packages/19/f6/33980b7b802a048e546a6d9ad2ea783a6cf6b10a86aaccd10db462d8b913/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bf91921009025e96ce57a03ced6d14604fc3baf0530351638e9504a55da6fa3b", size = 259101, upload-time = "2026-08-12T14:32:41.02Z" }, - { url = "https://files.pythonhosted.org/packages/a6/b7/0d19bde844bff9165377c1da9ef3c4792a4c24bd49b5b7094d9e6f6ab58b/charset_normalizer-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b2e44e6d42d1a4ff78ccc219a93c5449105d10b16198d1aea581080df8073f9", size = 249246, upload-time = "2026-08-12T14:32:42.47Z" }, - { url = "https://files.pythonhosted.org/packages/1c/cc/2c34fdfaacdf0e96e880ef562cbf80a9b5f8ea97e0dd9e57ba348a9c65cf/charset_normalizer-3.5.0-cp312-cp312-win32.whl", hash = "sha256:deb99535e9bf0bea8e274c6413eb939a21be35a3f492678dba4d5b1f4d70f142", size = 178025, upload-time = "2026-08-12T14:32:43.909Z" }, - { url = "https://files.pythonhosted.org/packages/76/d0/c34dbd1df23bcbdc1b5d2f48256340d72fa747f1eb03924a9d2fa35ed85b/charset_normalizer-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54dd1a66fa4bce0ccaf0db9dde336e49b3eec646dc4c1c0991279369d373a14", size = 200143, upload-time = "2026-08-12T14:32:45.254Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/4e6bf1465d60c3d8f488d5bde140d0cb91cd23ccab0dc2895cc6c6982047/charset_normalizer-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:b8ea208b304587d47931b36481342d20336e0d338ab052f8b4305926482598d6", size = 180046, upload-time = "2026-08-12T14:32:46.545Z" }, - { url = "https://files.pythonhosted.org/packages/1d/be/cc7b7b6fc41984902c0d31b06f5d9297e67705c1dae9352608e5540fad09/charset_normalizer-3.5.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:5c23fa4f6eccdd601949cb00f3988c01d64e671d8faba356397971077022e144", size = 211050, upload-time = "2026-08-12T14:32:47.81Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ae/e3ec8f17313609f43f7b323012fdb1ee37b83432277ca4eceba83e00366c/charset_normalizer-3.5.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:07f6f42b5a6325df35b458004fb5f9f29bf502d89287a33c7cdef3590e31de0f", size = 222768, upload-time = "2026-08-12T14:32:49.027Z" }, - { url = "https://files.pythonhosted.org/packages/c5/50/9f9c0d7ccc1512d49e27a0e7c12c58ec71dfe91698fa4326f058c33e1f1b/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8efc3f1563ed431882dd0dc0411b5f8ace1b1b89074981deaf6bd8af77dbe1bc", size = 193907, upload-time = "2026-08-12T14:32:50.414Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1d/cfe7b745ef7f4c3b7214581955b5a0869ba2ac551a58fc11036281ae167c/charset_normalizer-3.5.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:368eb2fc9482158b3a3386e8f01fa61f479c968e9a19ceab8f0188b86b312991", size = 197135, upload-time = "2026-08-12T14:32:51.605Z" }, - { url = "https://files.pythonhosted.org/packages/28/55/30fafdcfca9ba616bc394240545e4cd52f4f66dea43ded81b7d2d5274fde/charset_normalizer-3.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:826a295a039178479a325be1ae60eded1f0b10f7dda749df59e2440de8f61d64", size = 339892, upload-time = "2026-08-12T14:32:52.82Z" }, - { url = "https://files.pythonhosted.org/packages/f4/08/bdca5fc2bdc36ee443673dc7d12b23885a5a7b282bef85a1a4c3b325b40e/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70ff1c16eb0eb5ee6bb12739292347f981a5ba764cc4df1bc2e69b0405d4ac3b", size = 239439, upload-time = "2026-08-12T14:32:54.058Z" }, - { url = "https://files.pythonhosted.org/packages/d1/9e/506c8d7a7722bba7c8cdd78c1b5ef23bda92bfbe0b3e28ea84673d519a0f/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3cfdab178a4add5483e26a9bb1c16d8018ccf39b4be7a3aea6c3979e6828f2ee", size = 227896, upload-time = "2026-08-12T14:32:55.326Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1a/dd828f2b1d6f4bf10821b9a74d866be05ffcdbfcddfc501d6fe6428762a7/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6083d10a846218502d664375b9448508d9fa580bd834567423156c6abfbe899d", size = 262548, upload-time = "2026-08-12T14:32:56.484Z" }, - { url = "https://files.pythonhosted.org/packages/be/81/196d26f6bd78b93e0d451b69082a71027ceeddd4b0be9170b81bb038f824/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f211c21aa316cb6e2662e54a1194633a79d98a50a876addacfce7ba5b34b09f", size = 259986, upload-time = "2026-08-12T14:32:57.661Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6a/5b964a1eb0f9075ecd45083eeb21aaec215334f98bac3d400302ea73875d/charset_normalizer-3.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b08ebf9488c7ff5eff038e48e6ea938178dfd9dcc8598b5ca941e4ae27b20be", size = 249853, upload-time = "2026-08-12T14:32:59.123Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b4/aee3a9d82edd0e931091ef3e9f03e46491ae3590e96e998d0975dadbe17c/charset_normalizer-3.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95450fce59f00c6d08eff6572ec2e736e5054c9450253afd5748f8416f2eb9", size = 244217, upload-time = "2026-08-12T14:33:00.341Z" }, - { url = "https://files.pythonhosted.org/packages/22/3e/33f72ca11c1b619b220fd9f35905ebd171cbd0e0470f2357e467b9e861ee/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5780a29823e1d2bec69b7a104ead4195a43f3e97782efaedbf1f79a0157af715", size = 241307, upload-time = "2026-08-12T14:33:01.589Z" }, - { url = "https://files.pythonhosted.org/packages/78/27/6029dccba958621c7f3a65136f87c5512d712aef9e890f09512cc171bd03/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:054420b5db984971d886e5e4e2c37c760ae6682aedbd066687ff0949d9ed5f08", size = 233305, upload-time = "2026-08-12T14:33:02.866Z" }, - { url = "https://files.pythonhosted.org/packages/18/d7/691c967be459153fe9faf49bf78bc95639ef8bf6dd008f38cc6389a349eb/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d016dc857136c726958102c3b8a3986acdc65ace6fbf12cfdc09cc4bfa2935b2", size = 263465, upload-time = "2026-08-12T14:33:04.166Z" }, - { url = "https://files.pythonhosted.org/packages/7c/2d/9202221be5c90b2a835924191e362690ed8dc8c7d6606100c2bd03fe0f8c/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ce3d39fb4a9d674e6639dd5d3146b2e273475d2260f10163228d66fc04433d", size = 245060, upload-time = "2026-08-12T14:33:05.325Z" }, - { url = "https://files.pythonhosted.org/packages/b3/9a/298772fd0a0cbccadf36451a1cd7eef4b66a11e99b4a7f6fafc47cc62c75/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5613a3a82c974227bde18f03409e30c467f8065cb56d822e3eb83708a5f223d", size = 261091, upload-time = "2026-08-12T14:33:06.475Z" }, - { url = "https://files.pythonhosted.org/packages/82/3b/1a11fe66e555dbe2f5714ade6ba74fa29edc9155d9cf1001d4d6ed096aa7/charset_normalizer-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fded2e82ff082e5d8e017e2ddcc1411bd8cb83b8585097fc401ef574f756b888", size = 251857, upload-time = "2026-08-12T14:33:07.784Z" }, - { url = "https://files.pythonhosted.org/packages/17/fc/73b817e8af3f1d25ec5cf458d405abba5a144cf9812238a61530f5eac186/charset_normalizer-3.5.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8f006866047c6ec4b627ec144b1e0bbc7427cb31fd7c08d19897d0ac9032af3d", size = 139745, upload-time = "2026-08-12T14:33:09.123Z" }, - { url = "https://files.pythonhosted.org/packages/b3/fb/ddb66303c86f7dc5043a457dad9fa82b4d6d0cb97094f9bcdde21693fd58/charset_normalizer-3.5.0-cp313-cp313-win32.whl", hash = "sha256:196e270c4e80827b5072eed7d6aa661d133afada94fe366669f9609e718d305e", size = 177217, upload-time = "2026-08-12T14:33:10.305Z" }, - { url = "https://files.pythonhosted.org/packages/fb/88/6018cc8d76ea2b7cb02918f37e23e86c261d1a102713d7e88d2cfb8b211c/charset_normalizer-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:72982d9958a42f8132bf2d6b90214ed66477295ef1188731f98ae3511c6eeb5a", size = 198896, upload-time = "2026-08-12T14:33:11.51Z" }, - { url = "https://files.pythonhosted.org/packages/20/2e/04c0bbfc8d9abf91959f7a3d207d45cbf63a8116984caae2381890019bb5/charset_normalizer-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:0b373bab0b867b68b8eb249da9478cab9181a42993437cd2f5dba5fb0b4fbd1b", size = 179193, upload-time = "2026-08-12T14:33:12.726Z" }, - { url = "https://files.pythonhosted.org/packages/43/14/d098868dac5ff27e0258f548b1c74c6484be528384965d8fcf8fc6a4011d/charset_normalizer-3.5.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d95244906ed69d0f79f190893c65e336c15959003e21449256dc05c001b52ea2", size = 211664, upload-time = "2026-08-12T14:33:14.153Z" }, - { url = "https://files.pythonhosted.org/packages/e7/da/a944b32a46601ae5a4c3499e8d64ecd14fe82313f00da74dcdf00273a0b4/charset_normalizer-3.5.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:d788e2ded0c4c47efa4d73cfe59eaf975ee32f425219873d2cb3e3fbaa00f636", size = 224375, upload-time = "2026-08-12T14:33:15.472Z" }, - { url = "https://files.pythonhosted.org/packages/f7/db/eabb5996be2f529744755e7b2fc9396eff4a64961f034e7fd49d54b9afb2/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f9f91d3e8382900f3a68fa0ce94294479de9cd2de6bc0c70acd0f0dfd511836b", size = 194364, upload-time = "2026-08-12T14:33:16.607Z" }, - { url = "https://files.pythonhosted.org/packages/78/65/4ad3c5be108930310d8003f5602861d5b89f728293b9f09c3a4837f7ba10/charset_normalizer-3.5.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d54625cbf4e6b60bf0639728cb8b4cb541e340f6d7cafae5806051a40ddf4c45", size = 197643, upload-time = "2026-08-12T14:33:17.88Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8fee3201b98d52289be60a775797d69be05a04fb6cfb48c1587dad33e649/charset_normalizer-3.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1f99a8c3a1da5d955edbad18208b3d627bdd54c48a6e739fa877bdca98c686d6", size = 341384, upload-time = "2026-08-12T14:33:19.239Z" }, - { url = "https://files.pythonhosted.org/packages/a7/dd/9e757101d1f76c35c0643684ba499ac3a181fb2b264c68174bf727d627e8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf1e75dc07a3850b53d1e5f75e04d3ae12afe56284be7821771eaa2466350c73", size = 241637, upload-time = "2026-08-12T14:33:20.619Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e4/7857023015400bc4aa0a82fbcca29fa2dc7ec25f971a130764cb2dc7a589/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac5a9cc079c67d75f4ddf343276031879eadbb333d1bb231cce297b8d7b9aae8", size = 226170, upload-time = "2026-08-12T14:33:21.773Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ae/8b52935b304f7b6bbf33151ed2b75266b09aa4b6f8f04230d948885b2577/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0dfe83c1b4d00abbf433998117a14f56a5c2bc68226c0d331709eed0d1ce539b", size = 265093, upload-time = "2026-08-12T14:33:22.999Z" }, - { url = "https://files.pythonhosted.org/packages/dc/78/6e838f6bb059f2c0afc60a4e7f294252f043c254656ad4114c50302cae4d/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e8fa586df2208ef040684751345f10f503834a757c9a74ecd19c1a2f9b1ccd", size = 262789, upload-time = "2026-08-12T14:33:24.214Z" }, - { url = "https://files.pythonhosted.org/packages/c2/08/189b27e51fddc9d6b3695331da0e31792c1d88b953ad854e57f06e9b2cc8/charset_normalizer-3.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82cc5835997ec78afe293a192e385099355770a7db94b2fb1239d36b32796f1c", size = 250580, upload-time = "2026-08-12T14:33:25.707Z" }, - { url = "https://files.pythonhosted.org/packages/ac/55/64854e99b25841f83e8e37d9df2f3d1f96f693439f80e5fabd542a7e47ab/charset_normalizer-3.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:19e52bda45086df8a4be4bb5910af6f5d9d3b538c78712c8ae09ef10b85bf458", size = 245008, upload-time = "2026-08-12T14:33:26.971Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/7720c904fa635d4260b4dced6029cf3d298c57b26741365d5a8d28c54043/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3418edd0ecb72a0a3861cf72f31be0ad9b7fe338ce2b58fb5cc80b9aeb792700", size = 243892, upload-time = "2026-08-12T14:33:28.237Z" }, - { url = "https://files.pythonhosted.org/packages/70/50/7bfcb327631d4870c720872b548745f6ec8baa044d51c21b5d1d32ac4e3a/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:125ee619611019471b177c70bc3e9d4cda9fad7e01d93523501d3b188df0193a", size = 230996, upload-time = "2026-08-12T14:33:29.511Z" }, - { url = "https://files.pythonhosted.org/packages/24/51/40c45d6d940c04005ed721aa54bdebf1ebb2930f8a2ae537e8d60484fb27/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a3ad0e3da22852533858663848608f3f24c0d35e5cde415a4903476f2b4c88ec", size = 265834, upload-time = "2026-08-12T14:33:30.689Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d4/ef7a227ef89d215b47f9df79c3966610b17faa13bb2f236989207a631622/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4ebebb410bc517e1d284c52a123e82704b21e4e7e26a21ebecf7439d0647b8a3", size = 245544, upload-time = "2026-08-12T14:33:31.859Z" }, - { url = "https://files.pythonhosted.org/packages/37/a9/a4ca9156964ded61c7718eba410ce11be2fd2b263fda4bcf08367b6578cd/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:91f9f7c151e772acebe489eaec96e96a2877202d7dd144e3f96b8676881715a0", size = 264110, upload-time = "2026-08-12T14:33:33.13Z" }, - { url = "https://files.pythonhosted.org/packages/38/6a/838364bb8702229c6e5f8b23f80ff0f052a12dfaf3113a12fd6acbe92a44/charset_normalizer-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:401ea6e7af9e7852ed818f64714b579c1935482049670847ca3bd7ba45dc63fb", size = 252303, upload-time = "2026-08-12T14:33:34.98Z" }, - { url = "https://files.pythonhosted.org/packages/9c/5f/d88032edce951f499a2321cf7ae0d35a043c74be12bc22d81084cc7afbcc/charset_normalizer-3.5.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f7496aed56b06325a1ad419c5bf23c6dd042558e874f71dd1b958f3e255f3053", size = 139964, upload-time = "2026-08-12T14:33:36.195Z" }, - { url = "https://files.pythonhosted.org/packages/37/ae/1c4a46b6b00d1c34d2ee355ef99ad6173674166800d1af0f05f85028d513/charset_normalizer-3.5.0-cp314-cp314-win32.whl", hash = "sha256:606a86c1c3196f3738de39a67a7490bbd61cb31c0e0436070bd0c6a48170b38e", size = 179790, upload-time = "2026-08-12T14:33:37.356Z" }, - { url = "https://files.pythonhosted.org/packages/01/51/f94dcf34fa8eba48c1fb89b6490a5f1426e19488fe5f38aac6c648c99057/charset_normalizer-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ec6c464cf45867f66a2273e2214d9199a8fbad5cb95ca0fd45f6a2fe1d9d2cf4", size = 203723, upload-time = "2026-08-12T14:33:38.639Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ba/91d386870b5d9e4b0d8c4034f63877cc2e47b99c81ef05f3e6d42bf9a53f/charset_normalizer-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc28949de1bb5f7f30a46f15d74ce7ac5aaa63e03c5de04d68f571c7423af834", size = 183423, upload-time = "2026-08-12T14:33:39.899Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c9/534ecb17b7fb95f9052c4a44cf316316a27d4a8f73e8475ff55e778dcdd7/charset_normalizer-3.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:68b7e84ae8239a94f8d2c8f3f3a3a81bcde54805ec8f42a34de927d155688ec6", size = 368967, upload-time = "2026-08-12T14:33:41.093Z" }, - { url = "https://files.pythonhosted.org/packages/3c/b2/ad7c3242d7fe55cd55126c22c65cb1b49779782cdf8932fd01d12232d86a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58ca5dc0a0ef99f2801ec0574214c978e9574055bc783830bbb6e7433218609f", size = 239478, upload-time = "2026-08-12T14:33:42.428Z" }, - { url = "https://files.pythonhosted.org/packages/7e/62/77f0b850048e430fc350ec58876b0c020f5c8d0d3956fd1a4d6ae2fa292f/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6c57af4084c10cb3286688d65e4c654190ff5edcbc2411d08cdca0a8a44c59a1", size = 227036, upload-time = "2026-08-12T14:33:43.635Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ba/47d951e1a51dddbaad0a1410baf49fb1d897ceb00281568f1183b79bce9a/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1619a3cc174a7e3963dd34348e6fceb6e50db0ddeb0031bd7c73a58286454fa", size = 260772, upload-time = "2026-08-12T14:33:44.96Z" }, - { url = "https://files.pythonhosted.org/packages/b0/61/8c7ff4c81b2a88271126acf4b83ab3e31f6d63868b0f01d331eaa0f9cb67/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1328cc57dd4372be1265f68232cee890e087416e3e6e93e6ffb32c2bad4d36a4", size = 259273, upload-time = "2026-08-12T14:33:46.185Z" }, - { url = "https://files.pythonhosted.org/packages/e1/fd/36129689be08dc287b951306946657ff70d76e287dd57018861f86d0e474/charset_normalizer-3.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06f4fb62a9139bef056b8b2da6773c94c2f259f90e4b8e53b166f3d0372d7cf6", size = 248086, upload-time = "2026-08-12T14:33:47.54Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f8/bcae67f994c8fd31dda445e5ebf84045823c31443fe46f0e9ee6aca99aa0/charset_normalizer-3.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:478650a70a750d75d5add401606c77f77069c32e4ba2c9131dc6cee566962ca0", size = 242671, upload-time = "2026-08-12T14:33:48.746Z" }, - { url = "https://files.pythonhosted.org/packages/61/92/0472cdad1061c2f0e4d3aee29973eb6e81bb8fe256ff2860cf115b15f1c9/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:48920bf6fe83eb2226756ac623fa54940487154eb18f80889d5735cf234965c0", size = 241311, upload-time = "2026-08-12T14:33:50.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/89/04a03de5d27c77c624d9fcf6287073754bd438df1b58cb7d030c57c2824d/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:168a0cb536b5123a77bc42ecf5e0bf6f923d0d9ae43c42a14eb0677c19ac6c19", size = 229898, upload-time = "2026-08-12T14:33:51.523Z" }, - { url = "https://files.pythonhosted.org/packages/42/a2/639c4278adcb7ed1f4db608dd9ac19b6774fa2285a96b1c0bdb9c124ccbd/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f278e131afa96a3622cef9211c406ea2ad1b68eb06f8837cd443684a40e0ae50", size = 262852, upload-time = "2026-08-12T14:33:52.924Z" }, - { url = "https://files.pythonhosted.org/packages/9d/95/02e34c97bedfd0c5574efb9179c850591acc7f967ba039ed8dd29d332b73/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d6100f877d2ed95f0856a3fde25334153add94bf2224c43f45f88e7039262aaa", size = 242913, upload-time = "2026-08-12T14:33:54.18Z" }, - { url = "https://files.pythonhosted.org/packages/a3/64/0946aeab6462dad9f160a50dfb4704d3f58a5ee708f085abc2105fbbff0c/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4c440122e1ea68b1f8b44a631ebf49c39180f6869b1da22d76e8a724208ec6e9", size = 257938, upload-time = "2026-08-12T14:33:55.802Z" }, - { url = "https://files.pythonhosted.org/packages/79/77/36787d41ead124746506a4425c729f4f17c68280af8a6a5baa0a598cae86/charset_normalizer-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e5f834965c2fe589837bac1002e07e25734ff70381903ccd95b3d649e22bfa40", size = 249467, upload-time = "2026-08-12T14:33:57.081Z" }, - { url = "https://files.pythonhosted.org/packages/65/10/d9f6c5589cd24198d4ce6cd2948191c18e657272f433e5a00d258d9f5c22/charset_normalizer-3.5.0-cp314-cp314t-win32.whl", hash = "sha256:076cf9d3f3c7e410295c09d96355cf3b1bcae74990034d80e4371e20fe1ba4c6", size = 190624, upload-time = "2026-08-12T14:33:58.449Z" }, - { url = "https://files.pythonhosted.org/packages/6c/81/43e0584a802051a22c725795ebe1df78263abc7de858eef6cdc9b36637e9/charset_normalizer-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3288a560dc3114d5d2ebe309b1ef43f8af355eafe25856832415c2a8196c9db3", size = 215902, upload-time = "2026-08-12T14:33:59.753Z" }, - { url = "https://files.pythonhosted.org/packages/30/f3/af6a1160fef0eac4510d035241e11eccf78e5350e4cd4de79e79fe02a5e5/charset_normalizer-3.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a284c36b9c6616bf0a8aa4aabba668a0c75ba65ccf40a79868aeaa69ad996897", size = 193452, upload-time = "2026-08-12T14:34:01.017Z" }, - { url = "https://files.pythonhosted.org/packages/42/a4/dee470afb7a55c4f78b6fef37306c51fed17ebf94dbe530798c91d394350/charset_normalizer-3.5.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:c38d1e9bc2073b0984d2099ea647fd7f6c0d8f83a1e14e0cd32926f16e4c44ce", size = 341595, upload-time = "2026-08-12T14:34:02.4Z" }, - { url = "https://files.pythonhosted.org/packages/6a/32/9c3126dc429c6d9d7f79c52681a7c4453ed20a26267c9a8275d7ab620aba/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9f45186390aee4d1f26f723c615b67df346766c3b16df000d84d6e374f06757", size = 242177, upload-time = "2026-08-12T14:34:03.741Z" }, - { url = "https://files.pythonhosted.org/packages/4b/9d/5b616a887301ff4cc0916b39ba44257390d3da80deeed6e8b6f2f26b14a8/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f0fde5e5100c735b2274ab898f0742a5dcde492796296cfbe7e0ad6a4cd1a396", size = 236730, upload-time = "2026-08-12T14:34:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/d6d3e70be93ebe5fabef65e4c7ac113e1d1705cbaeb5fb72467e713aca17/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d00e18e7bbf47e332ab63903d18bae31efc701b1d8cca0382b97784a621fc44", size = 265158, upload-time = "2026-08-12T14:34:06.235Z" }, - { url = "https://files.pythonhosted.org/packages/30/e7/3f1fafa87e2643257474f9c4eec609f2193a61d907dce7dd4f3f2390ebd5/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b81980668800dd1c69faad8aea6e85a8cee0e13bcd3bba7671695ff16260293", size = 262931, upload-time = "2026-08-12T14:34:07.511Z" }, - { url = "https://files.pythonhosted.org/packages/0a/df/ebeb224a949d91829e5e114c6b64372a3c792b00762a9e951ce416f3a32d/charset_normalizer-3.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb3e0d1345b9c0fe73673ea656375f38a78ec679c2edeae0c24800f04798a85", size = 251388, upload-time = "2026-08-12T14:34:08.949Z" }, - { url = "https://files.pythonhosted.org/packages/a0/64/9a6ce2e7acc5cf1b4636f78f82e89ff581e06a0216a40678b28bd4d832c4/charset_normalizer-3.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2401f7671242e921e604f609d429f6b282ea4ca787a6ffd22ed7372011ddb9d1", size = 251821, upload-time = "2026-08-12T14:34:10.138Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b1/6e69b8056f615e5ccff6b91ca16db2d47922251f016821a300c115267fef/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:96720f2aeed3434bc48f4d52fbad64ecc820cfed88915d664780ed9ba09ede78", size = 244507, upload-time = "2026-08-12T14:34:11.488Z" }, - { url = "https://files.pythonhosted.org/packages/0f/34/02c15d6a0aa6b934dcdc136b111da63ae857b9fd51cf5505b0736337c2eb/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:c455829625df983f716cbaecbba77f2d1dc2e0e0ed1638c059cece15a279344b", size = 240951, upload-time = "2026-08-12T14:34:12.991Z" }, - { url = "https://files.pythonhosted.org/packages/ae/15/0fe893d3e1c7d111280bd6c4bd4c1e431487a1124a1bcbce78dfeda3a3a8/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:c41b067eddcfa5ee6b1169c287605be7fb6b0ea22bba6474c5bb978a668def4f", size = 266162, upload-time = "2026-08-12T14:34:14.232Z" }, - { url = "https://files.pythonhosted.org/packages/55/ea/eca03527307670f5d102c295671a800c404ca958cf94fefd10fc963a72f0/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:e31786a947b136329bfdc458c82c06d4ec539b4a4436b7da4df4aafc9902ee80", size = 251835, upload-time = "2026-08-12T14:34:15.48Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/fee5633081e595fe9e191df6f215106c791ad596eddf5e41e39b8ea0f2e2/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:c5c6d47a865147e0ae3322ce92e7fb52ba3169d94b447deda56897ea2aa6fac9", size = 264314, upload-time = "2026-08-12T14:34:16.679Z" }, - { url = "https://files.pythonhosted.org/packages/cf/fb/17f47ae6ca35b562fb6e6f4b05f7aec6034217353eb4a23aaa3566dc7340/charset_normalizer-3.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c75191e3c8052045179646cb40e280800a4e0bdfda34d9c949c2f268d44e80e4", size = 253194, upload-time = "2026-08-12T14:34:17.975Z" }, - { url = "https://files.pythonhosted.org/packages/59/88/f2b0f7ebb92493e925889ff29239b3b0073ffafd91230dbfc69e5cf9389c/charset_normalizer-3.5.0-cp315-cp315-win32.whl", hash = "sha256:83b62410bd36bb1178a7d563e2ee0cf21eb1c980c912ab99c2c78f06227f1731", size = 179800, upload-time = "2026-08-12T14:34:19.409Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f0/afb5bfdea52fd943b1960403847a276b8e900c6e4cd6a38752321b4eda64/charset_normalizer-3.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:e3b9eaa99a6d8c9ace4cd303915947ef55088d4cd87c6676874f98c5c03aa040", size = 203726, upload-time = "2026-08-12T14:34:20.656Z" }, - { url = "https://files.pythonhosted.org/packages/fc/71/219783eb691aa2ec879c0e521afdfe2b826f9678eed51b9c039d03e0db2b/charset_normalizer-3.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:fec352b793cdc183cc9e7e0b6c10fd7bff38ec54ba44cc43599b9b56f7f3db2e", size = 183428, upload-time = "2026-08-12T14:34:21.975Z" }, - { url = "https://files.pythonhosted.org/packages/d1/d0/14aef3b9f80f2593c039d897e89034635b9eb0eb44b6ce5173bbd79ff338/charset_normalizer-3.5.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c9bde7a960720c8b8e1b5ef7afaa0c9a2f3b55c44abd635b2b29dd066b298e3a", size = 368728, upload-time = "2026-08-12T14:34:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/10/fc/b249466ddbbeffa448b6597631e9091d1f01b5132ff8e7a0e21a6eb72b63/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8cd1283a9fe6c2065c807e9d5da81afe5e1e004caef39adc0d8ae86dd883698", size = 240925, upload-time = "2026-08-12T14:34:24.504Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b9/c17e72aaa1b3e1ca6c184e8025cf138ed492d01a54f85286ff7d31253a4b/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1c010dd86d3f4c4433c9634d33ce8147393b270dfa54f217f965540b8ae8e075", size = 234932, upload-time = "2026-08-12T14:34:25.822Z" }, - { url = "https://files.pythonhosted.org/packages/18/d7/f84ef0966bbe216f71029e34e7fa425a16b1682e2a40265e679dedf2b655/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5e68229977b2dea28e7061c0c0630a23f2f9f6e9c6fb38d77d3d6dbfe3768b74", size = 261733, upload-time = "2026-08-12T14:34:27.112Z" }, - { url = "https://files.pythonhosted.org/packages/3b/73/3e887fa0781a395339355ed934ab6561ceb5bb52574160f070224039c630/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a60773eb5fda796e6e6f76b9c152d270fe59f9788a51a6ff8ba44082d8548ae4", size = 258460, upload-time = "2026-08-12T14:34:28.431Z" }, - { url = "https://files.pythonhosted.org/packages/b3/81/52ebd9849bf9e35d0b21fff115cb6543162a8e1f2f564e8f87121a336b8c/charset_normalizer-3.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9419f44e568f7fafcdc0b3b5c766a2364e705a9b34fb8a56b431e0d1f3f4258", size = 249894, upload-time = "2026-08-12T14:34:29.698Z" }, - { url = "https://files.pythonhosted.org/packages/85/f3/9366492b8a5fe0187de282e001d61345740cf79eb4a5f20181d769be02b5/charset_normalizer-3.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75e243abbb528c1a774390ed71e3f868a9f37b1373442e4bbadd401cfc505ff4", size = 249540, upload-time = "2026-08-12T14:34:30.96Z" }, - { url = "https://files.pythonhosted.org/packages/0b/af/28bb5e5dbd3e67cb9196a62781ac2b6d79492f4fc7a069b6ca7d6d6c8d58/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:54c963ce6404e52255b737e8a06d356fc762d59096ae566203a67cf2b7d050f2", size = 242734, upload-time = "2026-08-12T14:34:32.481Z" }, - { url = "https://files.pythonhosted.org/packages/26/d6/7ccfa62b53b40fc06b2d3504825aa400764740bd10cf248fdc4272441b93/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:63ea0cc840c66670183578c2630d138c0e944aeadfc33f25173ee240f5db780d", size = 239580, upload-time = "2026-08-12T14:34:33.916Z" }, - { url = "https://files.pythonhosted.org/packages/0b/82/71c0c9b046697b8da66b3acefa8d5f92d00a9ef433ad7c3522b971d0369a/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6c06875a1d4a7537bef70f659b55c6b55b9a47ec3ba8f2db610350c2d9915e6e", size = 263281, upload-time = "2026-08-12T14:34:35.333Z" }, - { url = "https://files.pythonhosted.org/packages/d6/01/d027583c869f40ba980c1c76994adbd522c360a6327e72beb44d7c267385/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:4253da1b4456b633651a8d59eb1dc7a8a8fa38241014dd7c217b353e547ae394", size = 250027, upload-time = "2026-08-12T14:34:36.991Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e9/6475d739e0ec8bb1236e06263dc3affaffdf947d8114ad27024932f325da/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:f044cb1cf44012184715f46584658993b5fee9344d71c4b0c455a17a299730c0", size = 257547, upload-time = "2026-08-12T14:34:38.277Z" }, - { url = "https://files.pythonhosted.org/packages/a5/60/d1f502fcaa048a2aca3ab80bfef8407659c131e4f1792fa805fec14b4960/charset_normalizer-3.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a17864853f7c518ae7d4b368af98f427f9396805476af40af8698560f09d7d97", size = 251718, upload-time = "2026-08-12T14:34:39.562Z" }, - { url = "https://files.pythonhosted.org/packages/c3/69/76343dcf4381a698807ff8a20d89f66bbdd9f6222b0b17740f77ab764335/charset_normalizer-3.5.0-cp315-cp315t-win32.whl", hash = "sha256:9e726478d7a213847860219d74665a6892a643ac93b8f76580f6cf9ed39996b7", size = 190757, upload-time = "2026-08-12T14:34:41.053Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ea/d18147626a1667cc773c42104ab155a4ca5d6d4d174b7a35e01062213ea5/charset_normalizer-3.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:d7229a99120c6c2792d96f4857c2648ce5530e93667a2c2388c5ef69a6b84775", size = 215431, upload-time = "2026-08-12T14:34:42.501Z" }, - { url = "https://files.pythonhosted.org/packages/47/21/4869598aae0872d94faa5933918a4fe37ab2c5af9d095786e241f9506fed/charset_normalizer-3.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:527e28a5e751d9e11369b9c5f9ab35c748eb9c109101920c7deb40d6eadf8d03", size = 193205, upload-time = "2026-08-12T14:34:43.781Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f3/7b523d807cb5e73562ef8acf21d39cdb9d704955327362c781bc3478a73d/charset_normalizer-3.5.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:5a4ee37248dfac25107c758bda99d545ce73e60b44d2dd39e4a2bb9f2831e9f5", size = 330840, upload-time = "2026-08-12T14:34:45.06Z" }, - { url = "https://files.pythonhosted.org/packages/f0/de/fc68978fe78ca97063c96d764e41ff92ca639948f319271e0ff450e577a2/charset_normalizer-3.5.0-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a864bdcacd8bff58bb4845304e031f821a3ec64b2b7259f2d409cd49c9e59ca3", size = 251862, upload-time = "2026-08-12T14:34:46.58Z" }, - { url = "https://files.pythonhosted.org/packages/a9/cb/82b41a0ab7fb1a88065f1d78ad32696ad88ea3fe8e25b8189d08833938de/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84b736e3b391601bc47b86da381c749c0f894e9191aaca9f31f30c2632206df3", size = 239484, upload-time = "2026-08-12T14:34:47.869Z" }, - { url = "https://files.pythonhosted.org/packages/e8/0c/19608b631f4538f908098d4a2d56a8f79a665e27cc58e9d90479761a9227/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6abb1f356fb865baeb6ebc3fadd843e9a96fbf49b9adcca55037f3cceccb7438", size = 230602, upload-time = "2026-08-12T14:34:49.265Z" }, - { url = "https://files.pythonhosted.org/packages/29/db/f648eb30e14eba301aed61e11672156f137905c1bdbb530151abe8065943/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d366548d2ee28a8cfdcc4296363978cc644a728333be9824d2de4652e83df0a", size = 259208, upload-time = "2026-08-12T14:34:50.632Z" }, - { url = "https://files.pythonhosted.org/packages/d3/e0/ed2c8bdbac484d69614d6993143aeb6cb0f4dd1561c883402517b623c8ef/charset_normalizer-3.5.0-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d672f329ae504ee240eb39b6effb3318aa8e7e8924c0ce8eee5760b3fad98539", size = 253659, upload-time = "2026-08-12T14:34:52.11Z" }, - { url = "https://files.pythonhosted.org/packages/32/08/b4907cb9ec5b521d9d024ced13611240b86ef065c2eb15b3ad2334dc9940/charset_normalizer-3.5.0-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c54036a518748b6c02e666f6d46c3817561998fb904c3be25b56fb4fe3dc5706", size = 248821, upload-time = "2026-08-12T14:34:53.399Z" }, - { url = "https://files.pythonhosted.org/packages/12/b2/e2d1abcfbc05822f0030869efb4e9f8a3658e13b4821796d4b62da917327/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:22a1889f1c9b752c63c36758a0c2145458e3cadb20fced7a0790002e9dd12b26", size = 240271, upload-time = "2026-08-12T14:34:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f9/4ba127ad610542fa3eabfa41c45bf12d357860a815b3566374ec0188e213/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b7eb3eab5c646d3de7dcb14a7c9caebace5249c5767da39e1761cb1576e521a3", size = 232155, upload-time = "2026-08-12T14:34:56.543Z" }, - { url = "https://files.pythonhosted.org/packages/01/68/40613182366d00bd6dbd5f6c84a926cbd120960e038a8269e9ae7d782762/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2080aa129a28267984cdc902898993d788c995c384e285d0d19199f56760d52e", size = 259674, upload-time = "2026-08-12T14:34:57.815Z" }, - { url = "https://files.pythonhosted.org/packages/0a/53/4574a14fa4c9de4a6c9f31725354bfa40b67f653e6d594ce1654f9a41b32/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd68c825548a611158230e2f9222e210ceb2e3391995c0aa5865cbdf3ab4bd49", size = 246122, upload-time = "2026-08-12T14:34:59.337Z" }, - { url = "https://files.pythonhosted.org/packages/5a/02/bd8030d13d92c058ca7b2b9615bbb3169569e144db64d65c149cd45abf5e/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:d8a9316f4da85e937242642b537c6d55d7e9287dd38e5634732f8233932aff45", size = 255221, upload-time = "2026-08-12T14:35:00.71Z" }, - { url = "https://files.pythonhosted.org/packages/77/9d/10ecd3bcbe2666b3d4d4026c97b48f73990682815db516052a1e8f4a31c5/charset_normalizer-3.5.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d90254c8f609338c53ec180fcd4c4f9c16502e238e3fc88ca7fd4c2f38d445b8", size = 253450, upload-time = "2026-08-12T14:35:02.217Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0f/d044c4872c0938a84f87b5027a698c0e61bacfc5c3551a4e749ca9b7bc5c/charset_normalizer-3.5.0-cp37-abi3-win32.whl", hash = "sha256:8b3e9e29b8b07cc461b9ce7768db7693a93979d0dadf22046f6f3555ded2f516", size = 173594, upload-time = "2026-08-12T14:35:03.845Z" }, - { url = "https://files.pythonhosted.org/packages/10/6b/6046773901f1944b9a89436351529811ee958afc7b774563be9d74a6f0c3/charset_normalizer-3.5.0-cp37-abi3-win_amd64.whl", hash = "sha256:0c8953d9d1617794cfc40d81179571c9ba3805dd029623a15c93f1fb70e60a74", size = 198959, upload-time = "2026-08-12T14:35:05.187Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a6/b57708ac92aefc8e8389d51d5178129b81f03196da61ee2c23e687b8178a/charset_normalizer-3.5.0-cp37-abi3-win_arm64.whl", hash = "sha256:562d24ca7797c1af8852994950c2e623a907b201fc4b0ed29e92af173d3828ca", size = 267055, upload-time = "2026-08-12T14:35:06.533Z" }, - { url = "https://files.pythonhosted.org/packages/22/c7/754d09943a616937df61e4ba367c409ded2a987e872972098d51a6fcf73b/charset_normalizer-3.5.0-py3-none-any.whl", hash = "sha256:993dfcbe75a85a3784abb5084f2c41b915767c90546fcc92803cffa28611baea", size = 67943, upload-time = "2026-08-12T14:35:30.363Z" }, + { url = "https://files.pythonhosted.org/packages/71/aa/554e2614f38fc34c58ff1d0911ae8535ad2516440d5482d76fe59f1088b0/charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa", size = 369072, upload-time = "2026-08-15T08:16:22.964Z" }, + { url = "https://files.pythonhosted.org/packages/03/6d/439231dfc3ccfa6f8c06477b7da2219cbd41a2de3d49084df8ec7b5100f2/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda", size = 251142, upload-time = "2026-08-15T08:16:24.81Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/7d819bd23a00ef45039146fa2cce1daa2f0771e758c5653ee1f6edac91ed/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45", size = 240714, upload-time = "2026-08-15T08:16:26.392Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2c/45847198c16f4b38090cc7423b2b6a9008e438704d8ab413211832498d31/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab", size = 279637, upload-time = "2026-08-15T08:16:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/d8be3523ddf9f0b0f3e56d1359034aa10653a4d11564c697f802b4775766/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a", size = 276543, upload-time = "2026-08-15T08:16:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/32/cd/4f564b8f132de25db594efc706897069f016790cea63a5669c9df2675f64/charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3", size = 261644, upload-time = "2026-08-15T08:16:30.722Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e3/38b975422534a608f98c360e79c2f07c763d66dd4272300d45fb1fee54b0/charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb", size = 259609, upload-time = "2026-08-15T08:16:32.248Z" }, + { url = "https://files.pythonhosted.org/packages/87/bd/fbc24d825c66f1c74f6ccdea3742c3d8354a4888e86d1315a197fee69061/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f", size = 252457, upload-time = "2026-08-15T08:16:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2d/918d0e98a0e679469ed05bb2d90c2088b4d315bb612969d8499f76fb5210/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea", size = 242240, upload-time = "2026-08-15T08:16:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/20/c8/c36f6e0b2dfec351bd38cbc05362697e58bcd073d7dbd95154290c9714ce/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f", size = 280308, upload-time = "2026-08-15T08:16:36.825Z" }, + { url = "https://files.pythonhosted.org/packages/ca/7b/311b3e02e8c4092400c449c850a760d8c45d900983c83a70cc07208c551d/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182", size = 258679, upload-time = "2026-08-15T08:16:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b9/90/082cc45599c392f28c036a497f49e0634041a785fc3849c80ccf396d096f/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa", size = 277221, upload-time = "2026-08-15T08:16:39.62Z" }, + { url = "https://files.pythonhosted.org/packages/58/ad/b9aecf38d805cbcf84fa94f14c5d972a16561e20296a11dc799a5dcf3763/charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818", size = 263799, upload-time = "2026-08-15T08:16:40.885Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/b38a20598d5a825f85d9d7636860e56ff0db1479f86497a6e485aa9326f7/charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20", size = 182037, upload-time = "2026-08-15T08:16:42.198Z" }, + { url = "https://files.pythonhosted.org/packages/d2/21/83fffb77864408b8bf0fe1ca603926401d6f8775a8e150b39aacc9958f8a/charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d", size = 206030, upload-time = "2026-08-15T08:16:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/2e/b93135b5034b1157fb29554b0d06d4844ce62282f0e0a14036f93d7ee2e7/charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5", size = 185092, upload-time = "2026-08-15T08:16:45.177Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, ] [[package]] @@ -411,22 +407,13 @@ 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 = "execnet" -version = "2.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, -] - [[package]] name = "idna" -version = "3.18" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -440,26 +427,26 @@ wheels = [ [[package]] name = "maturin" -version = "1.14.1" +version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/b3/addd877f871fb1860d46d3a4f206ecb10b946c85846805e6367631926fd3/maturin-1.14.1.tar.gz", hash = "sha256:9d6577a62cd08e0ceba7a0db06fb098e0c9b1b3429bad747a4f3a18215a1b3df", size = 369637, upload-time = "2026-06-19T05:19:49.774Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/c8/22e5e21b2679c9bce6415ca578034ca2cc9316be0642ae21e051a2d5198c/maturin-1.15.0.tar.gz", hash = "sha256:94b26cc8e8aba61a5f2099715fe640e18c5f678e9a500408b38761263954228a", size = 385504, upload-time = "2026-08-24T12:11:22.665Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/f0/97c5a5bd9c71653a066c0976a484eaaae50b9369557838a4176b7b0bdaa5/maturin-1.14.1-py3-none-linux_armv6l.whl", hash = "sha256:522292398945442cdafa9daeb2271b2340fbde57027b818f923f88eab04174f8", size = 10207496, upload-time = "2026-06-19T05:19:09.321Z" }, - { url = "https://files.pythonhosted.org/packages/fe/83/294bca639b0e052f1e2f65199b3db258780c7d4e31408b934c9c974a1379/maturin-1.14.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ffe5ad71f21d1e6603c4dd75f7fee34adf5ed5ebcebb692886549888ebb329ed", size = 19680113, upload-time = "2026-06-19T05:19:13.43Z" }, - { url = "https://files.pythonhosted.org/packages/43/b6/79c881410a3b1c187f7eb3d407aecae646c6a4433d630d72200359015e83/maturin-1.14.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f3306078070c1508fd715b9116070cbcaff5959024272a9f1e6f5cb29768b86c", size = 10169205, upload-time = "2026-06-19T05:19:16.615Z" }, - { url = "https://files.pythonhosted.org/packages/93/9d/44b6f26dcb7f7a04c5501ac2dbb6ca1490150682baa525ca5860504f9eab/maturin-1.14.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:cd457cd88961156e26379e1155bd287cc0ec1c8b2f1582b0660fb31b87c8842d", size = 10188098, upload-time = "2026-06-19T05:19:19.736Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bd/9c0d5d6983905ce2c9edaa073a7e89355a9cf7f396988e05d32f1c37785d/maturin-1.14.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:dfc54ae32e6fcb18302193ab9a30b0b25eefffba994ae13238974805533ef75e", size = 10627576, upload-time = "2026-06-19T05:19:22.713Z" }, - { url = "https://files.pythonhosted.org/packages/e5/33/b096412bd6a7cb399652b260666f901adf88a687181a6dbd6a3f89f0a94e/maturin-1.14.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:a131d912b5267e640bc96d70f4914e10590aed64082ec9abacba7cea52004224", size = 10085181, upload-time = "2026-06-19T05:19:25.69Z" }, - { url = "https://files.pythonhosted.org/packages/56/8d/08c3bf469c38a23c9e6c877e338193001eb604d010fedc08341974e38528/maturin-1.14.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:be18fc568fb76884c0205456336892a75105ec398e6b667cd777c6268bd06d69", size = 10026363, upload-time = "2026-06-19T05:19:28.904Z" }, - { url = "https://files.pythonhosted.org/packages/3a/a4/c4d1a92839f8745ab4aab988a7db884a79d6d710bd3b286fcf9316dece1a/maturin-1.14.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:994a0c8ba3ad8a92b3a9ee1b02645d200d610216b15cff5102b0fe65e8e08666", size = 13321347, upload-time = "2026-06-19T05:19:32.411Z" }, - { url = "https://files.pythonhosted.org/packages/b3/fa/170f04624d03fd07d2a8b1b67de83a127af93aef9eaa425839553347297b/maturin-1.14.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be80866363e605d137991b491a741a84cde9ae350183c4c85f49690ca9aaaa65", size = 10877609, upload-time = "2026-06-19T05:19:35.448Z" }, - { url = "https://files.pythonhosted.org/packages/61/ad/1ae2e1d0ded282bf2c55ac13f0811d87deb425e200ae64a15785675dede9/maturin-1.14.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:5282dffd4b539d2be245f4e5b1a5ab6bc1033b58f4a4872f5833f9d43c954aa4", size = 10417316, upload-time = "2026-06-19T05:19:38.28Z" }, - { url = "https://files.pythonhosted.org/packages/fb/27/bf677183920718da49cd7982d6a3ffc440aad8919329f571d189f81b7bdf/maturin-1.14.1-py3-none-win32.whl", hash = "sha256:1a04de0a20188f95c721b5702eed18140bdcccb28c386797093eca3f62f4d4e0", size = 8931293, upload-time = "2026-06-19T05:19:41.183Z" }, - { url = "https://files.pythonhosted.org/packages/63/4b/585adeb9167b08d3cdff0032a938b0e72655c92003df4f52c3f696a1bcc2/maturin-1.14.1-py3-none-win_amd64.whl", hash = "sha256:3c9f94640ecc4895e94abaf834a0684430032c865b2748a36c12461fd9252fdd", size = 10314067, upload-time = "2026-06-19T05:19:44.389Z" }, - { url = "https://files.pythonhosted.org/packages/51/d4/dac8c0720ae246be1700afb6fbdbbea20fe35b13f6570b2f70faa005df77/maturin-1.14.1-py3-none-win_arm64.whl", hash = "sha256:15cea8fcb3ba47dd636f50092bb34baea8b04ac777392f23e6bf8a9a61efb894", size = 9718943, upload-time = "2026-06-19T05:19:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/14/69/5c01b461044eb1f45ddcce006706eb88110c793cdb11c7ae0b5e08492e94/maturin-1.15.0-py3-none-linux_armv6l.whl", hash = "sha256:6bf6dc62e22d4dcfd5a51244ff0d58975fa4979c48209fe84159617648956d82", size = 10206220, upload-time = "2026-08-24T12:10:53.327Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1f/2b431554e11687cdb1077e0cdadcc118c53f611086b3af00c8545a67c6a5/maturin-1.15.0-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:cd35772633f489841132bc8e71d6fc7f842df30b9c05cd5cdf1ee1ddcb744cc7", size = 19416513, upload-time = "2026-08-24T12:10:56.126Z" }, + { url = "https://files.pythonhosted.org/packages/51/36/e23a21cb34a648b711036b9b2fe1d4f3f4ee24f8db54215d73f1a9a3a3ec/maturin-1.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c40b4eae7bf5ef1f4b1af8d623fe4105016f93578fb15b764e741d08ec3b92dd", size = 10014962, upload-time = "2026-08-24T12:10:58.486Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/33b15cb2d8f30f12c807955e8f2fd775027692904e30ec0784744ce8cd83/maturin-1.15.0-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:7eb066372f541f8eb4909c79c5d9bd0b9e8125980bdf1ec9e8aba23c6c8d6c55", size = 10196223, upload-time = "2026-08-24T12:11:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/fe/91/b495e19e2f5c503b540452b2039115e7b2363867e8c5ad4179eb752fa92c/maturin-1.15.0-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:653020a63525bb224e5ab0adf02e17a2e08bc86dbea7fc1399c9a56d7529b99e", size = 10541186, upload-time = "2026-08-24T12:11:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2b/2abff58037188d852b124871b1f0d720e1c2bfb3d4f1b03d87c52cd66488/maturin-1.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:0ebf9767892725083138e671c34482c660317a2f3d6a29fc0e0f34e9d8c99136", size = 10083468, upload-time = "2026-08-24T12:11:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/b7e9f8be99a6627849e81ac7b6694876bce8f50a92995fe17e3cf2610f0a/maturin-1.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:7ab7eebffd7b8debca2265985de4eaeb332141276d24b9560b5ad484d4b3add1", size = 10047786, upload-time = "2026-08-24T12:11:07.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ab/167e3cb7accee11b507dbe53e0e87aeccb376d44ae66284c96ee4df3a9fd/maturin-1.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:126e12e618b4db42f68c779a56d41f82a390145ba36ac3f621d057eb34f5ad9d", size = 13315332, upload-time = "2026-08-24T12:11:09.433Z" }, + { url = "https://files.pythonhosted.org/packages/14/4d/801379f646cbc6b00998e5289b0630a886be3a4ee4c75b6bc9b87478a7f1/maturin-1.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f9d33e6c3f9615c8caceecbbbd440f8eb25a3ddeb687077682cd5eca2e9ae15", size = 10807183, upload-time = "2026-08-24T12:11:11.73Z" }, + { url = "https://files.pythonhosted.org/packages/89/27/2e612e1cbd1580e9e94d4722c227b5180dca27b32b955a34b79918aa1292/maturin-1.15.0-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:bf29beddd0c6708f112db51d5275fc28b28b9e9c9c5faae387eaef662918b176", size = 10413274, upload-time = "2026-08-24T12:11:14.04Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/202a7b4d75a51f20f84ec9ce3b7345b12b822207072164e2b1c6ef665125/maturin-1.15.0-py3-none-win32.whl", hash = "sha256:da649988be98e87e009e51b1bf0d301b6a301bc0cecbdd60d40d8ba60748d1ca", size = 8928744, upload-time = "2026-08-24T12:11:16.269Z" }, + { url = "https://files.pythonhosted.org/packages/40/dc/4e90da594986ba78dd3bc8a5921ecdcdb11085b22b02a412caab3b225601/maturin-1.15.0-py3-none-win_amd64.whl", hash = "sha256:552c2be4afd43fe8d5c9f3ec8d4c4756d973b8dcbe94c14084390301f50243e1", size = 10335085, upload-time = "2026-08-24T12:11:18.326Z" }, + { url = "https://files.pythonhosted.org/packages/8b/10/15d4314edf130955edf2dc237aa393a8a7c10f2b9b57b89fa2f61f915659/maturin-1.15.0-py3-none-win_arm64.whl", hash = "sha256:c7dc0c66c78d3debdd9c5aa807e861fbcbf07f3505d34b125df74c03986b0f48", size = 9713795, upload-time = "2026-08-24T12:11:20.83Z" }, ] [[package]] @@ -482,11 +469,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.20.0" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] @@ -566,26 +553,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] -[[package]] -name = "pytest-xdist" -version = "3.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "execnet" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, -] - [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] @@ -630,27 +604,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.3" +version = "0.16.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, - { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, - { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, - { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, - { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, - { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, - { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] [[package]] diff --git a/gateways/kafka/Cargo.toml b/gateways/kafka/Cargo.toml index 648cd70385..f2c76e644b 100644 --- a/gateways/kafka/Cargo.toml +++ b/gateways/kafka/Cargo.toml @@ -34,11 +34,14 @@ path = "src/main.rs" [dependencies] bytes = { workspace = true } +iggy = { workspace = true } # Broker-role only: decodes requests and encodes responses. Default features also pull in # client-role codec paths and compression codecs (gzip/lz4/snappy/zstd) this gateway never # uses, since RecordBatch payloads stay opaque `Bytes` here. -kafka-protocol = { version = "0.17", default-features = false, features = ["broker"] } +kafka-protocol = { workspace = true } libc = { workspace = true } +secrecy = { workspace = true } +serde = { workspace = true } socket2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = [ @@ -51,11 +54,14 @@ tokio = { workspace = true, features = [ "signal", ] } tokio-util = { workspace = true, features = ["rt"] } +toml = { workspace = true } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } [dev-dependencies] +serial_test = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util", "time"] } [lints.clippy] diff --git a/gateways/kafka/README.md b/gateways/kafka/README.md index 14984f44f0..13ad30652c 100644 --- a/gateways/kafka/README.md +++ b/gateways/kafka/README.md @@ -23,6 +23,7 @@ Default bind: `127.0.0.1:9093`. Environment variables: | `IGGY_KAFKA_READ_TIMEOUT_SECS` | `15` | Seconds allowed to read a frame body once its length prefix arrives | | `IGGY_KAFKA_WRITE_TIMEOUT_SECS` | `10` | Seconds allowed to write a response frame | | `IGGY_KAFKA_SHUTDOWN_DRAIN_TIMEOUT_SECS` | `25` | Seconds graceful shutdown waits for in-flight connections before abandoning them | +| `IGGY_KAFKA_BRIDGE_ENABLED` | `false` | Connect the Iggy bridge at startup. While false every API answers with its stub, and the `IGGY_KAFKA_IGGY_*` variables below are read by nothing. A failed connection is fatal, not a downgrade to stubs. | ## Test @@ -58,6 +59,175 @@ Before check-in, run the procedure in [docs/MANUAL_TESTING.md](docs/MANUAL_TESTI See [docs/SCOPE.md](docs/SCOPE.md) for [#3421](https://github.com/apache/iggy/issues/3421) deliverables, supported API key/version table, and post-foundation TODO backlog. +## Design decisions + +- [docs/BRIDGE_MAPPING.md](docs/BRIDGE_MAPPING.md) — how a Kafka record becomes an Iggy message, and back +- [docs/IDEMPOTENCE.md](docs/IDEMPOTENCE.md) — InitProducerId, and why delivery is at-least-once +- [docs/OFFSET_STORAGE.md](docs/OFFSET_STORAGE.md) — where Kafka consumer group offsets live + +### Delivery guarantees + +Delivery through this gateway is **at-least-once**, and stays at-least-once across a gateway +restart. Transactions are not supported, and will not be. An idempotent Kafka producer is given +a producer id so that it starts, but its retries are not deduplicated: a retry after a network +timeout writes the record twice, and both copies reach the stream with their own offsets. + +Iggy deduplicates writes on its own partition plane, and that does not close this gap, because it +guards the hop from the gateway to Iggy rather than the hop from the producer to the gateway. +[docs/IDEMPOTENCE.md](docs/IDEMPOTENCE.md) has the detail and what closing it needs. + +## Iggy bridge ([#3533](https://github.com/apache/iggy/issues/3533)) + +`src/bridge/` is the SDK integration layer: connects to Iggy, maps Kafka topics to Iggy +streams/topics, provisions them on demand, and looks up high watermarks (one or many partitions of +a topic per call) for `ListOffsets`. +**Not wired into the live Produce/Fetch dispatch path yet** - that lands with +[#3535](https://github.com/apache/iggy/issues/3535)/[#3536](https://github.com/apache/iggy/issues/3536). +Exercised today by `bridge`'s own unit tests and `tests/bridge_iggy_integration_tests.rs` (spawns a +real `iggy-server`). + +### Connection config + +| Variable | Default | Description | +| --- | --- | --- | +| `IGGY_KAFKA_IGGY_ADDR` | `127.0.0.1:8090` | Address of the Iggy server to bridge to | +| `IGGY_KAFKA_IGGY_USERNAME` | `iggy` | Iggy username | +| `IGGY_KAFKA_IGGY_PASSWORD` | none - **required** | Iggy password. No default: `iggy-server` only uses the well-known `iggy`/`iggy` root credentials when started with `--with-default-root-credentials` (dev-only); otherwise it generates a random password, so a hardcoded default here could never be right and would invite running as root unnoticed | +| `IGGY_KAFKA_IGGY_STREAM` | `kafka` | Default Iggy stream for a Kafka topic with no explicit mapping override | +| `IGGY_KAFKA_TOPIC_MAP_PATH` | unset | Path to a topic-mapping TOML file (see below); omit to use only the default rule | + +The initial connect retries a fixed, bounded number of times (`RECONNECTION_RETRIES = 3`, not the +Iggy SDK client's own default of unlimited retries, one dial per second, forever), and the whole +attempt - retries included - is capped at `REQUEST_TIMEOUT` (15s) wall-clock, so `IggyBridge::connect` +fails in bounded time whether the address refuses the connection or silently drops it, instead of +blocking the calling task indefinitely. Every other bridge call (`ensure_stream_and_topic`, +`high_watermark(s)`, `close`) carries the same `REQUEST_TIMEOUT` for the same reason: the SDK +reconnects internally, mid-call, on a transport error, through the same undead-lined dial path - a +bridge call made well after the initial connect can still hit this if Iggy becomes unreachable +later. + +This timeout only bounds the *caller's* wait, not the SDK's own work: the SDK writes and reads on +a detached background task specifically so that dropping the awaiting future - what this timeout +does on expiry - cannot abort it mid-flight. A timed-out call can leave that task holding the +shared client's connection lock for up to another 30s (the SDK's own reply deadline), queuing +every other bridge call behind it. `IggyBridge` holds one `IggyClient` with no pooling (see +Concurrency ceiling below), so this only matters once concurrent Kafka connections share a bridge + +- tolerable today only because nothing calls this bridge from a live handler yet; must be resolved +before `#3535`/`#3536`. See `IggyBridge`'s own doc comment (its rustdoc is private, so this isn't +a followable link outside the crate - read the source at `src/bridge/iggy_bridge.rs`). + +### Topic mapping + +Default rule, no config file needed: a Kafka topic `orders` maps to Iggy stream +`IGGY_KAFKA_IGGY_STREAM` (default `kafka`), topic `orders` - the Kafka topic name carries over +unchanged. Override specific topics with a TOML file: + +```toml +default_stream = "kafka" + +[topics.orders] +stream = "billing" +topic = "orders_v2" + +# A Kafka topic name containing dots needs the key quoted, or TOML parses it as nested +# tables ([topics.org] containing [apache] containing [kafka]) instead of one topic named +# "org.apache.kafka.events". +[topics."org.apache.kafka.events"] +stream = "billing" +topic = "kafka_events" +``` + +Point `IGGY_KAFKA_TOPIC_MAP_PATH` at the file to load it; topics not listed under `[topics.*]` +still fall back to the default rule. + +`default_stream` is required in a map file - it has no `#[serde(default)]`, unlike `topics` - +so an override-only file with no `default_stream` key fails to load rather than falling back to +`kafka`. When both `IGGY_KAFKA_TOPIC_MAP_PATH` and `IGGY_KAFKA_IGGY_STREAM` are set, the file's own +`default_stream` always wins and the env var is ignored entirely: a TOML file is a complete mapping +document, not an overlay on top of the env var. + +### Provisioning and idempotency + +`ensure_stream_and_topic(kafka_topic, partition_count)` creates the mapped Iggy stream and topic +if either is missing. Idempotent when repeated with the *same* `partition_count`: a no-op if both +already exist with that count, and a `NameAlreadyExists` race against a concurrent caller creating +the same stream/topic is treated as success, not an error - the goal is "it exists," not "this +call created it." A *different* `partition_count` against an already-existing topic returns +`BridgeError::PartitionCountMismatch` rather than silently keeping the old count or growing it - +two concurrent callers requesting different counts for the same topic must not both see success. + +Topics created this way have **no message expiry** - Iggy's own server default, not Kafka's 7-day +default. Nothing is bounding retention until it's configured explicitly (Iggy's own topic options, +outside this bridge today); repointing a Kafka app that assumes bounded retention onto this bridge +will accumulate data indefinitely unless you set that up yourself. + +They also use Iggy's default **durability**, `Durability::Replicated` - quorum commit without an +additional stable-storage barrier, with the disk write itself threshold-gated (flushed at 1024 +messages or 1 MiB of unflushed data, whichever comes first). Kafka's own defaults take the same +posture, so this isn't a wrong choice, but on a single node both can lose an acked write to a power +cut before that threshold is reached - worth knowing rather than discovering later. + +### Concurrency ceiling + +One `IggyBridge` (one `IggyClient`) is meant to serve every Kafka connection this gateway handles, +and the Iggy SDK's TCP transport is lockstep - one request in flight per client, its stream mutex +held across write, flush, and read. Every concurrent Kafka connection ends up serialized behind +whichever single Iggy request is in flight; the Kafka side's own connection limit +(`IGGY_KAFKA_MAX_CONNECTIONS`) does nothing to relieve this. No connection pooling exists yet - it +is a known gap to address before `#3535`/`#3536` put this on a hot path, not a design decision to +rely on. + +### Error mapping + +`BridgeError::to_kafka_error_code()` maps Iggy failures to Kafka wire error codes: + +- Stream/topic/partition not found → `UNKNOWN_TOPIC_OR_PARTITION` (3) +- A rejected *permission* (`Unauthorized`) → `TOPIC_AUTHORIZATION_FAILED` (29) - a real, + fixable-by-the-Kafka-operator ACL problem +- A rejected *login* (the bridge's own `IGGY_KAFKA_IGGY_USERNAME`/`_PASSWORD` are wrong) → + `UNKNOWN_SERVER_ERROR` (-1), deliberately **not** 29 - the Kafka client can't fix a bridge-side + credential misconfiguration, and blaming its own ACLs for one is worse than an unexplained + fatal error +- Connection-shaped failures → `NOT_LEADER_OR_FOLLOWER` (6, the same retriable code the + foundation's own stubs send, so a client backs off and retries) +- An Iggy commit whose outcome is genuinely unknown (`TransientNotCommitted`) → + `REQUEST_TIMED_OUT` (7) - retriable in real Kafka too, chosen because it's what a real broker + sends for the same shape of failure, not to make a client stop retrying +- A bridge-side call timeout (`BridgeError::Timeout`) → `REQUEST_TIMED_OUT` (7), the same code and + the same reasoning as `TransientNotCommitted` above - the SDK's write/read run on a task this + timeout cannot abort, so the outcome is unknown, not known-safe-to-retry (see Connection config's + timeout caveat above) +- An invalid Kafka-side topic name (empty, whitespace-padded, oversized, illegal characters) → + `INVALID_TOPIC_EXCEPTION` (17), checked before any Iggy call is made +- `PartitionCountMismatch` → `TOPIC_ALREADY_EXISTS` (36, not `INVALID_PARTITIONS` - that code's + own text is "below 1", a different condition) +- Too many partitions requested (`TooManyPartitions`) → `INVALID_PARTITIONS` (37), reachable + through `ensure_topic`'s `partition_count` argument once it exceeds the server's cap +- Anything else → `UNKNOWN_SERVER_ERROR` (-1) + +### Server limits the gateway inherits + +These are Iggy server limits, not gateway settings. A Kafka client cannot act on any of them, so +an operator has to. + +| Limit | Default | Where | +| ------- | --------- | ------- | +| Consumer offset keys per partition, per consumer kind | 4096, ceiling 262144 | `partition.consumer_offsets_max` | +| One user header name, and one header value | 255 bytes | fixed, `user_headers.rs` | +| All user headers of one message | 100 KB | fixed, `MAX_USER_HEADERS_SIZE` | +| Message payload | 64 MB | fixed, `MAX_PAYLOAD_SIZE` | + +Only the first is configurable. A Kafka consumer group commits one offset key per partition it +holds, so `partition.consumer_offsets_max` is what bounds the number of groups that can commit +against one partition. Passing it returns `TooManyConsumerOffsets` (3024), which reaches the +client as `UNKNOWN_SERVER_ERROR` because Kafka has no code for the condition. The gateway logs +the real Iggy error, so the server log is where an operator diagnoses it. + +The other three decide when a Kafka record goes into the envelope instead of being stored +natively. See [docs/OFFSET_STORAGE.md](docs/OFFSET_STORAGE.md) and +[docs/BRIDGE_MAPPING.md](docs/BRIDGE_MAPPING.md). + ## Wire fixture tool See [tools/kafka-tool/README.md](tools/kafka-tool/README.md). diff --git a/gateways/kafka/docs/BRIDGE_MAPPING.md b/gateways/kafka/docs/BRIDGE_MAPPING.md new file mode 100644 index 0000000000..da213deeb1 --- /dev/null +++ b/gateways/kafka/docs/BRIDGE_MAPPING.md @@ -0,0 +1,275 @@ +# Kafka to Iggy record mapping + +Status: proposed. Direction agreed with @spetz and @hubcio on 2026-09-16 ("messages should be +stored in iggy format"); the escape hatches below still need sign-off. Closes the last open +scope item of [#3533](https://github.com/apache/iggy/issues/3533) and blocks +[#3535](https://github.com/apache/iggy/issues/3535) (Produce) and +[#3536](https://github.com/apache/iggy/issues/3536) (Fetch). + +## Decision + +One Kafka record becomes one Iggy message, in Iggy's own format: the record value is the +message payload, the key and the Kafka headers become Iggy user headers. The gateway rebuilds +a Kafka record batch on Fetch. + +Two properties drive this. + +A Kafka consumer must be able to read a topic an Iggy producer wrote. This is the staged +migration the maintainers described: rewrite producers to the Iggy SDK first, leave consumers +on the gateway until later. The gateway can only encode an arbitrary Iggy message as a Kafka +record if the stored form has no Kafka framing in it. + +Kafka offsets must line up with Iggy offsets. A Kafka record batch carries N records under one +base offset, while an Iggy message consumes exactly one offset. Storing a batch whole makes +every offset the gateway reports wrong by the batch size, and fixing that inside Iggy means +teaching the server to count records inside an opaque payload. + +Storing the Kafka payload and headers as a dump inside the Iggy payload was the alternative +raised in the same thread. It does not satisfy the first property on its own: the gateway would +still need a native path for Iggy-written messages, so it would carry two storage formats +instead of one. Native storage with a narrow fallback (see below) keeps that to one. + +## Field mapping + +Produce, per record: + +| Kafka | Iggy | +| ------- | ------ | +| record value | `payload` | +| record key | `kafka.key` user header, `Raw` | +| record header `name` | `kafka.h.` user header, `Raw` | +| record timestamp (CreateTime), milliseconds | `origin_timestamp`, microseconds | +| record offset | partition offset, assigned by Iggy | +| partition index | partition index, both 0-based | +| topic | stream and topic per `TopicMapping` | + +Fetch reverses it. A message with no `kafka.*` headers is a message an Iggy client wrote, and +encodes as a Kafka record with a null key, its Iggy user headers as Kafka headers, and +`origin_timestamp` as the record timestamp (falling back to the server-assigned `timestamp` +when the origin timestamp is zero). Iggy header kinds other than `Raw` and `String` are emitted +as their raw value bytes. + +### Timestamps + +Kafka carries a record timestamp in milliseconds. Iggy carries `origin_timestamp` in +microseconds (`core/common/src/types/message/iggy_message.rs:191`). Produce multiplies by 1000. +Fetch divides by 1000 and truncates toward zero. + +A record that arrived through Produce survives the round trip exactly, because its microsecond +value is always a whole number of milliseconds. A message an Iggy client wrote does not. Its +sub-millisecond digits are lost on the way out, and Kafka has no field to keep them in. + +Kafka sends `-1` for a record with no timestamp. That is stored as `0`, and Fetch already reads +a zero origin timestamp as an instruction to use the server-assigned timestamp instead. A real +broker does the same thing under `LogAppendTime`, so the two agree. + +## Records Iggy cannot hold natively + +Iggy rejects an empty payload (`core/common/src/types/message/iggy_message.rs:169`), caps a +user header value at 255 bytes (`core/common/src/types/message/user_headers.rs:631`), keys +headers in a `BTreeMap` so a name cannot repeat, and caps all user headers of one message at +100 KB (`MAX_USER_HEADERS_SIZE`, `iggy_message.rs:58`). Kafka allows all of the shapes those +rules exclude, so two mechanisms cover them. + +None of those four numbers is a gateway setting. They are fixed constants on the server's +message type, with no configuration knob and no recorded rationale. The header budget works out +to roughly 350 headers at the 255-byte value cap, so in practice the key-length rule below is +what sends a record into the fallback and the budget is not. + +### Null and empty values + +A record with a null value (a tombstone) or a zero-length value is stored with a single `0x00` +byte payload and a `kafka.value` header holding `null` or `empty`. Fetch reads that header and +restores the original, discarding the placeholder byte. + +This keeps tombstones on the fast path rather than pushing them into the fallback, because they +are ordinary traffic on compacted Kafka topics. Iggy has no compaction, so a tombstone is stored +and served like any other record and nothing acts on it. + +### Everything else: the envelope fallback + +A record takes the fallback when any of these hold: + +- the key is present and zero-length, which is not the same as a null key. Iggy rejects an + empty header value on the same rule that caps it at 255 bytes, so `kafka.key` cannot carry it +- the key is longer than 255 bytes +- a header name, prefixed with `kafka.h.`, is longer than 255 bytes +- a header value is null, empty, or longer than 255 bytes +- two headers share a name +- the headers together would exceed the 100 KB user-header budget + +Such a record is stored with a `kafka.envelope` header whose value is one byte, the format +version, currently `1`. The payload holds the key, the value and the headers in the layout +below. Fetch checks for that header first and takes the plain path only when it is absent. + +The layout is fixed here rather than left to the implementation, because `kafka_protocol` hands +a handler a decoded `Record` and never a raw slice of the record, so there is no verbatim body +to copy. All integers are little-endian. + +```text +u8 flags bit 0 key present, bit 1 value present +u32 key_len 0 when the key is absent +.. key +u32 value_len 0 when the value is absent +.. value +u32 header_count + repeated header_count times: + u32 name_len + .. name + u8 value_present + u32 value_len 0 when the header value is absent + .. value +``` + +That is 13 bytes of fixed overhead plus 9 bytes per header. Re-encoding the record as a +one-record Kafka batch would also work and would cost less code, but it puts batch framing back +into storage, which is the thing this document decided against. + +The cost is that these messages are opaque to Iggy consumers and connectors. That is the point +of confining the fallback to record shapes that are rare in practice, rather than making it the +default storage form. + +## Batch-level fields + +Per-record storage drops what the Kafka record batch header carries: producer id, producer +epoch, base sequence, the transactional flag, compression and the batch CRC. Fetch synthesizes +a batch with producer id `-1`, epoch `-1`, base sequence `-1`, no compression, `CreateTime` +timestamps, and a recomputed CRC32C. + +Two consequences worth stating before they surprise someone: + +- Idempotent-producer deduplication cannot be reconstructed from stored data later. If + [#3545](https://github.com/apache/iggy/issues/3545) ever grows past a stub, producer id, + epoch and sequence need their own tracking. +- The bytes a consumer receives are not the bytes the producer sent, so anything comparing + batches byte for byte across the gateway will differ. + +Whether producer id `-1` is what Fetch actually sends depends on the InitProducerId decision in +[`IDEMPOTENCE.md`](IDEMPOTENCE.md). Allocating producer ids does not change what is stored, only +what Produce accepts, so this section holds under either answer. + +Produce decompresses gzip, snappy, lz4 and zstd batches, which means turning those features back +on for the `kafka-protocol` dependency (`Cargo.toml:217` currently builds it with +`default-features = false, features = ["broker"]`). Fetch emits uncompressed batches. + +Decompression needs its own bound, and the bound is per request rather than per batch. +`max_frame_size` bounds the frame a client sent, which is the compressed size, and zstd reaches +1000 to 1 on repetitive input without being asked, so an 8 MiB frame can expand to gigabytes. One +frame also carries many batches: the request holds up to `MAX_REQUEST_ELEMENTS` (4096) topic and +partition entries, each with its own records blob. A cap applied to one batch at a time would +therefore still admit 4096 times that much output. + +Produce keeps a single decompression budget for the whole request, set to `max_frame_size`, so a +compressed request can never yield more than the same client could have sent uncompressed. A +batch that exhausts the budget is rejected with `MESSAGE_TOO_LARGE` (10) before the output is +allocated. Each decompressed record value has to clear Iggy's own `MAX_PAYLOAD_SIZE` (64 MB, +`iggy_message.rs:44`) separately, since one record becomes one message. + +Nothing decompresses today. The record batch stays an opaque `Bytes` on both paths, so the bound +above is a requirement on [#3535](https://github.com/apache/iggy/issues/3535) rather than a +description of current behavior. + +## Offsets + +Kafka offset and Iggy offset are the same number for the same record, and both partition spaces +are 0-based, so neither direction converts. + +Produce takes the base offset from the send confirmation +(`SendMessagesConfirmationResponse::base_offset`). The server may return no confirmation, for +example for a request it classifies as a duplicate, in which case the response carries `-1` +rather than a guessed offset; Kafka clients surface that as an unknown offset. + +ListOffsets LATEST is the high watermark from `IggyBridge::high_watermarks`. EARLIEST has no +server-side field today (`Partition` carries no log start offset), so it reads the first +retained message instead, and the `(messages_count, current_offset) == (0, 0)` ambiguity +documented on `high_watermarks` applies to both. + +### Header order + +Kafka carries record headers as an ordered list. Iggy keys them in a `BTreeMap`, so Fetch emits +them sorted by name and the producer's order is gone. + +There is no fallback for this, because the gateway cannot tell whether a record's header order +carries meaning. The envelope does preserve order, since it stores the headers as a list, but a +record only reaches the envelope for one of the reasons above. A consumer that depends on header +order therefore sees a different order through the gateway than a real broker would give it. + +## Partitioning + +Both systems number partitions from 0, so the partition index passes through unchanged in each +direction and neither side converts. + +Produce sends to the partition the request names, `Partitioning::partition_id(index)`. A Kafka +producer resolves the partition itself before it builds the request, so every partition index in +a `ProduceRequest` is a real one and `Partitioning::balanced()` has no trigger on this path. The +`-1` that `SCOPE.md` refers to belongs to CreateTopics, where it means "use the broker default +partition count", and it is handled there rather than here. + +Kafka consumer groups are not mapped onto Iggy consumer groups. The gateway assigns partitions +to group members the way Kafka does, in the client, and polls every partition by explicit offset. +Iggy's group registry is used as an offset key and for nothing else, which +[`OFFSET_STORAGE.md`](OFFSET_STORAGE.md) covers. + +## Reserved header namespace + +`kafka.` is reserved on messages the gateway writes and reads. An Iggy producer that sets a +header in that namespace on a topic a Kafka consumer reads will have it interpreted as gateway +metadata. + +## Open questions + +Four questions need an answer before Produce ([#3535](https://github.com/apache/iggy/issues/3535)) +is written. Each one carries a default. If no answer lands by 2026-09-22, the default is taken, +this document is updated to record that it was decided by default, and the work proceeds. + +### 1. Envelope fallback, or reject the record? + +A record that Iggy cannot hold natively goes into the envelope described above. The alternative +is to reject it with `MESSAGE_TOO_LARGE` (10), so that nothing an Iggy consumer cannot read ever +reaches a stream. + +Rejecting is the stricter guarantee and the worse compatibility story: a Kafka producer that +sends a 300-byte key works against a real broker and fails against the gateway. + +Default: keep the envelope. + +### 2. Is `kafka.` the right prefix? + +Every Kafka header name is stored as `kafka.h.`, which spends 8 of the 255 bytes an Iggy +header name has, on every header of every record. A shorter prefix buys those bytes back and +costs readability for anyone reading a stream by hand. + +Default: keep `kafka.`. + +### 3. Is the placeholder byte acceptable for tombstones? + +A null or empty value is stored as one `0x00` byte plus a `kafka.value` marker header. The +payload a native Iggy consumer sees is therefore a byte the producer never sent. + +The alternative is the envelope, which costs a tombstone the fast path. Tombstones are ordinary +traffic on compacted Kafka topics, and Iggy has no compaction, so they are stored and served +like any other record. + +Default: keep the placeholder byte. + +### 4. Recompress on Fetch, or always emit uncompressed? + +Produce decompresses, and Fetch currently rebuilds an uncompressed batch. Recompressing per +topic costs CPU on the read path and saves bytes on the wire to the consumer. + +Default: always emit uncompressed, and revisit when a benchmark says it matters. + +### Not asked here + +A Kafka `retention.ms` topic config could map onto Iggy's `message_expiry` at creation time. +`ensure_stream_and_topic` leaves topics on the server default, which never expires. That belongs +to CreateTopics ([#3538](https://github.com/apache/iggy/issues/3538)), which owns topic +configuration, rather than to the record mapping. + +## References + +- Scope and phases: [`SCOPE.md`](SCOPE.md) +- Bridge API: `gateways/kafka/src/bridge/iggy_bridge.rs` +- Iggy message limits: `core/common/src/types/message/iggy_message.rs`, + `core/common/src/types/message/user_headers.rs` +- Produce confirmations: `core/binary_protocol/src/responses/messages/send_messages.rs` diff --git a/gateways/kafka/docs/IDEMPOTENCE.md b/gateways/kafka/docs/IDEMPOTENCE.md new file mode 100644 index 0000000000..3ff1f7f314 --- /dev/null +++ b/gateways/kafka/docs/IDEMPOTENCE.md @@ -0,0 +1,179 @@ +# InitProducerId and idempotent producers + +Status: proposed. Answers the open half of +[#3545](https://github.com/apache/iggy/issues/3545) and gates the Phase 1 end-to-end test +([#3539](https://github.com/apache/iggy/issues/3539)), which drives +`kafka-console-producer.sh`. + +## The problem + +A stock Java producer sets `enable.idempotence=true` without being asked. That default arrived +in Kafka 3.0 and took effect from 3.0.1, 3.1.1 and 3.2.0, where a bug that suppressed it was +fixed. `kafka-console-producer.sh` leaves it on. + +An idempotent producer sends InitProducerId (key 22) before its first record. The gateway does +not list key 22, so ApiVersions does not advertise it, and the producer raises +`UnsupportedVersionException`. That exception is fatal. +`TransactionManager.maybeTransitionToErrorState` tests it above the `isTransactional()` branch. +The producer therefore enters a fatal error state instead of dropping back to weaker semantics. +It fails at startup, before it sends a record. + +The gateway's stated purpose is that a Kafka user swaps the broker and changes no application +code. A broker that the default producer cannot start against does not meet it. + +## What Iggy already deduplicates + +Iggy deduplicates on the partition plane, and did so before this gateway existed. The key is +`(client_id, user_id, request)`. `client_id` is a `u128` the client generates, and `request` is a +monotonic counter on the client's session. Each partition group holds a per-client watermark with +a 128-bit `committed_window` under it. A request below the watermark with its bit clear is a +reordered arrival still to execute. A request below the window reads as committed. +`partition.dedup_clients_max` caps the distinct clients one partition tracks, at 4096 by default +and 65536 at the ceiling. + +The shape is closer to Kafka's than it looks. A producer id with a per-partition sequence maps +onto a session id with a request id. The window is also deeper than the five batches a Kafka +producer keeps in flight. + +What breaks the mapping is the hop each side protects. Iggy's covers gateway to Iggy. Kafka's +covers producer to gateway. A retrying producer sends a fresh Produce request, and the gateway +turns it into a fresh Iggy send carrying a fresh request id. Nothing recognises the replay. + +## Closing that gap + +Both halves of Iggy's key have to come from the Kafka request rather than from the SDK. + +The client id is not a per-request field. `dispatch_partition_request` takes it from the +transport's bound session, and the header transmute at +`core/server/src/dispatch/partition.rs:247` overwrites whatever the SDK sent. Deriving it from a +Kafka producer id therefore needs one bound session per producer. + +The request id is different. The transmute copies the header and replaces only `group`, `client`, +`session` and `user_id`, so a caller-supplied request id survives to the `ClientTable`. + +One session per producer is a connection pool keyed by producer id. That is the pool the +README's "Concurrency ceiling" section already owes before +[#3535](https://github.com/apache/iggy/issues/3535) and +[#3536](https://github.com/apache/iggy/issues/3536). + +Two SDK seams are missing for it: + +- a caller-chosen client id at build time. `ConsensusSession::with_client_id` is public, but both + construction sites build with `ConsensusSession::new()` + (`core/sdk/src/tcp/tcp_client.rs:378` and `:583`) +- a caller-supplied request id. `send_raw_with_response` already takes + `preencoded: Option` for transient replay, and it is private + +None of this lands in this phase. Produce and Fetch do not need it, delivery is at-least-once +before and after, and the additions belong to whoever owns that SDK surface. + +### Restart + +A session registers with a fresh random client id per gateway process. A restarted gateway +therefore cannot collide with a watermark that outlived it, and a retry that spans a restart is +not deduplicated. That is still at-least-once, which the README states plainly. + +The alternative is a stable client id derived from the producer id. It deduplicates across a +restart, and it is unsafe without also persisting the last request id per producer. Request ids +restarting at 1 under a live watermark read as duplicates, so fresh writes are discarded. Silent +loss is worse than duplicate delivery, so the fresh random id wins. + +### Confirmed and not + +The hop mismatch, one session per producer, and the restart choice are agreed in the maintainer +thread on [#3545](https://github.com/apache/iggy/issues/3545) and in Discord. + +Two further readings are ours and are not confirmed yet. One session per producer is enough, +rather than one per producer and partition, because the `ClientTable` is per partition group. The +same request number on two partitions is two entries, so `request_id = base_sequence + 1` stays +monotonic inside each. And the gateway has to preserve per-partition ordering per producer, +because sequences advance by record count. A lower sequence arriving after a higher one lands +below the watermark, outside the window, and reads as committed. + +## Options + +| Option | Cost | What a stock producer does | +| -------- | ------ | ---------------------------- | +| Stub with `UNSUPPORTED_VERSION` | none | fails at startup unless the user sets `enable.idempotence=false` | +| Allocate only | about a day | works untouched, at-least-once delivery | +| Allocate and pool | weeks, blocked on the SDK | works untouched, retries absorbed on both hops | + +## Decision + +Allocate only, and defer the pool. + +Rejecting the stock producer to avoid the pool trades away the one requirement the maintainers +named. It buys a guarantee nobody is asking for yet. Allocating costs about a day, leaves +delivery where it already is, and blocks nothing the pool later needs. + +## Behavior + +Add key 22 to `SUPPORTED_RANGES` in `src/protocol/api.rs` and advertise it through ApiVersions. +Without both, the producer never sends the request. `kafka-protocol` 0.18 carries the schemas, +request v0 to v5 and response v0 to v6, flexible from v2. + +InitProducerId with no `transactional_id`: + +- allocate the next producer id, return it with epoch 0 and error code 0 +- build the id from an instance number in the high 16 bits and a counter in the low 47 bits, + leaving bit 63 clear. `producer_id` is an `i64` and `-1` means no producer id, so the value has + to stay non-negative. That leaves room for 65536 instances holding 140 trillion ids each + +The instance number comes from configuration (`IGGY_KAFKA_INSTANCE_ID`, default 0), not from a +draw at startup. A random 16-bit number collides with even odds at around 300 instances. That is +a birthday collision, not a remote one. + +The id is a pool key, not a dedup identity. Under the design above, the dedup identity is the +session's own random client id, minted at register. The producer id only decides which connection +serves a producer. Kafka still requires it to be unique across the cluster, which is what the +instance number buys. It does not have to survive a restart. + +InitProducerId with a `transactional_id`: + +- answer `UNSUPPORTED_VERSION` (35), unchanged. Transactions stay out of scope, and so do + AddPartitionsToTxn (24), AddOffsetsToTxn (25), EndTxn (26) and TxnOffsetCommit (28) + +35 rather than `INVALID_REQUEST` (42), because of the same fatal set quoted above. +`maybeTransitionToErrorState` holds ClusterAuthorization, TransactionalIdAuthorization, +ProducerFenced, UnsupportedVersion and InvalidPidMapping. `INVALID_REQUEST` is not in it, so a +transactional producer moves to an abortable error instead. The application is then told to abort +and retry something that can never succeed. `COORDINATOR_NOT_AVAILABLE` (15) is worse again. It +is retriable, so the producer never stops trying. + +Produce: + +- accept `producer_id`, `producer_epoch` and `base_sequence` on the request and ignore them +- never answer `OUT_OF_ORDER_SEQUENCE_NUMBER` (45) or `DUPLICATE_SEQUENCE_NUMBER` (46) + +Those two codes stay unsent even once the pool lands. The watermark accepts any request above it +without noticing a gap, so a gap cannot be told apart from ordinary traffic. Sending either code +claims a detection the gateway does not have. + +## What this does not give you + +A producer that holds an id believes its retries are deduplicated. They are not. A retry after a +network timeout writes the record twice, and both copies reach the stream with their own offsets. + +Iggy's own deduplication does not help, because it guards the other hop. Delivery through the +gateway is at-least-once until the pool lands, and at-least-once across a gateway restart after +that. + +State that limitation in the README, next to the transaction section, in those words. Do not +leave a user to infer it from the presence of key 22. + +## Open question + +Allocate only, as above, or stub and document `enable.idempotence=false`? + +If no answer lands by 2026-09-22, allocate only is taken and the work proceeds. This document +is then updated to record that it was decided by default. + +## References + +- Record mapping: [`BRIDGE_MAPPING.md`](BRIDGE_MAPPING.md), batch-level fields +- Scope and phases: [`SCOPE.md`](SCOPE.md) +- Version firewall: `src/protocol/api.rs`, `SUPPORTED_RANGES` +- Dedup key and window: `core/consensus/src/client_table.rs` +- Session identity: `core/sdk/src/session.rs`, `core/sdk/src/tcp/tcp_client.rs` +- Header rewrite: `core/server/src/dispatch/partition.rs` +- Fatal path: `TransactionManager.maybeTransitionToErrorState`, apache/kafka trunk diff --git a/gateways/kafka/docs/OFFSET_STORAGE.md b/gateways/kafka/docs/OFFSET_STORAGE.md new file mode 100644 index 0000000000..cd8421f6da --- /dev/null +++ b/gateways/kafka/docs/OFFSET_STORAGE.md @@ -0,0 +1,163 @@ +# Consumer offset storage + +Status: proposed. Answers [#3540](https://github.com/apache/iggy/issues/3540) and blocks +[#3542](https://github.com/apache/iggy/issues/3542), OffsetCommit and OffsetFetch. + +## Decision + +Store Kafka group offsets as Iggy consumer offsets, one key per partition, under a consumer +group whose name is derived from the Kafka group id. + +The issue lists three options. None of them is this one. + +| Option | Why not | +| -------- | --------- | +| A, an Iggy-backed `__consumer_offsets` topic | Rebuilds what Iggy already has. A compacted offset topic needs compaction, which Iggy does not have, so the gateway replays the whole topic at every startup | +| B, a SQLite file on the gateway host | A second durability story, a second backup story, and offsets that do not survive moving the gateway | +| C, in memory only | Fails the acceptance criterion in #3542, which is that offsets survive a restart | + +Iggy already stores a durable offset per consumer and per partition, replicated with the +partition itself. Using it costs one call per partition on commit and one on fetch. + +## The key + +One Iggy consumer offset per Kafka `(group, topic, partition)`. + +- consumer kind: `ConsumerKind::ConsumerGroup` +- consumer id: `Identifier::named("kafka.cg.")` +- stream and topic: whatever `TopicMapping` resolves the Kafka topic to +- partition: the Kafka partition index, unchanged, because both sides number from 0 + +The gateway calls `create_consumer_group(stream, topic, "kafka.cg.")` before the first +commit for a group on a topic. If the group does not resolve in metadata, the server rejects the +offset write. The group has to exist first. The gateway never joins the group. Offsets are +readable by any client, member or not. + +### Why the group kind and not a named consumer + +`ConsumerKind::Consumer` with a name looks simpler, because it needs no registration call. It is +not. The server hashes a named consumer id to a `u32` with `XxHash32` +(`core/server/src/dispatch/partition.rs:916`), and that hash is the offset key. Two different +group names can collide and silently share one offset. + +A consumer group name resolves through metadata to a monotonic id instead. No hash, no +collision, and `get_consumer_groups(stream, topic)` lists what exists. + +### Why the prefix + +`kafka.cg.` keeps a Kafka group called `orders` off the key that a native Iggy consumer group +called `orders` uses. Without it the two share an offset and each one moves the other. + +The prefix does not make the offsets safe to poll with. That is the next section. + +## What is stored + +The Kafka committed offset, verbatim, with no conversion. + +The two systems mean different things by the number. Kafka commits the next offset to read. +Iggy stores the last offset processed, and `PollingKind::Next` resumes at the stored value plus +one (`core/partitions/src/iggy_partition.rs:3835`). A Kafka offset stored in an Iggy key is +therefore one greater than Iggy's own convention for that key. + +This is inert because the gateway never polls that way. Fetch always polls with an explicit +offset, `PollingKind::Offset`, taken from the Kafka request. Nothing in the gateway reads the +stored value to decide where to resume. It is returned to the client on OffsetFetch and +otherwise untouched. + +The rule this creates: no code path polls a `kafka.cg.*` key with `PollingKind::Next`. Doing so +skips one record per partition. The prefix is what keeps a native Iggy consumer from doing it by +accident. + +Converting on write instead, and storing the Kafka offset minus one, breaks at offset 0. It also +makes an empty commit look the same as a commit of the first record. Storing verbatim is the +smaller problem. + +### A commit of -1 + +Kafka does not validate the sign of a committed offset. `OffsetMetadataManager` checks the +metadata length and nothing else, so a real broker stores `-1` and hands it back on the next +OffsetFetch, where a consumer reads it as no committed offset. + +Iggy cannot store that value, because `store_consumer_offset` takes a `u64`. A commit of `-1` +therefore calls `delete_consumer_offset` on the key. What a client can observe is the same: the +next OffsetFetch finds nothing and the gateway answers `-1`. + +Deleting a key that is not there returns `ConsumerOffsetNotFound` (3021). On this path that +counts as success, because the client asked for the offset to be absent and it is absent. Any +other negative offset is rejected with `OFFSET_OUT_OF_RANGE` (1). + +## OffsetFetch with no topics named + +OffsetFetch v2 and later let a client pass a null topic list, which asks for every offset the +group holds. `kafka-consumer-groups.sh --describe` does this. + +Iggy has no lookup by consumer. Offsets are read one partition at a time +(`core/common/src/traits/consumer_offset_client.rs:41`). The gateway answers by enumerating the +topics in the mapped stream and querying each partition of each one. + +Answering also means naming each topic the way the client named it, and `TopicMapping::resolve` +runs the wrong way. Its own doc says it is not injective, so it cannot be inverted in general. +Two cases divide it. A topic with no override resolves to `(default_stream, kafka_topic)`, so the +Iggy topic name is the Kafka name and reverses for free. A topic with an override needs a reverse +index, built once at config load, which `TopicMapping::new` already makes safe by rejecting two +overrides that share a target. + +What neither case covers is an unlisted Kafka topic whose name collides with an override's target +inside the default stream. `new` rejects the shapes it can check, but the space of unlisted names +is unbounded, so the collision is disclosed rather than prevented. An offset under a colliding +name is reported against whichever Kafka name the reverse index holds. + +That is one round trip per partition on an admin call. The cost is bounded by the topic and +partition count of one stream. This is an admin path and not a data path, so the cost is +acceptable. It is written here so nobody discovers it in a test. + +## What is dropped + +Kafka lets a client attach a metadata string to a commit. Iggy stores a number and nothing else. +The string is dropped on commit, and OffsetFetch returns an empty string. + +`committed_leader_epoch` is dropped the same way. The gateway reports `-1`. + +## Limits + +A partition admits a bounded number of distinct offset keys per consumer kind. The default is +4096, set by `partition.consumer_offsets_max` +(`core/configs/src/server_config/partition.rs:151`). The configurable ceiling is 262144. Passing +the limit returns `TooManyConsumerOffsets` (3024). Kafka has no error code for this condition, so +it maps to `UNKNOWN_SERVER_ERROR`, which is what `bridge/error.rs` already does where no honest +code exists. + +Consumer groups and plain consumers count against separate limits, so Kafka groups do not +compete with native Iggy consumers for the same 4096. + +A client cannot act on that error, so the operator has to. `partition.consumer_offsets_max` is +named in the gateway README for that reason, and a handler that hits the limit logs the Iggy +error at `error!` level, which is what `bridge/error.rs` already asks handlers to do wherever the +Kafka code it sends is less specific than the Iggy error it received. + +An Iggy name is capped at 255 bytes (`core/common/src/lib.rs:168`), which leaves 246 for a Kafka +group id after the prefix. A longer group id is rejected with `INVALID_GROUP_ID` (24). + +## More than one gateway instance + +Two gateway instances that share an Iggy cluster read and write the same offset keys. The key +comes from the Kafka group id and nothing else. Two instances that serve one group therefore +agree on committed offsets without talking to each other. + +They do not agree on group membership. That belongs to the coordinator +([#3541](https://github.com/apache/iggy/issues/3541)) and is not settled here. + +## Open question + +Iggy consumer offsets keyed by group, as above, or one of A, B and C from the issue? + +If no answer lands by 2026-09-22, the design above is taken and the work proceeds. This document +is then updated to record that it was decided by default. + +## References + +- Record mapping: [`BRIDGE_MAPPING.md`](BRIDGE_MAPPING.md) +- Scope and phases: [`SCOPE.md`](SCOPE.md) +- Offset API: `core/common/src/traits/consumer_offset_client.rs` +- Group API: `core/common/src/traits/consumer_group_client.rs` +- Offset key resolution: `core/server/src/dispatch/partition.rs` diff --git a/gateways/kafka/docs/SCOPE.md b/gateways/kafka/docs/SCOPE.md index 0b510a5fb5..30289a934e 100644 --- a/gateways/kafka/docs/SCOPE.md +++ b/gateways/kafka/docs/SCOPE.md @@ -89,7 +89,7 @@ Full reference for future phases: [`kafka_api_keys_reference.md`](kafka_api_keys | ------- | ------- | ------------- | | **1 — Wire framing** | In scope | `server.rs` — custom, zero-copy frame I/O; `header.rs` delegates version selection to `kafka_protocol::messages::ApiKey` | | **2 — Request/response codecs** | Partial | Decode/encode via the `kafka_protocol` crate (broker feature only) for 6 hot-path keys; `bounds_guard.rs` pre-validates against unbounded allocation before handing a frame to the crate; stub responses only | -| **3 — Iggy bridge** | Out of scope | Produce/Fetch → Iggy SDK; deferred to a follow-on issue | +| **3 — Iggy bridge** | Landed, not wired in | `bridge/` module (connection, topic mapping, provisioning, high watermark) landed; Produce/Fetch handler wiring itself is a follow-on ([#3535](https://github.com/apache/iggy/issues/3535)/[#3536](https://github.com/apache/iggy/issues/3536)) | --- @@ -97,14 +97,22 @@ Full reference for future phases: [`kafka_api_keys_reference.md`](kafka_api_keys Items from the [hybrid architecture review](https://github.com/apache/iggy/discussions/3252) and maintainer feedback. **Not part of #3421.** -### Phase 2 — Iggy bridge (new issue) +### Phase 2 — Iggy bridge -- [ ] Add `bridge/` module (`iggy_bridge`): Produce → `send_messages`, Fetch → `poll_messages` -- [ ] Document partition mapping in `docs/BRIDGE_MAPPING.md`: +[#3533](https://github.com/apache/iggy/issues/3533) landed the bridge module itself; the items +below it are still open for the issues that build on top of it. + +- [x] Add `bridge/` module (`iggy_bridge`) - connection lifecycle, topic mapping, provisioning, + high watermark, error mapping. See [README.md](../README.md#iggy-bridge-3533). Produce → + `send_messages` / Fetch → `poll_messages` handler wiring itself is + [#3535](https://github.com/apache/iggy/issues/3535)/[#3536](https://github.com/apache/iggy/issues/3536), + not part of `bridge/`'s own scope. +- [x] Idempotent `ensure_stream_and_topic()` (create-if-not-exists) - `src/bridge/iggy_bridge.rs`, + exercised end-to-end in `tests/bridge_iggy_integration_tests.rs`. +- [x] Document partition mapping in [`BRIDGE_MAPPING.md`](BRIDGE_MAPPING.md): - Iggy partitions are **0-based** (same as Kafka) — direct `partition_id` mapping, no offset conversion - - Iggy **consumer groups exist** — map Kafka group APIs to Iggy consumer group APIs - - Use `Partitioning::balanced()` only when Kafka sends `partition == -1`; otherwise use request partition ID -- [ ] Idempotent `ensure_stream_and_topic()` (create-if-not-exists) + - Kafka consumer groups do **not** map onto Iggy consumer groups. Assignment stays client-side, and Iggy's group registry is used as an offset key only ([`OFFSET_STORAGE.md`](OFFSET_STORAGE.md)) + - `Partitioning::partition_id(index)` on every Produce. A Kafka producer resolves the partition before it builds the request, so `Partitioning::balanced()` has no trigger there. The `-1` default-partition-count case belongs to CreateTopics - [ ] Real Metadata topology (brokers, partitions, leaders) backed by Iggy state ### `kafka-protocol` crate adoption — superseded, done differently @@ -122,12 +130,18 @@ above). ### Phase 3 — Consumer groups (~7 API keys) +Offset persistence design ([#3540](https://github.com/apache/iggy/issues/3540)): +[`OFFSET_STORAGE.md`](OFFSET_STORAGE.md). + - [ ] OffsetCommit (8), OffsetFetch (9), FindCoordinator (10) - [ ] JoinGroup (11), Heartbeat (12), LeaveGroup (13), SyncGroup (14) - [ ] DescribeGroups (15), ListGroups (16) as needed by target clients ### Phase 3+ — Auth, admin, tuning +InitProducerId and idempotent producers +([#3545](https://github.com/apache/iggy/issues/3545)): [`IDEMPOTENCE.md`](IDEMPOTENCE.md). + - [ ] SASL (17, 36) if required by deployment - [ ] Tune `max_frame_size` per workload (Kafka defaults: ~1 MiB produce, ~50 MiB fetch; current default 8 MiB) - [ ] Target **~15–20 API keys** total for a functional bridge — not all 74+ admin keys diff --git a/gateways/kafka/docs/TEST_SUITE.md b/gateways/kafka/docs/TEST_SUITE.md index a20cef8ae6..9a5361cabd 100644 --- a/gateways/kafka/docs/TEST_SUITE.md +++ b/gateways/kafka/docs/TEST_SUITE.md @@ -20,6 +20,22 @@ suite goes through `tests/common/fixtures.rs::load_fixture_body_or_skip`, which regeneration hint when a fixture is missing, and panics instead when `KAFKA_FIXTURES_REQUIRED=1` is set (CI sets this) so a broken generation step can't leave a suite green with zero assertions. +### `iggy-server` binary (required for `bridge_iggy_integration_tests`) + +No fixtures needed, but `iggy-server` has to be built *first* - this suite spawns it directly and +does not build it for you: + +```bash +cargo build --package server --bin iggy-server +cargo test -p iggy-gateway-kafka +``` + +Same prerequisite `core/integration`'s own server-spawning tests already carry (this suite's +`iggy_server_binary()` walks up from its own `env::current_exe()` to find the already-built binary +in the same target directory; neither harness invokes `cargo build` itself). Skipping this step +fails with a clear "binary not found" message naming the build command to run, not a hang or a +silent skip. + --- ## Test files @@ -47,6 +63,7 @@ file under `tests/` anymore. | [`server_integration_tests.rs`](../tests/server_integration_tests.rs) | `read_frame` unit-level I/O | No | | [`server_e2e_tests.rs`](../tests/server_e2e_tests.rs) | Full `KafkaGateway` TCP round-trips | Partial | | [`listener_robustness_tests.rs`](../tests/listener_robustness_tests.rs) | TCP listener robustness — framing, pipelining, concurrency, connection limits | No | +| [`bridge_iggy_integration_tests.rs`](../tests/bridge_iggy_integration_tests.rs) | `IggyBridge` against a real, spawned `iggy-server` — provisioning idempotency, high watermark, credential/connection edge cases | No (needs the `iggy-server` binary - see Prerequisites) | `tests/common/` holds shared helpers (`codec.rs`, `fixtures.rs`, `scope.rs`, `server.rs`, `tcp.rs`, `wire.rs`), compiled per test binary via `#[path]`, not a test binary itself. `codec.rs` diff --git a/gateways/kafka/src/bridge/config.rs b/gateways/kafka/src/bridge/config.rs new file mode 100644 index 0000000000..63266f480b --- /dev/null +++ b/gateways/kafka/src/bridge/config.rs @@ -0,0 +1,379 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use std::path::Path; + +use secrecy::SecretString; +use tracing::warn; + +use crate::bridge::error::BridgeError; +use crate::bridge::topic_map::{TopicMapping, validate_identifier_name}; + +const DEFAULT_IGGY_ADDR: &str = "127.0.0.1:8090"; +/// Matches `DEFAULT_ROOT_USERNAME` (`core/server/src/boot/credentials.rs`) - the root user's +/// username is always `iggy` regardless of how its password was provisioned, so defaulting this +/// one field is safe. The password is a different story - see why there is no +/// `DEFAULT_IGGY_PASSWORD` at [`IggyBridgeConfig::from_env`]. +const DEFAULT_IGGY_USERNAME: &str = "iggy"; + +/// Connection + topic-mapping config for [`IggyBridge`](crate::bridge::iggy_bridge::IggyBridge). +/// +/// `Debug` is safe to derive: `password` is `SecretString`, which redacts on `Debug` by design +/// (`secrecy` crate) - never add a plain `String` credential field here without the same +/// treatment (see `connector-pr-review` blocker B1 in the connectors subsystem for why). +#[derive(Debug, Clone)] +pub struct IggyBridgeConfig { + pub address: String, + pub username: String, + pub password: SecretString, + pub topic_mapping: TopicMapping, +} + +impl IggyBridgeConfig { + /// The complete set of `IGGY_KAFKA_*` vars this module reads. `main.rs`'s + /// `reject_unknown_kafka_env_vars` checks this list *and* its own separate + /// `KNOWN_KAFKA_ENV_VARS`, not one merged copy - a var added only here is already + /// recognized there with no corresponding edit needed, and vice versa. A var this module + /// reads still has to be listed *somewhere* the guard checks, or a typo silently no-ops + /// instead of surfacing (`IGGY_KAFKA_` is a `SERVER_ALLOWED_ENV_PREFIXES` entry in + /// `core/configs`, so the server's own unknown-variable check doesn't cover this namespace + /// either). + pub const KNOWN_ENV_VARS: &'static [&'static str] = &[ + "IGGY_KAFKA_IGGY_ADDR", + "IGGY_KAFKA_IGGY_USERNAME", + "IGGY_KAFKA_IGGY_PASSWORD", + "IGGY_KAFKA_IGGY_STREAM", + "IGGY_KAFKA_TOPIC_MAP_PATH", + ]; + + /// Builds config from `IGGY_KAFKA_*` env vars, defaulting to the Iggy server's own + /// out-of-the-box address and root username. + /// + /// `IGGY_KAFKA_IGGY_PASSWORD` has no default and must be set explicitly. `iggy-server` only + /// provisions the well-known `iggy`/`iggy` root credentials when started with + /// `--with-default-root-credentials` (`args.rs`, itself documented "INSECURE - FOR DEVELOPMENT + /// ONLY!"); without that flag it generates a random password (`boot/credentials.rs`) that no + /// constant here could ever guess. Defaulting the password to `"iggy"` would make this bridge + /// silently authenticate as root, with the credential in no config file and no log, on any + /// server that does happen to run dev-flagged - the failure mode of getting it wrong is a + /// loud, immediate auth rejection instead. + /// + /// `IGGY_KAFKA_IGGY_STREAM` and `IGGY_KAFKA_TOPIC_MAP_PATH` both influence the mapping's + /// default stream; when both are set, the TOML file's own `default_stream` wins and + /// `IGGY_KAFKA_IGGY_STREAM` is ignored entirely for the topics it covers (a TOML file is a + /// complete mapping document, not an overlay) - a `warn!` fires so that isn't silently + /// mysterious to whoever set the env var expecting it to matter. + /// + /// # Errors + /// + /// Returns [`BridgeError::InvalidConfig`] if `IGGY_KAFKA_IGGY_PASSWORD` is unset or empty, if + /// `IGGY_KAFKA_IGGY_USERNAME` is set but empty, or if `IGGY_KAFKA_TOPIC_MAP_PATH` is set but + /// the file is missing, fails to parse, or fails validation (e.g. a malformed mapping key or + /// an empty `default_stream`). + pub fn from_env() -> Result { + let address = + std::env::var("IGGY_KAFKA_IGGY_ADDR").unwrap_or_else(|_| DEFAULT_IGGY_ADDR.to_string()); + let username = std::env::var("IGGY_KAFKA_IGGY_USERNAME") + .unwrap_or_else(|_| DEFAULT_IGGY_USERNAME.to_string()); + // set-but-empty (`VAR=""`) is `Ok("")` from `env::var`, not `Err` - distinct from unset, + // and unchecked here would otherwise pass both cleanly through to a login attempt neither + // could ever satisfy. + if username.is_empty() { + return Err(BridgeError::InvalidConfig( + "IGGY_KAFKA_IGGY_USERNAME must not be empty".to_string(), + )); + } + let password = std::env::var("IGGY_KAFKA_IGGY_PASSWORD").map_err(|_| { + BridgeError::InvalidConfig( + "IGGY_KAFKA_IGGY_PASSWORD must be set - iggy-server generates a random root \ + password unless started with --with-default-root-credentials, so there is no \ + safe default to fall back to" + .to_string(), + ) + })?; + if password.is_empty() { + return Err(BridgeError::InvalidConfig( + "IGGY_KAFKA_IGGY_PASSWORD must not be empty".to_string(), + )); + } + let stream_env = std::env::var("IGGY_KAFKA_IGGY_STREAM").ok(); + let topic_map_path = std::env::var("IGGY_KAFKA_TOPIC_MAP_PATH").ok(); + + let topic_mapping = if let Some(path) = topic_map_path { + if stream_env.is_some() { + warn!( + "IGGY_KAFKA_IGGY_STREAM is set but ignored: IGGY_KAFKA_TOPIC_MAP_PATH's own \ + default_stream takes precedence" + ); + } + TopicMapping::from_file(Path::new(&path))? + } else { + // Validated here, not unconditionally above: on the file branch above, + // IGGY_KAFKA_IGGY_STREAM is ignored entirely (the warn! there already says so), so a + // padded or empty value must not hard-fail startup when a topic-map file is also set. + // TopicMapping::new below validates default_stream on this branch too, but its own + // message says "topic mapping's default_stream", not IGGY_KAFKA_IGGY_STREAM, leaving + // whoever reads the error to work out which env var that phrase actually refers to. + // Checked here first so the message names the var an operator can actually go fix. + if let Some(ref stream) = stream_env { + validate_identifier_name("IGGY_KAFKA_IGGY_STREAM", stream)?; + } + TopicMapping::new( + stream_env.unwrap_or_else(|| "kafka".to_string()), + std::collections::HashMap::new(), + )? + }; + + Ok(Self { + address, + username, + password: SecretString::from(password), + topic_mapping, + }) + } +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + + use super::*; + + fn test_config() -> IggyBridgeConfig { + IggyBridgeConfig { + address: "127.0.0.1:8090".to_string(), + username: "iggy".to_string(), + password: SecretString::from("iggy"), + topic_mapping: TopicMapping::new("kafka".to_string(), std::collections::HashMap::new()) + .expect("valid mapping for this test's fixture data"), + } + } + + #[test] + fn debug_output_does_not_expose_password() { + // A distinctive value, not the shared fixture's "iggy" - that string also appears as the + // username and inside the struct's own name, which would make a substring check here + // pass trivially regardless of whether the password field itself is actually redacted. + let mut config = test_config(); + config.password = SecretString::from("correct-horse-battery-staple"); + let debug_output = format!("{config:?}"); + assert!( + !debug_output.contains("correct-horse-battery-staple"), + "Debug output must not expose the plaintext password: {debug_output}" + ); + } + + /// Regression test for coverage: every other `from_env` test sets `IGGY_KAFKA_IGGY_ADDR`/ + /// `_USERNAME`/`_STREAM` explicitly (or goes through the topic-map-file branch), so the four + /// documented defaults - address, username, no-file branch, and `default_stream` - never + /// actually ran. + #[test] + #[serial] + fn from_env_uses_documented_defaults_when_only_password_is_set() { + // Safety: process-wide, not per-variable - env::set_var/remove_var race any concurrent + // env read or write on another thread, not just ones touching these same vars. + // #[serial] (this test's own attribute) is what prevents that. + unsafe { + std::env::set_var("IGGY_KAFKA_IGGY_PASSWORD", "iggy"); + std::env::remove_var("IGGY_KAFKA_IGGY_ADDR"); + std::env::remove_var("IGGY_KAFKA_IGGY_USERNAME"); + std::env::remove_var("IGGY_KAFKA_IGGY_STREAM"); + std::env::remove_var("IGGY_KAFKA_TOPIC_MAP_PATH"); + } + let result = IggyBridgeConfig::from_env(); + unsafe { + std::env::remove_var("IGGY_KAFKA_IGGY_PASSWORD"); + } + + let config = result.expect("valid config from documented defaults alone"); + assert_eq!(config.address, "127.0.0.1:8090"); + assert_eq!(config.username, "iggy"); + assert_eq!(config.topic_mapping.default_stream(), "kafka"); + // No overrides in the no-file default: an arbitrary topic must resolve via the plain + // default-stream rule, not fall through to some file-loaded override. + assert_eq!( + config.topic_mapping.resolve("anything"), + ("kafka", "anything") + ); + } + + #[test] + #[serial] + fn from_env_rejects_missing_password() { + // Safety: process-wide, not per-variable - see the note on + // from_env_uses_documented_defaults_when_only_password_is_set above. + unsafe { + std::env::remove_var("IGGY_KAFKA_IGGY_PASSWORD"); + } + let result = IggyBridgeConfig::from_env(); + assert!( + matches!(result, Err(BridgeError::InvalidConfig(_))), + "no default password must mean no default: {result:?}" + ); + } + + #[test] + #[serial] + fn from_env_rejects_empty_password() { + // Safety: process-wide, not per-variable - see the note on + // from_env_uses_documented_defaults_when_only_password_is_set above. + unsafe { + std::env::set_var("IGGY_KAFKA_IGGY_PASSWORD", ""); + } + let result = IggyBridgeConfig::from_env(); + unsafe { + std::env::remove_var("IGGY_KAFKA_IGGY_PASSWORD"); + } + assert!( + matches!(result, Err(BridgeError::InvalidConfig(_))), + "set-but-empty (VAR=\"\") must not be treated as a usable password: {result:?}" + ); + } + + #[test] + #[serial] + fn from_env_rejects_empty_username() { + // Safety: process-wide, not per-variable - see the note on + // from_env_uses_documented_defaults_when_only_password_is_set above. + unsafe { + std::env::set_var("IGGY_KAFKA_IGGY_PASSWORD", "iggy"); + std::env::set_var("IGGY_KAFKA_IGGY_USERNAME", ""); + } + let result = IggyBridgeConfig::from_env(); + unsafe { + std::env::remove_var("IGGY_KAFKA_IGGY_PASSWORD"); + std::env::remove_var("IGGY_KAFKA_IGGY_USERNAME"); + } + assert!( + matches!(result, Err(BridgeError::InvalidConfig(_))), + "set-but-empty (VAR=\"\") must not skip the DEFAULT_IGGY_USERNAME fallback silently: \ + {result:?}" + ); + } + + #[test] + #[serial] + fn from_env_rejects_missing_topic_map_file() { + // Safety: process-wide, not per-variable - see the note on + // from_env_uses_documented_defaults_when_only_password_is_set above. + unsafe { + std::env::set_var("IGGY_KAFKA_IGGY_PASSWORD", "iggy"); + std::env::set_var("IGGY_KAFKA_TOPIC_MAP_PATH", "/nonexistent/topic_map.toml"); + } + let result = IggyBridgeConfig::from_env(); + unsafe { + std::env::remove_var("IGGY_KAFKA_IGGY_PASSWORD"); + std::env::remove_var("IGGY_KAFKA_TOPIC_MAP_PATH"); + } + assert!(matches!(result, Err(BridgeError::InvalidConfig(_)))); + } + + #[test] + #[serial] + fn from_env_prefers_topic_map_files_default_stream_over_env_var() { + let file = tempfile::NamedTempFile::new().expect("create temp file"); + std::fs::write(file.path(), "default_stream = \"from-toml\"\n").expect("write temp file"); + + // Safety: process-wide, not per-variable - see the note on + // from_env_uses_documented_defaults_when_only_password_is_set above. + unsafe { + std::env::set_var("IGGY_KAFKA_IGGY_PASSWORD", "iggy"); + std::env::set_var("IGGY_KAFKA_IGGY_STREAM", "from-env"); + std::env::set_var("IGGY_KAFKA_TOPIC_MAP_PATH", file.path()); + } + let result = IggyBridgeConfig::from_env(); + unsafe { + std::env::remove_var("IGGY_KAFKA_IGGY_PASSWORD"); + std::env::remove_var("IGGY_KAFKA_IGGY_STREAM"); + std::env::remove_var("IGGY_KAFKA_TOPIC_MAP_PATH"); + } + + assert_eq!( + result.expect("valid config").topic_mapping.default_stream(), + "from-toml" + ); + } + + #[test] + #[serial] + fn from_env_ignores_invalid_stream_env_var_when_topic_map_file_is_set() { + // Regression test: IGGY_KAFKA_IGGY_STREAM is documented (README.md) and warn!()-logged as + // fully ignored once IGGY_KAFKA_TOPIC_MAP_PATH is set - a padded/empty value there must + // not hard-fail startup on a var the map-file branch never reads. + let file = tempfile::NamedTempFile::new().expect("create temp file"); + std::fs::write(file.path(), "default_stream = \"from-toml\"\n").expect("write temp file"); + + // Safety: process-wide, not per-variable - see the note on + // from_env_uses_documented_defaults_when_only_password_is_set above. + unsafe { + std::env::set_var("IGGY_KAFKA_IGGY_PASSWORD", "iggy"); + std::env::set_var("IGGY_KAFKA_IGGY_STREAM", " padded and invalid "); + std::env::set_var("IGGY_KAFKA_TOPIC_MAP_PATH", file.path()); + } + let result = IggyBridgeConfig::from_env(); + unsafe { + std::env::remove_var("IGGY_KAFKA_IGGY_PASSWORD"); + std::env::remove_var("IGGY_KAFKA_IGGY_STREAM"); + std::env::remove_var("IGGY_KAFKA_TOPIC_MAP_PATH"); + } + + assert_eq!( + result + .expect("an ignored var's own invalidity must not fail startup") + .topic_mapping + .default_stream(), + "from-toml" + ); + } + + #[test] + #[serial] + fn from_env_rejects_empty_stream_env_var() { + // Safety: process-wide, not per-variable - see the note on + // from_env_uses_documented_defaults_when_only_password_is_set above. + unsafe { + std::env::set_var("IGGY_KAFKA_IGGY_PASSWORD", "iggy"); + std::env::set_var("IGGY_KAFKA_IGGY_STREAM", ""); + } + let result = IggyBridgeConfig::from_env(); + unsafe { + std::env::remove_var("IGGY_KAFKA_IGGY_PASSWORD"); + std::env::remove_var("IGGY_KAFKA_IGGY_STREAM"); + } + assert!(matches!(result, Err(BridgeError::InvalidConfig(_)))); + } + + #[test] + #[serial] + fn from_env_rejects_stream_env_var_with_whitespace_instead_of_trimming_it() { + // Safety: process-wide, not per-variable - see the note on + // from_env_uses_documented_defaults_when_only_password_is_set above. + unsafe { + std::env::set_var("IGGY_KAFKA_IGGY_PASSWORD", "iggy"); + std::env::set_var("IGGY_KAFKA_IGGY_STREAM", " kafka "); + } + let result = IggyBridgeConfig::from_env(); + unsafe { + std::env::remove_var("IGGY_KAFKA_IGGY_PASSWORD"); + std::env::remove_var("IGGY_KAFKA_IGGY_STREAM"); + } + assert!( + matches!(result, Err(BridgeError::InvalidConfig(_))), + "must reject, not silently trim, and store a stream named ' kafka '" + ); + } +} diff --git a/gateways/kafka/src/bridge/error.rs b/gateways/kafka/src/bridge/error.rs new file mode 100644 index 0000000000..10f2035e32 --- /dev/null +++ b/gateways/kafka/src/bridge/error.rs @@ -0,0 +1,395 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use iggy::prelude::IggyError; +use thiserror::Error; + +use crate::protocol::api::{ + ERROR_INVALID_PARTITIONS, ERROR_INVALID_TOPIC_EXCEPTION, ERROR_NOT_LEADER_OR_FOLLOWER, + ERROR_REQUEST_TIMED_OUT, ERROR_TOPIC_ALREADY_EXISTS, ERROR_TOPIC_AUTHORIZATION_FAILED, + ERROR_UNKNOWN_SERVER_ERROR, ERROR_UNKNOWN_TOPIC_OR_PARTITION, +}; + +/// Errors from the `IggyBridge`: connection lifecycle, config, and Iggy SDK calls. +#[derive(Debug, Error)] +pub enum BridgeError { + /// Bridge config is structurally invalid (empty address, missing credentials, malformed + /// topic-mapping TOML) - caught before any connection attempt. + #[error("invalid bridge configuration: {0}")] + InvalidConfig(String), + /// The Iggy client could not connect or authenticate, or a call failed after connecting. + /// Wraps the SDK's own error rather than re-deriving a parallel taxonomy. + /// + /// Caution logging or otherwise trusting this variant's `Display`: a server-side rejection + /// crosses the wire as a bare numeric code (`ReplyHeader.status` / a committed metadata + /// result), and the SDK reconstructs the full `IggyError` from just that code via + /// `IggyError::from_code` (`vsr.rs`). Any data-carrying variant's fields get + /// `Default::default()` in that path, not the real value - `Identifier::default()` is numeric + /// `0`, itself a valid id, so e.g. a reconstructed `StreamIdNotFound` can print "ID: 0" whether + /// or not stream 0 is the one that actually failed. Locally-constructed variants (this + /// module's own `ok_or_else` calls, for instance) carry real data; only ones that came back + /// from an actual server rejection are affected. + #[error("Iggy client error: {0}")] + Iggy(#[from] IggyError), + /// A bridge call exceeded its wall-clock budget (`with_request_timeout`'s `REQUEST_TIMEOUT`) + /// without a definitive answer from Iggy. + /// + /// Distinct from `Iggy(IggyError::CannotEstablishConnection)`: that variant means the SDK + /// itself gave up on establishing or using the connection, a known failure safe to retry. A + /// timeout here means only that *this caller* stopped waiting - the SDK does its write/read + /// inside a detached task this timeout cannot abort (`with_request_timeout`'s own doc has the + /// mechanism), so the request may already be on the wire, or already applied server-side, by + /// the time this fires. That is the same unknown-outcome shape `IggyError::TransientNotCommitted` + /// is, which is why this maps like that variant, not like a known connection failure. + #[error("bridge call timed out waiting for a reply from Iggy")] + Timeout, + /// `high_watermark` was asked about a partition index the topic doesn't have. + #[error( + "partition {partition} out of range for topic '{topic}' ({partitions_count} partitions)" + )] + PartitionOutOfRange { + topic: String, + partition: u32, + partitions_count: u32, + }, + /// `ensure_topic` was asked to ensure a topic that already exists with a different partition + /// count. `ensure_topic`'s whole contract is "the topic has `partition_count` partitions + /// afterward" - silently keeping the old count and returning `Ok(())` would let two + /// concurrent callers requesting different counts for the same topic both believe they + /// succeeded. + #[error( + "topic '{topic}' already exists with {existing} partitions, but {requested} were requested" + )] + PartitionCountMismatch { + topic: String, + existing: u32, + requested: u32, + }, + /// A Kafka-side topic name failed Kafka's own naming rules (empty, whitespace-padded, over + /// 249 bytes, or outside `[A-Za-z0-9._-]`) before any Iggy call was made. A conformant Kafka + /// client library validates topic names before sending them, so this is defense against a + /// raw or non-conformant client, not an expected path. + #[error("invalid Kafka topic name '{kafka_topic}': {reason}")] + InvalidKafkaTopicName { kafka_topic: String, reason: String }, +} + +impl BridgeError { + /// Maps this error to the Kafka protocol error code a handler should answer with. + /// + /// Connection-shaped failures reuse `NOT_LEADER_OR_FOLLOWER` (6) - the same retriable code + /// the foundation's Produce/Fetch stubs already send - so a client backs off and retries + /// rather than treating a transient Iggy outage as a permanent failure. Not-found maps to + /// `UNKNOWN_TOPIC_OR_PARTITION` (3). A bridge-side [`Self::Timeout`] maps to + /// `REQUEST_TIMED_OUT` (7), the same code `IggyError::TransientNotCommitted` gets and for the + /// same reason: the outcome is unknown, not known-safe-to-retry, so it must not share a code + /// with the connection-shaped set. Anything without a closer analogue falls back to + /// `UNKNOWN_SERVER_ERROR` (-1). + #[must_use] + pub const fn to_kafka_error_code(&self) -> i16 { + match self { + Self::Iggy(err) => iggy_error_to_kafka_code(err), + Self::Timeout => ERROR_REQUEST_TIMED_OUT, + Self::PartitionOutOfRange { .. } => ERROR_UNKNOWN_TOPIC_OR_PARTITION, + Self::PartitionCountMismatch { .. } => ERROR_TOPIC_ALREADY_EXISTS, + Self::InvalidKafkaTopicName { .. } => ERROR_INVALID_TOPIC_EXCEPTION, + // Not a wire-response case in practice: an invalid bridge config is caught at + // `IggyBridge::connect` before any handler exists to answer a Kafka request, so this + // is reachable only if a future caller starts constructing configs at request time. + // `UNKNOWN_SERVER_ERROR` at least doesn't claim a specific, wrong cause the way + // `UNSUPPORTED_VERSION` (misleadingly implies a Kafka API version mismatch) would. + Self::InvalidConfig(_) => ERROR_UNKNOWN_SERVER_ERROR, + } + } +} + +/// Kept private so this association can change without touching call sites. +/// +/// The connection-shaped set (`Disconnected`, `EmptyResponse`, `Unauthenticated`, `StaleClient`, +/// `NotConnected`, `CannotEstablishConnection`, `TcpError`) mirrors exactly what +/// `TcpClient::send_raw_with_response` (`tcp_client.rs`) itself treats as worth a reconnect retry. +/// A mapping that only covered some of these would send a permanent-looking Kafka error for a +/// condition the SDK itself considers transient. `Unauthenticated` belongs here, not with the +/// credential-rejection group below: `fail_if_not_authenticated` (`binary_auth.rs`) returns it for +/// `ClientState::Connected`, the ordinary window between a reconnect's TCP handshake completing +/// and its auto-sign-in landing, not for a rejected login. +/// +/// `TransientNotAccepted` (Iggy replica-side "retry, on any replica") is retriable the same way. +/// `TransientNotCommitted` is not folded into that set: its outcome is genuinely unknown rather +/// than known-safe-to-retry, so it maps to `REQUEST_TIMED_OUT` instead of +/// `NOT_LEADER_OR_FOLLOWER` - a caller must not treat it as an ordinary retriable failure and risk +/// a duplicate write. +/// +/// `Unauthorized` is the only one of the four credential-shaped variants that stays on +/// `TOPIC_AUTHORIZATION_FAILED` (29): it means the authenticated user lacks a permission, which is +/// a real, fixable-by-the-Kafka-operator ACL problem. `InvalidCredentials`/`InvalidUsername`/ +/// `InvalidPassword` mean the *bridge's own* `IGGY_KAFKA_IGGY_USERNAME`/`_PASSWORD` are wrong +/// (`tcp_client.rs`'s own sign-in path raises exactly these for a rejected login, with the comment +/// "the caller's to fix" - meaning the gateway operator, not the Kafka client). Sending 29 for +/// these blames the Kafka client's own ACLs for a problem it cannot see or fix, and it is not +/// startup-only: the SDK re-runs sign-in on every reconnect (`tcp_client.rs`), so a since-rotated +/// bridge password surfaces this mid-request, not just at boot. They fall to +/// `UNKNOWN_SERVER_ERROR` instead - correctly fatal (retrying won't fix a wrong password), but +/// without asserting a cause the Kafka client cannot act on. A handler wiring this in +/// (`#3535`/`#3536`) should log the real `IggyError` at `error!` level server-side, since the +/// Kafka client will never see more than "-1" for it. +const fn iggy_error_to_kafka_code(err: &IggyError) -> i16 { + match err { + IggyError::StreamIdNotFound(_) + | IggyError::StreamNameNotFound(_) + | IggyError::TopicIdNotFound(_, _) + | IggyError::TopicNameNotFound(_, _) + | IggyError::PartitionNotFound(_, _, _) => ERROR_UNKNOWN_TOPIC_OR_PARTITION, + IggyError::Unauthorized => ERROR_TOPIC_AUTHORIZATION_FAILED, + IggyError::Disconnected + | IggyError::EmptyResponse + | IggyError::Unauthenticated + | IggyError::StaleClient + | IggyError::NotConnected + | IggyError::CannotEstablishConnection + | IggyError::TcpError + | IggyError::TransientNotAccepted => ERROR_NOT_LEADER_OR_FOLLOWER, + IggyError::TransientNotCommitted => ERROR_REQUEST_TIMED_OUT, + IggyError::TooManyPartitions => ERROR_INVALID_PARTITIONS, + _ => ERROR_UNKNOWN_SERVER_ERROR, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use iggy::prelude::Identifier; + + #[test] + fn stream_id_not_found_maps_to_unknown_topic_or_partition() { + let err = BridgeError::Iggy(IggyError::StreamIdNotFound(Identifier::numeric(1).unwrap())); + assert_eq!(err.to_kafka_error_code(), ERROR_UNKNOWN_TOPIC_OR_PARTITION); + } + + #[test] + fn stream_name_not_found_maps_to_unknown_topic_or_partition() { + let err = BridgeError::Iggy(IggyError::StreamNameNotFound("orders".to_string())); + assert_eq!(err.to_kafka_error_code(), ERROR_UNKNOWN_TOPIC_OR_PARTITION); + } + + #[test] + fn topic_name_not_found_maps_to_unknown_topic_or_partition() { + let err = BridgeError::Iggy(IggyError::TopicNameNotFound( + "orders".to_string(), + "kafka".to_string(), + )); + assert_eq!(err.to_kafka_error_code(), ERROR_UNKNOWN_TOPIC_OR_PARTITION); + } + + #[test] + fn unauthenticated_maps_to_not_leader_or_follower_for_retry() { + // Not a credential rejection: fail_if_not_authenticated (binary_auth.rs) returns this for + // ClientState::Connected, the ordinary window mid-reconnect before auto-sign-in lands. + let err = BridgeError::Iggy(IggyError::Unauthenticated); + assert_eq!(err.to_kafka_error_code(), ERROR_NOT_LEADER_OR_FOLLOWER); + } + + #[test] + fn partition_not_found_maps_to_unknown_topic_or_partition() { + let stream_id = Identifier::numeric(1).unwrap(); + let topic_id = Identifier::numeric(2).unwrap(); + let err = BridgeError::Iggy(IggyError::PartitionNotFound(3, stream_id, topic_id)); + assert_eq!(err.to_kafka_error_code(), ERROR_UNKNOWN_TOPIC_OR_PARTITION); + } + + #[test] + fn too_many_partitions_maps_to_invalid_partitions() { + let err = BridgeError::Iggy(IggyError::TooManyPartitions); + assert_eq!(err.to_kafka_error_code(), ERROR_INVALID_PARTITIONS); + } + + // The bridge's own credentials failing (a wrong IGGY_KAFKA_IGGY_USERNAME/_PASSWORD) is not + // the Kafka client's fault and not something it can fix - these three must NOT share + // Unauthorized's TOPIC_AUTHORIZATION_FAILED (29), which would blame the Kafka client's own + // ACLs for a bridge-side misconfiguration. + #[test] + fn invalid_credentials_maps_to_unknown_server_error_not_authorization_failed() { + let err = BridgeError::Iggy(IggyError::InvalidCredentials); + assert_eq!(err.to_kafka_error_code(), ERROR_UNKNOWN_SERVER_ERROR); + } + + #[test] + fn invalid_username_maps_to_unknown_server_error_not_authorization_failed() { + let err = BridgeError::Iggy(IggyError::InvalidUsername); + assert_eq!(err.to_kafka_error_code(), ERROR_UNKNOWN_SERVER_ERROR); + } + + #[test] + fn invalid_password_maps_to_unknown_server_error_not_authorization_failed() { + let err = BridgeError::Iggy(IggyError::InvalidPassword); + assert_eq!(err.to_kafka_error_code(), ERROR_UNKNOWN_SERVER_ERROR); + } + + #[test] + fn invalid_kafka_topic_name_maps_to_invalid_topic_exception() { + let err = BridgeError::InvalidKafkaTopicName { + kafka_topic: " orders ".to_string(), + reason: "must not have leading or trailing whitespace".to_string(), + }; + assert_eq!(err.to_kafka_error_code(), ERROR_INVALID_TOPIC_EXCEPTION); + } + + #[test] + fn unauthorized_maps_to_topic_authorization_failed() { + let err = BridgeError::Iggy(IggyError::Unauthorized); + assert_eq!(err.to_kafka_error_code(), ERROR_TOPIC_AUTHORIZATION_FAILED); + } + + #[test] + fn invalid_config_maps_to_unknown_server_error_not_unsupported_version() { + let err = BridgeError::InvalidConfig("bad config".to_string()); + assert_eq!(err.to_kafka_error_code(), ERROR_UNKNOWN_SERVER_ERROR); + } + + #[test] + fn disconnected_maps_to_not_leader_or_follower_for_retry() { + let err = BridgeError::Iggy(IggyError::Disconnected); + assert_eq!(err.to_kafka_error_code(), ERROR_NOT_LEADER_OR_FOLLOWER); + } + + #[test] + fn cannot_establish_connection_maps_to_not_leader_or_follower_for_retry() { + let err = BridgeError::Iggy(IggyError::CannotEstablishConnection); + assert_eq!(err.to_kafka_error_code(), ERROR_NOT_LEADER_OR_FOLLOWER); + } + + #[test] + fn empty_response_maps_to_not_leader_or_follower_for_retry() { + let err = BridgeError::Iggy(IggyError::EmptyResponse); + assert_eq!(err.to_kafka_error_code(), ERROR_NOT_LEADER_OR_FOLLOWER); + } + + #[test] + fn stale_client_maps_to_not_leader_or_follower_for_retry() { + let err = BridgeError::Iggy(IggyError::StaleClient); + assert_eq!(err.to_kafka_error_code(), ERROR_NOT_LEADER_OR_FOLLOWER); + } + + #[test] + fn not_connected_maps_to_not_leader_or_follower_for_retry() { + let err = BridgeError::Iggy(IggyError::NotConnected); + assert_eq!(err.to_kafka_error_code(), ERROR_NOT_LEADER_OR_FOLLOWER); + } + + #[test] + fn tcp_error_maps_to_not_leader_or_follower_for_retry() { + let err = BridgeError::Iggy(IggyError::TcpError); + assert_eq!(err.to_kafka_error_code(), ERROR_NOT_LEADER_OR_FOLLOWER); + } + + #[test] + fn transient_not_accepted_maps_to_not_leader_or_follower_for_retry() { + let err = BridgeError::Iggy(IggyError::TransientNotAccepted); + assert_eq!(err.to_kafka_error_code(), ERROR_NOT_LEADER_OR_FOLLOWER); + } + + #[test] + fn transient_not_committed_maps_to_request_timed_out_not_plain_retry() { + // Outcome is unknown, not known-safe-to-retry - must not share a code with the + // unambiguous retriable set, or a caller could blindly retry a Produce into a duplicate. + let err = BridgeError::Iggy(IggyError::TransientNotCommitted); + assert_eq!(err.to_kafka_error_code(), ERROR_REQUEST_TIMED_OUT); + } + + #[test] + fn timeout_maps_to_request_timed_out_not_not_leader_or_follower() { + // A caller-side timeout is the same unknown-outcome shape as TransientNotCommitted (the + // SDK's write/read run in a detached task this timeout cannot abort, so the request may + // already be on the wire) - must not borrow CannotEstablishConnection's known-safe code. + let err = BridgeError::Timeout; + assert_eq!(err.to_kafka_error_code(), ERROR_REQUEST_TIMED_OUT); + assert_ne!(err.to_kafka_error_code(), ERROR_NOT_LEADER_OR_FOLLOWER); + } + + #[test] + fn unmatched_iggy_error_falls_back_to_unknown_server_error() { + let err = BridgeError::Iggy(IggyError::InvalidConfiguration); + assert_eq!(err.to_kafka_error_code(), ERROR_UNKNOWN_SERVER_ERROR); + } + + #[test] + fn partition_out_of_range_maps_to_unknown_topic_or_partition() { + let err = BridgeError::PartitionOutOfRange { + topic: "t".to_string(), + partition: 5, + partitions_count: 2, + }; + assert_eq!(err.to_kafka_error_code(), ERROR_UNKNOWN_TOPIC_OR_PARTITION); + } + + #[test] + fn partition_count_mismatch_maps_to_topic_already_exists_not_invalid_partitions() { + // INVALID_PARTITIONS (37) means "count is below 1" (kafka-protocol's own error table) - + // a different condition than "this topic already exists with a different count". + let err = BridgeError::PartitionCountMismatch { + topic: "t".to_string(), + existing: 3, + requested: 5, + }; + assert_eq!(err.to_kafka_error_code(), ERROR_TOPIC_ALREADY_EXISTS); + } + + /// Every `ERROR_*` constant this module sends, checked against `kafka-protocol`'s own + /// `ResponseError` table - the crate's canonical copy of Kafka's real wire numbers, not this + /// module's own. Every other test above pins routing (which `IggyError` maps to which + /// constant); without this, a wrong constant value would still pass all of them, since they + /// only ever compare against the same constants the function returns. + #[test] + fn every_sent_error_code_matches_kafka_protocols_own_table() { + use kafka_protocol::error::ResponseError; + + for (ours, theirs) in [ + ( + ERROR_UNKNOWN_SERVER_ERROR, + ResponseError::UnknownServerError, + ), + ( + ERROR_UNKNOWN_TOPIC_OR_PARTITION, + ResponseError::UnknownTopicOrPartition, + ), + ( + ERROR_NOT_LEADER_OR_FOLLOWER, + ResponseError::NotLeaderOrFollower, + ), + (ERROR_REQUEST_TIMED_OUT, ResponseError::RequestTimedOut), + ( + ERROR_INVALID_TOPIC_EXCEPTION, + ResponseError::InvalidTopicException, + ), + ( + ERROR_TOPIC_AUTHORIZATION_FAILED, + ResponseError::TopicAuthorizationFailed, + ), + ( + ERROR_TOPIC_ALREADY_EXISTS, + ResponseError::TopicAlreadyExists, + ), + (ERROR_INVALID_PARTITIONS, ResponseError::InvalidPartitions), + ] { + assert_eq!( + ours, + theirs.code(), + "{theirs:?} is {} in kafka-protocol, not {ours}", + theirs.code() + ); + } + } +} diff --git a/gateways/kafka/src/bridge/iggy_bridge/fetch.rs b/gateways/kafka/src/bridge/iggy_bridge/fetch.rs new file mode 100644 index 0000000000..eb630c943c --- /dev/null +++ b/gateways/kafka/src/bridge/iggy_bridge/fetch.rs @@ -0,0 +1,21 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Fetch-side bridge calls. +//! +//! Empty until the Fetch handler lands. Separate from `produce.rs` so the two never edit one +//! file. diff --git a/gateways/kafka/src/bridge/iggy_bridge/mod.rs b/gateways/kafka/src/bridge/iggy_bridge/mod.rs new file mode 100644 index 0000000000..e11079141f --- /dev/null +++ b/gateways/kafka/src/bridge/iggy_bridge/mod.rs @@ -0,0 +1,187 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use std::future::Future; +use std::time::Duration; + +use iggy::prelude::{AutoLogin, Client, Credentials, IggyClient, IggyClientBuilder, IggyError}; +use tracing::info; + +use crate::bridge::config::IggyBridgeConfig; +use crate::bridge::error::BridgeError; + +mod fetch; +mod offsets; +mod produce; +mod topics; + +/// Passes attempted, after the first, before [`IggyBridge::connect`] gives up and returns `Err`. +/// +/// Not the SDK's own default (`TcpClientReconnectionConfig::default()` is `max_retries: None` - +/// unlimited, one dial per second, forever). A Kafka client already retries at the wire-protocol +/// level once a handler maps a bridge failure to a retriable error code; the bridge blocking a +/// request task inside an unbounded internal reconnect loop would just add a second, invisible +/// retry layer underneath that one instead of surfacing the failure so the mapped code can be +/// sent. +/// +/// This bounds the *count*, not the *wall-clock time*, of that inner retry loop - see +/// [`REQUEST_TIMEOUT`] for the latter. +const RECONNECTION_RETRIES: u32 = 3; + +/// Wall-clock ceiling [`with_request_timeout`] applies uniformly: to the initial `client.connect()` +/// in [`IggyBridge::connect`] (including every attempt [`RECONNECTION_RETRIES`] makes internally), +/// and to every call made after it succeeds (`get_stream`, `create_stream`, `get_topic`, +/// `create_topic`, `shutdown`). +/// +/// One constant, not two separately-named ones with the same value: both call sites bound the +/// identical underlying hazard. `TcpClient::establish_bounded` only applies its own +/// `FAILOVER_DIAL_TIMEOUT` (2s) when at least two failover candidates are configured +/// (`tcp_client.rs`) - a bridge always configures exactly one address, so that guard never engages +/// at either site, and the plain `TcpStream::connect` underneath has no deadline of its own. +/// Against a firewall that drops SYN packets instead of refusing them, each dial attempt pays the +/// kernel's own SYN-retry timeout (minutes, not seconds) rather than the `reconnection_interval` +/// between attempts - a closed port (instant RST) never exercises this path, so the failure mode +/// only shows up in production. 15s comfortably covers a slow-but-alive server's handshake (well +/// above p99 login latency) while still failing well short of the pathological multi-minute case. +/// +/// Known limitation, not closed by this constant: [`with_request_timeout`] cancels only the +/// *caller's* wait, not the SDK's own work. `TcpClient::send_raw_vsr_attempt` (`tcp_client.rs`) +/// writes, flushes and reads inside a detached `tokio::spawn` specifically so that dropping the +/// awaiting future - exactly what this timeout does on expiry - cannot abort it mid-flight (that +/// function's own "SAFETY: we run code holding the `stream` lock in a task so we can't be +/// cancelled while holding the lock"). A call that times out here can leave that detached task +/// still holding `IggyClient`'s single stream mutex for up to `RESPONSE_READ_TIMEOUT` (30s, +/// `tcp_client.rs`) longer, queuing every other bridge call behind it. Raising this constant does +/// not close the gap either: a mid-call reconnect (`send_raw_with_response`, `tcp_client.rs`) +/// replays on a fresh `RESPONSE_READ_TIMEOUT` budget, and the reconnect dial itself goes through +/// the same undead-lined `TcpStream::connect` this constant exists to bound - genuinely unbounded, +/// so no finite value here can guarantee catching it. That same unboundedness is why this timeout +/// cannot simply be dropped for post-connect calls either: [`IggyBridge`] holds one `IggyClient` +/// with no pooling, so an unbounded reconnect dial with nothing here to stop it would wedge every +/// later call on this bridge, not just the one that triggered it. Tolerable only because nothing +/// calls this bridge from a live Kafka handler yet - closing it needs either a cooperatively +/// cancellable SDK call or a deadline on the SDK's own reconnect dial, neither of which this +/// bridge can add from the outside. Must be resolved before #3535/#3536 share this client across +/// concurrent connections. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(15); + +/// Wraps a single Iggy client call in [`REQUEST_TIMEOUT`]. See that constant's doc for why every +/// bridge method needs this, not just [`IggyBridge::connect`] - and for the cancellation gap this +/// wrapper does not close. +/// +/// On expiry, maps to [`BridgeError::Timeout`], not `IggyError::CannotEstablishConnection`: the +/// SDK's write/read run in a detached task this timeout cannot abort (see [`REQUEST_TIMEOUT`]'s +/// doc), so by the time this fires the request may already be on the wire, or already applied +/// server-side - a genuinely unknown outcome, not a known connection failure. `BridgeError`'s own +/// mapping already treats an unknown outcome (`IggyError::TransientNotCommitted`) as distinct from +/// a known-safe-to-retry one for exactly this reason; a caller-side timeout is the same shape and +/// must not borrow the known-safe code. +async fn with_request_timeout( + op: impl Future>, +) -> Result { + tokio::time::timeout(REQUEST_TIMEOUT, op) + .await + .map_err(|_elapsed| BridgeError::Timeout)? + .map_err(BridgeError::Iggy) +} + +/// Owns one connected `IggyClient` and resolves Kafka topics against it. +/// +/// Produce/Fetch handler wiring is a separate, later change (`#3535`/`#3536`) - this type is the +/// shared plumbing those handlers will call into, exercised standalone here via its own tests and +/// an integration test against a real `iggy-server`. +/// +/// One `IggyClient`, shared across every Kafka connection this gateway serves - and the SDK's TCP +/// transport is lockstep, one request in flight at a time (`tcp_client.rs`: "the connection is +/// lockstep", its stream mutex held across write, flush, and read). Every concurrent Kafka +/// connection ends up serialized behind whichever single Iggy request is in flight; the Kafka +/// side's own connection limit does nothing to relieve this. No pooling exists yet - `close` +/// already takes `self` by value, which anticipates an eventual `Arc`-shared bridge, but nothing +/// wires that up today. Documented here and in the README rather than silently discovered under +/// load once `#3535`/`#3536` land. +pub struct IggyBridge { + client: IggyClient, + config: IggyBridgeConfig, +} + +impl IggyBridge { + /// Connects to Iggy using `config` and authenticates. + /// + /// Builds the client through the SDK's fluent TCP builder rather than hand-assembling an + /// `iggy://user:pass@host` connection string: that string format splits on `@` then `:`, so a + /// password containing either character (`p@ss:word`) would be misparsed into a garbled + /// address instead of failing with a diagnosable config error. The fluent builder passes + /// `username`/`password` as already-separated fields, sidestepping the ambiguity entirely. + /// + /// # Errors + /// + /// Returns [`BridgeError::InvalidConfig`] if `config.address` is empty. Returns + /// [`BridgeError::Timeout`] if connecting takes longer than `REQUEST_TIMEOUT` (an + /// unreachable-and-silently-dropping address, not just a refused one, is covered - see that + /// constant's doc). Returns [`BridgeError::Iggy`] if the address is malformed, the TCP + /// connection fails, or authentication is rejected - this is the boundary + /// [`BridgeError::to_kafka_error_code`] exists for: a handler calling this must map the error + /// to a wire response, never panic or unwrap, since an unreachable Iggy backend is an + /// expected runtime condition, not a bug. + pub async fn connect(config: IggyBridgeConfig) -> Result { + if config.address.trim().is_empty() { + return Err(BridgeError::InvalidConfig( + "Iggy address must not be empty".to_string(), + )); + } + + let credentials = + Credentials::UsernamePassword(config.username.clone(), config.password.clone()); + let client = IggyClientBuilder::new() + .with_tcp() + .with_server_address(config.address.clone()) + .with_auto_sign_in(AutoLogin::Enabled(credentials)) + .with_reconnection_max_retries(Some(RECONNECTION_RETRIES)) + .build() + .map_err(BridgeError::Iggy)?; + with_request_timeout(client.connect()).await?; + info!("Iggy bridge connected to {}", config.address); + + Ok(Self { client, config }) + } + + /// Tears down the underlying Iggy client, including its background heartbeat task. + /// + /// Not `IggyClient::disconnect`: that only tears down the transport + /// (`TcpClient::disconnect_transport`) and never touches `heartbeat_handle` - only + /// `IggyClient`'s own `Drop` aborts that task (`client.rs`). A `disconnect`ed-but-not-dropped + /// bridge would keep heartbeating on a schedule, hit `NotConnected` (itself in the SDK's + /// retriable set), and reconnect plus re-authenticate using the credentials `connect` + /// configured, so the "closed" client silently comes back. `shutdown` sets + /// `ClientState::Shutdown`, which the heartbeat loop's next `ping` observes as + /// `IggyError::ClientShutdown` and self-terminates on, and which `sign_in_credentials` never + /// dials past. + /// + /// Takes `self` by value: `shutdown` is terminal (no reconnect is coming back from it), so + /// nothing legitimate is left to call on this bridge afterward. This does mean a bridge shared + /// via `Arc` cannot call this directly (`Arc::try_unwrap` first) - not a concern before + /// `#3535`/`#3536` wire an owning caller in. + /// + /// # Errors + /// + /// Returns [`BridgeError::Timeout`] if it takes longer than `REQUEST_TIMEOUT`. Returns + /// [`BridgeError::Iggy`] if the underlying client reports a shutdown failure (e.g. the socket + /// was already in a state that rejects a clean shutdown). + pub async fn close(self) -> Result<(), BridgeError> { + with_request_timeout(self.client.shutdown()).await + } +} diff --git a/gateways/kafka/src/bridge/iggy_bridge/offsets.rs b/gateways/kafka/src/bridge/iggy_bridge/offsets.rs new file mode 100644 index 0000000000..b199f7f656 --- /dev/null +++ b/gateways/kafka/src/bridge/iggy_bridge/offsets.rs @@ -0,0 +1,176 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Offset and high-watermark lookups. + +use iggy::prelude::{Identifier, IggyError, TopicClient}; + +use super::{IggyBridge, with_request_timeout}; +use crate::bridge::error::BridgeError; +use crate::bridge::topic_map::validate_kafka_topic_name; + +impl IggyBridge { + /// Returns the high watermark (one past the highest *committed* offset - see + /// `Partition::offset_frontier`'s own definition) for every partition in `partitions` of the + /// Iggy topic `kafka_topic` maps to, in one round trip. + /// + /// Takes `kafka_topic`, not raw Iggy stream/topic names, and resolves it through the same + /// [`TopicMapping`](crate::bridge::topic_map::TopicMapping) `ensure_stream_and_topic` + /// uses - a caller (a future `ListOffsets` handler) only ever has the Kafka-side name, and a + /// topic with a mapping override would silently query the wrong Iggy resource if this took + /// Iggy-space names directly instead. + /// + /// One call to `get_topic`, not one per partition: a single Kafka `ListOffsets` request asks + /// about many partitions of one topic at once (`ListOffsetsRequest.json`'s + /// `Topics[] -> Partitions[]`), and `get_topic`'s decode already rebuilds and sorts the whole + /// partition vector regardless of how many of them the caller wants + /// (`wire_conversions.rs`) - a naive one-call-per-partition wrapper around a + /// single-partition method would turn one Kafka request into N round trips and N times the + /// decode work. Returns results in the same order as `partitions`. + /// + /// `i64`, not `u64`: the `ListOffsets` response field this exists to fill is `int64` + /// (`ListOffsetsResponse.json`), so a future handler needs no cast at the wire boundary - the + /// lossy conversion happens once, here (`Partition::current_offset` is `u64`; converted via + /// `i64::try_from`, saturating to `i64::MAX` on the practically-unreachable overflow case + /// rather than panicking or wrapping). + /// + /// `Partition::current_offset` is the offset of the *last written* message, not "next offset + /// to produce" - confirmed against a live server (3 produced messages read back + /// `current_offset == 2`). An empty partition has no last-written offset at all, so this + /// needs a dedicated empty case rather than inferring it from `current_offset == 0` (also a + /// fresh partition's default value, indistinguishable from "one message at offset 0"). + /// + /// That empty case is `messages_count == 0 && current_offset == 0`, not `messages_count == 0` + /// alone: retention cleanup decrements `messages_count` + /// (`iggy_partition.rs::decrement_messages_count`) without rewinding `current_offset`, so a + /// fully-purged but previously-produced-to partition would otherwise read as empty. This is + /// still not exact: a partition whose *only* message, at offset 0, gets trimmed by the same + /// retention pass reports `(messages_count, current_offset) == (0, 0)` too, so its watermark + /// would read `1` then drop back to `0` - a real Kafka watermark never moves backward. Narrow + /// (needs retention to fully empty a partition that only ever held one message) and not + /// fixable client-side (`PartitionResponse` carries no frontier field to disambiguate it), but + /// worth having on the record rather than only implicitly true of the check below. + /// + /// Not the same value as `Partition::mint_frontier` (the offset the *next* mint will take): + /// after a crash-recovery reservation, the append point can sit above the committed frontier + /// by the reservation's lease block, and neither value is on the wire to tell them apart. This + /// is still correct for `ListOffsets` LATEST (defined in terms of the committed offset), but a + /// future Produce handler must not use it to predict a base offset for a write in flight. + /// + /// Known remaining gap, not fixable client-side: `(0, 0)` is also what a stats-registry MISS + /// reports (`responses.rs`'s `PartitionResponse` builder), indistinguishable on the wire from + /// a genuinely empty partition. Narrow and self-healing - `partition_reconciler.rs`'s + /// `settle_partition_stats` opens this only on the teardown-for-rebuild path, closing once the + /// rebuild completes; deletes never open it. The real answer (`Partition::offset_frontier`) + /// stays server-side and isn't in this response. The check matches the server's own + /// `PartitionState::store_offset_range_error` condition, so it's not an invented heuristic - a + /// future `ListOffsets` (`#3537`) built on this inherits the same blind spot. + /// + /// # Errors + /// + /// Returns [`BridgeError::InvalidKafkaTopicName`] if `kafka_topic` fails Kafka's own + /// topic-naming rules. Returns [`BridgeError::Timeout`] if the call takes longer than + /// `REQUEST_TIMEOUT`. Returns [`BridgeError::Iggy`] if the mapped stream doesn't exist (see + /// the note above on which resource is actually missing). These are call-level failures - + /// nothing about any individual partition could be resolved. A single out-of-range + /// `partition` does not fail the whole call: it is reported per-partition (see the return + /// type), so the partitions that did resolve are never discarded to report one that didn't. + pub async fn high_watermarks( + &self, + kafka_topic: &str, + partitions: &[u32], + ) -> Result)>, BridgeError> { + validate_kafka_topic_name("kafka_topic", kafka_topic)?; + let (stream_name, topic_name) = self.config.topic_mapping.resolve(kafka_topic); + let stream_id = Identifier::named(stream_name).map_err(BridgeError::Iggy)?; + let topic_id = Identifier::named(topic_name).map_err(BridgeError::Iggy)?; + let details = with_request_timeout(self.client.get_topic(&stream_id, &topic_id)) + .await? + .ok_or_else(|| { + // A missing *stream* also makes get_topic return Ok(None) (responses.rs), so this + // reports TopicNameNotFound even when the stream is what's actually gone - both + // map to the same Kafka wire code either way, so only the log text is affected. + BridgeError::Iggy(IggyError::TopicNameNotFound( + topic_name.to_string(), + stream_name.to_string(), + )) + })?; + + Ok(partitions + .iter() + .map(|&partition| { + // `TryFrom for TopicDetails` (wire_conversions.rs) sorts + // `partitions` by `id` on every decode, so a binary search is correct here, not + // just faster than a linear scan - for a 1000-partition topic, the difference is + // O(log n) vs O(n) probes per requested partition. + let watermark = details + .partitions + .binary_search_by_key(&partition, |p| p.id) + .map(|index| &details.partitions[index]) + .map_err(|_| BridgeError::PartitionOutOfRange { + // The Kafka-side name a caller (a future ListOffsets handler) actually + // asked about, not `topic_name` - a mapping override would otherwise + // quote the wrong (Iggy-side) name back at a Kafka client that never + // heard of it. + topic: kafka_topic.to_string(), + partition, + partitions_count: details.partitions_count, + }) + .map(|partition_details| { + if partition_details.messages_count == 0 + && partition_details.current_offset == 0 + { + 0 + } else { + partition_details.current_offset.saturating_add(1) + } + }) + .map(|watermark| i64::try_from(watermark).unwrap_or(i64::MAX)); + (partition, watermark) + }) + .collect()) + } + + /// Convenience wrapper around [`Self::high_watermarks`] for a single partition. See that + /// method's doc for the full semantics - callers wanting more than one partition of the same + /// topic should call it directly instead of this in a loop, to get its one-round-trip batching. + /// + /// # Errors + /// + /// See [`Self::high_watermarks`]. + /// + /// # Panics + /// + /// Never in practice: [`Self::high_watermarks`] returns exactly one result per requested + /// partition on `Ok`, and this always requests exactly one. + pub async fn high_watermark( + &self, + kafka_topic: &str, + partition: u32, + ) -> Result { + let (_, watermark) = self + .high_watermarks(kafka_topic, &[partition]) + .await? + .into_iter() + .next() + .expect( + "high_watermarks returns exactly one result per requested partition, \ + and exactly one was requested", + ); + watermark + } +} diff --git a/gateways/kafka/src/bridge/iggy_bridge/produce.rs b/gateways/kafka/src/bridge/iggy_bridge/produce.rs new file mode 100644 index 0000000000..4537322123 --- /dev/null +++ b/gateways/kafka/src/bridge/iggy_bridge/produce.rs @@ -0,0 +1,21 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Produce-side bridge calls. +//! +//! Empty until the Produce handler lands. Separate from `fetch.rs` so the two never edit one +//! file. diff --git a/gateways/kafka/src/bridge/iggy_bridge/topics.rs b/gateways/kafka/src/bridge/iggy_bridge/topics.rs new file mode 100644 index 0000000000..395f6f27d2 --- /dev/null +++ b/gateways/kafka/src/bridge/iggy_bridge/topics.rs @@ -0,0 +1,220 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Stream and topic provisioning. + +use iggy::prelude::{Identifier, IggyError, StreamClient, TopicClient, TopicCreateOptions}; +use tracing::{debug, info}; + +use super::{IggyBridge, with_request_timeout}; +use crate::bridge::error::BridgeError; +use crate::bridge::topic_map::validate_kafka_topic_name; + +impl IggyBridge { + /// Ensures the Iggy stream and topic backing `kafka_topic` exist, creating either or both if + /// missing. Resolves `kafka_topic` through the configured [`TopicMapping`](crate::bridge::topic_map::TopicMapping). + /// + /// Idempotent when repeated with the *same* `partition_count`: a `get` before each `create` + /// means calling this twice for the same topic is a no-op the second time, and a + /// `NameAlreadyExists` race from a concurrent caller creating the same stream/topic between + /// this call's `get` and `create` is treated as success, not an error - the desired end state + /// (it exists) is what idempotency actually promises, not that this call was the one that + /// created it. A *different* `partition_count` against an already-existing topic is not + /// idempotent - see [`BridgeError::PartitionCountMismatch`]. + /// + /// Ensures the stream before the topic, so a topic-creation failure (a different + /// `partition_count`, a transient error) can leave a stream that now exists with no topic in + /// it yet - a retry heals this (idempotent on the stream half too), and no rollback is + /// attempted: `TopicMapping::resolve` sends every *unmapped* Kafka topic to the same + /// `default_stream`, so deleting a stream on a topic-creation failure risks deleting another + /// topic's data that happens to share it, and this call has no way to tell whether it was the + /// one that created the stream in the first place. + /// + /// `partition_count` is `u32`, so it cannot carry Kafka's own `CreateTopics` sentinel + /// (`num_partitions == -1`, KIP-464 "use the broker default" - `protocol/responses.rs` + /// already accepts that sentinel at the wire-validation layer). Resolving *what* the broker + /// default should be for this bridge is a decision for whichever of `#3535`/`#3536` first + /// calls this with a real Kafka request in hand, not one to invent here ahead of that need. + /// + /// No caching: every call pays a `get_stream` and a `get_topic` (two round trips once both + /// already exist), even for a topic this same bridge already confirmed a moment ago. A cache + /// keyed on `kafka_topic` would remove that cost, but would also have to answer "how does a + /// cache entry ever get invalidated" - the topic being deleted and recreated with a different + /// partition count out from under a stale cache entry is exactly + /// `ensure_topic_targets_the_streams_live_incarnation_after_a_delete_and_recreate`'s own + /// scenario, and a naive cache breaks that guarantee to save two round trips. Whatever wires + /// this into `#3535`/`#3536` should call it once per topic and remember that it did, rather + /// than once per Produce/Fetch - the cost belongs at the call site's discretion, not hidden + /// (and potentially made wrong) inside this method. + /// + /// # Errors + /// + /// Returns [`BridgeError::InvalidKafkaTopicName`] if `kafka_topic` fails Kafka's own + /// topic-naming rules. Returns [`BridgeError::Timeout`] if a call takes longer than + /// `REQUEST_TIMEOUT`. Returns [`BridgeError::Iggy`] for connectivity/auth failures. Returns + /// [`BridgeError::PartitionCountMismatch`] if the topic already exists with a different + /// partition count than `partition_count`. + pub async fn ensure_stream_and_topic( + &self, + kafka_topic: &str, + partition_count: u32, + ) -> Result<(), BridgeError> { + validate_kafka_topic_name("kafka_topic", kafka_topic)?; + let (stream_name, topic_name) = self.config.topic_mapping.resolve(kafka_topic); + let stream_id = self.ensure_stream(stream_name).await?; + self.ensure_topic(&stream_id, topic_name, kafka_topic, partition_count) + .await?; + Ok(()) + } + + /// Ensures the stream named `stream_name` exists, creating it if missing. + /// + /// `Identifier::named` - never `Identifier::try_from`/`FromStr` - because the latter parses + /// an all-digit string as a numeric Iggy ID rather than a name. A stream or topic named e.g. + /// `"42"` would otherwise resolve against the wrong resource on every call after the first: + /// the first `ensure_stream_and_topic("42", ...)` creates a stream *named* `"42"`, but a + /// second call would look it up *by ID* `42` instead, almost certainly finding nothing and + /// breaking the "idempotent on repeated calls" guarantee. + /// + /// Returns the same *named* `Identifier` it was given, not the numeric id the SDK hands back + /// from `get`/`create` - streams are backed by a recycled slab (`core/metadata`'s + /// `stm/stream.rs`: freed keys are reused by the next created stream), so a numeric id + /// captured here could point at a *different* stream by the time `ensure_topic` uses it, if + /// this stream is deleted and recreated in between. The name has no such window. + async fn ensure_stream(&self, stream_name: &str) -> Result { + let identifier = Identifier::named(stream_name).map_err(BridgeError::Iggy)?; + if with_request_timeout(self.client.get_stream(&identifier)) + .await? + .is_some() + { + debug!("Iggy stream '{stream_name}' already exists"); + return Ok(identifier); + } + + match with_request_timeout(self.client.create_stream(stream_name)).await { + Ok(_created) => { + info!("created Iggy stream '{stream_name}'"); + Ok(identifier) + } + Err(BridgeError::Iggy(IggyError::StreamNameAlreadyExists(_))) => { + // Lost a create race - the name now exists regardless of who won it. + Ok(identifier) + } + Err(err) => Err(err), + } + } + + /// Looks up (or creates) the topic named `topic_name` under `stream_id`. + /// + /// `Identifier::named`, not `Identifier::try_from` - the same numeric-name ambiguity + /// [`Self::ensure_stream`]'s doc comment describes for stream names applies to topic names. + /// + /// `partition_count == 0` is accepted here as defense in depth, not the primary guard: the + /// server allows it by design (`rewrite.rs`), and the `CreateTopics` stub already rejects it + /// at the wire level (`protocol/responses.rs`) before any bridge call would be reachable. + async fn ensure_topic( + &self, + stream_id: &Identifier, + topic_name: &str, + kafka_topic: &str, + partition_count: u32, + ) -> Result<(), BridgeError> { + let identifier = Identifier::named(topic_name).map_err(BridgeError::Iggy)?; + if let Some(existing) = + with_request_timeout(self.client.get_topic(stream_id, &identifier)).await? + { + debug!("Iggy topic '{topic_name}' already exists"); + // ensure_topic's contract is "the topic has partition_count partitions afterward" - + // a mismatch here means that's false. Returning Ok(()) anyway (even with a warn!) + // would let two concurrent callers requesting different counts for the same topic + // both believe they succeeded; growing partitions on the caller's behalf is also a + // bigger decision (CreatePartitions has its own semantics) than this method should + // make silently. Erring is the only response that keeps the postcondition honest. + if existing.partitions_count != partition_count { + return Err(BridgeError::PartitionCountMismatch { + // The Kafka-side name a caller actually asked about, not `topic_name` - see + // the identical note on `PartitionOutOfRange` in `high_watermark`. + topic: kafka_topic.to_string(), + existing: existing.partitions_count, + requested: partition_count, + }); + } + return Ok(()); + } + + // message_expiry left at TopicCreateOptions::default() (None -> ServerDefault) means + // never-expire (segment_cleaner.rs treats ServerDefault the same as NeverExpire), not + // Kafka's own 7-day default - deliberate for now (imposing a retention policy is a product + // decision this bridge shouldn't make unasked), but a real surprise for anyone repointing + // a Kafka app that assumes bounded retention. Flagged in the README; revisit once there's + // a way to configure it (env var, topic-mapping field) rather than hardcoding a number. + let options = TopicCreateOptions { + partitions_count: Some(partition_count), + ..TopicCreateOptions::default() + }; + match with_request_timeout(self.client.create_topic(stream_id, topic_name, &options)).await + { + Ok(created) => { + info!("created Iggy topic '{topic_name}' with {partition_count} partitions"); + // Cheap: TopicDetails is already in hand, no extra round trip. `partitions_count` + // is a hard argument to create_topic (Some(partition_count), never None), so the + // server has no "resolve at admission" substitution to fall back on here - but + // checking anyway, the same way the other two branches check their own + // postcondition, means a future server-side clamp/cap fails loudly here instead + // of this method silently reporting success under a broken contract. + if created.partitions_count != partition_count { + return Err(BridgeError::PartitionCountMismatch { + topic: kafka_topic.to_string(), + existing: created.partitions_count, + requested: partition_count, + }); + } + Ok(()) + } + // Lost a create race - re-verify by name rather than trusting the race outcome alone. + // The winner may have created it with a different partition count than this call + // requested, so this needs the same mismatch check the existing-topic branch above + // makes - skipping it here would let two concurrent ensure_topic(N) / ensure_topic(M) + // calls for the same topic both return Ok(()). + // + // Untested: reaching this arm needs a real concurrent second caller mid-race, and + // `IggyBridge` holds a concrete `IggyClient` with no seam for a fake that returns + // `TopicNameAlreadyExists` on demand. A test that spins up two real concurrent callers + // would hit it only sometimes - flaky, and passing wouldn't prove this arm ran. Left + // as a known gap until something introduces a client seam. + Err(BridgeError::Iggy(IggyError::TopicNameAlreadyExists(_, _))) => { + let existing = with_request_timeout(self.client.get_topic(stream_id, &identifier)) + .await? + .ok_or_else(|| { + BridgeError::Iggy(IggyError::TopicNameNotFound( + topic_name.to_string(), + stream_id.to_string(), + )) + })?; + if existing.partitions_count != partition_count { + return Err(BridgeError::PartitionCountMismatch { + topic: kafka_topic.to_string(), + existing: existing.partitions_count, + requested: partition_count, + }); + } + Ok(()) + } + Err(err) => Err(err), + } + } +} diff --git a/gateways/kafka/src/bridge/mod.rs b/gateways/kafka/src/bridge/mod.rs new file mode 100644 index 0000000000..dde4c5e9db --- /dev/null +++ b/gateways/kafka/src/bridge/mod.rs @@ -0,0 +1,32 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Iggy SDK integration layer. +//! +//! Maps Kafka topics to Iggy streams/topics, exposes create-if-missing provisioning and +//! high-watermark lookups, and translates Iggy errors to Kafka wire error codes. Not yet wired +//! into the live Produce/Fetch dispatch path - that lands with `#3535`/`#3536`. + +pub mod config; +pub mod error; +pub mod iggy_bridge; +pub mod topic_map; + +pub use config::IggyBridgeConfig; +pub use error::BridgeError; +pub use iggy_bridge::IggyBridge; +pub use topic_map::{TopicMapping, TopicOverride}; diff --git a/gateways/kafka/src/bridge/topic_map.rs b/gateways/kafka/src/bridge/topic_map.rs new file mode 100644 index 0000000000..9802ad5647 --- /dev/null +++ b/gateways/kafka/src/bridge/topic_map.rs @@ -0,0 +1,566 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use serde::Deserialize; + +use crate::bridge::error::BridgeError; + +/// Upper bound `Identifier::named` itself enforces (`identifier/mod.rs`). Checked here too so a +/// too-long name in the mapping file fails at config load with a message that names the file and +/// field at fault, instead of surfacing much later as an opaque `InvalidIdentifier` deep inside +/// `IggyBridge::ensure_stream`/`ensure_topic`. +const MAX_IDENTIFIER_LEN: usize = 255; + +/// Kafka's own topic-name cap (`Topic.MAX_NAME_LENGTH` in Kafka's own source) - smaller than +/// [`MAX_IDENTIFIER_LEN`], so a Kafka-side name needs its own limit, not Iggy's. +const MAX_KAFKA_TOPIC_NAME_LEN: usize = 249; + +/// Rejects an empty name, one with leading/trailing whitespace, or one over +/// [`MAX_IDENTIFIER_LEN`] bytes. +/// +/// Whitespace is rejected outright rather than trimmed and stored: silently trimming here (or +/// adding that later) would change the resolved Iggy stream/topic name under an already-running +/// deployment without the operator changing anything they can see in their own config file. +/// +/// `pub(crate)`: also used by `bridge::config` for `IGGY_KAFKA_IGGY_STREAM`, which feeds +/// `default_stream` through the same no-file path this module's own validation covers on the +/// TOML-file path. For a Kafka-side topic name (a TOML key, or a `kafka_topic` argument), use +/// [`validate_kafka_topic_name`] instead - Kafka's own limits and legal characters differ from +/// Iggy's. +pub(crate) fn validate_identifier_name(field: &str, value: &str) -> Result<(), BridgeError> { + if value.is_empty() { + return Err(BridgeError::InvalidConfig(format!( + "{field} must not be empty" + ))); + } + if value.trim() != value { + return Err(BridgeError::InvalidConfig(format!( + "{field} must not have leading or trailing whitespace: {value:?}" + ))); + } + if value.len() > MAX_IDENTIFIER_LEN { + return Err(BridgeError::InvalidConfig(format!( + "{field} is {} bytes, over the {MAX_IDENTIFIER_LEN}-byte limit Identifier::named \ + enforces: {value:?}", + value.len() + ))); + } + Ok(()) +} + +/// Rejects a Kafka topic name that fails Kafka's own naming rules: empty, leading/trailing +/// whitespace, over [`MAX_KAFKA_TOPIC_NAME_LEN`] bytes, or containing a byte outside +/// `[A-Za-z0-9._-]` (Kafka's own `Topic.legalChars`). +/// +/// Used both for TOML mapping-file keys (`from_toml_str`) and for the `kafka_topic` argument +/// `IggyBridge::ensure_stream_and_topic`/`high_watermark` take directly - the two paths must agree, +/// or a name the config file would reject can still reach Iggy unvalidated through the second +/// path. A whitespace-padded key is the sharpest failure mode this catches: `resolve`'s lookup is +/// an exact `HashMap::get`, so a padded key parses out of the TOML file cleanly and then matches +/// nothing at runtime, silently falling through to the default stream instead of erroring - the +/// one place in this module that would otherwise fail silently instead of loudly. +/// +/// `field` is `"topic mapping key"` or `"kafka_topic"` in current callers, not a generic template - +/// kept as a parameter (like [`validate_identifier_name`]) so the message names the actual site. +pub(crate) fn validate_kafka_topic_name(field: &str, value: &str) -> Result<(), BridgeError> { + if value.is_empty() { + return Err(BridgeError::InvalidKafkaTopicName { + kafka_topic: value.to_string(), + reason: format!("{field} must not be empty"), + }); + } + if value.trim() != value { + return Err(BridgeError::InvalidKafkaTopicName { + kafka_topic: value.to_string(), + reason: format!("{field} must not have leading or trailing whitespace"), + }); + } + if value.len() > MAX_KAFKA_TOPIC_NAME_LEN { + return Err(BridgeError::InvalidKafkaTopicName { + kafka_topic: value.to_string(), + reason: format!( + "{field} is {} bytes, over Kafka's {MAX_KAFKA_TOPIC_NAME_LEN}-byte topic name limit", + value.len() + ), + }); + } + // Kafka's own `Topic.validate` special-cases these two names as invalid even though every + // byte in them is otherwise legal (`.` is itself an allowed character) - not a traversal risk + // here, since Iggy builds on-disk paths from numeric ids rather than the topic name, but the + // doc above claims this function applies Kafka's own rules, and these are two of them. + if value == "." || value == ".." { + return Err(BridgeError::InvalidKafkaTopicName { + kafka_topic: value.to_string(), + reason: format!("{field} cannot be \".\" or \"..\""), + }); + } + if let Some(illegal) = value + .bytes() + .find(|b| !(b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))) + { + return Err(BridgeError::InvalidKafkaTopicName { + kafka_topic: value.to_string(), + reason: format!( + "{field} contains {:?}, outside Kafka's legal topic-name characters \ + (letters, digits, '.', '_', '-')", + illegal as char + ), + }); + } + Ok(()) +} + +/// Explicit Kafka-topic → Iggy stream/topic override. Absent entries fall back to +/// [`TopicMapping::default_stream`] plus the Kafka topic name unchanged - see +/// [`TopicMapping::resolve`]. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct TopicOverride { + pub stream: String, + pub topic: String, +} + +/// Kafka topic name → Iggy stream/topic mapping. +/// +/// Default rule (no override): the Iggy stream is [`default_stream`](Self::default_stream) and +/// the Iggy topic name is the Kafka topic name unchanged. A gateway that fronts a single Kafka +/// "cluster" for one Iggy stream never needs an override entry at all. +/// +/// Fields are private and every value is validated (empty/whitespace/length/injectivity - see +/// [`new`](Self::new)) on the only two ways to build one: [`new`](Self::new) and +/// [`from_toml_str`](Self::from_toml_str) (which deserializes into a private, unchecked +/// `RawTopicMapping` first, then calls `new`). Public fields plus `#[derive(Deserialize)]` +/// directly on this type would let any caller construct or mutate one straight from TOML or a +/// literal, skipping every check below - which is exactly what happened before this type had a +/// checked constructor: `bridge::config`'s no-file path built one by hand and needed its own +/// separate validation call to make up for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TopicMapping { + default_stream: String, + topics: HashMap, +} + +/// Unchecked shadow of [`TopicMapping`], `Deserialize`'s only target - never constructed by hand, +/// never exposed. `TopicMapping::from_toml_str` is the sole path from TOML to a validated +/// `TopicMapping`, by deserializing into this first and then calling +/// [`TopicMapping::new`](TopicMapping::new). +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawTopicMapping { + default_stream: String, + #[serde(default)] + topics: HashMap, +} + +impl TopicMapping { + /// Builds a validated `TopicMapping` from already-parsed parts - the checked constructor the + /// no-file `IGGY_KAFKA_IGGY_STREAM` path in `bridge::config` uses, and the sole validation + /// gate `from_toml_str` also funnels through. + /// + /// # Errors + /// + /// Returns [`BridgeError::InvalidConfig`] if `default_stream` or any override's `stream`/ + /// `topic` is empty, has leading/trailing whitespace, or exceeds `MAX_IDENTIFIER_LEN`; if any + /// override key fails Kafka's own topic-naming rules (see + /// `validate_kafka_topic_name`); or if two override entries are not injective (see + /// [`resolve`](Self::resolve)). + pub fn new( + default_stream: String, + topics: HashMap, + ) -> Result { + validate_identifier_name("topic mapping's default_stream", &default_stream)?; + + let mut targets = HashSet::with_capacity(topics.len()); + for (kafka_topic, over) in &topics { + // `validate_kafka_topic_name` returns `InvalidKafkaTopicName` - the variant a live + // Kafka request's own `kafka_topic` argument fails with, wire-mapped to + // `INVALID_TOPIC_EXCEPTION` (17), "the client asked for a bad name." A mapping-file + // key is not that: it is an operator's config, validated once at load time, long + // before any Kafka client is involved. Rewrapped as `InvalidConfig` (wire-mapped to + // -1) so a TOML typo reads as the bridge's own fault, not as if some Kafka client had + // sent this exact malformed name - matching this function's own `# Errors` doc, which + // promises `InvalidConfig` for a bad key. + if let Err(BridgeError::InvalidKafkaTopicName { reason, .. }) = + validate_kafka_topic_name("topic mapping key", kafka_topic) + { + return Err(BridgeError::InvalidConfig(format!( + "topic mapping key {kafka_topic:?}: {reason}" + ))); + } + validate_identifier_name( + &format!("topic mapping override for '{kafka_topic}': its stream"), + &over.stream, + )?; + validate_identifier_name( + &format!("topic mapping override for '{kafka_topic}': its topic"), + &over.topic, + )?; + + let target = (over.stream.clone(), over.topic.clone()); + if !targets.insert(target) { + return Err(BridgeError::InvalidConfig(format!( + "topic mapping override for '{kafka_topic}' targets Iggy stream '{}' topic \ + '{}', which another override already targets - two Kafka topics would \ + silently merge into one Iggy topic", + over.stream, over.topic + ))); + } + // A Kafka topic named exactly `over.topic`, if it never gets its own override entry, + // resolves via the default path to (default_stream, over.topic) - the same pair this + // override targets, if the target stream is also the default one. Only the aliasing + // direction that maps to the *default* stream is checkable at load time; an override + // targeting some other, non-default stream can't collide with the default path. + if over.stream == default_stream && !topics.contains_key(&over.topic) { + return Err(BridgeError::InvalidConfig(format!( + "topic mapping override for '{kafka_topic}' targets Iggy stream '{}' topic \ + '{}', which is also where an unmapped Kafka topic literally named '{}' \ + would resolve to by default - the two would silently merge into one Iggy \ + topic; target a different Iggy topic name for '{kafka_topic}', or add an \ + explicit override that redirects a Kafka topic named '{}' elsewhere", + over.stream, over.topic, over.topic, over.topic + ))); + } + } + Ok(Self { + default_stream, + topics, + }) + } + + /// The Iggy stream a Kafka topic with no explicit override resolves to. + #[must_use] + pub fn default_stream(&self) -> &str { + &self.default_stream + } + + /// Resolves a Kafka topic name to `(iggy_stream, iggy_topic)`. + /// + /// Not injective: two distinct Kafka topics can resolve to the same Iggy stream/topic pair, + /// merging their messages. `new` rejects the checkable cases (two overrides sharing a target, + /// or an override's target aliasing the default-path resolution of its own topic name) at + /// config load, but the space of *unlisted* Kafka topic names is unbounded, so a collision + /// against one that never gets an override entry can't be ruled out ahead of time. + #[must_use] + pub fn resolve<'a>(&'a self, kafka_topic: &'a str) -> (&'a str, &'a str) { + self.topics.get(kafka_topic).map_or_else( + || (self.default_stream.as_str(), kafka_topic), + |over| (over.stream.as_str(), over.topic.as_str()), + ) + } + + /// Parses a `TopicMapping` from a TOML document. + /// + /// # Errors + /// + /// Returns [`BridgeError::InvalidConfig`] if `raw` is not valid TOML for this shape (an + /// unrecognized field - e.g. a `[topic.x]` typo for `[topics.x]` - is rejected here rather + /// than silently parsing to an empty override map), or on the same conditions as + /// [`new`](Self::new) - in every validation case, letting it through here would otherwise + /// fail much later, deep in `IggyBridge::ensure_stream`/`ensure_topic`, with no link back to + /// the config entry at fault. + pub fn from_toml_str(raw: &str) -> Result { + let raw_mapping: RawTopicMapping = toml::from_str(raw) + .map_err(|e| BridgeError::InvalidConfig(format!("invalid topic mapping TOML: {e}")))?; + Self::new(raw_mapping.default_stream, raw_mapping.topics) + } + + /// Reads and parses a `TopicMapping` TOML file. + /// + /// # Errors + /// + /// Returns [`BridgeError::InvalidConfig`] if the file cannot be read, or on the same + /// conditions as [`from_toml_str`](Self::from_toml_str). + pub fn from_file(path: &Path) -> Result { + let raw = std::fs::read_to_string(path).map_err(|e| { + BridgeError::InvalidConfig(format!( + "failed to read topic mapping file '{}': {e}", + path.display() + )) + })?; + Self::from_toml_str(&raw) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mapping_with(default_stream: &str, topics: HashMap) -> TopicMapping { + TopicMapping::new(default_stream.to_string(), topics) + .expect("valid mapping for this test's fixture data") + } + + #[test] + fn given_no_override_should_resolve_to_default_stream_and_same_topic_name() { + let mapping = mapping_with("kafka", HashMap::new()); + assert_eq!(mapping.resolve("orders"), ("kafka", "orders")); + } + + #[test] + fn given_override_should_resolve_to_mapped_stream_and_topic() { + let mut topics = HashMap::new(); + topics.insert( + "orders".to_string(), + TopicOverride { + stream: "billing".to_string(), + topic: "orders_v2".to_string(), + }, + ); + let mapping = mapping_with("kafka", topics); + assert_eq!(mapping.resolve("orders"), ("billing", "orders_v2")); + assert_eq!(mapping.resolve("payments"), ("kafka", "payments")); + } + + #[test] + fn from_toml_str_parses_default_stream_and_overrides() { + let toml = r#" + default_stream = "kafka" + + [topics.orders] + stream = "billing" + topic = "orders_v2" + "#; + let mapping = TopicMapping::from_toml_str(toml).unwrap(); + assert_eq!(mapping.default_stream(), "kafka"); + assert_eq!(mapping.resolve("orders"), ("billing", "orders_v2")); + } + + #[test] + fn from_toml_str_rejects_empty_default_stream() { + let toml = r#"default_stream = """#; + let err = TopicMapping::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_rejects_empty_override_stream() { + let toml = r#" + default_stream = "kafka" + + [topics.orders] + stream = "" + topic = "orders_v2" + "#; + let err = TopicMapping::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_rejects_empty_override_topic() { + let toml = r#" + default_stream = "kafka" + + [topics.orders] + stream = "billing" + topic = "" + "#; + let err = TopicMapping::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_rejects_malformed_toml() { + let err = TopicMapping::from_toml_str("not valid toml {{{").unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_file_rejects_missing_file() { + let err = TopicMapping::from_file(Path::new("/nonexistent/topic_map.toml")).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_rejects_a_typo_d_top_level_table_instead_of_silently_ignoring_it() { + // "topic" (singular) for "topics" - without deny_unknown_fields this parses cleanly to + // an empty override map, and every topic silently falls back to the default stream. + let toml = r#" + default_stream = "kafka" + + [topic.orders] + stream = "billing" + topic = "orders_v2" + "#; + let err = TopicMapping::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_rejects_default_stream_with_leading_or_trailing_whitespace() { + let toml = r#"default_stream = " kafka ""#; + let err = TopicMapping::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_rejects_default_stream_over_the_identifier_length_limit() { + let toml = format!( + "default_stream = \"{}\"", + "a".repeat(MAX_IDENTIFIER_LEN + 1) + ); + let err = TopicMapping::from_toml_str(&toml).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_rejects_two_overrides_targeting_the_same_stream_and_topic() { + let toml = r#" + default_stream = "kafka" + + [topics.orders] + stream = "billing" + topic = "orders_v2" + + [topics.legacy_orders] + stream = "billing" + topic = "orders_v2" + "#; + let err = TopicMapping::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_rejects_an_override_that_aliases_an_unmapped_topics_default_resolution() { + // orders_v2 has no override of its own, so it resolves to (kafka, orders_v2) by default - + // the same pair this override targets. + let toml = r#" + default_stream = "kafka" + + [topics.orders] + stream = "kafka" + topic = "orders_v2" + "#; + let err = TopicMapping::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_accepts_an_override_targeting_a_non_default_stream_with_the_same_topic_name() { + // Targets "billing", not the default "kafka" stream, so there is no default-path + // aliasing to catch regardless of what orders_v2 itself would resolve to. + let toml = r#" + default_stream = "kafka" + + [topics.orders] + stream = "billing" + topic = "orders_v2" + "#; + TopicMapping::from_toml_str(toml).expect("non-default-stream target is not an alias risk"); + } + + #[test] + fn new_rejects_empty_default_stream() { + // The bypass this closes: bridge::config's no-file path (and anyone else) used to be + // able to hand-build a TopicMapping struct literal, skipping every check from_toml_str + // ran. new() is now the only way in, TOML or not. + let err = TopicMapping::new(String::new(), HashMap::new()).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn new_rejects_a_padded_override_target_stream() { + let mut topics = HashMap::new(); + topics.insert( + "orders".to_string(), + TopicOverride { + stream: " billing".to_string(), + topic: "orders_v2".to_string(), + }, + ); + let err = TopicMapping::new("kafka".to_string(), topics).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_rejects_a_whitespace_padded_topic_mapping_key() { + // The one silent-failure case this whole module exists to close: without key + // validation, " orders " parses out of the TOML cleanly, then never matches + // resolve()'s exact HashMap::get for the real Kafka topic "orders" - it just silently + // falls through to the default stream instead of erroring. + // + // InvalidConfig, not InvalidKafkaTopicName: this is an operator's TOML typo, caught at + // config load, not a live Kafka client sending a malformed topic name - InvalidConfig + // wire-maps to -1 (the bridge's own fault), InvalidKafkaTopicName to 17 (the client's). + let toml = r#" + default_stream = "kafka" + + [topics." orders "] + stream = "billing" + topic = "orders_v2" + "#; + let err = TopicMapping::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_rejects_an_empty_topic_mapping_key() { + let toml = r#" + default_stream = "kafka" + + [topics.""] + stream = "billing" + topic = "orders_v2" + "#; + let err = TopicMapping::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_rejects_a_topic_mapping_key_over_kafkas_length_limit() { + let toml = format!( + "default_stream = \"kafka\"\n\n[topics.{:?}]\nstream = \"billing\"\ntopic = \"orders_v2\"\n", + "a".repeat(MAX_KAFKA_TOPIC_NAME_LEN + 1) + ); + let err = TopicMapping::from_toml_str(&toml).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn from_toml_str_rejects_a_topic_mapping_key_with_an_illegal_character() { + // Kafka's own legal-character set has no '/' in it - a topic named this could never be + // sent by a real Kafka client, so an override entry for it can never fire either. + let toml = r#" + default_stream = "kafka" + + [topics."orders/2024"] + stream = "billing" + topic = "orders_v2" + "#; + let err = TopicMapping::from_toml_str(toml).unwrap_err(); + assert!(matches!(err, BridgeError::InvalidConfig(_))); + } + + #[test] + fn validate_kafka_topic_name_rejects_dot_and_dotdot() { + // Kafka's own Topic.validate special-cases exactly these two names as invalid, even + // though '.' alone is a legal character in every other position. + let err = validate_kafka_topic_name("kafka_topic", ".").unwrap_err(); + assert!(matches!(err, BridgeError::InvalidKafkaTopicName { .. })); + let err = validate_kafka_topic_name("kafka_topic", "..").unwrap_err(); + assert!(matches!(err, BridgeError::InvalidKafkaTopicName { .. })); + // A real Kafka application name containing dots (not equal to "." or "..") stays legal. + validate_kafka_topic_name("kafka_topic", "org.apache.kafka.events").unwrap(); + } + + #[test] + fn validate_kafka_topic_name_accepts_every_legal_character_class() { + validate_kafka_topic_name("kafka_topic", "Order.Events_2024-v2").unwrap(); + } +} diff --git a/gateways/kafka/src/lib.rs b/gateways/kafka/src/lib.rs index ce77d86c25..5ccd935ae1 100644 --- a/gateways/kafka/src/lib.rs +++ b/gateways/kafka/src/lib.rs @@ -17,6 +17,7 @@ //! Kafka wire protocol gateway foundation for Apache Iggy. +pub mod bridge; pub mod error; pub mod protocol; pub mod server; diff --git a/gateways/kafka/src/main.rs b/gateways/kafka/src/main.rs index 9886d9fd53..7e01391fec 100644 --- a/gateways/kafka/src/main.rs +++ b/gateways/kafka/src/main.rs @@ -17,11 +17,14 @@ use std::fmt::Display; use std::str::FromStr; +use std::sync::Arc; use std::time::Duration; use tokio::signal; use tokio::sync::{Semaphore, broadcast}; +use tracing::{info, warn}; +use iggy_gateway_kafka::bridge::{IggyBridge, IggyBridgeConfig}; use iggy_gateway_kafka::server::{bind_listener, init_tracing}; use iggy_gateway_kafka::{GatewayConfig, KafkaGateway}; @@ -33,10 +36,11 @@ async fn main() -> Result<(), Box> { let _tracing_guard = init_tracing(); let config = load_config()?; + let bridge = connect_bridge().await?; let listener = bind_listener(&config.bind_addr) .map_err(|e| format!("failed to bind {}: {e}", config.bind_addr))?; - let server = KafkaGateway::new(config); + let server = KafkaGateway::new(config).with_bridge(bridge); let (tx, rx) = broadcast::channel(1); let mut server_task = tokio::spawn(async move { server.run(listener, rx).await }); @@ -54,11 +58,15 @@ async fn main() -> Result<(), Box> { Ok(()) } -/// The complete set of `IGGY_KAFKA_*` vars `load_config` reads. `IGGY_KAFKA_` is a -/// `DELEGATED_ENV_VAR_PREFIXES` entry in `core/configs` (see that file's comment), which trades -/// away the central provider's typo-detection for this whole namespace - a misspelled key here -/// would otherwise silently no-op instead of surfacing anywhere. `reject_unknown_kafka_env_vars` -/// is this crate's own replacement for that lost check. +/// The `IGGY_KAFKA_*` vars `load_config` itself reads. `IGGY_KAFKA_` is a +/// `SERVER_ALLOWED_ENV_PREFIXES` entry in `core/configs`, which trades away the server's +/// unknown-variable check for this whole namespace - a misspelled key here would otherwise +/// silently no-op instead of surfacing anywhere. `reject_unknown_kafka_env_vars` is this crate's +/// own replacement for that lost check. +/// +/// Deliberately excludes `IggyBridgeConfig::KNOWN_ENV_VARS`: `reject_unknown_kafka_env_vars` +/// checks both lists rather than one merged copy, so a bridge var rename can't silently desync +/// this array from the one `bridge::config` actually reads. const KNOWN_KAFKA_ENV_VARS: &[&str] = &[ "IGGY_KAFKA_BIND_ADDR", "IGGY_KAFKA_ADVERTISED_HOST", @@ -69,15 +77,22 @@ const KNOWN_KAFKA_ENV_VARS: &[&str] = &[ "IGGY_KAFKA_READ_TIMEOUT_SECS", "IGGY_KAFKA_WRITE_TIMEOUT_SECS", "IGGY_KAFKA_SHUTDOWN_DRAIN_TIMEOUT_SECS", + "IGGY_KAFKA_BRIDGE_ENABLED", ]; -/// Rejects any `IGGY_KAFKA_*` env var not in [`KNOWN_KAFKA_ENV_VARS`] - a typo (e.g. -/// `IGGY_KAFKA_BIN_ADDR`) would otherwise be silently ignored: `core/configs`' central provider -/// skips the whole `IGGY_KAFKA_` prefix, and this crate's own `env_var()` only ever looks up -/// exact known names, so nothing reads the misspelled var and nothing warns either. +/// Rejects any `IGGY_KAFKA_*` env var not in [`KNOWN_KAFKA_ENV_VARS`] or +/// [`IggyBridgeConfig::KNOWN_ENV_VARS`] - a typo (e.g. `IGGY_KAFKA_BIN_ADDR`) would otherwise be +/// silently ignored: the server's check in `core/configs` skips the whole `IGGY_KAFKA_` prefix, and +/// this crate's own `env_var()` only ever looks up exact known names, so nothing reads the +/// misspelled var and nothing warns either. Checking both lists is deliberate: a user who +/// exports a bridge var while `IGGY_KAFKA_BRIDGE_ENABLED` is off must not see a spurious +/// "unknown env var" rejection for a name this crate already recognizes. fn reject_unknown_kafka_env_vars() -> Result<(), String> { for (key, _) in std::env::vars() { - if key.starts_with("IGGY_KAFKA_") && !KNOWN_KAFKA_ENV_VARS.contains(&key.as_str()) { + if key.starts_with("IGGY_KAFKA_") + && !KNOWN_KAFKA_ENV_VARS.contains(&key.as_str()) + && !IggyBridgeConfig::KNOWN_ENV_VARS.contains(&key.as_str()) + { return Err(format!( "unknown Kafka gateway env var '{key}' (not in the recognized IGGY_KAFKA_* set - \ check for a typo; it would otherwise be silently ignored)" @@ -87,6 +102,46 @@ fn reject_unknown_kafka_env_vars() -> Result<(), String> { Ok(()) } +/// Connects the Iggy bridge when `IGGY_KAFKA_BRIDGE_ENABLED` is true, otherwise returns `None` +/// and every handler keeps answering with its stub. +/// +/// Off by default, so wiring one API at a time does not change what an operator sees. A failed +/// connect is fatal: an operator who turned the bridge on must learn it is unreachable at +/// startup, not from `NOT_LEADER_OR_FOLLOWER` on every produce. +async fn connect_bridge() -> Result>, String> { + if !bridge_enabled()? { + let set: Vec<&str> = IggyBridgeConfig::KNOWN_ENV_VARS + .iter() + .copied() + .filter(|var| std::env::var(var).is_ok()) + .collect(); + if !set.is_empty() { + warn!( + "{} set but IGGY_KAFKA_BRIDGE_ENABLED is not true: the gateway is answering with \ + stub responses and reads none of them", + set.join(", ") + ); + } + return Ok(None); + } + let config = IggyBridgeConfig::from_env().map_err(|e| format!("invalid bridge config: {e}"))?; + let bridge = IggyBridge::connect(config) + .await + .map_err(|e| format!("failed to connect the Iggy bridge: {e}"))?; + info!("Iggy bridge connected"); + Ok(Some(Arc::new(bridge))) +} + +/// `IGGY_KAFKA_BRIDGE_ENABLED`, rejecting anything that is neither `true` nor `false`. +/// +/// A typo otherwise reads as "off", which leaves stub responses and nothing in the log. +fn bridge_enabled() -> Result { + env_var("IGGY_KAFKA_BRIDGE_ENABLED").map_or(Ok(false), |raw| { + raw.parse() + .map_err(|e| format!("invalid IGGY_KAFKA_BRIDGE_ENABLED `{raw}`: {e}")) + }) +} + /// Build [`GatewayConfig`] from `IGGY_KAFKA_*` env vars, rejecting values that would silently /// break the listener (a zero connection cap serves nothing, a zero timeout drops every /// connection, a connection cap above `Semaphore::MAX_PERMITS` panics at startup). @@ -186,15 +241,26 @@ async fn shutdown_signal() { #[cfg(test)] mod tests { + use serial_test::serial; + use super::{parse_positive, reject_unknown_kafka_env_vars}; - /// Sequential (not two separate `#[test]` fns) so the two env-var mutations can't race - /// against each other under the test harness's default parallel execution - env vars are - /// process-global state. + /// Sequential (not two separate `#[test]` fns), and `#[serial]` (unkeyed - this binary's + /// default group). This is the only `#[serial]` test compiled into *this* binary + /// (`main.rs` -> the `iggy-gateway-kafka` bin's own test harness) - `bridge::config`'s and + /// `server`'s env-touching tests compile into the separate lib test binary, and + /// `serial_test`'s mutex is process-local, so it does not (and does not need to) coordinate + /// with either of those; `server.rs`'s own `#[serial]` test makes the mirror-image note. /// /// # Safety - /// Single-threaded within this function; no other test in this crate touches `IGGY_KAFKA_*`. + /// Edition 2024's `env::set_var`/`remove_var` are unsound against *any* concurrent env read + /// or write on another thread in this process, not merely one touching this same key - env + /// vars are process-wide C `environ` state, and the race is at that level. `#[serial]` is what + /// makes this sound, by excluding every other `#[serial]`-tagged test in this binary while + /// this one runs; being single-threaded within this function is necessary but not sufficient + /// on its own. #[test] + #[serial] fn reject_unknown_kafka_env_vars_flags_typo_but_accepts_known_keys() { unsafe { std::env::set_var("IGGY_KAFKA_BIN_ADDR", "127.0.0.1:9093"); // typo: missing D @@ -219,6 +285,20 @@ mod tests { known_result.is_ok(), "known IGGY_KAFKA_ var must be accepted" ); + + // A bridge-specific var (not in this module's own KNOWN_KAFKA_ENV_VARS) must also be + // accepted - the two lists are checked separately, not merged into one. + unsafe { + std::env::set_var("IGGY_KAFKA_IGGY_ADDR", "127.0.0.1:8090"); + } + let bridge_var_result = reject_unknown_kafka_env_vars(); + unsafe { + std::env::remove_var("IGGY_KAFKA_IGGY_ADDR"); + } + assert!( + bridge_var_result.is_ok(), + "known bridge IGGY_KAFKA_ var must be accepted" + ); } #[test] diff --git a/gateways/kafka/src/protocol/api.rs b/gateways/kafka/src/protocol/api.rs index 9f2b22ea81..adf815eeda 100644 --- a/gateways/kafka/src/protocol/api.rs +++ b/gateways/kafka/src/protocol/api.rs @@ -15,25 +15,13 @@ // specific language governing permissions and limitations // under the License. -use bytes::{Buf, Bytes}; -use kafka_protocol::messages::api_versions_response::ApiVersion; -use kafka_protocol::messages::metadata_response::{MetadataResponseBroker, MetadataResponseTopic}; -use kafka_protocol::messages::{ - ApiVersionsRequest, ApiVersionsResponse, BrokerId, CreateTopicsRequest, FetchRequest, - ListOffsetsRequest, MetadataRequest, MetadataResponse, ProduceRequest, TopicName, -}; -use kafka_protocol::protocol::{Decodable, StrBytes}; +use std::sync::Arc; -use crate::error::{KafkaProtocolError, Result}; -use crate::protocol::bounds_guard::{ - validate_api_versions_shape, validate_create_topics_shape, validate_fetch_shape, - validate_list_offsets_shape, validate_metadata_shape, validate_produce_shape, -}; -use crate::protocol::responses::{ - encode_create_topics_error_response, encode_create_topics_response, - encode_fetch_error_response, encode_fetch_response, encode_list_offsets_error_response, - encode_list_offsets_response, encode_message, encode_produce_error_response, - encode_produce_response, +use bytes::Bytes; + +use crate::bridge::IggyBridge; +use crate::protocol::handlers::{ + api_versions, create_topics, dispatch, fetch, list_offsets, metadata, produce, }; pub const API_KEY_PRODUCE: i16 = 0; @@ -45,11 +33,43 @@ pub const API_KEY_CREATE_TOPICS: i16 = 19; pub const DEFAULT_KAFKA_PORT: u16 = 9093; +/// Generic catch-all. Not sent by any stub response today; the `bridge` module's error mapping +/// uses it for an `IggyError` with no closer Kafka analogue. +pub const ERROR_UNKNOWN_SERVER_ERROR: i16 = -1; pub const ERROR_NONE: i16 = 0; pub const ERROR_UNKNOWN_TOPIC_OR_PARTITION: i16 = 3; /// Retriable; Produce stub uses this until the Iggy bridge persists records. pub const ERROR_NOT_LEADER_OR_FOLLOWER: i16 = 6; +/// `bridge`'s mapping for `IggyError::TransientNotCommitted`: the request's outcome is genuinely +/// unknown (neither confirmed applied nor confirmed rejected). +/// +/// Retriable in real Kafka too (`TimeoutException extends RetriableException`; the Java producer's +/// `Sender.canRetry` treats it the same as `NOT_LEADER_OR_FOLLOWER`) - this is not chosen to make +/// clients stop retrying. It is chosen because it is the code a real broker sends for the same +/// unknown-outcome shape (an ack that timed out with no confirmation either way), and Kafka has no +/// dedicated "outcome unknown, retry could duplicate" code. The duplicate-write risk on retry is +/// real regardless of which retriable code is sent; it closes only once `#3535` has an idempotent +/// produce path, not by picking a different error code here. +pub const ERROR_REQUEST_TIMED_OUT: i16 = 7; +/// `bridge`'s mapping for a Kafka-side topic name that fails Kafka's own naming rules. +/// +/// Empty, whitespace-padded, over 249 bytes, or outside `[A-Za-z0-9._-]`, checked before any Iggy +/// call is made - a real Kafka client library validates topic names client-side and would never +/// send one of these, but a raw/non-conformant client could. +pub const ERROR_INVALID_TOPIC_EXCEPTION: i16 = 17; +/// Closest fit for an Iggy permission/credential rejection in `bridge`'s error mapping. +/// +/// There is no bridge-side SASL exchange yet (`#3549`), so `SASL_AUTHENTICATION_FAILED` would +/// misstate the failure point. Not sent by any stub response today. +pub const ERROR_TOPIC_AUTHORIZATION_FAILED: i16 = 29; pub const ERROR_UNSUPPORTED_VERSION: i16 = 35; +/// `bridge`'s mapping for `BridgeError::PartitionCountMismatch`: the topic exists, just not with +/// the requested partition count. +/// +/// Not [`ERROR_INVALID_PARTITIONS`] - `kafka-protocol`'s own error table (`error.rs`) defines that +/// code's text as "Number of partitions is below 1", which is a different condition (a client +/// asking for zero/negative partitions) than "this topic already exists with a different count". +pub const ERROR_TOPIC_ALREADY_EXISTS: i16 = 36; pub const ERROR_INVALID_PARTITIONS: i16 = 37; pub const ERROR_INVALID_REPLICATION_FACTOR: i16 = 38; /// `CreateTopics` stub: do not claim topics were created (no controller / no Iggy bridge). @@ -116,36 +136,12 @@ pub struct ApiVersionRange { } static SUPPORTED_RANGES: &[ApiVersionRange] = &[ - ApiVersionRange { - api_key: API_KEY_PRODUCE, - min_version: 3, - max_version: 9, - }, - ApiVersionRange { - api_key: API_KEY_FETCH, - min_version: 4, - max_version: 12, - }, - ApiVersionRange { - api_key: API_KEY_LIST_OFFSETS, - min_version: 1, - max_version: 6, - }, - ApiVersionRange { - api_key: API_KEY_METADATA, - min_version: 0, - max_version: 9, - }, - ApiVersionRange { - api_key: API_KEY_API_VERSIONS, - min_version: 0, - max_version: 3, - }, - ApiVersionRange { - api_key: API_KEY_CREATE_TOPICS, - min_version: 2, - max_version: 5, - }, + produce::RANGE, + fetch::RANGE, + list_offsets::RANGE, + metadata::RANGE, + api_versions::RANGE, + create_topics::RANGE, ]; #[must_use] @@ -153,20 +149,56 @@ pub fn supported_api_ranges() -> &'static [ApiVersionRange] { SUPPORTED_RANGES } -/// Default `max_frame_size` used by [`handle_request`] - the ~150 direct call sites across this +/// Everything a handler needs that outlives one request. +/// +/// `bridge` is `None` until `IGGY_KAFKA_BRIDGE_ENABLED` turns it on. A handler that finds `None` +/// answers with its stub, so APIs can be wired one at a time. +/// +/// One `IggyBridge` is one `IggyClient` and its TCP transport is lockstep, so Kafka connections +/// serialize behind whichever Iggy request is in flight. The `Arc` does not change that. See the +/// README's "Concurrency ceiling". +pub struct GatewayState { + pub broker: BrokerAdvertise, + pub bridge: Option>, + pub max_frame_size: usize, +} + +impl GatewayState { + #[must_use] + pub const fn new( + broker: BrokerAdvertise, + bridge: Option>, + max_frame_size: usize, + ) -> Self { + Self { + broker, + bridge, + max_frame_size, + } + } + + /// State with no bridge, so every handler takes its stub path. + #[must_use] + pub const fn stub(broker: BrokerAdvertise, max_frame_size: usize) -> Self { + Self::new(broker, None, max_frame_size) + } +} + +/// Default `max_frame_size` used by [`handle_request`] - the direct call sites across this /// crate's test suite that don't care about the response-size guard specifically. Production /// traffic goes through [`handle_request_bounded`] instead (see `server.rs`'s call site), with /// the connection's actual configured `max_frame_size`. const DEFAULT_MAX_FRAME_SIZE: usize = 8 * 1024 * 1024; /// Handles one decoded request frame and returns how the connection should proceed. -pub fn handle_request( +pub async fn handle_request( api_key: i16, api_version: i16, body: Bytes, broker: &BrokerAdvertise, ) -> HandleOutcome { - handle_request_bounded(api_key, api_version, body, broker, DEFAULT_MAX_FRAME_SIZE) + let state = GatewayState::stub(broker.clone(), DEFAULT_MAX_FRAME_SIZE); + handle_request_bounded(&state, api_key, api_version, body).await } /// Same as [`handle_request`], but rejects a request whose declared array/string lengths project @@ -176,300 +208,13 @@ pub fn handle_request( /// docs for the CPU/memory amplification this closes (a request within the old element budget /// alone could still produce a multi-megabyte response from a single synchronous, non-yielding /// call). -pub fn handle_request_bounded( - api_key: i16, - api_version: i16, - body: Bytes, - broker: &BrokerAdvertise, - max_frame_size: usize, -) -> HandleOutcome { - if api_key == API_KEY_PRODUCE { - return handle_produce_request(api_version, body, max_frame_size); - } - handle_other_request(api_key, api_version, body, broker, max_frame_size) -} - -/// Decode `T` from the whole request body and reject unconsumed trailing bytes. -/// -/// `kafka_protocol`'s `Decodable` stops once it has read the fields its schema defines; it does -/// not know (or care) whether the caller handed it an exact-length body, so the trailing-bytes -/// check has to live here. -fn decode_exhaustive(version: i16, mut body: Bytes) -> Result { - let value = - T::decode(&mut body, version).map_err(|e| KafkaProtocolError::Malformed(e.to_string()))?; - if body.has_remaining() { - return Err(KafkaProtocolError::Malformed( - "unexpected trailing bytes in request body".to_string(), - )); - } - Ok(value) -} - -/// Runs `validate` (see [`crate::protocol::bounds_guard`]) before [`decode_exhaustive`]. -/// -/// `kafka_protocol` validates wire-declared array/string lengths as non-negative but never -/// against the bytes actually remaining in the frame, so a tiny frame declaring a huge count -/// drives an allocation attempt that aborts the whole process (`handle_alloc_error`, not a -/// panic - uncatchable, and it kills every connection, not just the offending one). `validate` -/// rejects that class of frame first, on a walk that never allocates a collection. -fn decode_guarded( - version: i16, - body: Bytes, - validate: impl FnOnce(i16, &Bytes) -> Result<()>, -) -> Result { - validate(version, &body)?; - decode_exhaustive(version, body) -} - -/// Turn an encode [`Result`] into a [`HandleOutcome`], closing the connection when encoding -/// fails rather than propagating - there is no parseable response to send in that case. -fn respond_or_close(result: Result, api_name: &str) -> HandleOutcome { - match result { - Ok(body) => HandleOutcome::Respond(body), - Err(error) => { - tracing::warn!(%error, "failed to encode {api_name} response; closing connection"); - HandleOutcome::Close - } - } -} - -/// Produce is the only request the wire protocol allows to go unanswered -/// (`acks=0`), so it gets its own path that may return [`HandleOutcome::NoResponse`]. -/// -/// The firewall check runs AFTER decoding the request, not before: `ApiVersions` advertises -/// Produce min=0 (see [`advertised_min_version`]) while the firewall's real floor is 3, so a -/// spec-compliant client can legitimately send Produce v0-2 with `acks=0`. Rejecting those -/// versions before reading `acks` would send an error response the client never expects, -/// desyncing the next correlation id it reads. -fn handle_produce_request(api_version: i16, body: Bytes, max_frame_size: usize) -> HandleOutcome { - // Above the encoder max there is no response parseable at the client's version, so close - // rather than decode (same policy the other APIs apply). Fail-closed on a missing row - // (i16::MIN, not i16::MAX): `handle_request` dispatches Produce on a hard-coded `api_key ==` - // check, not from this table, so if a future edit ever drops the Produce row to disable the - // API, a fail-open default here would leave Produce v0-2 acks=0 silently accepted on an API - // the operator believes is off - unwrap_or(i16::MAX) is not the sound default the sibling - // lookup at `unsupported_version_response` uses (`map_or(0, ...)`, i.e. fail-closed). - if api_version > supported_max_version(API_KEY_PRODUCE).unwrap_or(i16::MIN) { - return HandleOutcome::Close; - } - // `kafka_protocol`'s ProduceRequest/ProduceResponse schemas only go back to v3, so v0-2 - // (still advertised as the min in ApiVersions per KAFKA-18659) can be neither decoded nor - // encoded by the crate - there is no parseable response at these versions regardless of - // body content. `acks` is always the first i16 on the wire there (`transactional_id` was - // added in v3), so it's peeked by hand: acks=0 must keep the connection open per the wire - // protocol's fire-and-forget rule even though no response can ever be encoded for it. - if api_version < 3 { - let acks = match body.get(0..2) { - Some(&[hi, lo]) => Some(i16::from_be_bytes([hi, lo])), - _ => None, - }; - return match acks { - Some(0) | None => HandleOutcome::NoResponse, - Some(_) => unsupported_version_response(API_KEY_PRODUCE, api_version, |v| { - encode_produce_error_response(v, ERROR_UNSUPPORTED_VERSION) - }), - }; - } - match decode_guarded::(api_version, body, |v, b| { - validate_produce_shape(v, b, max_frame_size) - }) { - // acks=0 is fire-and-forget: the client isn't reading a response, so - // sending one desyncs the next correlation id it expects. - Ok(req) if req.acks == 0 => HandleOutcome::NoResponse, - // `api_version` is always in `[3, supported_max]` here: the `< 3` case returned above, - // and the `> max` case returned at the top of this function - so it is always within - // `SUPPORTED_RANGES`' Produce row and an `is_supported_version` re-check can never fail. - Ok(req) => respond_or_close(encode_produce_response(api_version, &req), "Produce"), - Err(error) => { - // `kafka_protocol` decodes the whole request in one shot; a failure anywhere gives - // no partial-field access, so `acks` is unknowable here (unlike the pre-migration - // field-by-field decoder, which could still know `acks` on a later-field failure). - // Responding risks desyncing an acks=0 fire-and-forget client's correlation stream, - // so every Produce decode failure now stays silent - a behavior change from the - // hand-rolled decoder, which answered with INVALID_REQUEST when `acks` was known and - // nonzero. - // debug!, not warn!: the body is attacker-controlled, not operator-actionable, and - // a client looping malformed bodies on one connection (never disconnected - this - // arm returns NoResponse) has no rate limit here. - tracing::debug!(%error, "failed to decode Produce request (no response)"); - HandleOutcome::NoResponse - } - } -} - -fn handle_other_request( - api_key: i16, - api_version: i16, - body: Bytes, - broker: &BrokerAdvertise, - max_frame_size: usize, -) -> HandleOutcome { - match api_key { - API_KEY_API_VERSIONS => handle_api_versions(api_version, body), - API_KEY_METADATA => handle_metadata(api_version, body, broker, max_frame_size), - API_KEY_FETCH => handle_versioned_request( - API_KEY_FETCH, - api_version, - body, - |v, b| { - decode_guarded::(v, b, |v, b| { - validate_fetch_shape(v, b, max_frame_size) - }) - }, - encode_fetch_response, - encode_fetch_error_response, - "Fetch", - ), - API_KEY_LIST_OFFSETS => handle_versioned_request( - API_KEY_LIST_OFFSETS, - api_version, - body, - |v, b| { - decode_guarded::(v, b, |v, b| { - validate_list_offsets_shape(v, b, max_frame_size) - }) - }, - encode_list_offsets_response, - encode_list_offsets_error_response, - "ListOffsets", - ), - API_KEY_CREATE_TOPICS => handle_versioned_request( - API_KEY_CREATE_TOPICS, - api_version, - body, - |v, b| { - decode_guarded::(v, b, |v, b| { - validate_create_topics_shape(v, b, max_frame_size) - }) - }, - encode_create_topics_response, - encode_create_topics_error_response, - "CreateTopics", - ), - // Unknown API key: no api-specific response schema exists, so any body we send is - // misparsed by the client against the schema it expected. Close is unambiguous. - _ => HandleOutcome::Close, - } -} - -fn handle_api_versions(api_version: i16, body: Bytes) -> HandleOutcome { - if !is_supported_version(API_KEY_API_VERSIONS, api_version) { - // KIP-511: reply with v0 when the requested version is not understood. - return respond_or_close( - encode_api_versions_response(0, ERROR_UNSUPPORTED_VERSION), - "ApiVersions", - ); - } - match decode_guarded::(api_version, body, validate_api_versions_shape) { - Ok(_) => respond_or_close( - encode_api_versions_response(api_version, ERROR_NONE), - "ApiVersions", - ), - Err(error) => { - // debug!, not warn!: attacker-controlled, not operator-actionable (see the same - // note on the Produce decode-failure arm above). - tracing::debug!(%error, "failed to decode ApiVersions request"); - respond_or_close( - encode_api_versions_response(api_version, ERROR_INVALID_REQUEST), - "ApiVersions", - ) - } - } -} - -fn handle_metadata( - api_version: i16, - body: Bytes, - broker: &BrokerAdvertise, - max_frame_size: usize, -) -> HandleOutcome { - if !is_supported_version(API_KEY_METADATA, api_version) { - // Clamping the response to the supported max leaves a body the client parses at its own - // (unsupported) version, so UNSUPPORTED_VERSION never survives. Clients that skip - // ApiVersions get a naked close instead. - tracing::warn!( - api_version, - max_supported = supported_max_version(API_KEY_METADATA), - "Metadata version unsupported; closing connection" - ); - return HandleOutcome::Close; - } - match decode_metadata_topics(api_version, body, max_frame_size) { - Ok(topics) => respond_or_close( - encode_metadata_response(api_version, &topics, broker, ERROR_NONE), - "Metadata", - ), - Err(error) => { - // Metadata has no top-level error field; a malformed body cannot carry - // INVALID_REQUEST in a version-correct way for every client. Close. - // debug!, not warn!: attacker-controlled, not operator-actionable. - tracing::debug!( - %error, - api_version, - "Failed to decode Metadata request; closing connection" - ); - HandleOutcome::Close - } - } -} - -fn handle_versioned_request( +pub async fn handle_request_bounded( + state: &GatewayState, api_key: i16, api_version: i16, body: Bytes, - decode: impl FnOnce(i16, Bytes) -> Result, - encode_ok: impl FnOnce(i16, &T) -> Result, - encode_err: impl Fn(i16, i16) -> Result, - api_name: &str, ) -> HandleOutcome { - if is_supported_version(api_key, api_version) { - match decode(api_version, body) { - Ok(req) => respond_or_close(encode_ok(api_version, &req), api_name), - Err(error) => { - // debug!, not warn!: attacker-controlled, not operator-actionable. - tracing::debug!(%error, "Failed to decode {api_name} request"); - respond_or_close(encode_err(api_version, ERROR_INVALID_REQUEST), api_name) - } - } - } else { - unsupported_version_response(api_key, api_version, |version| { - encode_err(version, ERROR_UNSUPPORTED_VERSION) - }) - } -} - -/// Unsupported-version policy for APIs whose encoders only implement up to -/// [`ApiVersionRange::max_version`]. -/// -/// - `api_version > max`: Close. `SUPPORTED_RANGES` is the governance boundary, not just an -/// encoding-capability limit - `kafka_protocol` can often encode versions above our firewall -/// max just fine, but responding there would silently widen what this gateway accepts. -/// - `api_version < min`: Respond with an error shaped for that version when `kafka_protocol` -/// can encode it, otherwise `encode` fails and [`respond_or_close`] closes instead. In -/// practice every `SUPPORTED_RANGES` min was chosen at or above the oldest version -/// `kafka_protocol` implements for that message, so this always closes today (e.g. -/// `ListOffsets` v0's legacy `old_style_offsets` shape predates the crate's schema) - kept -/// generic rather than hard-coded so a future `kafka_protocol` upgrade that widens a schema's -/// floor is picked up automatically instead of silently staying on `Close`. -fn unsupported_version_response( - api_key: i16, - api_version: i16, - encode: impl FnOnce(i16) -> Result, -) -> HandleOutcome { - let max_version = SUPPORTED_RANGES - .iter() - .find(|r| r.api_key == api_key) - .map_or(0, |r| r.max_version); - if api_version > max_version { - tracing::warn!( - api_key, - api_version, - max_version, - "request version above encoder max; closing connection" - ); - return HandleOutcome::Close; - } - respond_or_close(encode(api_version), "unsupported-version") + dispatch(state, api_key, api_version, body).await } #[must_use] @@ -501,137 +246,3 @@ pub const fn advertised_min_version(api_key: i16, firewall_min: i16) -> i16 { firewall_min } } - -fn encode_api_versions_response(api_version: i16, error_code: i16) -> Result { - let api_keys = SUPPORTED_RANGES - .iter() - .map(|r| { - ApiVersion::default() - .with_api_key(r.api_key) - .with_min_version(advertised_min_version(r.api_key, r.min_version)) - .with_max_version(r.max_version) - }) - .collect(); - let resp = ApiVersionsResponse::default() - .with_error_code(error_code) - .with_api_keys(api_keys); - encode_message(&resp, api_version, 128) -} - -fn encode_metadata_response( - response_version: i16, - topics: &[StrBytes], - broker: &BrokerAdvertise, - topic_error_override: i16, -) -> Result { - // Stub has no topic catalog: echo requested names with UNKNOWN_TOPIC_OR_PARTITION, - // or a forced override (unused today; kept for symmetry with other encoders). - let topic_error = if topic_error_override == ERROR_NONE { - ERROR_UNKNOWN_TOPIC_OR_PARTITION - } else { - topic_error_override - }; - - let response_topics = topics - .iter() - .map(|name| { - MetadataResponseTopic::default() - .with_error_code(topic_error) - .with_name(Some(TopicName(name.clone()))) - }) - .collect(); - - let broker_entry = MetadataResponseBroker::default() - .with_node_id(BrokerId(1)) - .with_host(StrBytes::from_string(broker.host.clone())) - .with_port(broker.port); - - let resp = MetadataResponse::default() - .with_brokers(vec![broker_entry]) - .with_controller_id(BrokerId(1)) - .with_topics(response_topics); - - encode_message(&resp, response_version, 256) -} - -/// Decodes a Metadata request body so the response can echo topic names. -/// -/// A null topics array (`-1` legacy / `varint=0` compact) means "all topics" and decodes to an -/// empty list for this stub. A null per-topic `name` (v10+ allows topic-id-only lookups) has no -/// name to echo, so it errors rather than silently dropping the topic from the response. -fn decode_metadata_topics( - api_version: i16, - body: Bytes, - max_frame_size: usize, -) -> Result> { - let req = decode_guarded::(api_version, body, |v, b| { - validate_metadata_shape(v, b, max_frame_size) - })?; - req.topics - .unwrap_or_default() - .into_iter() - .map(|topic| { - topic - .name - .map(|name| name.0) - .ok_or(KafkaProtocolError::NullTopicName) - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - const TEST_MAX_FRAME_SIZE: usize = 8 * 1024 * 1024; - - #[test] - fn decode_metadata_topics_legacy_null_topic_name_fails() { - let body = Bytes::from_static(&[ - 0x00, 0x00, 0x00, 0x01, // one topic - 0xff, 0xff, // null topic name - ]); - let err = decode_metadata_topics(0, body, TEST_MAX_FRAME_SIZE).unwrap_err(); - assert!(matches!(err, KafkaProtocolError::NullTopicName)); - } - - #[test] - fn decode_metadata_topics_legacy_null_array_means_all_topics() { - // -1 is the spec-defined "all topics" sentinel for the legacy i32 array count, not a - // malformed request - must decode to an empty list. - let body = Bytes::from_static(&[0xff, 0xff, 0xff, 0xff]); // -1 - let topics = decode_metadata_topics(0, body, TEST_MAX_FRAME_SIZE).unwrap(); - assert!(topics.is_empty()); - } - - #[test] - fn decode_metadata_topics_empty_body_is_malformed() { - assert!(decode_metadata_topics(0, Bytes::new(), TEST_MAX_FRAME_SIZE).is_err()); - } - - #[test] - fn decode_metadata_topics_flexible_truncated_after_topics_fails() { - // topics = null (all topics) but missing allow_auto / auth flags / tagged fields. - let body = Bytes::from_static(&[0x00]); - assert!(decode_metadata_topics(9, body, TEST_MAX_FRAME_SIZE).is_err()); - } - - #[test] - fn decode_api_versions_v3_requires_software_fields() { - assert!(decode_exhaustive::(3, Bytes::new()).is_err()); - } - - #[test] - fn decode_api_versions_v3_accepts_valid_body() { - // Hand-encoded rather than round-tripped through `ApiVersionsRequest::encode`: encoding - // is gated behind the crate's "client" feature, which this broker-only binary doesn't - // enable. - let body = Bytes::from_static(&[ - 0x0a, b'i', b'g', b'g', b'y', b'-', b't', b'e', b's', - b't', // compact string (len 9) - 0x06, b'0', b'.', b'1', b'.', b'0', // compact string (len 5) - 0x00, // empty tagged fields - ]); - decode_exhaustive::(3, body).unwrap(); - } -} diff --git a/gateways/kafka/src/protocol/handlers/api_versions.rs b/gateways/kafka/src/protocol/handlers/api_versions.rs new file mode 100644 index 0000000000..9f4ef87954 --- /dev/null +++ b/gateways/kafka/src/protocol/handlers/api_versions.rs @@ -0,0 +1,107 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! `ApiVersions` (API key 18). + +use bytes::Bytes; +use kafka_protocol::messages::api_versions_response::ApiVersion; +use kafka_protocol::messages::{ApiVersionsRequest, ApiVersionsResponse}; + +use crate::error::Result; +use crate::protocol::api::{ + API_KEY_API_VERSIONS, ApiVersionRange, ERROR_INVALID_REQUEST, ERROR_NONE, + ERROR_UNSUPPORTED_VERSION, GatewayState, HandleOutcome, advertised_min_version, + is_supported_version, supported_api_ranges, +}; +use crate::protocol::bounds_guard::validate_api_versions_shape; +use crate::protocol::handlers::{decode_guarded, encode_message, respond_or_close}; + +pub const RANGE: ApiVersionRange = ApiVersionRange { + api_key: API_KEY_API_VERSIONS, + min_version: 0, + max_version: 3, +}; + +#[expect( + clippy::unused_async, + reason = "the shared handler signature, kept until a handler awaits the bridge" +)] +pub async fn handle(_state: &GatewayState, api_version: i16, body: Bytes) -> HandleOutcome { + if !is_supported_version(API_KEY_API_VERSIONS, api_version) { + // KIP-511: reply with v0 when the requested version is not understood. + return respond_or_close(encode_response(0, ERROR_UNSUPPORTED_VERSION), "ApiVersions"); + } + match decode_guarded::(api_version, body, validate_api_versions_shape) { + Ok(_) => respond_or_close(encode_response(api_version, ERROR_NONE), "ApiVersions"), + Err(error) => { + // debug!, not warn!: attacker-controlled, not operator-actionable (see the same + // note on the Produce decode-failure arm). + tracing::debug!(%error, "failed to decode ApiVersions request"); + respond_or_close( + encode_response(api_version, ERROR_INVALID_REQUEST), + "ApiVersions", + ) + } + } +} + +/// Advertise exactly what the firewall allows, so a client never negotiates a version this +/// gateway then refuses. +/// +/// # Errors +/// +/// Returns an error when `kafka_protocol` cannot encode the response at `api_version`. +pub fn encode_response(api_version: i16, error_code: i16) -> Result { + let api_keys = supported_api_ranges() + .iter() + .map(|r| { + ApiVersion::default() + .with_api_key(r.api_key) + .with_min_version(advertised_min_version(r.api_key, r.min_version)) + .with_max_version(r.max_version) + }) + .collect(); + let resp = ApiVersionsResponse::default() + .with_error_code(error_code) + .with_api_keys(api_keys); + encode_message(&resp, api_version, 128) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::handlers::decode_exhaustive; + + #[test] + fn decode_v3_requires_software_fields() { + assert!(decode_exhaustive::(3, Bytes::new()).is_err()); + } + + #[test] + fn decode_v3_accepts_valid_body() { + // Hand-encoded rather than round-tripped through `ApiVersionsRequest::encode`: encoding + // is gated behind the crate's "client" feature, which this broker-only binary doesn't + // enable. + let body = Bytes::from_static(&[ + 0x0a, b'i', b'g', b'g', b'y', b'-', b't', b'e', b's', + b't', // compact string (len 9) + 0x06, b'0', b'.', b'1', b'.', b'0', // compact string (len 5) + 0x00, // empty tagged fields + ]); + decode_exhaustive::(3, body).unwrap(); + } +} diff --git a/gateways/kafka/src/protocol/handlers/create_topics.rs b/gateways/kafka/src/protocol/handlers/create_topics.rs new file mode 100644 index 0000000000..19d763476d --- /dev/null +++ b/gateways/kafka/src/protocol/handlers/create_topics.rs @@ -0,0 +1,130 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! `CreateTopics` (API key 19). + +use bytes::Bytes; +use kafka_protocol::messages::create_topics_request::CreatableTopic; +use kafka_protocol::messages::create_topics_response::CreatableTopicResult; +use kafka_protocol::messages::{CreateTopicsRequest, CreateTopicsResponse}; + +use crate::error::Result; +use crate::protocol::api::{ + API_KEY_CREATE_TOPICS, ApiVersionRange, ERROR_INVALID_PARTITIONS, + ERROR_INVALID_REPLICATION_FACTOR, ERROR_NONE, ERROR_NOT_CONTROLLER, GatewayState, + HandleOutcome, +}; +use crate::protocol::bounds_guard::validate_create_topics_shape; +use crate::protocol::handlers::{decode_guarded, encode_message, handle_versioned_request}; + +pub const RANGE: ApiVersionRange = ApiVersionRange { + api_key: API_KEY_CREATE_TOPICS, + min_version: 2, + max_version: 5, +}; + +#[expect( + clippy::unused_async, + reason = "the shared handler signature, kept until a handler awaits the bridge" +)] +pub async fn handle(state: &GatewayState, api_version: i16, body: Bytes) -> HandleOutcome { + handle_versioned_request( + API_KEY_CREATE_TOPICS, + api_version, + body, + |v, b| { + decode_guarded::(v, b, |v, b| { + validate_create_topics_shape(v, b, state.max_frame_size) + }) + }, + encode_response, + encode_error_response, + "CreateTopics", + ) +} + +/// Well-formed `CreateTopics` response with a single placeholder topic. +/// +/// # Errors +/// +/// Returns an error when `kafka_protocol` cannot encode the response at `version`. +pub fn encode_error_response(version: i16, error_code: i16) -> Result { + let topics = vec![ + CreatableTopic::default() + .with_num_partitions(1) + .with_replication_factor(1), + ]; + encode_inner(version, &topics, error_code) +} + +/// # Errors +/// +/// Returns an error when `kafka_protocol` cannot encode the response at `version`. +pub fn encode_response(version: i16, req: &CreateTopicsRequest) -> Result { + encode_inner(version, &req.topics, ERROR_NONE) +} + +/// Resolve per-topic `CreateTopics` error. +/// +/// KIP-464: `num_partitions = -1` / `replication_factor = -1` mean broker default when either +/// (a) the version is v4+, or (b) the topic carries a manual partition assignment (valid on +/// v2/v3 as well). Otherwise non-positive values are [`ERROR_INVALID_PARTITIONS`] / +/// [`ERROR_INVALID_REPLICATION_FACTOR`]. When validation passes, the stub returns +/// [`ERROR_NOT_CONTROLLER`] so clients do not believe the topic was created. +const fn topic_error(version: i16, topic: &CreatableTopic, forced_error: i16) -> i16 { + if forced_error != ERROR_NONE { + return forced_error; + } + + let broker_default_ok = version >= 4 || !topic.assignments.is_empty(); + + let partitions_ok = if broker_default_ok { + topic.num_partitions == -1 || topic.num_partitions > 0 + } else { + topic.num_partitions > 0 + }; + if !partitions_ok { + return ERROR_INVALID_PARTITIONS; + } + + let replication_ok = if broker_default_ok { + topic.replication_factor == -1 || topic.replication_factor > 0 + } else { + topic.replication_factor > 0 + }; + if !replication_ok { + return ERROR_INVALID_REPLICATION_FACTOR; + } + + ERROR_NOT_CONTROLLER +} + +fn encode_inner(version: i16, topics: &[CreatableTopic], forced_error: i16) -> Result { + let results = topics + .iter() + .map(|topic| { + CreatableTopicResult::default() + .with_name(topic.name.clone()) + .with_error_code(topic_error(version, topic, forced_error)) + .with_error_message(None) + .with_num_partitions(topic.num_partitions) + .with_replication_factor(topic.replication_factor) + }) + .collect(); + let resp = CreateTopicsResponse::default().with_topics(results); + encode_message(&resp, version, 256) +} diff --git a/gateways/kafka/src/protocol/handlers/fetch.rs b/gateways/kafka/src/protocol/handlers/fetch.rs new file mode 100644 index 0000000000..c1e5e685f6 --- /dev/null +++ b/gateways/kafka/src/protocol/handlers/fetch.rs @@ -0,0 +1,118 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Fetch (API key 1). + +use bytes::Bytes; +use kafka_protocol::messages::fetch_response::{FetchableTopicResponse, PartitionData}; +use kafka_protocol::messages::{FetchRequest, FetchResponse}; + +use crate::error::Result; +use crate::protocol::api::{ + API_KEY_FETCH, ApiVersionRange, ERROR_NONE, ERROR_NOT_LEADER_OR_FOLLOWER, GatewayState, + HandleOutcome, +}; +use crate::protocol::bounds_guard::validate_fetch_shape; +use crate::protocol::handlers::{decode_guarded, encode_message, handle_versioned_request}; + +pub const RANGE: ApiVersionRange = ApiVersionRange { + api_key: API_KEY_FETCH, + min_version: 4, + max_version: 12, +}; + +#[expect( + clippy::unused_async, + reason = "the shared handler signature, kept until a handler awaits the bridge" +)] +pub async fn handle(state: &GatewayState, api_version: i16, body: Bytes) -> HandleOutcome { + handle_versioned_request( + API_KEY_FETCH, + api_version, + body, + |v, b| { + decode_guarded::(v, b, |v, b| { + validate_fetch_shape(v, b, state.max_frame_size) + }) + }, + encode_response, + encode_error_response, + "Fetch", + ) +} + +/// Well-formed Fetch response. Uses top-level `error_code` at v7+, or a single +/// placeholder topic/partition with per-partition `error_code` below v7. +/// +/// # Errors +/// +/// Returns an error when `kafka_protocol` cannot encode the response at `version`. +pub fn encode_error_response(version: i16, error_code: i16) -> Result { + if version >= 7 { + return encode_inner(version, Vec::new(), error_code); + } + // No top-level error field below v7; the error surfaces on the placeholder partition instead. + let topics = vec![ + FetchableTopicResponse::default().with_partitions(vec![partition_response(0, error_code)]), + ]; + encode_inner(version, topics, ERROR_NONE) +} + +/// Stub: discard the payload and return a retriable error so clients don't mistake "no real +/// data yet" for a genuinely empty partition (same philosophy as Produce). +/// +/// # Errors +/// +/// Returns an error when `kafka_protocol` cannot encode the response at `version`. +pub fn encode_response(version: i16, req: &FetchRequest) -> Result { + let topics = req + .topics + .iter() + .map(|topic| { + FetchableTopicResponse::default() + .with_topic(topic.topic.clone()) + .with_partitions( + topic + .partitions + .iter() + .map(|p| partition_response(p.partition, ERROR_NOT_LEADER_OR_FOLLOWER)) + .collect(), + ) + }) + .collect(); + encode_inner(version, topics, ERROR_NONE) +} + +fn encode_inner( + version: i16, + topics: Vec, + top_level_error: i16, +) -> Result { + let resp = FetchResponse::default() + .with_error_code(top_level_error) + .with_responses(topics); + encode_message(&resp, version, 512) +} + +fn partition_response(partition: i32, error_code: i16) -> PartitionData { + PartitionData::default() + .with_partition_index(partition) + .with_error_code(error_code) + .with_last_stable_offset(0) + .with_log_start_offset(0) + .with_records(None) +} diff --git a/gateways/kafka/src/protocol/handlers/list_offsets.rs b/gateways/kafka/src/protocol/handlers/list_offsets.rs new file mode 100644 index 0000000000..acc29a3127 --- /dev/null +++ b/gateways/kafka/src/protocol/handlers/list_offsets.rs @@ -0,0 +1,115 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! `ListOffsets` (API key 2). + +use bytes::Bytes; +use kafka_protocol::messages::list_offsets_response::{ + ListOffsetsPartitionResponse, ListOffsetsTopicResponse, +}; +use kafka_protocol::messages::{ListOffsetsRequest, ListOffsetsResponse}; + +use crate::error::Result; +use crate::protocol::api::{ + API_KEY_LIST_OFFSETS, ApiVersionRange, ERROR_NOT_LEADER_OR_FOLLOWER, GatewayState, + HandleOutcome, +}; +use crate::protocol::bounds_guard::validate_list_offsets_shape; +use crate::protocol::handlers::{decode_guarded, encode_message, handle_versioned_request}; + +pub const RANGE: ApiVersionRange = ApiVersionRange { + api_key: API_KEY_LIST_OFFSETS, + min_version: 1, + max_version: 6, +}; + +#[expect( + clippy::unused_async, + reason = "the shared handler signature, kept until a handler awaits the bridge" +)] +pub async fn handle(state: &GatewayState, api_version: i16, body: Bytes) -> HandleOutcome { + handle_versioned_request( + API_KEY_LIST_OFFSETS, + api_version, + body, + |v, b| { + decode_guarded::(v, b, |v, b| { + validate_list_offsets_shape(v, b, state.max_frame_size) + }) + }, + encode_response, + encode_error_response, + "ListOffsets", + ) +} + +/// Well-formed `ListOffsets` response with a single placeholder topic/partition. +/// +/// `kafka_protocol` has no encodable representation for `ListOffsets` v0 (the legacy +/// `old_style_offsets` shape predates the schema this crate generates from); a v0 request now +/// falls through `super::unsupported_version_response`'s encode-failure path to `Close` +/// instead of the pre-migration downgraded response. +/// +/// # Errors +/// +/// Returns an error when `kafka_protocol` cannot encode the response at `version` (always the +/// case for `version == 0`). +pub fn encode_error_response(version: i16, error_code: i16) -> Result { + let topics = vec![ + ListOffsetsTopicResponse::default() + .with_partitions(vec![partition_response(0, error_code)]), + ]; + encode_inner(version, topics) +} + +/// Stub: discard the payload and return a retriable error, matching Produce/Fetch - a genuine +/// offset lookup requires the same partition-leadership the stub doesn't have yet. +/// +/// # Errors +/// +/// Returns an error when `kafka_protocol` cannot encode the response at `version`. +pub fn encode_response(version: i16, req: &ListOffsetsRequest) -> Result { + let topics = req + .topics + .iter() + .map(|topic| { + ListOffsetsTopicResponse::default() + .with_name(topic.name.clone()) + .with_partitions( + topic + .partitions + .iter() + .map(|p| { + partition_response(p.partition_index, ERROR_NOT_LEADER_OR_FOLLOWER) + }) + .collect(), + ) + }) + .collect(); + encode_inner(version, topics) +} + +fn encode_inner(version: i16, topics: Vec) -> Result { + let resp = ListOffsetsResponse::default().with_topics(topics); + encode_message(&resp, version, 256) +} + +fn partition_response(partition: i32, error_code: i16) -> ListOffsetsPartitionResponse { + ListOffsetsPartitionResponse::default() + .with_partition_index(partition) + .with_error_code(error_code) +} diff --git a/gateways/kafka/src/protocol/handlers/metadata.rs b/gateways/kafka/src/protocol/handlers/metadata.rs new file mode 100644 index 0000000000..7a4cc29a98 --- /dev/null +++ b/gateways/kafka/src/protocol/handlers/metadata.rs @@ -0,0 +1,171 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Metadata (API key 3). + +use bytes::Bytes; +use kafka_protocol::messages::metadata_response::{MetadataResponseBroker, MetadataResponseTopic}; +use kafka_protocol::messages::{BrokerId, MetadataRequest, MetadataResponse, TopicName}; +use kafka_protocol::protocol::StrBytes; + +use crate::error::{KafkaProtocolError, Result}; +use crate::protocol::api::{ + API_KEY_METADATA, ApiVersionRange, BrokerAdvertise, ERROR_NONE, + ERROR_UNKNOWN_TOPIC_OR_PARTITION, GatewayState, HandleOutcome, is_supported_version, + supported_max_version, +}; +use crate::protocol::bounds_guard::validate_metadata_shape; +use crate::protocol::handlers::{decode_guarded, encode_message, respond_or_close}; + +pub const RANGE: ApiVersionRange = ApiVersionRange { + api_key: API_KEY_METADATA, + min_version: 0, + max_version: 9, +}; + +#[expect( + clippy::unused_async, + reason = "the shared handler signature, kept until a handler awaits the bridge" +)] +pub async fn handle(state: &GatewayState, api_version: i16, body: Bytes) -> HandleOutcome { + if !is_supported_version(API_KEY_METADATA, api_version) { + // Clamping the response to the supported max leaves a body the client parses at its own + // (unsupported) version, so UNSUPPORTED_VERSION never survives. Clients that skip + // ApiVersions get a naked close instead. + tracing::warn!( + api_version, + max_supported = supported_max_version(API_KEY_METADATA), + "Metadata version unsupported; closing connection" + ); + return HandleOutcome::Close; + } + match decode_topics(api_version, body, state.max_frame_size) { + Ok(topics) => respond_or_close( + encode_response(api_version, &topics, &state.broker, ERROR_NONE), + "Metadata", + ), + Err(error) => { + // Metadata has no top-level error field; a malformed body cannot carry + // INVALID_REQUEST in a version-correct way for every client. Close. + // debug!, not warn!: attacker-controlled, not operator-actionable. + tracing::debug!( + %error, + api_version, + "Failed to decode Metadata request; closing connection" + ); + HandleOutcome::Close + } + } +} + +/// # Errors +/// +/// Returns an error when `kafka_protocol` cannot encode the response at `response_version`. +pub fn encode_response( + response_version: i16, + topics: &[StrBytes], + broker: &BrokerAdvertise, + topic_error_override: i16, +) -> Result { + // Stub has no topic catalog: echo requested names with UNKNOWN_TOPIC_OR_PARTITION, + // or a forced override (unused today; kept for symmetry with other encoders). + let topic_error = if topic_error_override == ERROR_NONE { + ERROR_UNKNOWN_TOPIC_OR_PARTITION + } else { + topic_error_override + }; + + let response_topics = topics + .iter() + .map(|name| { + MetadataResponseTopic::default() + .with_error_code(topic_error) + .with_name(Some(TopicName(name.clone()))) + }) + .collect(); + + let broker_entry = MetadataResponseBroker::default() + .with_node_id(BrokerId(1)) + .with_host(StrBytes::from_string(broker.host.clone())) + .with_port(broker.port); + + let resp = MetadataResponse::default() + .with_brokers(vec![broker_entry]) + .with_controller_id(BrokerId(1)) + .with_topics(response_topics); + + encode_message(&resp, response_version, 256) +} + +/// Decodes a Metadata request body so the response can echo topic names. +/// +/// A null topics array (`-1` legacy / `varint=0` compact) means "all topics" and decodes to an +/// empty list for this stub. A null per-topic `name` (v10+ allows topic-id-only lookups) has no +/// name to echo, so it errors rather than silently dropping the topic from the response. +fn decode_topics(api_version: i16, body: Bytes, max_frame_size: usize) -> Result> { + let req = decode_guarded::(api_version, body, |v, b| { + validate_metadata_shape(v, b, max_frame_size) + })?; + req.topics + .unwrap_or_default() + .into_iter() + .map(|topic| { + topic + .name + .map(|name| name.0) + .ok_or(KafkaProtocolError::NullTopicName) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_MAX_FRAME_SIZE: usize = 8 * 1024 * 1024; + + #[test] + fn decode_topics_legacy_null_topic_name_fails() { + let body = Bytes::from_static(&[ + 0x00, 0x00, 0x00, 0x01, // one topic + 0xff, 0xff, // null topic name + ]); + let err = decode_topics(0, body, TEST_MAX_FRAME_SIZE).unwrap_err(); + assert!(matches!(err, KafkaProtocolError::NullTopicName)); + } + + #[test] + fn decode_topics_legacy_null_array_means_all_topics() { + // -1 is the spec-defined "all topics" sentinel for the legacy i32 array count, not a + // malformed request - must decode to an empty list. + let body = Bytes::from_static(&[0xff, 0xff, 0xff, 0xff]); // -1 + let topics = decode_topics(0, body, TEST_MAX_FRAME_SIZE).unwrap(); + assert!(topics.is_empty()); + } + + #[test] + fn decode_topics_empty_body_is_malformed() { + assert!(decode_topics(0, Bytes::new(), TEST_MAX_FRAME_SIZE).is_err()); + } + + #[test] + fn decode_topics_flexible_truncated_after_topics_fails() { + // topics = null (all topics) but missing allow_auto / auth flags / tagged fields. + let body = Bytes::from_static(&[0x00]); + assert!(decode_topics(9, body, TEST_MAX_FRAME_SIZE).is_err()); + } +} diff --git a/gateways/kafka/src/protocol/handlers/mod.rs b/gateways/kafka/src/protocol/handlers/mod.rs new file mode 100644 index 0000000000..a03fa9eac4 --- /dev/null +++ b/gateways/kafka/src/protocol/handlers/mod.rs @@ -0,0 +1,179 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! One module per Kafka API key. +//! +//! Each module owns its firewall range, its response encoders, and the handler [`dispatch`] +//! routes to. A new API key costs a module, a line in [`dispatch`], and a line in +//! `api::SUPPORTED_RANGES`. +//! +//! `kafka_protocol` owns wire encoding. What lives here is policy: which placeholder values and +//! error codes a request gets back, and when the connection closes instead. + +pub mod api_versions; +pub mod create_topics; +pub mod fetch; +pub mod list_offsets; +pub mod metadata; +pub mod produce; + +use bytes::{Buf, Bytes, BytesMut}; +use kafka_protocol::protocol::{Decodable, Encodable}; + +use crate::error::{KafkaProtocolError, Result}; +use crate::protocol::api::{ + API_KEY_API_VERSIONS, API_KEY_CREATE_TOPICS, API_KEY_FETCH, API_KEY_LIST_OFFSETS, + API_KEY_METADATA, API_KEY_PRODUCE, ERROR_INVALID_REQUEST, ERROR_UNSUPPORTED_VERSION, + GatewayState, HandleOutcome, is_supported_version, supported_max_version, +}; + +/// Routes one decoded request body to the module that owns its API key. +/// +/// An unknown key closes the connection: no response schema exists for it, so any body sent +/// back is misparsed against the schema the client expected. +pub async fn dispatch( + state: &GatewayState, + api_key: i16, + api_version: i16, + body: Bytes, +) -> HandleOutcome { + match api_key { + API_KEY_PRODUCE => produce::handle(state, api_version, body).await, + API_KEY_FETCH => fetch::handle(state, api_version, body).await, + API_KEY_LIST_OFFSETS => list_offsets::handle(state, api_version, body).await, + API_KEY_METADATA => metadata::handle(state, api_version, body).await, + API_KEY_API_VERSIONS => api_versions::handle(state, api_version, body).await, + API_KEY_CREATE_TOPICS => create_topics::handle(state, api_version, body).await, + _ => HandleOutcome::Close, + } +} + +/// Encode a `kafka_protocol` message, mapping its `anyhow::Error` (the crate has no stable +/// decode/encode error taxonomy) to a variant callers can log or fold into +/// [`HandleOutcome::Close`]. +pub(crate) fn encode_message( + msg: &T, + version: i16, + capacity: usize, +) -> Result { + let mut buf = BytesMut::with_capacity(capacity); + msg.encode(&mut buf, version) + .map_err(|e| KafkaProtocolError::Malformed(e.to_string()))?; + Ok(buf.freeze()) +} + +/// Decode `T` from the whole request body and reject unconsumed trailing bytes. +/// +/// `kafka_protocol`'s `Decodable` stops once it has read the fields its schema defines; it does +/// not know (or care) whether the caller handed it an exact-length body, so the trailing-bytes +/// check has to live here. +pub(crate) fn decode_exhaustive(version: i16, mut body: Bytes) -> Result { + let value = + T::decode(&mut body, version).map_err(|e| KafkaProtocolError::Malformed(e.to_string()))?; + if body.has_remaining() { + return Err(KafkaProtocolError::Malformed( + "unexpected trailing bytes in request body".to_string(), + )); + } + Ok(value) +} + +/// Runs `validate` (see [`crate::protocol::bounds_guard`]) before [`decode_exhaustive`]. +/// +/// `kafka_protocol` validates wire-declared array/string lengths as non-negative but never +/// against the bytes actually remaining in the frame, so a tiny frame declaring a huge count +/// drives an allocation attempt that aborts the whole process (`handle_alloc_error`, not a +/// panic - uncatchable, and it kills every connection, not just the offending one). `validate` +/// rejects that class of frame first, on a walk that never allocates a collection. +pub(crate) fn decode_guarded( + version: i16, + body: Bytes, + validate: impl FnOnce(i16, &Bytes) -> Result<()>, +) -> Result { + validate(version, &body)?; + decode_exhaustive(version, body) +} + +/// Turn an encode [`Result`] into a [`HandleOutcome`], closing the connection when encoding +/// fails rather than propagating - there is no parseable response to send in that case. +pub(crate) fn respond_or_close(result: Result, api_name: &str) -> HandleOutcome { + match result { + Ok(body) => HandleOutcome::Respond(body), + Err(error) => { + tracing::warn!(%error, "failed to encode {api_name} response; closing connection"); + HandleOutcome::Close + } + } +} + +/// The shared shape of every API whose unsupported-version answer is an error response rather +/// than a close, and whose decode failure is reportable. +pub(crate) fn handle_versioned_request( + api_key: i16, + api_version: i16, + body: Bytes, + decode: impl FnOnce(i16, Bytes) -> Result, + encode_ok: impl FnOnce(i16, &T) -> Result, + encode_err: impl Fn(i16, i16) -> Result, + api_name: &str, +) -> HandleOutcome { + if is_supported_version(api_key, api_version) { + match decode(api_version, body) { + Ok(req) => respond_or_close(encode_ok(api_version, &req), api_name), + Err(error) => { + // debug!, not warn!: attacker-controlled, not operator-actionable. + tracing::debug!(%error, "Failed to decode {api_name} request"); + respond_or_close(encode_err(api_version, ERROR_INVALID_REQUEST), api_name) + } + } + } else { + unsupported_version_response(api_key, api_version, |version| { + encode_err(version, ERROR_UNSUPPORTED_VERSION) + }) + } +} + +/// Unsupported-version policy for APIs whose encoders only implement up to +/// `ApiVersionRange::max_version`. +/// +/// - `api_version > max`: Close. `SUPPORTED_RANGES` is the governance boundary, not just an +/// encoding-capability limit - `kafka_protocol` can often encode versions above our firewall +/// max just fine, but responding there would silently widen what this gateway accepts. +/// - `api_version < min`: Respond with an error shaped for that version when `kafka_protocol` +/// can encode it, otherwise `encode` fails and [`respond_or_close`] closes instead. In +/// practice every `SUPPORTED_RANGES` min was chosen at or above the oldest version +/// `kafka_protocol` implements for that message, so this always closes today (e.g. +/// `ListOffsets` v0's legacy `old_style_offsets` shape predates the crate's schema) - kept +/// generic rather than hard-coded so a future `kafka_protocol` upgrade that widens a schema's +/// floor is picked up automatically instead of silently staying on `Close`. +pub(crate) fn unsupported_version_response( + api_key: i16, + api_version: i16, + encode: impl FnOnce(i16) -> Result, +) -> HandleOutcome { + let max_version = supported_max_version(api_key).unwrap_or(0); + if api_version > max_version { + tracing::warn!( + api_key, + api_version, + max_version, + "request version above encoder max; closing connection" + ); + return HandleOutcome::Close; + } + respond_or_close(encode(api_version), "unsupported-version") +} diff --git a/gateways/kafka/src/protocol/handlers/produce.rs b/gateways/kafka/src/protocol/handlers/produce.rs new file mode 100644 index 0000000000..944b72503f --- /dev/null +++ b/gateways/kafka/src/protocol/handlers/produce.rs @@ -0,0 +1,150 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Produce (API key 0). + +use bytes::Bytes; +use kafka_protocol::messages::produce_response::{PartitionProduceResponse, TopicProduceResponse}; +use kafka_protocol::messages::{ProduceRequest, ProduceResponse}; + +use crate::error::Result; +use crate::protocol::api::{ + API_KEY_PRODUCE, ApiVersionRange, ERROR_NOT_LEADER_OR_FOLLOWER, ERROR_UNSUPPORTED_VERSION, + GatewayState, HandleOutcome, supported_max_version, +}; +use crate::protocol::bounds_guard::validate_produce_shape; +use crate::protocol::handlers::{ + decode_guarded, encode_message, respond_or_close, unsupported_version_response, +}; + +pub const RANGE: ApiVersionRange = ApiVersionRange { + api_key: API_KEY_PRODUCE, + min_version: 3, + max_version: 9, +}; + +/// Produce is the only request the wire protocol allows to go unanswered +/// (`acks=0`), so it gets its own path that may return [`HandleOutcome::NoResponse`]. +/// +/// The firewall check runs AFTER decoding the request, not before: `ApiVersions` advertises +/// Produce min=0 (see `api::advertised_min_version`) while the firewall's real floor is 3, so a +/// spec-compliant client can legitimately send Produce v0-2 with `acks=0`. Rejecting those +/// versions before reading `acks` would send an error response the client never expects, +/// desyncing the next correlation id it reads. +#[expect( + clippy::unused_async, + reason = "the shared handler signature, kept until a handler awaits the bridge" +)] +pub async fn handle(state: &GatewayState, api_version: i16, body: Bytes) -> HandleOutcome { + // Above the encoder max there is no response parseable at the client's version, so close + // rather than decode (same policy the other APIs apply). Fail-closed on a missing row + // (i16::MIN, not i16::MAX): dispatch routes Produce on its api key, not from this table, so + // if a future edit ever drops the Produce row to disable the API, a fail-open default here + // would leave Produce v0-2 acks=0 silently accepted on an API the operator believes is off. + if api_version > supported_max_version(API_KEY_PRODUCE).unwrap_or(i16::MIN) { + return HandleOutcome::Close; + } + // `kafka_protocol`'s ProduceRequest/ProduceResponse schemas only go back to v3, so v0-2 + // (still advertised as the min in ApiVersions per KAFKA-18659) can be neither decoded nor + // encoded by the crate - there is no parseable response at these versions regardless of + // body content. `acks` is always the first i16 on the wire there (`transactional_id` was + // added in v3), so it's peeked by hand: acks=0 must keep the connection open per the wire + // protocol's fire-and-forget rule even though no response can ever be encoded for it. + if api_version < 3 { + let acks = match body.get(0..2) { + Some(&[hi, lo]) => Some(i16::from_be_bytes([hi, lo])), + _ => None, + }; + return match acks { + Some(0) | None => HandleOutcome::NoResponse, + Some(_) => unsupported_version_response(API_KEY_PRODUCE, api_version, |v| { + encode_error_response(v, ERROR_UNSUPPORTED_VERSION) + }), + }; + } + match decode_guarded::(api_version, body, |v, b| { + validate_produce_shape(v, b, state.max_frame_size) + }) { + // acks=0 is fire-and-forget: the client isn't reading a response, so + // sending one desyncs the next correlation id it expects. + Ok(req) if req.acks == 0 => HandleOutcome::NoResponse, + // `api_version` is always in `[3, supported_max]` here: the `< 3` case returned above, + // and the `> max` case returned at the top of this function - so it is always within + // `SUPPORTED_RANGES`' Produce row and an `is_supported_version` re-check can never fail. + Ok(req) => respond_or_close(encode_response(api_version, &req), "Produce"), + Err(error) => { + // `kafka_protocol` decodes the whole request in one shot; a failure anywhere gives + // no partial-field access, so `acks` is unknowable here (unlike the pre-migration + // field-by-field decoder, which could still know `acks` on a later-field failure). + // Responding risks desyncing an acks=0 fire-and-forget client's correlation stream, + // so every Produce decode failure now stays silent - a behavior change from the + // hand-rolled decoder, which answered with INVALID_REQUEST when `acks` was known and + // nonzero. + // debug!, not warn!: the body is attacker-controlled, not operator-actionable, and + // a client looping malformed bodies on one connection (never disconnected - this + // arm returns NoResponse) has no rate limit here. + tracing::debug!(%error, "failed to decode Produce request (no response)"); + HandleOutcome::NoResponse + } + } +} + +/// Well-formed Produce response with a single placeholder topic/partition. +/// +/// # Errors +/// +/// Returns an error when `kafka_protocol` cannot encode the response at `version`. +pub fn encode_error_response(version: i16, error_code: i16) -> Result { + let resp = ProduceResponse::default().with_responses(vec![ + TopicProduceResponse::default() + .with_partition_responses(vec![partition_response(0, error_code)]), + ]); + encode_message(&resp, version, 512) +} + +/// Stub: discard the payload and return a retriable error so clients keep data locally until +/// the Iggy bridge lands (do not advertise silent success). +/// +/// # Errors +/// +/// Returns an error when `kafka_protocol` cannot encode the response at `version`. +pub fn encode_response(version: i16, req: &ProduceRequest) -> Result { + let responses = req + .topic_data + .iter() + .map(|topic| { + TopicProduceResponse::default() + .with_name(topic.name.clone()) + .with_partition_responses( + topic + .partition_data + .iter() + .map(|p| partition_response(p.index, ERROR_NOT_LEADER_OR_FOLLOWER)) + .collect(), + ) + }) + .collect(); + let resp = ProduceResponse::default().with_responses(responses); + encode_message(&resp, version, 512) +} + +fn partition_response(index: i32, error_code: i16) -> PartitionProduceResponse { + PartitionProduceResponse::default() + .with_index(index) + .with_error_code(error_code) + .with_log_start_offset(0) +} diff --git a/gateways/kafka/src/protocol/mod.rs b/gateways/kafka/src/protocol/mod.rs index d7fd8a2144..f07afdb1f0 100644 --- a/gateways/kafka/src/protocol/mod.rs +++ b/gateways/kafka/src/protocol/mod.rs @@ -17,5 +17,5 @@ pub mod api; pub mod bounds_guard; +pub mod handlers; pub mod header; -pub mod responses; diff --git a/gateways/kafka/src/protocol/responses.rs b/gateways/kafka/src/protocol/responses.rs deleted file mode 100644 index b929b56960..0000000000 --- a/gateways/kafka/src/protocol/responses.rs +++ /dev/null @@ -1,318 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - -//! Kafka response encoders (stub implementations). -//! -//! Wire encoding (field order, version gating, compact vs. legacy shapes) is -//! `kafka_protocol`'s responsibility; everything here is stub *policy* - which placeholder -//! values and error codes a request gets back before the Iggy bridge lands. - -use bytes::{Bytes, BytesMut}; -use kafka_protocol::messages::create_topics_request::CreatableTopic; -use kafka_protocol::messages::create_topics_response::CreatableTopicResult; -use kafka_protocol::messages::fetch_response::{FetchableTopicResponse, PartitionData}; -use kafka_protocol::messages::list_offsets_response::{ - ListOffsetsPartitionResponse, ListOffsetsTopicResponse, -}; -use kafka_protocol::messages::produce_response::{PartitionProduceResponse, TopicProduceResponse}; -use kafka_protocol::messages::{ - CreateTopicsRequest, CreateTopicsResponse, FetchRequest, FetchResponse, ListOffsetsRequest, - ListOffsetsResponse, ProduceRequest, ProduceResponse, -}; -use kafka_protocol::protocol::Encodable; - -use crate::error::{KafkaProtocolError, Result}; -use crate::protocol::api::{ - ERROR_INVALID_PARTITIONS, ERROR_INVALID_REPLICATION_FACTOR, ERROR_NONE, ERROR_NOT_CONTROLLER, - ERROR_NOT_LEADER_OR_FOLLOWER, -}; - -/// Encode a `kafka_protocol` message, mapping its `anyhow::Error` (the crate has no stable -/// decode/encode error taxonomy) to a variant callers can log or fold into [`HandleOutcome::Close`]. -/// -/// [`HandleOutcome::Close`]: crate::protocol::api::HandleOutcome::Close -pub(crate) fn encode_message( - msg: &T, - version: i16, - capacity: usize, -) -> Result { - let mut buf = BytesMut::with_capacity(capacity); - msg.encode(&mut buf, version) - .map_err(|e| KafkaProtocolError::Malformed(e.to_string()))?; - Ok(buf.freeze()) -} - -// ── Produce ────────────────────────────────────────────────────────────────── - -/// Well-formed Produce response with a single placeholder topic/partition. -/// -/// # Errors -/// -/// Returns an error when `kafka_protocol` cannot encode the response at `version`. -pub fn encode_produce_error_response(version: i16, error_code: i16) -> Result { - let resp = ProduceResponse::default().with_responses(vec![ - TopicProduceResponse::default() - .with_partition_responses(vec![produce_partition_response(0, error_code)]), - ]); - encode_message(&resp, version, 512) -} - -/// Stub: discard the payload and return a retriable error so clients keep data locally until -/// the Iggy bridge lands (do not advertise silent success). -/// -/// # Errors -/// -/// Returns an error when `kafka_protocol` cannot encode the response at `version`. -pub fn encode_produce_response(version: i16, req: &ProduceRequest) -> Result { - let responses = req - .topic_data - .iter() - .map(|topic| { - TopicProduceResponse::default() - .with_name(topic.name.clone()) - .with_partition_responses( - topic - .partition_data - .iter() - .map(|p| produce_partition_response(p.index, ERROR_NOT_LEADER_OR_FOLLOWER)) - .collect(), - ) - }) - .collect(); - let resp = ProduceResponse::default().with_responses(responses); - encode_message(&resp, version, 512) -} - -fn produce_partition_response(index: i32, error_code: i16) -> PartitionProduceResponse { - PartitionProduceResponse::default() - .with_index(index) - .with_error_code(error_code) - .with_log_start_offset(0) -} - -// ── Fetch ──────────────────────────────────────────────────────────────────── - -/// Well-formed Fetch response. Uses top-level `error_code` at v7+, or a single -/// placeholder topic/partition with per-partition `error_code` below v7. -/// -/// # Errors -/// -/// Returns an error when `kafka_protocol` cannot encode the response at `version`. -pub fn encode_fetch_error_response(version: i16, error_code: i16) -> Result { - if version >= 7 { - return encode_fetch_response_inner(version, Vec::new(), error_code); - } - // No top-level error field below v7; the error surfaces on the placeholder partition instead. - let topics = vec![ - FetchableTopicResponse::default() - .with_partitions(vec![fetch_partition_response(0, error_code)]), - ]; - encode_fetch_response_inner(version, topics, ERROR_NONE) -} - -/// Stub: discard the payload and return a retriable error so clients don't mistake "no real -/// data yet" for a genuinely empty partition (same philosophy as Produce). -/// -/// # Errors -/// -/// Returns an error when `kafka_protocol` cannot encode the response at `version`. -pub fn encode_fetch_response(version: i16, req: &FetchRequest) -> Result { - let topics = req - .topics - .iter() - .map(|topic| { - FetchableTopicResponse::default() - .with_topic(topic.topic.clone()) - .with_partitions( - topic - .partitions - .iter() - .map(|p| { - fetch_partition_response(p.partition, ERROR_NOT_LEADER_OR_FOLLOWER) - }) - .collect(), - ) - }) - .collect(); - encode_fetch_response_inner(version, topics, ERROR_NONE) -} - -fn encode_fetch_response_inner( - version: i16, - topics: Vec, - top_level_error: i16, -) -> Result { - let resp = FetchResponse::default() - .with_error_code(top_level_error) - .with_responses(topics); - encode_message(&resp, version, 512) -} - -fn fetch_partition_response(partition: i32, error_code: i16) -> PartitionData { - PartitionData::default() - .with_partition_index(partition) - .with_error_code(error_code) - .with_last_stable_offset(0) - .with_log_start_offset(0) - .with_records(None) -} - -// ── ListOffsets ────────────────────────────────────────────────────────────── - -/// Well-formed `ListOffsets` response with a single placeholder topic/partition. -/// -/// `kafka_protocol` has no encodable representation for `ListOffsets` v0 (the legacy -/// `old_style_offsets` shape predates the schema this crate generates from); a v0 request now -/// falls through `super::api::unsupported_version_response`'s encode-failure path to `Close` -/// instead of the pre-migration downgraded response. -/// -/// # Errors -/// -/// Returns an error when `kafka_protocol` cannot encode the response at `version` (always the -/// case for `version == 0`). -pub fn encode_list_offsets_error_response(version: i16, error_code: i16) -> Result { - let topics = vec![ - ListOffsetsTopicResponse::default() - .with_partitions(vec![list_offsets_partition_response(0, error_code)]), - ]; - encode_list_offsets_response_inner(version, topics) -} - -/// Stub: discard the payload and return a retriable error, matching Produce/Fetch - a genuine -/// offset lookup requires the same partition-leadership the stub doesn't have yet. -/// -/// # Errors -/// -/// Returns an error when `kafka_protocol` cannot encode the response at `version`. -pub fn encode_list_offsets_response(version: i16, req: &ListOffsetsRequest) -> Result { - let topics = req - .topics - .iter() - .map(|topic| { - ListOffsetsTopicResponse::default() - .with_name(topic.name.clone()) - .with_partitions( - topic - .partitions - .iter() - .map(|p| { - list_offsets_partition_response( - p.partition_index, - ERROR_NOT_LEADER_OR_FOLLOWER, - ) - }) - .collect(), - ) - }) - .collect(); - encode_list_offsets_response_inner(version, topics) -} - -fn encode_list_offsets_response_inner( - version: i16, - topics: Vec, -) -> Result { - let resp = ListOffsetsResponse::default().with_topics(topics); - encode_message(&resp, version, 256) -} - -fn list_offsets_partition_response( - partition: i32, - error_code: i16, -) -> ListOffsetsPartitionResponse { - ListOffsetsPartitionResponse::default() - .with_partition_index(partition) - .with_error_code(error_code) -} - -// ── CreateTopics ───────────────────────────────────────────────────────────── - -/// Well-formed `CreateTopics` response with a single placeholder topic. -/// -/// # Errors -/// -/// Returns an error when `kafka_protocol` cannot encode the response at `version`. -pub fn encode_create_topics_error_response(version: i16, error_code: i16) -> Result { - let topics = vec![ - CreatableTopic::default() - .with_num_partitions(1) - .with_replication_factor(1), - ]; - encode_create_topics_response_inner(version, &topics, error_code) -} - -/// # Errors -/// -/// Returns an error when `kafka_protocol` cannot encode the response at `version`. -pub fn encode_create_topics_response(version: i16, req: &CreateTopicsRequest) -> Result { - encode_create_topics_response_inner(version, &req.topics, ERROR_NONE) -} - -/// Resolve per-topic `CreateTopics` error. -/// -/// KIP-464: `num_partitions = -1` / `replication_factor = -1` mean broker default when either -/// (a) the version is v4+, or (b) the topic carries a manual partition assignment (valid on -/// v2/v3 as well). Otherwise non-positive values are [`ERROR_INVALID_PARTITIONS`] / -/// [`ERROR_INVALID_REPLICATION_FACTOR`]. When validation passes, the stub returns -/// [`ERROR_NOT_CONTROLLER`] so clients do not believe the topic was created. -const fn create_topics_topic_error(version: i16, topic: &CreatableTopic, forced_error: i16) -> i16 { - if forced_error != ERROR_NONE { - return forced_error; - } - - let broker_default_ok = version >= 4 || !topic.assignments.is_empty(); - - let partitions_ok = if broker_default_ok { - topic.num_partitions == -1 || topic.num_partitions > 0 - } else { - topic.num_partitions > 0 - }; - if !partitions_ok { - return ERROR_INVALID_PARTITIONS; - } - - let replication_ok = if broker_default_ok { - topic.replication_factor == -1 || topic.replication_factor > 0 - } else { - topic.replication_factor > 0 - }; - if !replication_ok { - return ERROR_INVALID_REPLICATION_FACTOR; - } - - ERROR_NOT_CONTROLLER -} - -fn encode_create_topics_response_inner( - version: i16, - topics: &[CreatableTopic], - topic_error: i16, -) -> Result { - let results = topics - .iter() - .map(|topic| { - CreatableTopicResult::default() - .with_name(topic.name.clone()) - .with_error_code(create_topics_topic_error(version, topic, topic_error)) - .with_error_message(None) - .with_num_partitions(topic.num_partitions) - .with_replication_factor(topic.replication_factor) - }) - .collect(); - let resp = CreateTopicsResponse::default().with_topics(results); - encode_message(&resp, version, 256) -} diff --git a/gateways/kafka/src/server.rs b/gateways/kafka/src/server.rs index b13a3b2d2c..e4455dc585 100644 --- a/gateways/kafka/src/server.rs +++ b/gateways/kafka/src/server.rs @@ -31,9 +31,10 @@ use tokio_util::task::TaskTracker; use tracing::{debug, error, info, warn}; use tracing_appender::non_blocking::WorkerGuard; +use crate::bridge::IggyBridge; use crate::error::{KafkaProtocolError, Result}; use crate::protocol::api::{ - BrokerAdvertise, DEFAULT_KAFKA_PORT, HandleOutcome, handle_request_bounded, + BrokerAdvertise, DEFAULT_KAFKA_PORT, GatewayState, HandleOutcome, handle_request_bounded, }; use crate::protocol::header::{request_header_version, response_header_version}; use std::io; @@ -162,6 +163,7 @@ pub fn bind_listener(addr: &str) -> Result { pub struct KafkaGateway { config: Arc, + bridge: Option>, } impl KafkaGateway { @@ -169,9 +171,19 @@ impl KafkaGateway { pub fn new(config: GatewayConfig) -> Self { Self { config: Arc::new(config), + bridge: None, } } + /// Serve requests against `bridge` instead of the stub answers. + /// + /// `None` is the default and keeps every handler on its stub. + #[must_use] + pub fn with_bridge(mut self, bridge: Option>) -> Self { + self.bridge = bridge; + self + } + /// Accept Kafka wire connections until `shutdown` fires, then drain in-flight tasks. /// /// `listener` must already be bound by the caller. This lets tests and `main` bind @@ -186,14 +198,16 @@ impl KafkaGateway { mut shutdown: broadcast::Receiver<()>, ) -> Result<()> { let local_addr = listener.local_addr()?; - let broker = Arc::new(BrokerAdvertise::from_server_config( - &self.config, - local_addr, - )?); + let broker = BrokerAdvertise::from_server_config(&self.config, local_addr)?; info!( "kafka listener bound on {} (advertised as {}:{})", local_addr, broker.host, broker.port ); + let state = Arc::new(GatewayState::new( + broker, + self.bridge.clone(), + self.config.max_frame_size, + )); let tracker = TaskTracker::new(); let conn_limiter = Arc::new(Semaphore::new(self.config.max_connections)); @@ -238,12 +252,12 @@ impl KafkaGateway { warn!(%peer, "TCP_KEEPALIVE failed: {e}"); } let cfg = Arc::clone(&self.config); - let broker = Arc::clone(&broker); + let state = Arc::clone(&state); let conn_cancel = cancel.child_token(); tracker.spawn(async move { let _permit = permit; if let Err(err) = - handle_connection(stream, cfg, peer, broker, conn_cancel).await + handle_connection(stream, cfg, peer, state, conn_cancel).await { // debug!, not warn!: every `KafkaProtocolError` that can // reach here is either a malformed/oversized frame from the @@ -311,7 +325,7 @@ async fn handle_connection( mut stream: TcpStream, config: Arc, peer: SocketAddr, - broker: Arc, + state: Arc, cancel: CancellationToken, ) -> Result<()> { debug!(%peer, "connection accepted"); @@ -350,13 +364,9 @@ async fn handle_connection( // `RequestHeader::decode` advances `body` past the header fields it consumed via // `Buf::advance`, so `body` is already exactly the request payload. - let outcome = handle_request_bounded( - req.request_api_key, - req.request_api_version, - body, - &broker, - config.max_frame_size, - ); + let outcome = + handle_request_bounded(&state, req.request_api_key, req.request_api_version, body) + .await; if dispatch_outcome(&mut stream, &peer, &config, &req, resp_hdr_ver, outcome).await? { return Ok(()); } @@ -567,6 +577,8 @@ pub fn init_tracing() -> WorkerGuard { #[cfg(test)] mod tests { + use serial_test::serial; + use super::*; async fn tcp_pair() -> (TcpStream, TcpStream) { @@ -888,7 +900,17 @@ mod tests { assert_eq!(&payload[4..], &body); } + /// `#[serial]`, unkeyed (shares `bridge::config`'s default group - both this module and + /// `bridge::config` compile into the same lib unit-test binary. `main.rs`'s own `#[serial]` + /// test does NOT share this group: `main.rs` is the separate `iggy-gateway-kafka` bin's own + /// test harness, a different process, and `serial_test`'s mutex is process-local - see that + /// test's own doc comment for the mirror-image note): `init_tracing` reads `RUST_LOG` via + /// `EnvFilter::try_from_default_env`, and edition 2024's `env::set_var`/`remove_var` are + /// unsound against *any* concurrent env read in another thread, not just a write to the same + /// key - a set/remove elsewhere in this binary racing this read is exactly the hazard, + /// regardless of which var either side touches. #[test] + #[serial] fn init_tracing_is_idempotent() { let _first_guard = init_tracing(); let _second_guard = init_tracing(); diff --git a/gateways/kafka/tests/api_handler_tests.rs b/gateways/kafka/tests/api_handler_tests.rs index eeffc5c157..580cb857ed 100644 --- a/gateways/kafka/tests/api_handler_tests.rs +++ b/gateways/kafka/tests/api_handler_tests.rs @@ -47,9 +47,10 @@ use wire::{ // ── ApiVersions ───────────────────────────────────────────────────────────── -#[test] -fn api_versions_v1_response_non_flexible_format() { +#[tokio::test] +async fn api_versions_v1_response_non_flexible_format() { let body = handle_request(API_KEY_API_VERSIONS, 1, Bytes::new(), &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); @@ -72,14 +73,15 @@ fn api_versions_v1_response_non_flexible_format() { } } -#[test] -fn api_versions_v3_response_flexible_format() { +#[tokio::test] +async fn api_versions_v3_response_flexible_format() { let body = handle_request( API_KEY_API_VERSIONS, 3, build_api_versions_flexible_request("apache-iggy", "0.1.0"), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); @@ -108,14 +110,15 @@ fn api_versions_v3_response_flexible_format() { // ── Metadata ───────────────────────────────────────────────────────────────── -#[test] -fn metadata_response_has_broker_array_and_topic_array() { +#[tokio::test] +async fn metadata_response_has_broker_array_and_topic_array() { let body = handle_request( API_KEY_METADATA, 0, build_metadata_all_topics_legacy(0), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); @@ -132,24 +135,27 @@ fn metadata_response_has_broker_array_and_topic_array() { assert_eq!(topic_count, 0); } -#[test] -fn metadata_empty_body_closes_connection() { +#[tokio::test] +async fn metadata_empty_body_closes_connection() { assert!( - handle_request(API_KEY_METADATA, 0, Bytes::new(), &default_broker()).is_close(), + handle_request(API_KEY_METADATA, 0, Bytes::new(), &default_broker()) + .await + .is_close(), "empty Metadata body is malformed, not all-topics" ); } -#[test] -fn api_versions_v3_empty_body_returns_invalid_request() { +#[tokio::test] +async fn api_versions_v3_empty_body_returns_invalid_request() { let body = handle_request(API_KEY_API_VERSIONS, 3, Bytes::new(), &default_broker()) + .await .expect_response("ApiVersions has a top-level error_code"); let mut d = Decoder::new(body); assert_eq!(d.read_i16().unwrap(), ERROR_INVALID_REQUEST); } -#[test] -fn unsupported_metadata_version_closes_connection() { +#[tokio::test] +async fn unsupported_metadata_version_closes_connection() { // Above max: a clamped v9 body is unparsable to a v99 client, so Close is the honest contract. assert!( handle_request( @@ -158,6 +164,7 @@ fn unsupported_metadata_version_closes_connection() { build_metadata_flexible_request_v10(&["orders"]), &default_broker(), ) + .await .is_close(), "Metadata above supported max must close rather than return a clamped body" ); @@ -165,9 +172,9 @@ fn unsupported_metadata_version_closes_connection() { // ── Misc ──────────────────────────────────────────────────────────────────── -#[test] -fn unknown_api_key_closes_connection() { - let outcome = handle_request(999, 0, Bytes::new(), &default_broker()); +#[tokio::test] +async fn unknown_api_key_closes_connection() { + let outcome = handle_request(999, 0, Bytes::new(), &default_broker()).await; assert!( outcome.is_close(), "unknown api_key must close (no parseable response schema)" @@ -182,9 +189,10 @@ fn version_support_table_is_applied() { assert!(!is_supported_version(API_KEY_METADATA, -1)); } -#[test] -fn apiversions_unsupported_version_uses_v0_encoding_without_throttle() { +#[tokio::test] +async fn apiversions_unsupported_version_uses_v0_encoding_without_throttle() { let body = handle_request(API_KEY_API_VERSIONS, 99, Bytes::new(), &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); // v0: error_code(2) + api_keys i32 count(4) + 6 entries × 6 bytes = 42 - no throttle_time_ms. assert_eq!(body.len(), 42); @@ -194,8 +202,8 @@ fn apiversions_unsupported_version_uses_v0_encoding_without_throttle() { assert_eq!(d.remaining(), 36); } -#[test] -fn produce_malformed_body_with_acks_one_stays_silent() { +#[tokio::test] +async fn produce_malformed_body_with_acks_one_stays_silent() { // `kafka_protocol` decodes Produce in one shot, so a decode failure never exposes `acks` // (unlike the pre-migration field-by-field decoder, which could still answer with // INVALID_REQUEST once it knew acks was nonzero). Every Produce decode failure now stays @@ -207,13 +215,15 @@ fn produce_malformed_body_with_acks_one_stays_silent() { 0x00, 0x00, 0x00, 0x01, // one topic ]); assert!( - handle_request(API_KEY_PRODUCE, 3, body, &default_broker()).is_no_response(), + handle_request(API_KEY_PRODUCE, 3, body, &default_broker()) + .await + .is_no_response(), "malformed Produce body must stay silent regardless of acks" ); } -#[test] -fn fetch_malformed_body_returns_invalid_request() { +#[tokio::test] +async fn fetch_malformed_body_returns_invalid_request() { let response = handle_request( API_KEY_FETCH, 12, @@ -224,14 +234,15 @@ fn fetch_malformed_body_returns_invalid_request() { ]), &default_broker(), ) + .await .expect_response("fetch must return error response"); let mut d = Decoder::new(response); assert_eq!(d.read_i32().unwrap(), 0); // throttle assert_eq!(d.read_i16().unwrap(), ERROR_INVALID_REQUEST); } -#[test] -fn list_offsets_malformed_body_returns_invalid_request() { +#[tokio::test] +async fn list_offsets_malformed_body_returns_invalid_request() { let response = handle_request( API_KEY_LIST_OFFSETS, 6, @@ -243,6 +254,7 @@ fn list_offsets_malformed_body_returns_invalid_request() { ]), &default_broker(), ) + .await .expect_response("list offsets must return error response"); let mut d = Decoder::new(response); assert_eq!(d.read_i32().unwrap(), 0); // throttle @@ -256,8 +268,8 @@ fn list_offsets_malformed_body_returns_invalid_request() { assert_eq!(d.read_i16().unwrap(), ERROR_INVALID_REQUEST); } -#[test] -fn create_topics_malformed_body_returns_invalid_request() { +#[tokio::test] +async fn create_topics_malformed_body_returns_invalid_request() { let response = handle_request( API_KEY_CREATE_TOPICS, 5, @@ -267,6 +279,7 @@ fn create_topics_malformed_body_returns_invalid_request() { ]), &default_broker(), ) + .await .expect_response("create topics must return error response"); let mut d = Decoder::new(response); assert_eq!(d.read_i32().unwrap(), 0); @@ -278,8 +291,8 @@ fn create_topics_malformed_body_returns_invalid_request() { assert_eq!(d.read_i16().unwrap(), ERROR_INVALID_REQUEST); } -#[test] -fn metadata_null_topic_name_closes_connection() { +#[tokio::test] +async fn metadata_null_topic_name_closes_connection() { assert!( handle_request( API_KEY_METADATA, @@ -290,6 +303,7 @@ fn metadata_null_topic_name_closes_connection() { ]), &default_broker(), ) + .await .is_close(), "null topic name cannot be echoed in a Metadata response" ); @@ -297,8 +311,8 @@ fn metadata_null_topic_name_closes_connection() { // ── Full handler regression - every scoped API key x version through `handle_request` ── -#[test] -fn handle_request_succeeds_for_every_supported_version_with_fixture() { +#[tokio::test] +async fn handle_request_succeeds_for_every_supported_version_with_fixture() { for &(api_key, name, min_ver, max_ver) in scope::SCOPED_API_KEYS { if api_key == API_KEY_METADATA { for version in min_ver..=max_ver { @@ -308,6 +322,7 @@ fn handle_request_succeeds_for_every_supported_version_with_fixture() { build_metadata_all_topics_legacy(version) }; let resp = handle_request(api_key, version, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); assert!( !resp.is_empty(), @@ -324,6 +339,7 @@ fn handle_request_succeeds_for_every_supported_version_with_fixture() { Bytes::new() }; let resp = handle_request(api_key, version, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); assert!( !resp.is_empty(), @@ -338,6 +354,7 @@ fn handle_request_succeeds_for_every_supported_version_with_fixture() { continue; }; let resp = handle_request(api_key, version, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); assert!( !resp.is_empty(), @@ -347,13 +364,14 @@ fn handle_request_succeeds_for_every_supported_version_with_fixture() { } } -#[test] -fn produce_stub_response_returns_retriable_not_leader() { +#[tokio::test] +async fn produce_stub_response_returns_retriable_not_leader() { for version in 3i16..=9 { let Some(body) = load_fixture_body_or_skip(0, "Produce", version) else { continue; }; let resp = handle_request(API_KEY_PRODUCE, version, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let flexible = version >= 9; let mut d = Decoder::new(resp); @@ -375,13 +393,14 @@ fn produce_stub_response_returns_retriable_not_leader() { } } -#[test] -fn fetch_stub_response_returns_retriable_not_leader() { +#[tokio::test] +async fn fetch_stub_response_returns_retriable_not_leader() { for version in 4i16..=12 { let Some(body) = load_fixture_body_or_skip(1, "Fetch", version) else { continue; }; let resp = handle_request(API_KEY_FETCH, version, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let flexible = version >= 12; let mut d = Decoder::new(resp); @@ -415,12 +434,13 @@ fn fetch_stub_response_returns_retriable_not_leader() { /// v9 legacy encoding with a populated forgotten-topics list, and v12 flexible encoding adding a /// rack id (introduced at v11) on top of that. Both must decode and reach the same stub outcome /// as an empty-topics request. -#[test] -fn fetch_decodes_request_with_forgotten_topics_and_rack_id() { +#[tokio::test] +async fn fetch_decodes_request_with_forgotten_topics_and_rack_id() { for (version, rack_id) in [(9i16, None), (12i16, Some("rack-a"))] { let body = wire::build_fetch_request_with_sections(version, "orders", 2, Some("stale"), rack_id); let resp = handle_request(API_KEY_FETCH, version, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let flexible = version >= 12; let mut d = Decoder::new(resp); @@ -445,13 +465,14 @@ fn fetch_decodes_request_with_forgotten_topics_and_rack_id() { } } -#[test] -fn list_offsets_stub_response_returns_retriable_not_leader() { +#[tokio::test] +async fn list_offsets_stub_response_returns_retriable_not_leader() { for version in 1i16..=6 { let Some(body) = load_fixture_body_or_skip(2, "ListOffsets", version) else { continue; }; let resp = handle_request(API_KEY_LIST_OFFSETS, version, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let flexible = version >= 6; let mut d = Decoder::new(resp); @@ -479,10 +500,11 @@ fn list_offsets_stub_response_returns_retriable_not_leader() { /// `list_offsets_stub_response_returns_retriable_not_leader` above is fixture-driven; this /// exercises `encode_list_offsets_response`'s per-partition echo loop with a hand-built request /// carrying a real (non-empty) topic/partition, independent of any `.bin` fixture. -#[test] -fn list_offsets_decodes_request_with_real_topic_and_partition() { +#[tokio::test] +async fn list_offsets_decodes_request_with_real_topic_and_partition() { let body = wire::build_list_offsets_branch_request(6, "orders", 2); let resp = handle_request(API_KEY_LIST_OFFSETS, 6, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(resp); let _throttle = d.read_i32().unwrap(); @@ -544,8 +566,8 @@ fn read_broker_flexible(d: &mut Decoder) -> (String, i32) { (host, port) } -#[test] -fn metadata_corrupt_partial_body_closes_connection() { +#[tokio::test] +async fn metadata_corrupt_partial_body_closes_connection() { assert!( handle_request( API_KEY_METADATA, @@ -553,19 +575,21 @@ fn metadata_corrupt_partial_body_closes_connection() { Bytes::from_static(&[0x00, 0x00]), &default_broker(), ) + .await .is_close(), "truncated Metadata body must close; there is no top-level error field" ); } -#[test] -fn metadata_v0_empty_topics_stub_broker() { +#[tokio::test] +async fn metadata_v0_empty_topics_stub_broker() { let body = handle_request( API_KEY_METADATA, 0, metadata_request_legacy(0, 0), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); let (host, port) = read_broker_legacy(&mut d); @@ -579,14 +603,15 @@ fn metadata_v0_empty_topics_stub_broker() { ); } -#[test] -fn metadata_v0_three_topics_each_unknown() { +#[tokio::test] +async fn metadata_v0_three_topics_each_unknown() { let body = handle_request( API_KEY_METADATA, 0, metadata_request_legacy(0, 3), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); let _ = read_broker_legacy(&mut d); @@ -601,14 +626,15 @@ fn metadata_v0_three_topics_each_unknown() { } } -#[test] -fn metadata_v1_includes_controller_id() { +#[tokio::test] +async fn metadata_v1_includes_controller_id() { let body = handle_request( API_KEY_METADATA, 1, metadata_request_legacy(1, 0), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); // Metadata v1 has no throttle_time_ms (added in v3). @@ -618,14 +644,15 @@ fn metadata_v1_includes_controller_id() { assert_eq!(controller, 1); } -#[test] -fn metadata_v2_includes_cluster_id_field() { +#[tokio::test] +async fn metadata_v2_includes_cluster_id_field() { let body = handle_request( API_KEY_METADATA, 2, metadata_request_legacy(2, 0), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); let _ = read_broker_legacy(&mut d); @@ -635,8 +662,8 @@ fn metadata_v2_includes_cluster_id_field() { assert_eq!(d.read_i32().unwrap(), 0); } -#[test] -fn metadata_all_legacy_versions_produce_valid_response() { +#[tokio::test] +async fn metadata_all_legacy_versions_produce_valid_response() { for version in 0i16..=8 { let body = handle_request( API_KEY_METADATA, @@ -644,6 +671,7 @@ fn metadata_all_legacy_versions_produce_valid_response() { metadata_request_legacy(version, 1), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); if version >= 3 { @@ -664,14 +692,15 @@ fn metadata_all_legacy_versions_produce_valid_response() { } } -#[test] -fn metadata_v9_flexible_encoding() { +#[tokio::test] +async fn metadata_v9_flexible_encoding() { let body = handle_request( API_KEY_METADATA, 9, metadata_request_flexible(2), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); let _throttle = d.read_i32().unwrap(); @@ -701,14 +730,15 @@ fn metadata_v9_flexible_encoding() { assert_eq!(d.remaining(), 0); } -#[test] -fn metadata_v8_includes_authorized_operations_legacy() { +#[tokio::test] +async fn metadata_v8_includes_authorized_operations_legacy() { let body = handle_request( API_KEY_METADATA, 8, metadata_request_legacy(8, 1), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); let _throttle = d.read_i32().unwrap(); @@ -726,13 +756,14 @@ fn metadata_v8_includes_authorized_operations_legacy() { assert_eq!(d.remaining(), 0); } -#[test] -fn create_topics_stub_response_returns_not_controller() { +#[tokio::test] +async fn create_topics_stub_response_returns_not_controller() { for version in 2i16..=5 { let Some(body) = load_fixture_body_or_skip(19, "CreateTopics", version) else { continue; }; let resp = handle_request(API_KEY_CREATE_TOPICS, version, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let flexible = version >= 5; let mut d = Decoder::new(resp); @@ -757,10 +788,11 @@ fn create_topics_stub_response_returns_not_controller() { /// `create_topics_stub_response_returns_not_controller` above is fixture-driven; this exercises /// `encode_create_topics_response`'s per-topic echo with a hand-built request carrying a real /// topic, replica assignment, and config entry, independent of any `.bin` fixture. -#[test] -fn create_topics_decodes_request_with_real_topic_and_assignment() { +#[tokio::test] +async fn create_topics_decodes_request_with_real_topic_and_assignment() { let body = wire::build_create_topics_request_with_sections(5, "orders"); let resp = handle_request(API_KEY_CREATE_TOPICS, 5, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(resp); let _throttle = d.read_i32().unwrap(); @@ -776,20 +808,22 @@ fn create_topics_decodes_request_with_real_topic_and_assignment() { // ── Produce acks=0 (broker must stay silent even on a malformed body) ────── -#[test] -fn produce_acks_zero_malformed_body_stays_silent() { +#[tokio::test] +async fn produce_acks_zero_malformed_body_stays_silent() { // topics array declares 1 element but no topic data follows - decode fails after acks=0 // was on the wire, though `kafka_protocol`'s one-shot decode no longer exposes that acks // was read. The handler must stay silent regardless. let body = build_produce_v3_body(0, 1); assert!( - handle_request(API_KEY_PRODUCE, 3, body, &default_broker()).is_no_response(), + handle_request(API_KEY_PRODUCE, 3, body, &default_broker()) + .await + .is_no_response(), "handler must not respond when acks=0 even if decode fails after acks" ); } -#[test] -fn produce_acks_zero_stays_silent_on_advertised_but_unsupported_version() { +#[tokio::test] +async fn produce_acks_zero_stays_silent_on_advertised_but_unsupported_version() { // ApiVersions advertises Produce min=0 (KAFKA-18659) while the firewall's real floor is 3 // (SUPPORTED_RANGES). A spec-compliant client can legitimately send v0-2 with acks=0; the // firewall must not run before acks is decoded, or this silence contract breaks for exactly @@ -797,7 +831,9 @@ fn produce_acks_zero_stays_silent_on_advertised_but_unsupported_version() { for version in 0i16..3 { let body = build_produce_legacy_request(version, 0, None, None); assert!( - handle_request(API_KEY_PRODUCE, version, body, &default_broker()).is_no_response(), + handle_request(API_KEY_PRODUCE, version, body, &default_broker()) + .await + .is_no_response(), "Produce v{version} acks=0 must stay silent even though the firewall doesn't accept v{version}" ); } @@ -805,11 +841,12 @@ fn produce_acks_zero_stays_silent_on_advertised_but_unsupported_version() { // ── Metadata topic name echo (must not hardcode a placeholder topic name) ── -#[test] -fn metadata_v1_echoes_requested_topic_name_in_response() { +#[tokio::test] +async fn metadata_v1_echoes_requested_topic_name_in_response() { let topic = "orders"; let request = build_metadata_legacy_request(&[topic]); let body = handle_request(API_KEY_METADATA, 1, request, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); @@ -826,14 +863,15 @@ fn metadata_v1_echoes_requested_topic_name_in_response() { // ── Metadata (spec + SCOPE.md coverage) ───────────────────────────────────── -#[test] -fn metadata_v0_empty_topics_returns_zero_length_topic_array() { +#[tokio::test] +async fn metadata_v0_empty_topics_returns_zero_length_topic_array() { let body = handle_request( API_KEY_METADATA, 0, build_metadata_legacy_request(&[]), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); let _brokers = d.read_i32().unwrap(); @@ -844,27 +882,29 @@ fn metadata_v0_empty_topics_returns_zero_length_topic_array() { assert_eq!(d.remaining(), 0); } -#[test] -fn metadata_v3_includes_throttle_time_ms_before_brokers() { +#[tokio::test] +async fn metadata_v3_includes_throttle_time_ms_before_brokers() { let body = handle_request( API_KEY_METADATA, 3, build_metadata_legacy_request_for_version(3, &[]), &default_broker(), ) + .await .expect_response("metadata v3 empty topics must succeed"); let mut d = Decoder::new(body); assert_eq!(d.read_i32().unwrap(), 0, "throttle_time_ms"); } -#[test] -fn metadata_v9_flexible_empty_topics_returns_zero_topics() { +#[tokio::test] +async fn metadata_v9_flexible_empty_topics_returns_zero_topics() { let body = handle_request( API_KEY_METADATA, 9, build_metadata_flexible_request(&[]), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); d.read_i32().unwrap(); // throttle @@ -886,8 +926,8 @@ fn metadata_v9_flexible_empty_topics_returns_zero_topics() { assert_eq!(topic_count, 0); } -#[test] -fn metadata_v9_flexible_echoes_each_requested_topic_name() { +#[tokio::test] +async fn metadata_v9_flexible_echoes_each_requested_topic_name() { let topics = ["orders", "payments", "inventory"]; let body = handle_request( API_KEY_METADATA, @@ -895,6 +935,7 @@ fn metadata_v9_flexible_echoes_each_requested_topic_name() { build_metadata_flexible_request(&topics), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); @@ -951,8 +992,8 @@ fn metadata_v9_flexible_echoes_each_requested_topic_name() { ); } -#[test] -fn metadata_v1_legacy_multiple_topics_echo_names() { +#[tokio::test] +async fn metadata_v1_legacy_multiple_topics_echo_names() { let topics = ["alpha", "beta"]; let body = handle_request( API_KEY_METADATA, @@ -960,6 +1001,7 @@ fn metadata_v1_legacy_multiple_topics_echo_names() { build_metadata_legacy_request(&topics), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); diff --git a/gateways/kafka/tests/bridge_iggy_integration_tests.rs b/gateways/kafka/tests/bridge_iggy_integration_tests.rs new file mode 100644 index 0000000000..286f58ad71 --- /dev/null +++ b/gateways/kafka/tests/bridge_iggy_integration_tests.rs @@ -0,0 +1,639 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Integration tests for `IggyBridge` against a real `iggy-server` process - not the +//! `KafkaGateway` under test elsewhere in this suite. `#3533` acceptance criteria this file +//! exercises directly: `ensure_stream_and_topic` idempotent on repeated calls, and the bridge +//! module invoked from a real (non-unit) test rather than only compiled. + +use std::collections::HashMap; +use std::time::Duration; + +use iggy::prelude::{ + Identifier, IggyMessage, MessageClient, Partitioning, StreamClient, TopicClient, +}; +use secrecy::SecretString; +use serial_test::serial; + +use iggy_gateway_kafka::bridge::{ + BridgeError, IggyBridge, IggyBridgeConfig, TopicMapping, TopicOverride, +}; + +#[path = "common/iggy_server.rs"] +mod iggy_server; + +use iggy_server::{PortGuard, TestServer, raw_client}; + +#[tokio::test] +#[serial] +async fn ensure_stream_and_topic_is_idempotent_on_repeated_calls() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let bridge = IggyBridge::connect(server.test_config()) + .await + .expect("bridge should connect to a ready server"); + + bridge + .ensure_stream_and_topic("orders", 3) + .await + .expect("first call creates the stream and topic"); + bridge + .ensure_stream_and_topic("orders", 3) + .await + .expect("second call is a no-op against the now-existing stream and topic"); + bridge + .ensure_stream_and_topic("orders", 3) + .await + .expect("third call is still a no-op"); + + // Read back real state, not just three Oks: proves the second and third calls were actually + // no-ops against the one topic the first call created, not e.g. three independent topics + // that all happen to satisfy Ok(()) individually. + let raw = raw_client(&server).await; + let topics = raw + .get_topics(&Identifier::named("kafka").expect("valid stream name")) + .await + .expect("get_topics call"); + assert_eq!(topics.len(), 1, "must be exactly one topic, not three"); + assert_eq!(topics[0].name, "orders"); + assert_eq!(topics[0].partitions_count, 3); +} + +/// Regression test for coverage: nothing previously exercised `PartitionCountMismatch` on a real +/// server - only unit tests constructed the variant directly. +#[tokio::test] +#[serial] +async fn ensure_stream_and_topic_rejects_a_second_call_with_a_different_partition_count() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let bridge = IggyBridge::connect(server.test_config()) + .await + .expect("bridge should connect to a ready server"); + + bridge + .ensure_stream_and_topic("orders", 3) + .await + .expect("first call creates the topic with 3 partitions"); + + let err = bridge + .ensure_stream_and_topic("orders", 5) + .await + .expect_err( + "a different partition count against an existing topic must not silently succeed", + ); + match err { + BridgeError::PartitionCountMismatch { + topic, + existing, + requested, + } => { + assert_eq!(topic, "orders", "must report the Kafka-side name"); + assert_eq!(existing, 3); + assert_eq!(requested, 5); + } + other => panic!("expected PartitionCountMismatch, got {other:?}"), + } +} + +/// Regression test for coverage: every other test uses an empty `TopicMapping` (`HashMap::new()`), +/// so the Kafka-name-vs-Iggy-name distinction `ensure_topic`/`high_watermark` deliberately +/// maintain in their error paths (`kafka_topic`, not the resolved `topic_name`) can never actually +/// differ and so can never be caught wrong. This test maps a Kafka topic to a differently-named +/// Iggy stream/topic and checks both the happy path and the error paths report the Kafka-side +/// name a real caller (a future handler) would recognize. +#[tokio::test] +#[serial] +async fn bridge_operations_report_the_kafka_side_name_through_a_real_topic_mapping_override() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let mut config = server.test_config(); + let mut topics = HashMap::new(); + topics.insert( + "orders".to_string(), + TopicOverride { + stream: "billing".to_string(), + topic: "orders_v2".to_string(), + }, + ); + config.topic_mapping = TopicMapping::new("kafka".to_string(), topics) + .expect("valid mapping for this test's fixture data"); + let bridge = IggyBridge::connect(config) + .await + .expect("bridge should connect to a ready server"); + + bridge + .ensure_stream_and_topic("orders", 1) + .await + .expect("must create the mapped Iggy stream/topic, not one named after the Kafka topic"); + + // The Iggy-side resources are named differently from the Kafka topic - confirms the mapping + // actually took effect, not that resolve() was a no-op that happened to pass either way. + let raw = raw_client(&server).await; + let billing_topics = raw + .get_topics(&Identifier::named("billing").expect("valid stream name")) + .await + .expect("get_topics call"); + assert_eq!(billing_topics.len(), 1); + assert_eq!(billing_topics[0].name, "orders_v2"); + + let watermark = bridge + .high_watermark("orders", 0) + .await + .expect("must resolve through the mapping, not fail looking for a stream named 'orders'"); + assert_eq!(watermark, 0); + + let out_of_range = bridge + .high_watermark("orders", 5) + .await + .expect_err("partition 5 does not exist on a 1-partition topic"); + assert!( + matches!( + &out_of_range, + BridgeError::PartitionOutOfRange { topic, .. } if topic == "orders" + ), + "error must quote the Kafka topic name 'orders', not the Iggy name 'orders_v2': \ + {out_of_range:?}" + ); + + let mismatch = bridge + .ensure_stream_and_topic("orders", 2) + .await + .expect_err("different partition count against the mapped topic must not silently succeed"); + assert!( + matches!( + &mismatch, + BridgeError::PartitionCountMismatch { topic, .. } if topic == "orders" + ), + "error must quote the Kafka topic name 'orders', not the Iggy name 'orders_v2': \ + {mismatch:?}" + ); +} + +#[tokio::test] +#[serial] +async fn high_watermark_is_zero_for_a_fresh_empty_partition() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let bridge = IggyBridge::connect(server.test_config()) + .await + .expect("bridge should connect to a ready server"); + + bridge + .ensure_stream_and_topic("orders", 1) + .await + .expect("stream and topic must exist before checking the watermark"); + + let watermark = bridge + .high_watermark("orders", 0) + .await + .expect("fresh topic must report a watermark, not an error"); + assert_eq!( + watermark, 0, + "a freshly created, empty partition's high watermark must be 0" + ); +} + +/// Pins the exact semantics of `Iggy::Partition::current_offset` (offset of the *last written* +/// message, not Kafka's "next offset to produce") against a real server - a test that only +/// checked the empty-topic case would pass under either interpretation and hide an off-by-one. +#[tokio::test] +#[serial] +async fn high_watermark_reflects_produced_messages() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let bridge = IggyBridge::connect(server.test_config()) + .await + .expect("bridge should connect to a ready server"); + + bridge + .ensure_stream_and_topic("orders", 1) + .await + .expect("stream and topic must exist before producing"); + + let stream_id = Identifier::named("kafka").expect("valid stream name"); + let topic_id = Identifier::named("orders").expect("valid topic name"); + let mut messages: Vec = (0..3) + .map(|i| IggyMessage::from(format!("message-{i}"))) + .collect(); + let client = raw_client(&server).await; + client + .send_messages( + &stream_id, + &topic_id, + &Partitioning::partition_id(0), + &mut messages, + ) + .await + .expect("send 3 messages"); + + let watermark = bridge + .high_watermark("orders", 0) + .await + .expect("topic must report a watermark after producing"); + assert_eq!( + watermark, 3, + "high watermark after 3 messages (offsets 0, 1, 2) must be 3, not the last offset (2)" + ); +} + +#[tokio::test] +#[serial] +async fn high_watermark_rejects_out_of_range_partition() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let bridge = IggyBridge::connect(server.test_config()) + .await + .expect("bridge should connect to a ready server"); + + bridge + .ensure_stream_and_topic("orders", 1) + .await + .expect("stream and topic must exist before checking the watermark"); + + let err = bridge + .high_watermark("orders", 5) + .await + .expect_err("partition 5 does not exist on a 1-partition topic"); + assert!(matches!(err, BridgeError::PartitionOutOfRange { .. })); +} + +#[tokio::test] +#[serial] +async fn ensure_stream_and_topic_is_idempotent_for_a_numeric_topic_name() { + // Regression test: Identifier::try_from/FromStr parses an all-digit string as a numeric ID, + // not a name - a second call for the same numeric-named topic would look it up by the wrong + // resource kind and fail with StreamIdNotFound/TopicIdNotFound despite the topic existing. + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let bridge = IggyBridge::connect(server.test_config()) + .await + .expect("bridge should connect to a ready server"); + + bridge + .ensure_stream_and_topic("2024", 1) + .await + .expect("first call creates the numeric-named stream and topic"); + bridge + .ensure_stream_and_topic("2024", 1) + .await + .expect("second call must still find the numeric-named topic by name, not by ID"); +} + +/// Regression test: `ensure_stream` used to hand `ensure_topic` the stream's *numeric* id +/// (`Identifier::numeric`), and streams are backed by a recycled slab (freed keys are reused by +/// the next created stream) - a stream deleted and recreated *between* `ensure_stream`'s own +/// `get_stream`/`create_stream` call and `ensure_topic`'s use of the id it returned would leave +/// `ensure_topic` writing into whatever stream now holds that recycled numeric key, not the one +/// `ensure_stream_and_topic`'s caller actually resolved. The fix threads the *named* `Identifier` +/// through instead. +/// +/// What this test actually proves, and what it does not: `ensure_stream` and `ensure_topic` are +/// both private, called back-to-back inside one `ensure_stream_and_topic` invocation with no +/// `await` point this black-box test can land a delete-and-recreate inside - the exact race the +/// bug lived in is not reproducible from here, full stop, not just "hard." What this test does +/// verify is the weaker, but real, precondition: `ensure_stream_and_topic` has no bridge-level +/// cache, so its *second*, wholly separate invocation always resolves the stream fresh, by name. +/// A hypothetical regression back to `Identifier::numeric(...)` would still pass every assertion +/// below, because the numeric id that build would return comes from this call's own fresh, +/// by-name lookup - already correct by construction, not a stale value carried over from the +/// first call. Delete-and-recreate before the second call is not exercising the TOCTOU window at +/// all; it is here only to document (via the id-collision check right below) that the metadata +/// slab does in fact recycle freed keys, which is the premise the original bug depended on. +#[tokio::test] +#[serial] +async fn ensure_topic_targets_the_streams_live_incarnation_after_a_delete_and_recreate() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let bridge = IggyBridge::connect(server.test_config()) + .await + .expect("bridge should connect to a ready server"); + + bridge + .ensure_stream_and_topic("orders", 1) + .await + .expect("first call creates the default stream and topic"); + + let raw = raw_client(&server).await; + let stream_name = Identifier::named("kafka").expect("valid stream name"); + let original = raw + .get_stream(&stream_name) + .await + .expect("get_stream call") + .expect("stream exists after ensure_stream_and_topic"); + raw.delete_stream(&Identifier::numeric(original.id).expect("numeric id")) + .await + .expect("delete the stream (and its topic with it)"); + let recreated = raw + .create_stream("kafka") + .await + .expect("recreate a stream under the same name"); + if recreated.id == original.id { + // Not guaranteed by any API contract, but the metadata slab does reuse freed low + // indices - confirms this run actually exercised the recycled-key scenario, not just a + // coincidentally-fresh one. + eprintln!( + "recreated stream reused the original numeric id {}", + recreated.id + ); + } + + bridge + .ensure_stream_and_topic("orders", 1) + .await + .expect("must create the topic under the live stream, not a stale numeric id"); + + let topic = raw + .get_topic( + &stream_name, + &Identifier::named("orders").expect("valid topic name"), + ) + .await + .expect("get_topic call") + .expect("topic exists under the recreated stream"); + assert_eq!(topic.partitions_count, 1); +} + +/// Finding: the SDK's connection-string parser splits on `@` then `:`, so a password containing +/// either character breaks unless credentials are passed as already-separated fields. +#[tokio::test] +#[serial] +async fn connect_succeeds_with_password_containing_special_characters() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn_with_password(data_dir.path(), "p@ss:word").await; + + IggyBridge::connect(server.test_config()) + .await + .expect("bridge must connect with a password containing '@' and ':'"); +} + +/// Acceptance criterion: "no panics on Iggy unreachable at handler boundary." Connects to a port +/// nothing is listening on and asserts a plain `Err`, not a panic - the strongest way to fail this +/// assertion is exactly the failure mode being guarded against. +#[tokio::test] +async fn connect_to_unreachable_iggy_returns_err_not_panic() { + let port_guard = PortGuard::acquire(); // locked but never bound - nothing listens on it + let config = IggyBridgeConfig { + address: format!("127.0.0.1:{}", port_guard.port), + username: "iggy".to_string(), + password: SecretString::from("iggy"), + topic_mapping: TopicMapping::new("kafka".to_string(), HashMap::new()) + .expect("valid mapping for this test's fixture data"), + }; + + let result = IggyBridge::connect(config).await; + assert!(matches!(result, Err(BridgeError::Iggy(_)))); +} + +/// Regression test for the missing dial timeout: `TcpClient::establish_bounded` only applies its +/// own `FAILOVER_DIAL_TIMEOUT` when at least two failover candidates are configured, which a +/// bridge (always exactly one address) never has, so the underlying `TcpStream::connect` had no +/// deadline at all against an address that drops packets instead of refusing them. 192.0.2.1 is +/// RFC 5737 TEST-NET-1 - reserved for documentation, routed nowhere, so the connect attempt hangs +/// on the kernel's own SYN-retry timeout (confirmed against this exact address before writing this +/// test: over the sandbox's real network stack, a bare `connect()` past 3s had not yet failed). +/// Before `REQUEST_TIMEOUT`, this test would have hung for minutes; now it must fail within a +/// bounded window. +#[tokio::test] +async fn connect_to_a_black_hole_address_times_out_instead_of_hanging() { + let config = IggyBridgeConfig { + address: "192.0.2.1:1234".to_string(), + username: "iggy".to_string(), + password: SecretString::from("iggy"), + topic_mapping: TopicMapping::new("kafka".to_string(), HashMap::new()) + .expect("valid mapping for this test's fixture data"), + }; + + let start = tokio::time::Instant::now(); + // Outer safety net, not the behavior under test: if REQUEST_TIMEOUT regresses to "none" + // again, this fails the test in bounded time instead of hanging the whole suite. + let result = tokio::time::timeout(Duration::from_secs(30), IggyBridge::connect(config)) + .await + .expect("IggyBridge::connect must return on its own, not hang past a generous margin"); + + assert!( + start.elapsed() < Duration::from_secs(20), + "connect took {:?}, longer than REQUEST_TIMEOUT (15s) should allow", + start.elapsed() + ); + // BridgeError::Timeout, not BridgeError::Iggy: with_request_timeout's own elapsed branch + // maps here (see that function's doc for why an unknown outcome must not borrow + // CannotEstablishConnection's known-safe code). + assert!(matches!(result, Err(BridgeError::Timeout))); +} + +/// Regression test: `IggyClient::disconnect` (what `close` used to call) never touches +/// `heartbeat_handle`, so a bridge that called it kept heartbeating on a schedule and would +/// silently reconnect. `close` now calls `shutdown`, which this test only asserts succeeds against +/// a live connection - the heartbeat task's actual termination isn't observable from outside the +/// SDK, but a `close` that itself errored or hung would be a regression this catches directly. +#[tokio::test] +#[serial] +async fn close_succeeds_against_a_live_connection() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let bridge = IggyBridge::connect(server.test_config()) + .await + .expect("bridge should connect to a ready server"); + + bridge.close().await.expect("close must succeed"); +} + +/// Kafka-client-observable regression test for the auth-mapping fix: a wrong bridge password is +/// the *bridge's own* misconfiguration (`IGGY_KAFKA_IGGY_PASSWORD`), not anything the Kafka client +/// did - the SDK's own sign-in path raises `InvalidPassword`/`InvalidCredentials` for this, not +/// `Unauthorized` (that one means a real, authenticated-but-forbidden ACL problem). Mapping this +/// to `TOPIC_AUTHORIZATION_FAILED` (29) would hand a real Kafka client library a fatal, +/// non-retriable "Not authorized to access topics" it has no way to act on. Asserts the actual +/// wire code a handler would send, not just the `BridgeError` variant - that number is what a real +/// Kafka client's error-handling logic branches on. +#[tokio::test] +#[serial] +async fn connect_with_wrong_password_maps_to_unknown_server_error_not_authorization_failed() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn_with_password(data_dir.path(), "the-real-password").await; + + let mut config = server.test_config(); + config.password = SecretString::from("a-completely-wrong-password"); + // `IggyBridge` derives no `Debug`, so `expect_err`/`unwrap_err` (which require `T: Debug` on + // the `Ok` side too) don't apply here - match it out by hand instead. + let Err(err) = IggyBridge::connect(config).await else { + panic!("wrong password must not connect") + }; + + assert_eq!( + err.to_kafka_error_code(), + iggy_gateway_kafka::protocol::api::ERROR_UNKNOWN_SERVER_ERROR, + "a bridge-side credential error must not surface as the Kafka client's own \ + TOPIC_AUTHORIZATION_FAILED (29): {err:?}" + ); + assert_ne!( + err.to_kafka_error_code(), + iggy_gateway_kafka::protocol::api::ERROR_TOPIC_AUTHORIZATION_FAILED, + "must not blame the Kafka client's ACLs for the bridge's own wrong password: {err:?}" + ); +} + +/// End-to-end regression test for the batch high-watermark API: creates a 3-partition topic, +/// produces a different message count to each partition, and confirms one `high_watermarks` call +/// reports all three correctly and in the order requested - not just that a single-partition call +/// still works (that's `high_watermark_reflects_produced_messages`), but that the batching itself +/// keeps each partition's own count separate rather than conflating them. +#[tokio::test] +#[serial] +async fn high_watermarks_reports_every_requested_partition_from_one_round_trip() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let bridge = IggyBridge::connect(server.test_config()) + .await + .expect("bridge should connect to a ready server"); + + bridge + .ensure_stream_and_topic("orders", 3) + .await + .expect("stream and topic must exist before producing"); + + let stream_id = Identifier::named("kafka").expect("valid stream name"); + let topic_id = Identifier::named("orders").expect("valid topic name"); + let client = raw_client(&server).await; + for (partition, count) in [(0u32, 1usize), (1, 3), (2, 0)] { + if count == 0 { + continue; + } + let mut messages: Vec = (0..count) + .map(|i| IggyMessage::from(format!("partition-{partition}-message-{i}"))) + .collect(); + client + .send_messages( + &stream_id, + &topic_id, + &Partitioning::partition_id(partition), + &mut messages, + ) + .await + .expect("send messages to this partition"); + } + + let watermarks: Vec<(u32, i64)> = bridge + .high_watermarks("orders", &[0, 1, 2]) + .await + .expect("all three partitions exist on this topic") + .into_iter() + .map(|(partition, watermark)| { + ( + partition, + watermark.expect("every requested partition exists on this topic"), + ) + }) + .collect(); + + assert_eq!( + watermarks, + vec![(0, 1), (1, 3), (2, 0)], + "must report each partition's own watermark, in the order requested" + ); +} + +/// Regression test: an earlier version of `high_watermarks` collected the whole batch into one +/// `Result`, so the first out-of-range partition discarded every watermark already resolved for +/// the call. Mixes a valid and an out-of-range partition in one call and asserts the valid one's +/// watermark still comes back. +#[tokio::test] +#[serial] +async fn high_watermarks_keeps_resolved_partitions_when_another_is_out_of_range() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let bridge = IggyBridge::connect(server.test_config()) + .await + .expect("bridge should connect to a ready server"); + + bridge + .ensure_stream_and_topic("orders", 1) + .await + .expect("stream and topic must exist before checking watermarks"); + + let results = bridge + .high_watermarks("orders", &[0, 5]) + .await + .expect("the call itself succeeds - only partition 5 is out of range, not the topic"); + + assert_eq!( + results.len(), + 2, + "must report one entry per requested partition" + ); + // BridgeError has no PartialEq (it wraps IggyError, itself not comparable), so the per-partition + // Results are checked by hand rather than via assert_eq! on the whole tuple. + let (partition0, watermark0) = &results[0]; + assert_eq!(*partition0, 0); + assert_eq!( + *watermark0 + .as_ref() + .expect("partition 0 exists and must not be discarded by partition 5's error"), + 0 + ); + let (partition5, watermark5) = &results[1]; + assert_eq!(*partition5, 5); + assert!( + matches!(watermark5, Err(BridgeError::PartitionOutOfRange { .. })), + "partition 5 does not exist on a 1-partition topic: {watermark5:?}" + ); +} + +/// Kafka-client-observable regression test for topic-name validation: a whitespace-padded name is +/// not just unlikely for a real Kafka client to send, it's impossible - `Topic.legalChars` has no +/// space in it - so this exercises the defense-in-depth path a non-conformant client could still +/// reach, and asserts the wire code (`INVALID_TOPIC_EXCEPTION`, 17) a real client library would +/// recognize as "the topic name itself is the problem," not a generic server error. +#[tokio::test] +#[serial] +async fn ensure_stream_and_topic_rejects_a_padded_kafka_topic_name() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let bridge = IggyBridge::connect(server.test_config()) + .await + .expect("bridge should connect to a ready server"); + + let err = bridge + .ensure_stream_and_topic(" orders ", 1) + .await + .expect_err("a padded Kafka topic name must not silently create a padded Iggy topic"); + + assert!( + matches!(err, BridgeError::InvalidKafkaTopicName { .. }), + "expected InvalidKafkaTopicName, got {err:?}" + ); + assert_eq!( + err.to_kafka_error_code(), + iggy_gateway_kafka::protocol::api::ERROR_INVALID_TOPIC_EXCEPTION + ); +} + +#[tokio::test] +#[serial] +async fn high_watermark_rejects_a_padded_kafka_topic_name() { + let data_dir = tempfile::tempdir().expect("tempdir"); + let server = TestServer::spawn(data_dir.path()).await; + let bridge = IggyBridge::connect(server.test_config()) + .await + .expect("bridge should connect to a ready server"); + + let err = bridge + .high_watermark(" orders ", 0) + .await + .expect_err("a padded Kafka topic name must be rejected before any Iggy lookup"); + assert!(matches!(err, BridgeError::InvalidKafkaTopicName { .. })); +} diff --git a/gateways/kafka/tests/broker_advertise_tests.rs b/gateways/kafka/tests/broker_advertise_tests.rs index a4be845747..fc8b6c6e4c 100644 --- a/gateways/kafka/tests/broker_advertise_tests.rs +++ b/gateways/kafka/tests/broker_advertise_tests.rs @@ -34,8 +34,8 @@ fn default_matches_standard_gateway_port() { assert_eq!(b.port, 9093); } -#[test] -fn metadata_reflects_broker_addr() { +#[tokio::test] +async fn metadata_reflects_broker_addr() { let broker = BrokerAdvertise { host: "203.0.113.7".to_string(), port: 9093, @@ -43,6 +43,7 @@ fn metadata_reflects_broker_addr() { let mut req = Encoder::with_capacity(4); req.write_i32(0); let body = handle_request(API_KEY_METADATA, 0, req.freeze(), &broker) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); diff --git a/gateways/kafka/tests/common/iggy_server.rs b/gateways/kafka/tests/common/iggy_server.rs new file mode 100644 index 0000000000..478a7343bf --- /dev/null +++ b/gateways/kafka/tests/common/iggy_server.rs @@ -0,0 +1,406 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//! Real `iggy-server` process harness, shared by every suite in this crate that needs one. +//! +//! Included with `#[path]`, so its unit tests run once per including binary. They are pure +//! functions over a synthetic range, so the repeat is free. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::fs::{File, OpenOptions}; +use std::path::PathBuf; +use std::process::{Child, Command}; +use std::sync::OnceLock; +use std::time::Duration; + +use iggy::prelude::{AutoLogin, Client, Credentials, IggyClient, IggyClientBuilder}; +use secrecy::SecretString; + +use iggy_gateway_kafka::bridge::{IggyBridgeConfig, TopicMapping}; + +/// Desired slot count - the actual count `port_band()` computes may be smaller if the machine's +/// real ephemeral range leaves little room, but this crate never needs more than a handful live +/// at once (this binary's own tests already run one at a time - see `.config/nextest.toml`'s +/// `kafka_bridge` test-group). +const DESIRED_SLOTS: u16 = 200; + +/// Lowest port `port_band()` will ever place its band at - clear of the well-known/privileged +/// range (0-1023). +const MIN_CANDIDATE_PORT: u16 = 10000; + +/// Ephemeral range assumed when the kernel's own can't be read (no `/proc` - e.g. macOS), +/// matching the Linux default. Mirrors `core/integration`'s own `port_reserver.rs`. +const DEFAULT_EPHEMERAL_RANGE: (u16, u16) = (32768, 60999); + +const IP_LOCAL_PORT_RANGE: &str = "/proc/sys/net/ipv4/ip_local_port_range"; + +fn parse_ephemeral_range(range: &str) -> Option<(u16, u16)> { + let mut bounds = range.split_whitespace(); + let floor: u16 = bounds.next()?.parse().ok()?; + let ceiling: u16 = bounds.next()?.parse().ok()?; + Some((floor, ceiling)) +} + +/// The range the kernel picks `bind(0)` ports from, read once. +fn ephemeral_range() -> (u16, u16) { + static RANGE: OnceLock<(u16, u16)> = OnceLock::new(); + *RANGE.get_or_init(|| { + std::fs::read_to_string(IP_LOCAL_PORT_RANGE) + .ok() + .as_deref() + .and_then(parse_ephemeral_range) + .unwrap_or(DEFAULT_EPHEMERAL_RANGE) + }) +} + +/// First port and slot count of a band clear of the kernel's ephemeral range: below the floor by +/// preference (matches `core/integration`'s own `port_reserver.rs`), above the ceiling when the +/// floor leaves no room there. +/// +/// A *hardcoded* band, chosen once without reading the real range (this file's own prior version: +/// `15000..15199`, on the unverified assumption that every real deployment's floor sits above +/// it), is wrong on any box tuned wider - `net.ipv4.ip_local_port_range = "1024 65535"` swallows +/// that whole band. `flock` alone doesn't save it either: it is advisory, so it only excludes +/// another `PortGuard`-based process, never an unrelated `bind(0)` elsewhere on the box landing on +/// the same number from the kernel's own ephemeral pool. +/// +fn port_band() -> (u16, u16) { + static BAND: OnceLock<(u16, u16)> = OnceLock::new(); + *BAND.get_or_init(|| { + let (floor, ceiling) = ephemeral_range(); + band_for(floor, ceiling) + }) +} + +/// Pure band-selection logic, split out from [`port_band`] so it's testable against synthetic +/// ranges without needing to fake `/proc` contents. +fn band_for(floor: u16, ceiling: u16) -> (u16, u16) { + if floor > MIN_CANDIDATE_PORT { + let room = floor - MIN_CANDIDATE_PORT; + return (MIN_CANDIDATE_PORT, room.min(DESIRED_SLOTS)); + } + // Below this floor both allocators fall back to `ceiling + 1`, and they lock different + // files (`{port}.lock` here, `slot-{slot}.lock` in `core/integration`), so neither sees the + // other's reservation. A port handed out here collides silently, so refuse instead. + panic!( + "kernel ephemeral range [{floor}, {ceiling}] leaves no room for a test port band below \ + {MIN_CANDIDATE_PORT}, and the band above {ceiling} is shared with core/integration's \ + own allocator under a different lock - narrow the range, e.g. \ + sysctl -w net.ipv4.ip_local_port_range='32768 60999'" + ); +} + +#[test] +fn given_a_readable_range_when_parsed_should_take_both_bounds() { + assert_eq!( + parse_ephemeral_range("32768\t60999\n"), + Some((32768, 60999)) + ); + assert_eq!(parse_ephemeral_range("1024 65535"), Some((1024, 65535))); + assert_eq!(parse_ephemeral_range(""), None); + assert_eq!(parse_ephemeral_range("32768"), None); + assert_eq!(parse_ephemeral_range("garbage 60999"), None); +} + +#[test] +fn given_room_below_the_floor_when_choosing_a_band_should_take_it() { + let (start, slots) = band_for(32768, 60999); + assert_eq!(start, MIN_CANDIDATE_PORT); + assert!( + start + slots <= 32768, + "band [{start}, {}] runs into the ephemeral floor", + start + slots - 1 + ); +} + +/// `ip_local_port_range = "1024 60999"` leaves room above the ceiling, but `core/integration` +/// falls back to the same anchor under a lock this one cannot see. +#[test] +#[should_panic(expected = "shared with core/integration")] +fn given_no_room_below_the_floor_when_choosing_a_band_should_refuse() { + band_for(1024, 60999); +} + +/// A ceiling at the `u16` maximum leaves nothing above it, and must not overflow while saying +/// so. +#[test] +#[should_panic(expected = "leaves no room")] +fn given_a_ceiling_at_the_u16_maximum_when_choosing_a_band_should_refuse() { + band_for(1024, u16::MAX); +} + +/// Exclusive claim on one port, released (and the port freed for reuse) when dropped - including +/// on an unclean process exit, since the OS drops the `flock` with the file descriptor. Unlike +/// bind-then-drop, this process itself never binds the port before `iggy-server` does; see +/// `port_band`'s doc comment for the limits of that guarantee against *other* processes. +pub struct PortGuard { + pub port: u16, + _lock: File, +} + +impl PortGuard { + pub fn acquire() -> Self { + let (band_start, band_slots) = port_band(); + let lock_dir = std::env::temp_dir().join("iggy-kafka-gateway-test-port-locks"); + std::fs::create_dir_all(&lock_dir).expect("create port lock dir"); + for offset in 0..band_slots { + let port = band_start + offset; + let path = lock_dir.join(format!("{port}.lock")); + let Ok(file) = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + else { + continue; + }; + if file.try_lock().is_ok() { + return Self { port, _lock: file }; + } + } + panic!( + "no free port slot in [{band_start}, {}]", + band_start + band_slots - 1 + ); + } +} + +/// Locates the already-built `iggy-server` binary. Does not build it - see this crate's +/// `docs/TEST_SUITE.md` for the prerequisite, the same one `core/integration`'s own +/// server-spawning tests already carry. +/// +/// Walks up from this test binary's own path (`env::current_exe()`) rather than calling +/// `assert_cmd::cargo::cargo_bin("iggy-server")` directly: `cargo_bin`'s `CARGO_BIN_EXE_*` +/// env-var lookup only resolves for binaries owned by *this* package, so for `iggy-server` +/// (a different package) it always falls through to `cargo_bin`'s own `legacy_cargo_bin`, which +/// panics with its own internal message - not the "binary not found" text `TEST_SUITE.md` +/// promises - when the binary is not yet built (`CARGO_BIN_EXE_iggy-server` is unset, so its +/// panic path prints that plus every binary name it *did* find, not a helpful next step). Ancestor +/// directories of `current_exe()` reach the same `target//` directory +/// `legacy_cargo_bin` derives - correct under `CARGO_TARGET_DIR`, `--release`, or a `--target` +/// triple subdirectory for the identical reason: it reads back from where cargo actually placed +/// *this* binary, not a guessed path - so checking existence there first, before ever calling +/// into `assert_cmd`, gives this crate's own message instead of a Cargo internal one. An earlier +/// version of this function drove `cargo build --package server --bin iggy-server` directly from +/// every server-spawning test (nextest runs each as its own process, so the `OnceLock` memoized +/// nothing across them), serialized by `.config/nextest.toml`'s `kafka_bridge` test-group but +/// still real, avoidable per-test overhead this version has none of. +/// +/// # Panics +/// +/// Panics if `iggy-server` cannot be found alongside this test binary's own target directory. +fn iggy_server_binary() -> PathBuf { + let current_exe = std::env::current_exe().expect("resolve this test binary's own path"); + let binary_name = format!("iggy-server{}", std::env::consts::EXE_SUFFIX); + current_exe + .ancestors() + .map(|dir| dir.join(&binary_name)) + .find(|candidate| candidate.is_file()) + .unwrap_or_else(|| { + panic!( + "iggy-server binary not found near {} - build it first with \ + `cargo build --package server --bin iggy-server`", + current_exe.display() + ) + }) +} + +pub struct TestServer { + child: Child, + address: String, + password: String, + _port_guard: PortGuard, +} + +impl TestServer { + /// Spawns `iggy-server` with the default `iggy`/`iggy` root credentials. See + /// [`Self::spawn_with_password`] for the general form. + pub async fn spawn(data_dir: &std::path::Path) -> Self { + Self::spawn_with_password(data_dir, "iggy").await + } + + /// Spawns `iggy-server` with an isolated temp data dir, a locked TCP port, and the given root + /// password, then blocks until its listener is ready or the startup budget is exhausted. + /// + /// A dedicated password parameter (not just the `spawn()` default everywhere) lets a + /// password-shaped regression test (special characters, say) reuse this harness's + /// `PortGuard`/graceful-`Drop`/`wait_ready` machinery instead of hand-rolling a second, + /// `Drop`-less spawn: a `Drop`-less copy has no guard to run `graceful_kill` on a panic before + /// its assertions, orphaning a process that still holds its `PortGuard` slot, which then + /// fails the next test that draws that slot to bind. + pub async fn spawn_with_password(data_dir: &std::path::Path, password: &str) -> Self { + let port_guard = PortGuard::acquire(); + let address = format!("127.0.0.1:{}", port_guard.port); + + let mut command = Command::new(iggy_server_binary()); + command + .env("IGGY_PATH", data_dir.display().to_string()) + .env("IGGY_TCP_ADDRESS", &address) + .env("IGGY_HTTP_ENABLED", "false") + .env("IGGY_QUIC_ENABLED", "false") + // WebSocket defaults to enabled on a fixed 127.0.0.1:8092 (config.toml), unlike TCP + // which reads a per-test port from PortGuard - every spawned server here would fight + // over that one port otherwise, and a bind failure aborts boot. + .env("IGGY_WEBSOCKET_ENABLED", "false") + // Matches core/integration's own spawned-server harness (harness/handle/server.rs): + // pinned shards (config.toml default: pin_cores = true) of concurrently running + // servers pile onto the same cores and starve each other. This crate's own tests are + // serialized against each other (.config/nextest.toml's kafka_bridge test-group - + // #[serial] alone does not survive nextest, which runs each test as its own process), + // but a spawned server here can still land alongside a pinned server from a different + // package's test in the same nextest run. + .env("IGGY_SHARDING_PIN_CORES", "false") + // Only half of core/integration's own pairing: unpinning cores stops a server from + // being pinned to a specific range, but config.toml's own default + // (cpu_allocation = "numa:auto") still sizes the shard pool to the whole machine with + // nothing set here - every server this crate's tests spawn would still compete for + // the entire box against unrelated packages' tests in the same nextest run. A small + // fixed range (matching the same harness's own fallback for an unreadable core count) + // caps that instead. + .env("IGGY_SHARDING_CPU_ALLOCATION", "0..4") + // `--with-default-root-credentials` is off by default (args.rs) - without these, + // a fresh server provisions no loginable root user at all, and every bridge connect + // attempt fails with "invalid credentials" no matter what this test passes. + .env("IGGY_ROOT_USERNAME", "iggy") + .env("IGGY_ROOT_PASSWORD", password); + let child = command.spawn().expect("spawn iggy-server"); + + let mut server = Self { + child, + address, + password: password.to_string(), + _port_guard: port_guard, + }; + server.wait_ready().await; + server + } + + /// Polls with a bare TCP connect, not a full `IggyBridge::connect`: the latter carries + /// `RECONNECTION_RETRIES` (3 dials at ~1s apart) on every failed attempt, so a poll loop built + /// on it pays several real seconds per iteration instead of running at its own 100ms cadence, + /// and a *successful* poll iteration would authenticate a client and then drop it without + /// `close()`, leaking a session server-side. A TCP accept slightly ahead of the app being + /// ready to authenticate is fine - every caller's own subsequent `IggyBridge::connect` already + /// retries a few times, which covers the last few hundred ms of that gap. + /// + /// Checks `try_wait()` every iteration: a server that fails at boot (bad config, a port + /// stolen between `PortGuard::acquire` and its own bind) exits almost immediately, and without + /// this check that reads as "still starting" for the full 30s budget - the eventual failure + /// names the wrong cause ("did not become ready") instead of the real one (exited early, with + /// whatever it printed before dying). + async fn wait_ready(&mut self) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + loop { + if let Some(status) = self.child.try_wait().expect("poll child status") { + panic!( + "iggy-server at {} exited during startup with {status}", + self.address + ); + } + if tokio::net::TcpStream::connect(self.address.as_str()) + .await + .is_ok() + { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "iggy-server at {} did not become ready within the startup budget", + self.address + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + pub fn test_config(&self) -> IggyBridgeConfig { + IggyBridgeConfig { + address: self.address.clone(), + username: "iggy".to_string(), + password: SecretString::from(self.password.clone()), + topic_mapping: TopicMapping::new("kafka".to_string(), HashMap::new()) + .expect("valid mapping for this test's fixture data"), + } + } +} + +/// SIGTERM, wait up to `SIGTERM_TIMEOUT`, then SIGKILL if it hasn't exited. Mirrors +/// `core/integration`'s own `harness::handle::common::graceful_kill` (not reused directly - that +/// crate is heavyweight, and pulling it in for one function isn't worth it; this crate already +/// depends directly on `iggy`/`core/sdk`, so `core/integration` wouldn't add a *new* +/// core/sdk-change-reruns-these-tests edge, just an unrelated dependency). A bare SIGKILL skips +/// `iggy-server`'s shutdown path entirely, which is a materially different exit than what the +/// binary is actually built to do on `SIGTERM` - `.kill()` alone bypassed that in every test run +/// before this fix. +const SIGTERM_TIMEOUT: Duration = Duration::from_secs(5); +const SIGKILL_POLL_INTERVAL: Duration = Duration::from_millis(50); + +fn graceful_kill(child: &mut Child) { + // `wait_ready` calls `try_wait()` too (and panics on an early exit, unwinding into this via + // `Drop`) - if it already reaped the child, `child.id()` is a PID the OS is free to hand to an + // unrelated process by the time we get here, and a raw `libc::kill` (unlike `std::Child::kill`, + // which checks its own cached exit status first and no-ops instead) has no such guard against + // signaling that PID anyway. Checking here first closes the same gap `std` already closes for + // its own `kill`. + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } + + let pid = child.id() as libc::pid_t; + // Safety: `pid` is this process's own live child, confirmed by the `try_wait` check above; + // sending it a signal is exactly what `Child::kill` itself does internally. + unsafe { + libc::kill(pid, libc::SIGTERM); + } + + let deadline = std::time::Instant::now() + SIGTERM_TIMEOUT; + while std::time::Instant::now() < deadline { + match child.try_wait() { + Ok(None) => std::thread::sleep(SIGKILL_POLL_INTERVAL), + Ok(Some(_)) | Err(_) => return, + } + } + + let _ = child.kill(); +} + +impl Drop for TestServer { + fn drop(&mut self) { + graceful_kill(&mut self.child); + let _ = self.child.wait(); + } +} + +/// Builds and connects a raw `IggyClient` against `server` - for producing test data directly, +/// independent of the `IggyBridge` under test. Uses the fluent builder, not a hand-built +/// `iggy://user:pass@host` string: `ConnectionString` splits on `@` then `:`, which breaks for +/// any password containing either character. +pub async fn raw_client(server: &TestServer) -> IggyClient { + let client = IggyClientBuilder::new() + .with_tcp() + .with_server_address(server.address.clone()) + .with_auto_sign_in(AutoLogin::Enabled(Credentials::UsernamePassword( + "iggy".to_string(), + SecretString::from("iggy"), + ))) + .build() + .expect("build raw test client"); + client.connect().await.expect("connect raw test client"); + client +} diff --git a/gateways/kafka/tests/golden_wire_fixtures_tests.rs b/gateways/kafka/tests/golden_wire_fixtures_tests.rs index a992c14e96..ebbd9568a7 100644 --- a/gateways/kafka/tests/golden_wire_fixtures_tests.rs +++ b/gateways/kafka/tests/golden_wire_fixtures_tests.rs @@ -29,8 +29,8 @@ use iggy_gateway_kafka::protocol::api::{ use codec::Encoder; use wire::build_api_versions_flexible_request; -#[test] -fn golden_apiversions_v3_flexible_response_fixture() { +#[tokio::test] +async fn golden_apiversions_v3_flexible_response_fixture() { // Field-by-field structural tests (`version_firewall_tests.rs`'s `apiversions_advertises_ // exact_supported_ranges_v3_flexible`) check values against `SCOPED_API_KEYS`, but only this // exact-byte pin catches a wrong tagged-fields byte, wrong varint encoding, or misordered @@ -38,6 +38,7 @@ fn golden_apiversions_v3_flexible_response_fixture() { let broker = BrokerAdvertise::default(); let request = build_api_versions_flexible_request("iggy-test", "0.1.0"); let actual = handle_request(API_KEY_API_VERSIONS, 3, request, &broker) + .await .expect_response("test request has acks != 0 and expects a response"); // error_code=0, api_count=6 (compact array: N+1=7) @@ -63,10 +64,11 @@ fn golden_apiversions_v3_flexible_response_fixture() { assert_eq!(actual.as_ref(), &expected); } -#[test] -fn golden_apiversions_v1_response_fixture() { +#[tokio::test] +async fn golden_apiversions_v1_response_fixture() { let broker = BrokerAdvertise::default(); let actual = handle_request(API_KEY_API_VERSIONS, 1, Bytes::new(), &broker) + .await .expect_response("test request has acks != 0 and expects a response"); // error_code=0, api_count=6 @@ -91,8 +93,8 @@ fn golden_apiversions_v1_response_fixture() { assert_eq!(actual.as_ref(), &expected); } -#[test] -fn golden_metadata_v0_single_topic_response_fixture() { +#[tokio::test] +async fn golden_metadata_v0_single_topic_response_fixture() { let mut request = Encoder::with_capacity(32); request.write_i32(1); // one topic request @@ -101,6 +103,7 @@ fn golden_metadata_v0_single_topic_response_fixture() { let req_bytes = request.freeze(); let actual = handle_request(API_KEY_METADATA, 0, req_bytes, &BrokerAdvertise::default()) + .await .expect_response("test request has acks != 0 and expects a response"); // Metadata v0 layout: brokers[], topics[] (no controller_id - added in v1) diff --git a/gateways/kafka/tests/listener_robustness_tests.rs b/gateways/kafka/tests/listener_robustness_tests.rs index 9c8995b1af..a9c67f5991 100644 --- a/gateways/kafka/tests/listener_robustness_tests.rs +++ b/gateways/kafka/tests/listener_robustness_tests.rs @@ -231,6 +231,7 @@ async fn e2e_response_frames_have_positive_big_endian_length_prefix() { request.clone(), &BrokerAdvertise::default(), ) + .await .expect_response("test request has acks != 0 and expects a response") .len(); // The wire frame also carries the response header `handle_request`'s return value doesn't diff --git a/gateways/kafka/tests/response_negative_tests.rs b/gateways/kafka/tests/response_negative_tests.rs index 58bc110eac..db05f69d47 100644 --- a/gateways/kafka/tests/response_negative_tests.rs +++ b/gateways/kafka/tests/response_negative_tests.rs @@ -31,11 +31,7 @@ use iggy_gateway_kafka::protocol::api::{ ERROR_INVALID_PARTITIONS, ERROR_INVALID_REPLICATION_FACTOR, ERROR_INVALID_REQUEST, ERROR_NOT_CONTROLLER, ERROR_UNSUPPORTED_VERSION, }; -use iggy_gateway_kafka::protocol::responses::{ - encode_create_topics_error_response, encode_create_topics_response, - encode_fetch_error_response, encode_fetch_response, encode_list_offsets_error_response, - encode_produce_error_response, -}; +use iggy_gateway_kafka::protocol::handlers::{create_topics, fetch, list_offsets, produce}; use codec::Decoder; @@ -53,7 +49,7 @@ fn creatable_topic(name: &str, num_partitions: i32, replication_factor: i16) -> #[test] fn create_topics_response_flags_non_positive_partition_count_v2() { let req = CreateTopicsRequest::default().with_topics(vec![creatable_topic("bad-topic", 0, 1)]); - let body = encode_create_topics_response(2, &req).unwrap(); + let body = create_topics::encode_response(2, &req).unwrap(); let mut d = Decoder::new(body); assert_eq!(d.read_i32().unwrap(), 0); // throttle assert_eq!(d.read_i32().unwrap(), 1); // topics len @@ -70,7 +66,7 @@ fn create_topics_v5_broker_default_partitions_is_not_invalid_partitions() { let req = CreateTopicsRequest::default() .with_topics(vec![creatable_topic("default-parts", -1, 2)]) .with_validate_only(true); - let body = encode_create_topics_response(5, &req).unwrap(); + let body = create_topics::encode_response(5, &req).unwrap(); let mut d = Decoder::new(body); assert_eq!(d.read_i32().unwrap(), 0); // throttle assert_eq!(d.read_varint().unwrap(), 2); // one topic @@ -92,7 +88,7 @@ fn create_topics_v5_flags_zero_and_below_minus_one_partition_count() { num_partitions, 1, )]); - let body = encode_create_topics_response(5, &req).unwrap(); + let body = create_topics::encode_response(5, &req).unwrap(); let mut d = Decoder::new(body); assert_eq!(d.read_i32().unwrap(), 0); assert_eq!(d.read_varint().unwrap(), 2); @@ -116,7 +112,7 @@ fn create_topics_v5_flags_invalid_replication_factor() { 1, replication_factor, )]); - let body = encode_create_topics_response(5, &req).unwrap(); + let body = create_topics::encode_response(5, &req).unwrap(); let mut d = Decoder::new(body); assert_eq!(d.read_i32().unwrap(), 0); assert_eq!(d.read_varint().unwrap(), 2); @@ -136,7 +132,7 @@ fn create_topics_v5_flags_invalid_replication_factor() { fn create_topics_v2_rejects_broker_default_sentinel() { // KIP-464 defaults apply from v4; on v2 without assignments, -1 is INVALID_PARTITIONS. let req = CreateTopicsRequest::default().with_topics(vec![creatable_topic("legacy", -1, 1)]); - let body = encode_create_topics_response(2, &req).unwrap(); + let body = create_topics::encode_response(2, &req).unwrap(); let mut d = Decoder::new(body); assert_eq!(d.read_i32().unwrap(), 0); assert_eq!(d.read_i32().unwrap(), 1); @@ -158,7 +154,7 @@ fn create_topics_v2_with_assignments_allows_broker_default_sentinels() { let req = CreateTopicsRequest::default().with_topics(vec![ creatable_topic("assigned", -1, -1).with_assignments(vec![assignment]), ]); - let body = encode_create_topics_response(2, &req).unwrap(); + let body = create_topics::encode_response(2, &req).unwrap(); let mut d = Decoder::new(body); assert_eq!(d.read_i32().unwrap(), 0); assert_eq!(d.read_i32().unwrap(), 1); @@ -171,7 +167,7 @@ fn create_topics_v2_with_assignments_allows_broker_default_sentinels() { #[test] fn create_topics_error_response_carries_explicit_error_code() { - let body = encode_create_topics_error_response(5, ERROR_UNSUPPORTED_VERSION).unwrap(); + let body = create_topics::encode_error_response(5, ERROR_UNSUPPORTED_VERSION).unwrap(); let mut d = Decoder::new(body); assert_eq!(d.read_i32().unwrap(), 0); assert_eq!(d.read_varint().unwrap(), 2); @@ -184,7 +180,7 @@ fn create_topics_error_response_carries_explicit_error_code() { #[test] fn fetch_error_response_v7_uses_top_level_error_and_no_topics() { - let body = encode_fetch_error_response(7, ERROR_INVALID_REQUEST).unwrap(); + let body = fetch::encode_error_response(7, ERROR_INVALID_REQUEST).unwrap(); let mut d = Decoder::new(body); assert_eq!(d.read_i32().unwrap(), 0); // throttle assert_eq!(d.read_i16().unwrap(), ERROR_INVALID_REQUEST); @@ -195,7 +191,7 @@ fn fetch_error_response_v7_uses_top_level_error_and_no_topics() { #[test] fn fetch_error_response_v12_uses_flexible_empty_topics() { - let body = encode_fetch_error_response(12, ERROR_UNSUPPORTED_VERSION).unwrap(); + let body = fetch::encode_error_response(12, ERROR_UNSUPPORTED_VERSION).unwrap(); let mut d = Decoder::new(body); assert_eq!(d.read_i32().unwrap(), 0); // throttle assert_eq!(d.read_i16().unwrap(), ERROR_UNSUPPORTED_VERSION); @@ -208,9 +204,9 @@ fn fetch_error_response_v12_uses_flexible_empty_topics() { #[test] fn list_offsets_error_response_v6_uses_flexible_shape() { // v0's legacy `old_style_offsets` shape has no `kafka_protocol` encoder - see - // `responses::encode_list_offsets_error_response` and the version-firewall e2e coverage for + // `list_offsets::encode_error_response` and the version-firewall e2e coverage for // that behavior. This exercises the lowest version the crate can actually encode. - let body = encode_list_offsets_error_response(6, ERROR_UNSUPPORTED_VERSION).unwrap(); + let body = list_offsets::encode_error_response(6, ERROR_UNSUPPORTED_VERSION).unwrap(); let mut d = Decoder::new(body); assert_eq!(d.read_i32().unwrap(), 0); // throttle assert_eq!(d.read_varint().unwrap(), 2); // one topic @@ -229,7 +225,7 @@ fn list_offsets_error_response_v6_uses_flexible_shape() { #[test] fn produce_error_response_v9_uses_flexible_record_errors_shape() { - let body = encode_produce_error_response(9, ERROR_INVALID_REQUEST).unwrap(); + let body = produce::encode_error_response(9, ERROR_INVALID_REQUEST).unwrap(); let mut d = Decoder::new(body); assert_eq!(d.read_varint().unwrap(), 2); // one topic assert_eq!( @@ -252,25 +248,21 @@ fn success_responses_can_still_encode_empty_request_vectors() { .with_acks(1) .with_timeout_ms(1_000) .with_topic_data(Vec::::new()); - assert!( - !iggy_gateway_kafka::protocol::responses::encode_produce_response(3, &produce) - .unwrap() - .is_empty() - ); + assert!(!produce::encode_response(3, &produce).unwrap().is_empty()); let fetch = FetchRequest::default().with_topics(Vec::::new()); - assert!(!encode_fetch_response(4, &fetch).unwrap().is_empty()); + assert!(!fetch::encode_response(4, &fetch).unwrap().is_empty()); let list_offsets = ListOffsetsRequest::default().with_topics(Vec::::new()); assert!( - !iggy_gateway_kafka::protocol::responses::encode_list_offsets_response(1, &list_offsets) + !list_offsets::encode_response(1, &list_offsets) .unwrap() .is_empty() ); let create_topics = CreateTopicsRequest::default().with_topics(Vec::::new()); assert!( - !encode_create_topics_response(2, &create_topics) + !create_topics::encode_response(2, &create_topics) .unwrap() .is_empty() ); diff --git a/gateways/kafka/tests/server_e2e_tests.rs b/gateways/kafka/tests/server_e2e_tests.rs index 84ddde5d03..b09a044793 100644 --- a/gateways/kafka/tests/server_e2e_tests.rs +++ b/gateways/kafka/tests/server_e2e_tests.rs @@ -234,7 +234,7 @@ async fn e2e_produce_v3_acks_one_still_returns_response() { #[tokio::test] async fn e2e_list_offsets_v0_closes_connection() { // `kafka_protocol` has no encoder for ListOffsets v0's legacy `old_style_offsets` shape - // (it predates the schema the crate generates from - see `responses::encode_list_offsets_error_response`), + // (it predates the schema the crate generates from - see `list_offsets::encode_error_response`), // so a v0 request - already below the firewall's min=1 - now closes instead of getting the // pre-migration downgraded response. let (addr, _shutdown) = spawn_test_server().await; diff --git a/gateways/kafka/tests/version_firewall_tests.rs b/gateways/kafka/tests/version_firewall_tests.rs index 4c8b01323f..c0ea9f3156 100644 --- a/gateways/kafka/tests/version_firewall_tests.rs +++ b/gateways/kafka/tests/version_firewall_tests.rs @@ -100,9 +100,10 @@ fn is_supported_version_matches_scope_table() { /// (Produce, Fetch, `ListOffsets`, Metadata, `ApiVersions`, `CreateTopics`) - /// `supported_ranges_table_has_six_entries` plus `is_supported_version_matches_scope_table` /// already pin that both tables cover the same six keys. -#[test] -fn apiversions_advertises_exact_supported_ranges_v1() { +#[tokio::test] +async fn apiversions_advertises_exact_supported_ranges_v1() { let body = handle_request(API_KEY_API_VERSIONS, 1, Bytes::new(), &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); assert_eq!(d.read_i16().unwrap(), 0); @@ -128,14 +129,15 @@ fn apiversions_advertises_exact_supported_ranges_v1() { /// See `apiversions_advertises_exact_supported_ranges_v1` for why this checks against /// `SCOPED_API_KEYS`, not `supported_api_ranges()`. -#[test] -fn apiversions_advertises_exact_supported_ranges_v3_flexible() { +#[tokio::test] +async fn apiversions_advertises_exact_supported_ranges_v3_flexible() { let body = handle_request( API_KEY_API_VERSIONS, 3, build_api_versions_flexible_request("iggy-test", "0.1.0"), &default_broker(), ) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); assert_eq!(d.read_i16().unwrap(), 0); @@ -166,8 +168,8 @@ fn apiversions_advertises_exact_supported_ranges_v3_flexible() { // `SCOPED_API_KEYS` (independent of `supported_api_ranges()`, the function under test) plus the // acks=0/acks!=0 behavioral split this shorter version never covered. -#[test] -fn apiversions_all_versions_return_success() { +#[tokio::test] +async fn apiversions_all_versions_return_success() { for version in 0i16..=3 { let request = if version >= 3 { build_api_versions_flexible_request("iggy-test", "0.1.0") @@ -175,15 +177,17 @@ fn apiversions_all_versions_return_success() { Bytes::new() }; let body = handle_request(API_KEY_API_VERSIONS, version, request, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); assert_eq!(d.read_i16().unwrap(), 0, "ApiVersions v{version}"); } } -#[test] -fn apiversions_out_of_range_returns_unsupported_in_body() { +#[tokio::test] +async fn apiversions_out_of_range_returns_unsupported_in_body() { let body = handle_request(API_KEY_API_VERSIONS, 99, Bytes::new(), &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(body); assert_eq!(d.read_i16().unwrap(), ERROR_UNSUPPORTED_VERSION); @@ -193,8 +197,8 @@ fn metadata_request_one_topic() -> Bytes { build_metadata_legacy_request(&["test-topic"]) } -#[test] -fn metadata_below_min_version_closes_connection() { +#[tokio::test] +async fn metadata_below_min_version_closes_connection() { assert!( handle_request( API_KEY_METADATA, @@ -202,13 +206,14 @@ fn metadata_below_min_version_closes_connection() { metadata_request_one_topic(), &default_broker(), ) + .await .is_close(), "Metadata below supported min must close rather than return a clamped body" ); } -#[test] -fn metadata_above_max_version_closes_connection() { +#[tokio::test] +async fn metadata_above_max_version_closes_connection() { // v10 request uses flexible encoding; a clamped v9 reply would not survive client parsing. assert!( handle_request( @@ -217,6 +222,7 @@ fn metadata_above_max_version_closes_connection() { build_metadata_flexible_request_v10(&["test-topic"]), &default_broker(), ) + .await .is_close(), "Metadata above supported max must close rather than return a clamped body" ); @@ -238,8 +244,8 @@ async fn e2e_metadata_above_max_version_closes_tcp_connection() { ); } -#[test] -fn produce_below_min_version_with_nonzero_acks_closes_connection() { +#[tokio::test] +async fn produce_below_min_version_with_nonzero_acks_closes_connection() { // Produce v2 is below both the firewall min (3) and `kafka_protocol`'s schema floor (3-13) // - no encodable response exists at this version, so a client expecting a reply (acks != 0) // gets a close instead of the pre-migration downgraded error response. acks=0 still keeps @@ -250,83 +256,98 @@ fn produce_below_min_version_with_nonzero_acks_closes_connection() { 2, build_produce_v2_body(1, 0), &default_broker(), - ); + ) + .await; assert!( body.is_close(), "Produce v2 with acks != 0 has no encodable response shape and must close" ); } -#[test] -fn fetch_below_min_version_closes_connection() { +#[tokio::test] +async fn fetch_below_min_version_closes_connection() { // Fetch v3 is below both the firewall min (4) and `kafka_protocol`'s schema floor (4-18) - // no encodable response exists at this version, so this closes instead of the // pre-migration downgraded error response. assert!( - handle_request(API_KEY_FETCH, 3, Bytes::new(), &default_broker()).is_close(), + handle_request(API_KEY_FETCH, 3, Bytes::new(), &default_broker()) + .await + .is_close(), "Fetch v3 has no encodable response shape and must close" ); } -#[test] -fn fetch_unsupported_version_above_max_closes_connection() { +#[tokio::test] +async fn fetch_unsupported_version_above_max_closes_connection() { // Fetch v13+ response shape differs from the v12 encoder; a clamped body is unparsable. assert!( - handle_request(API_KEY_FETCH, 13, Bytes::new(), &default_broker()).is_close(), + handle_request(API_KEY_FETCH, 13, Bytes::new(), &default_broker()) + .await + .is_close(), "Fetch above encoder max must close rather than return a clamped body" ); } -#[test] -fn produce_unsupported_version_above_max_closes_connection() { +#[tokio::test] +async fn produce_unsupported_version_above_max_closes_connection() { assert!( - handle_request(API_KEY_PRODUCE, 13, Bytes::new(), &default_broker()).is_close(), + handle_request(API_KEY_PRODUCE, 13, Bytes::new(), &default_broker()) + .await + .is_close(), "Produce above encoder max must close rather than return a clamped body" ); } -#[test] -fn create_topics_unsupported_version_above_max_closes_connection() { +#[tokio::test] +async fn create_topics_unsupported_version_above_max_closes_connection() { assert!( - handle_request(API_KEY_CREATE_TOPICS, 7, Bytes::new(), &default_broker()).is_close(), + handle_request(API_KEY_CREATE_TOPICS, 7, Bytes::new(), &default_broker()) + .await + .is_close(), "CreateTopics above encoder max must close rather than return a clamped body" ); } -#[test] -fn list_offsets_unsupported_version_above_max_closes_connection() { +#[tokio::test] +async fn list_offsets_unsupported_version_above_max_closes_connection() { assert!( - handle_request(API_KEY_LIST_OFFSETS, 7, Bytes::new(), &default_broker()).is_close(), + handle_request(API_KEY_LIST_OFFSETS, 7, Bytes::new(), &default_broker()) + .await + .is_close(), "ListOffsets above encoder max must close rather than return a clamped body" ); } -#[test] -fn list_offsets_v0_closes_connection() { +#[tokio::test] +async fn list_offsets_v0_closes_connection() { // `kafka_protocol` has no encoder for ListOffsets v0's legacy `old_style_offsets` shape (it // predates the schema the crate generates from), so a v0 request - already below the // firewall's min=1 - now closes instead of getting the pre-migration downgraded response. assert!( - handle_request(API_KEY_LIST_OFFSETS, 0, Bytes::new(), &default_broker()).is_close(), + handle_request(API_KEY_LIST_OFFSETS, 0, Bytes::new(), &default_broker()) + .await + .is_close(), "ListOffsets v0 has no encodable response shape and must close" ); } -#[test] -fn create_topics_below_min_version_closes_connection() { +#[tokio::test] +async fn create_topics_below_min_version_closes_connection() { // CreateTopics v1 is below both the firewall min (2) and `kafka_protocol`'s schema floor // (2-7) - no encodable response exists at this version, so this closes instead of the // pre-migration downgraded error response. assert!( - handle_request(API_KEY_CREATE_TOPICS, 1, Bytes::new(), &default_broker()).is_close(), + handle_request(API_KEY_CREATE_TOPICS, 1, Bytes::new(), &default_broker()) + .await + .is_close(), "CreateTopics v1 has no encodable response shape and must close" ); } -#[test] -fn unsupported_api_keys_close_connection() { +#[tokio::test] +async fn unsupported_api_keys_close_connection() { for key in [8, 9, 10, 11, 17, 20, 42, 999] { - let outcome = handle_request(key, 0, Bytes::new(), &default_broker()); + let outcome = handle_request(key, 0, Bytes::new(), &default_broker()).await; assert!( outcome.is_close(), "unknown api_key {key} must close (no parseable response schema)" @@ -338,8 +359,8 @@ fn unsupported_api_keys_close_connection() { // `api_handler_tests.rs::handle_request_succeeds_for_every_supported_version_with_fixture` - // that generic per-scoped-API loop already exercises the exact same fixtures and version ranges. -#[test] -fn corrupt_produce_body_with_acks_stays_silent() { +#[tokio::test] +async fn corrupt_produce_body_with_acks_stays_silent() { // `kafka_protocol` decodes Produce in one shot, so a decode failure never exposes `acks` // (unlike the pre-migration field-by-field decoder, which could still answer with // INVALID_REQUEST once it knew acks was nonzero). Every Produce decode failure now stays @@ -351,27 +372,30 @@ fn corrupt_produce_body_with_acks_stays_silent() { 0xFF, 0xFF, 0xFF, // truncated topics count ]); assert!( - handle_request(API_KEY_PRODUCE, 3, body, &default_broker()).is_no_response(), + handle_request(API_KEY_PRODUCE, 3, body, &default_broker()) + .await + .is_no_response(), "malformed Produce body must stay silent regardless of acks" ); } -#[test] -fn corrupt_produce_body_before_acks_is_silent() { +#[tokio::test] +async fn corrupt_produce_body_before_acks_is_silent() { // Decode fails before acks is read: the client's response expectation is unknowable, and an // error response could desync an acks=0 fire-and-forget client, so the server stays silent. let body = Bytes::from_static(&[0xFF, 0xFF]); // null transactional_id, then EOF - let outcome = handle_request(API_KEY_PRODUCE, 3, body, &default_broker()); + let outcome = handle_request(API_KEY_PRODUCE, 3, body, &default_broker()).await; assert!( outcome.is_no_response(), "produce decode failure before acks must be silent" ); } -#[test] -fn corrupt_fetch_body_returns_invalid_request_error() { +#[tokio::test] +async fn corrupt_fetch_body_returns_invalid_request_error() { let body = Bytes::from_static(&[0xFF, 0xFF, 0xFF]); let resp = handle_request(API_KEY_FETCH, 4, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(resp); assert_eq!(d.read_i32().unwrap(), 0); @@ -384,11 +408,13 @@ fn corrupt_fetch_body_returns_invalid_request_error() { // ── ListOffsets v0 (no encodable representation in kafka_protocol) ───────── -#[test] -fn list_offsets_v0_with_topic_closes_connection() { +#[tokio::test] +async fn list_offsets_v0_with_topic_closes_connection() { let request_body = build_list_offsets_v0_request_with_topic_t(); assert!( - handle_request(API_KEY_LIST_OFFSETS, 0, request_body, &default_broker()).is_close(), + handle_request(API_KEY_LIST_OFFSETS, 0, request_body, &default_broker()) + .await + .is_close(), "ListOffsets v0 has no encodable response shape and must close, even with a well-formed body" ); } @@ -571,10 +597,10 @@ async fn apiversions_v4_out_of_range_e2e_returns_unsupported() { // ── Out-of-scope API keys (SCOPE.md unsupported list) ─────────────────────── -#[test] -fn out_of_scope_api_keys_close_without_panic() { +#[tokio::test] +async fn out_of_scope_api_keys_close_without_panic() { for &(api_key, name) in OUT_OF_SCOPE_API_KEYS { - let outcome = handle_request(api_key, 0, Bytes::new(), &default_broker()); + let outcome = handle_request(api_key, 0, Bytes::new(), &default_broker()).await; assert!(outcome.is_close(), "{name} (key {api_key}) must close"); } } @@ -685,8 +711,8 @@ async fn each_scoped_api_below_min_version_e2e_closes_except_api_versions() { ); } -#[test] -fn produce_advertises_min_zero_but_firewall_rejects_below_v3() { +#[tokio::test] +async fn produce_advertises_min_zero_but_firewall_rejects_below_v3() { let range = SCOPED_API_KEYS .iter() .find(|(k, _, _, _)| *k == API_KEY_PRODUCE) @@ -706,6 +732,7 @@ fn produce_advertises_min_zero_but_firewall_rejects_below_v3() { build_produce_v2_body(0, 0), &default_broker(), ) + .await .is_no_response(), "Produce v2 acks=0 must stay silent, not close" ); @@ -718,6 +745,7 @@ fn produce_advertises_min_zero_but_firewall_rejects_below_v3() { build_produce_v2_body(1, 0), &default_broker(), ) + .await .is_close(), "Produce v2 acks != 0 has no encodable response shape and must close" ); @@ -770,10 +798,11 @@ async fn create_topics_v1_unsupported_e2e_closes_connection() { /// `ERROR_INVALID_REQUEST` specifically - never `ERROR_UNSUPPORTED_VERSION`, which /// `handle_versioned_request` only reaches on the *unsupported-version* branch, not a decode /// failure - so this is a precise decode, not a scan for either. -#[test] -fn corrupt_list_offsets_body_returns_invalid_request_error() { +#[tokio::test] +async fn corrupt_list_offsets_body_returns_invalid_request_error() { let body = Bytes::from_static(&[0xFF, 0xFF, 0xFF]); let resp = handle_request(API_KEY_LIST_OFFSETS, 1, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(resp); // v1 has no throttle_time_ms (added v2+). @@ -786,10 +815,11 @@ fn corrupt_list_offsets_body_returns_invalid_request_error() { /// v2 (legacy, non-flexible) - see `corrupt_list_offsets_body_returns_invalid_request_error` /// above for why this is a precise decode of `ERROR_INVALID_REQUEST`, not a scan for either code. -#[test] -fn corrupt_create_topics_body_returns_invalid_request_error() { +#[tokio::test] +async fn corrupt_create_topics_body_returns_invalid_request_error() { let body = Bytes::from_static(&[0xFF, 0xFF, 0xFF]); let resp = handle_request(API_KEY_CREATE_TOPICS, 2, body, &default_broker()) + .await .expect_response("test request has acks != 0 and expects a response"); let mut d = Decoder::new(resp); assert_eq!(d.read_i32().unwrap(), 0, "throttle"); diff --git a/gateways/kafka/tools/kafka-tool/Cargo.toml b/gateways/kafka/tools/kafka-tool/Cargo.toml index 809704cb76..f9650df949 100644 --- a/gateways/kafka/tools/kafka-tool/Cargo.toml +++ b/gateways/kafka/tools/kafka-tool/Cargo.toml @@ -33,10 +33,10 @@ path = "src/main.rs" anyhow = { workspace = true } bytes = { workspace = true } clap = { workspace = true } -hex = "0.4" -iggy-gateway-kafka = { path = "../.." } +hex = { workspace = true } +iggy-gateway-kafka = { workspace = true } indexmap = { workspace = true } -kafka-protocol = "0.17" +kafka-protocol = { workspace = true, features = ["client"] } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/gateways/kafka/tools/kafka-tool/src/main.rs b/gateways/kafka/tools/kafka-tool/src/main.rs index d8ede17dd8..51421cbd6f 100644 --- a/gateways/kafka/tools/kafka-tool/src/main.rs +++ b/gateways/kafka/tools/kafka-tool/src/main.rs @@ -267,6 +267,7 @@ fn build_payload(api_key: i16, version: i16) -> Result { let rec = Record { transactional: false, control: false, + delete_horizon: false, partition_leader_epoch: 0, producer_id: -1, producer_epoch: -1, diff --git a/helm/charts/iggy/Chart.yaml b/helm/charts/iggy/Chart.yaml index 08f86a8030..a521509243 100644 --- a/helm/charts/iggy/Chart.yaml +++ b/helm/charts/iggy/Chart.yaml @@ -20,8 +20,8 @@ apiVersion: v2 name: iggy description: A Helm chart for Apache Iggy server and web-ui type: application -version: 0.5.0 -appVersion: "0.7.0" +version: 0.6.0 +appVersion: "0.9.0-edge.6" sources: - https://github.com/apache/iggy keywords: diff --git a/helm/charts/iggy/README.md b/helm/charts/iggy/README.md index 4e4cd42116..45eef8bd7e 100644 --- a/helm/charts/iggy/README.md +++ b/helm/charts/iggy/README.md @@ -2,7 +2,7 @@ A Helm chart for Apache Iggy server and web-ui -![Version: 0.5.0](https://img.shields.io/badge/Version-0.5.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.7.0](https://img.shields.io/badge/AppVersion-0.7.0-informational?style=flat-square) +![Version: 0.6.0](https://img.shields.io/badge/Version-0.6.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.9.0-edge.6](https://img.shields.io/badge/AppVersion-0.9.0--edge.6-informational?style=flat-square) ## Prerequisites @@ -15,8 +15,15 @@ A Helm chart for Apache Iggy server and web-ui Iggy server uses `io_uring` for high-performance async I/O. This requires: -1. **IPC_LOCK capability** - For locking memory required by io_uring -2. **Unconfined seccomp profile** - To allow io_uring syscalls +1. **Linux kernel 5.19 or newer on the node** + + * Shard rings require `IORING_SETUP_COOP_TASKRUN` and `IORING_SETUP_TASKRUN_FLAG`. + * Compio's asynchronous socket creation requires `IORING_OP_SOCKET`. + * Mainline Linux provides these features starting in 5.19. + * Older kernels fail during shard startup. The node kernel matters, not the container image. + +2. **IPC_LOCK capability** - For locking memory required by io_uring +3. **Unconfined seccomp profile** - To allow io_uring syscalls These are configured by default for the Iggy server via the chart's root-level `securityContext` and `podSecurityContext`. The web UI uses `ui.securityContext` @@ -76,6 +83,160 @@ If Prometheus Operator is installed and you want monitoring, set `server.serviceMonitor.enabled=true` in `custom-values.yaml` or pass it on the command line with `--set server.serviceMonitor.enabled=true`. +## Cluster Mode + +The server runs a Viewstamped Replication cluster when `cluster.enabled` is set +and each node is told which roster entry it is. The chart models this as **one +release per node**: every release is handed the same roster and overrides only +its own identity. + +`helm/charts/iggy/examples/cluster-3-node.yaml` is a ready three-node roster. +Point the `ip` values at the nodes you will pin the releases to, then: + +```bash +for i in 0 1 2; do + helm upgrade --install "iggy-n$i" ./helm/charts/iggy \ + -f ./helm/charts/iggy/examples/cluster-3-node.yaml \ + --set "server.cluster.selfReplicaId=$i" \ + --set "server.nodeSelector.kubernetes\.io/hostname=node-$i" +done +``` + +The chart turns `server.cluster` into the server's `IGGY_CLUSTER_*` environment +variables and passes `--replica-id`, so no configuration file is mounted. The +values mirror the server's `[[cluster.nodes]]` config one for one. + +### Why hostNetwork and node pinning are required + +The server's consensus listener binds `cluster.nodes[*].ip` **verbatim**, unlike +the client listeners, which keep their own bind address and take only the port +from the roster. A pod therefore has to own the address its roster entry names. +Pod IPs are neither stable nor known before install, and a Service ClusterIP is +not an address a pod can bind, so the workable layout today is `hostNetwork: +true` with each release pinned to a node and its roster `ip` set to that node's +IP. Node IPs are known up front and survive a pod restart, which is what lets a +replica come back and rejoin. + +The cost is real: the server pod shares the node's network namespace and its +ports. Weigh it before running this in a shared cluster. + +If a release lands on a node whose IP is not the one in its roster entry, the +server refuses to start rather than misbehaving quietly: + +```text +Error: ShardJoinFailures { failures: [ShardJoinFailure { shard_id: 0, + kind: Error(Iggy(CannotBindToSocket("10.0.1.11:9090"))) }] } +``` + +Check the `nodeSelector` on that release against the `ip` in its roster entry. + +### Upgrades + +Host ports make a rolling update impossible: the replacement pod cannot bind +ports the outgoing pod still holds, so it stays `Pending` while the Deployment +waits for it to become ready. The chart therefore switches the server to the +`Recreate` strategy whenever `hostNetwork` is set, which takes the node down +for the length of the restart. Roll **one release at a time** and let the +cluster regain quorum before starting the next. + +Turning `hostNetwork` on for a release that already exists moves the Deployment +from `RollingUpdate` to `Recreate`. Under Helm 4's server-side apply that is +rejected, because the patch cannot drop the `rollingUpdate` block the API server +defaulted in: + +```text +Deployment.apps "iggy-n0" is invalid: spec.strategy.rollingUpdate: Forbidden: + may not be specified when strategy `type` is 'Recreate' +``` + +On Helm 4, run that one upgrade with `helm upgrade --server-side=false`, which +replaces the strategy in place. Helm 3 has no such flag, since it does not use +server-side apply. Set `server.strategy` explicitly if you want a different +strategy. + +### Roster rules + +* Every node runs the **identical** `server.cluster.nodes` list. Only + `selfReplicaId` differs between releases. +* `replicaId` values are unique and cover `0..N-1` for an `N`-node roster. +* `ports.tcpReplica` is required on every entry. In cluster mode the server + takes every listener port from the roster and will not fall back to defaults, + because two nodes on one host would otherwise race for the same socket. The + remaining ports default to `server.ports`. +* `server.cluster.name` is hashed into the on-disk cluster id on first boot. + Changing it later makes the server refuse to start against existing data. +* `ip` must be a literal IP. Hostnames are rejected. Use + `advertisedAddress` for the name clients dial, which does accept DNS. + +### Secrets + +Four settings have to carry the byte-identical value on every node, so they +belong in one Secret that all releases reference rather than in each release's +values. Create it in the release namespace before the first install: + +```bash +JWT="$(head -c 32 /dev/urandom | base64)" +kubectl create secret generic iggy-cluster-secrets \ + --from-literal=username=iggy \ + --from-literal=password="$(head -c 24 /dev/urandom | base64)" \ + --from-literal=clusterSharedSecret="$(head -c 32 /dev/urandom | base64)" \ + --from-literal=jwtEncodingSecret="$JWT" \ + --from-literal=jwtDecodingSecret="$JWT" \ + --from-literal=encryptionKey="$(head -c 32 /dev/urandom | base64)" +``` + +`examples/cluster-3-node.yaml` points `server.users.root`, `server.encryption`, +`server.jwt` and `server.cluster.auth` at that one Secret. All four also accept +an inline value, which the chart turns into a Secret of its own: +`-root-credentials` for the root username and password, +`-secrets` for the other three. That is convenient for a single node +and a poor fit for a cluster, since the value then lives in every release's +stored values. + +* **`server.users.root`** seeds the root user. Every node creates it locally + from its own `IGGY_ROOT_USERNAME` and `IGGY_ROOT_PASSWORD`, so a password that + differs on one release logs in on that node only, and a first cluster boot + refuses to start without both. +* **`server.cluster.auth`** makes every replica connection complete an + authenticated handshake or be rejected. The key is at least 32 bytes of + CSPRNG output. Turning it on or off is a coordinated restart of the whole + cluster: a node that authenticates cannot talk to one that does not. +* **`server.encryption`** encrypts message payloads and state commands at rest + with AES-256-GCM, under a 32-byte base64 key. A node holding a different key + cannot read what its peers wrote. The chart selects the legacy configuration + for versioned images through `0.9.0-edge.7`, including its pinned image. + For newer builds with `[encryption]`, set `server.encryption.configVersion=flat`. + Custom tags require an explicit `flat` or `legacy` layout; `legacy` uses + `[system.encryption]`. +* **`server.jwt`** makes HTTP bearer tokens valid on every node and survive a + restart. With `cluster.auth` enabled the server already derives a cluster-wide + key from the replica PSK, so setting the JWT secrets is an alternative to that + rather than an addition; setting them anyway keeps tokens working if auth is + later turned off. + +None of the encryption, JWT and replica-auth values is written to the data +directory, and each is masked as `******` in the startup log. + +### Storage + +A replica cannot move between nodes without invalidating its roster entry, so +give each release storage that stays on its node, and size +`server.persistence` per node rather than for the cluster. A replica that starts +on an empty volume rejoins by state transfer, which is correct but re-reads the +whole dataset from its peers. `server.persistence.enabled: false` puts the +replica on `emptyDir` and forces exactly that on every pod replacement, so the +cluster example enables persistence and leaves `storageClass` for you to point +at a node-local provisioner. + +### What the chart refuses + +`server.replicaCount > 1` fails at render time. Scaling the server Deployment +produces N independent servers behind one Service, all writing the same PVC +subpath with no lock between them, which corrupts the data directory while +`helm --wait` still reports success. The chart ships no HorizontalPodAutoscaler +for the same reason. Cluster size is a roster decision, so add a node to +`server.cluster.nodes` and install another release instead. + ## Uninstallation ```bash @@ -141,13 +302,13 @@ If a previous local smoke install failed and left resources behind, reset the sm scripts/ci/test-helm.sh cleanup-smoke ``` -On Apple Silicon hosts, the released `apache/iggy:0.7.0` `arm64` image may still fail during the runtime smoke path in kind. If your Docker setup supports amd64 emulation well enough, you can try recreating the dedicated smoke cluster with: +On Apple Silicon hosts, the released `arm64` server image may still fail during the runtime smoke path in kind. If your Docker setup supports amd64 emulation well enough, you can try recreating the dedicated smoke cluster with: ```bash HELM_SMOKE_KIND_PLATFORM=linux/amd64 scripts/ci/setup-helm-smoke-cluster.sh ``` -The smoke script defaults `IGGY_SYSTEM_SHARDING_CPU_ALLOCATION=1` for the server pod so the local kind path avoids the chart's `numa:auto` default and keeps the local runtime to a single shard, which has been more reliable on containerized local nodes. If you need a different local override, set `HELM_SMOKE_SERVER_CPU_ALLOCATION` before running `scripts/ci/test-helm.sh smoke`. Pass `--cleanup` to remove the smoke namespace after a successful run; omit it if you want to inspect the deployed resources. +The smoke script defaults `IGGY_SHARDING_CPU_ALLOCATION=1` for the server pod so the local kind path avoids the chart's `numa:auto` default and keeps the local runtime to a single shard, which has been more reliable on containerized local nodes. If you need a different local override, set `HELM_SMOKE_SERVER_CPU_ALLOCATION` before running `scripts/ci/test-helm.sh smoke`. Pass `--cleanup` to remove the smoke namespace after a successful run; omit it if you want to inspect the deployed resources. On smoke-test failures you can collect the same diagnostics as CI with: @@ -210,6 +371,29 @@ Ensure the server binds to `0.0.0.0` instead of `127.0.0.1`. This is configured * `IGGY_TCP_ADDRESS=0.0.0.0:8090` * `IGGY_QUIC_ADDRESS=0.0.0.0:8080` +A wildcard bind says which interfaces accept connections, not where clients +reach the pod, so the server also needs the address to publish in cluster +metadata. Server builds that carry the setting refuse to start without it. +`0.9.0-edge.6`, the image this chart pins, predates it: +that build logs `IGGY_NODE_ADVERTISED_ADDRESS` as an unknown variable and +publishes the bind address, so the chart's default stays inert until the pinned +image moves past it. Either way the chart sets `IGGY_NODE_ADVERTISED_ADDRESS` to +the in-cluster Service DNS name; override it with `server.advertisedAddress` +when clients arrive through a LoadBalancer or an Ingress. + +In cluster mode the server ignores the variable altogether and publishes each +node's roster `ip`, or its `advertisedAddress` when the entry carries one, so +the chart leaves the variable out there and refuses a render that sets +`server.advertisedAddress` alongside `server.cluster.enabled`. + +Declaring `IGGY_NODE_ADVERTISED_ADDRESS` in `server.env` yourself works too: +the chart then leaves its own default out, so the variable is declared once. +Give that entry a non-empty value; an empty one suppresses the chart default +and reaches the server as unset, so the render fails instead. +Setting it in `server.env` and in `server.advertisedAddress` at the same time +is refused at render time rather than resolved silently, since only the +`server.env` entry would take effect. + ## Accessing the Server ### Port Forward @@ -301,10 +485,6 @@ pre-commit install | Key | Type | Default | Description | |-----|------|---------|-------------| | additionalLabels | object | `{}` | Additional labels for all resources | -| autoscaling.enabled | bool | `false` | Enable horizontal pod autoscaling | -| autoscaling.maxReplicas | int | `100` | Maximum replicas for autoscaling | -| autoscaling.minReplicas | int | `1` | Minimum replicas for autoscaling | -| autoscaling.targetCPUUtilizationPercentage | int | `80` | Target CPU utilization for autoscaling | | fullnameOverride | string | `""` | Override full release name | | imagePullSecrets | list | `[]` | Image pull secrets for private registries | | nameOverride | string | `""` | Override chart name | @@ -312,18 +492,45 @@ pre-commit install | podSecurityContext | object | `{"seccompProfile":{"type":"Unconfined"}}` | Pod security context (server uses io_uring, requires unconfined seccomp) | | resources | object | `{}` | Resource limits and requests for server | | securityContext | object | `{"capabilities":{"add":["IPC_LOCK"]}}` | Container security context (server requires IPC_LOCK for io_uring) | -| server | object | `{"affinity":{},"enabled":true,"env":[{"name":"RUST_LOG","value":"info"},{"name":"IGGY_HTTP_ADDRESS","value":"0.0.0.0:3000"},{"name":"IGGY_TCP_ADDRESS","value":"0.0.0.0:8090"},{"name":"IGGY_QUIC_ADDRESS","value":"0.0.0.0:8080"},{"name":"IGGY_WEBSOCKET_ADDRESS","value":"0.0.0.0:8092"}],"image":{"pullPolicy":"Always","repository":"apache/iggy","tag":"0.7.0"},"ingress":{"annotations":{},"className":"","enabled":false,"hosts":[{"host":"chart-example.local","paths":[{"path":"/","pathType":"ImplementationSpecific"}]}],"tls":[]},"nodeSelector":{},"persistence":{"accessMode":"ReadWriteOnce","annotations":{},"enabled":false,"existingClaim":"","size":"8Gi","storageClass":""},"ports":{"http":3000,"quic":8080,"tcp":8090},"replicaCount":1,"service":{"port":3000,"type":"ClusterIP"},"serviceMonitor":{"additionalLabels":{},"authorization":{},"enabled":false,"honorLabels":false,"interval":"30s","namespace":"","path":"/metrics","scrapeTimeout":"10s"},"tolerations":[],"users":{"root":{"createSecret":true,"existingSecret":{"name":"","passwordKey":"password","usernameKey":"username"},"password":"changeit","username":"iggy"}}}` | Iggy server configuration | +| server | object | `{"advertisedAddress":"","affinity":{},"cluster":{"auth":{"enabled":false,"existingSecret":{"name":"","previousSharedSecretKey":"clusterPreviousSharedSecret","sharedSecretKey":"clusterSharedSecret"},"previousSharedSecret":"","sharedSecret":""},"enabled":false,"name":"iggy-cluster","nodes":[],"requireHostNetwork":true,"selfReplicaId":0},"enabled":true,"encryption":{"configVersion":"auto","enabled":false,"existingSecret":{"key":"encryptionKey","name":""},"key":""},"env":[{"name":"RUST_LOG","value":"info"},{"name":"IGGY_HTTP_ADDRESS","value":"0.0.0.0:3000"},{"name":"IGGY_TCP_ADDRESS","value":"0.0.0.0:8090"},{"name":"IGGY_QUIC_ADDRESS","value":"0.0.0.0:8080"},{"name":"IGGY_WEBSOCKET_ADDRESS","value":"0.0.0.0:8092"}],"extraArgs":[],"hostNetwork":false,"image":{"pullPolicy":"Always","repository":"apache/iggy","tag":""},"ingress":{"annotations":{},"className":"","enabled":false,"hosts":[{"host":"chart-example.local","paths":[{"path":"/","pathType":"ImplementationSpecific"}]}],"tls":[]},"jwt":{"decodingSecret":"","encodingSecret":"","existingSecret":{"decodingSecretKey":"jwtDecodingSecret","encodingSecretKey":"jwtEncodingSecret","name":""}},"nodeSelector":{},"persistence":{"accessMode":"ReadWriteOnce","annotations":{},"enabled":false,"existingClaim":"","size":"8Gi","storageClass":""},"ports":{"http":3000,"quic":8080,"tcp":8090,"websocket":8092},"replicaCount":1,"service":{"port":3000,"type":"ClusterIP"},"serviceMonitor":{"additionalLabels":{},"authorization":{},"enabled":false,"honorLabels":false,"interval":"30s","namespace":"","path":"/metrics","scrapeTimeout":"10s"},"strategy":{},"tolerations":[],"users":{"root":{"createSecret":true,"existingSecret":{"name":"","passwordKey":"password","usernameKey":"username"},"password":"changeit","username":"iggy"}}}` | Iggy server configuration | +| server.advertisedAddress | string | `""` | Client-facing address published in cluster metadata. Declaring `IGGY_NODE_ADVERTISED_ADDRESS` in `server.env` instead also works, but setting both is refused at render time. Empty falls back to the in-cluster Service DNS name. Ignored in cluster mode, where the address comes from the node's roster entry, so setting both is refused there too. | | server.affinity | object | `{}` | Affinity rules for server pods | +| server.cluster.auth | object | `{"enabled":false,"existingSecret":{"name":"","previousSharedSecretKey":"clusterPreviousSharedSecret","sharedSecretKey":"clusterSharedSecret"},"previousSharedSecret":"","sharedSecret":""}` | Replica-to-replica authentication on the consensus port. When enabled every peer must complete an authenticated handshake or be rejected, and a shared secret becomes mandatory. Enabling it on a running cluster is a coordinated-restart change, not a rolling one. | +| server.cluster.auth.enabled | bool | `false` | Require the authenticated replica handshake | +| server.cluster.auth.existingSecret.name | string | `""` | Name of an existing Secret holding the pre-shared keys | +| server.cluster.auth.existingSecret.previousSharedSecretKey | string | `"clusterPreviousSharedSecret"` | Key inside that Secret holding the retiring shared secret | +| server.cluster.auth.existingSecret.sharedSecretKey | string | `"clusterSharedSecret"` | Key inside that Secret holding the active shared secret | +| server.cluster.auth.previousSharedSecret | string | `""` | Retiring key, accepted for verification only while a rotation is in flight. Leave empty outside a rotation. | +| server.cluster.auth.sharedSecret | string | `""` | Cluster-wide pre-shared key, at least 32 bytes of CSPRNG output, byte-identical on every node. Ignored when `existingSecret.name` is set. | +| server.cluster.enabled | bool | `false` | Enable cluster (VSR consensus) mode. One Helm release per node: every release shares the same `nodes` roster and overrides only `selfReplicaId`. See the Cluster Mode section of the chart README. | +| server.cluster.name | string | `"iggy-cluster"` | Cluster name, byte-identical on every node. Hashed into the on-disk cluster id on first boot, so changing it later means starting from an empty data directory. | +| server.cluster.nodes | list | `[]` | Cluster roster, mirroring the server's `[[cluster.nodes]]` config. Every node runs the identical list. `ip` is the replica-plane address: this node's consensus listener binds it verbatim and every peer dials it verbatim, so it must be a literal IP that the pod itself owns. With `server.hostNetwork` that is the node IP. `ports.tcpReplica` is required on every entry; the remaining ports default to `server.ports`. | +| server.cluster.requireHostNetwork | bool | `true` | Refuse to render a cluster node without `server.hostNetwork`. The replica listener binds the roster `ip` verbatim, which no pod owns on the cluster network, so the pod would die at boot with `CannotBindToSocket`. Set this to false only when the roster `ip` is an address the pod itself holds. | +| server.cluster.selfReplicaId | int | `0` | Which `nodes` entry this release runs, matched against `replicaId`. | | server.enabled | bool | `true` | Enable the Iggy server deployment | +| server.encryption | object | `{"configVersion":"auto","enabled":false,"existingSecret":{"key":"encryptionKey","name":""},"key":""}` | Server-side encryption of message payloads and state commands, using AES-256-GCM. Every node of a cluster must hold the identical key, or it cannot read data another node wrote. | +| server.encryption.configVersion | string | `"auto"` | Encryption config layout: `auto` selects `legacy` for released image versions through 0.9.0-edge.7. Set `flat` for newer server builds, or `legacy` for custom builds that still use `[system.encryption]`. | +| server.encryption.enabled | bool | `false` | Enable encryption at rest | +| server.encryption.existingSecret.key | string | `"encryptionKey"` | Key inside that Secret | +| server.encryption.existingSecret.name | string | `""` | Name of an existing Secret holding the encryption key | +| server.encryption.key | string | `""` | 32-byte key, base64 encoded. Ignored when `existingSecret.name` is set. Prefer `existingSecret` outside development: a value here is stored in the Helm release and readable by anyone who can read it. | | server.env | list | `[{"name":"RUST_LOG","value":"info"},{"name":"IGGY_HTTP_ADDRESS","value":"0.0.0.0:3000"},{"name":"IGGY_TCP_ADDRESS","value":"0.0.0.0:8090"},{"name":"IGGY_QUIC_ADDRESS","value":"0.0.0.0:8080"},{"name":"IGGY_WEBSOCKET_ADDRESS","value":"0.0.0.0:8092"}]` | Environment variables for the server container | +| server.extraArgs | list | `[]` | Extra command-line arguments appended to the server entrypoint, e.g. `["--with-default-root-credentials"]` for a throwaway development install. `--replica-id` is not one of them: the chart passes it already whenever `cluster.enabled` is set. | +| server.hostNetwork | bool | `false` | Run the server pod in the host network namespace. Required for cluster mode, where the replica listener binds the roster IP verbatim. | | server.image.pullPolicy | string | `"Always"` | Image pull policy | | server.image.repository | string | `"apache/iggy"` | Server image repository | -| server.image.tag | string | `"0.7.0"` | Server image tag (overrides chart appVersion) | +| server.image.tag | string | `""` | Server image tag. Empty uses the chart appVersion. | | server.ingress.annotations | object | `{}` | Ingress annotations (controller-specific) | | server.ingress.className | string | `""` | Ingress class name (controller-neutral) | | server.ingress.enabled | bool | `false` | Enable ingress for the server | | server.ingress.hosts | list | `[{"host":"chart-example.local","paths":[{"path":"/","pathType":"ImplementationSpecific"}]}]` | Ingress hosts configuration | | server.ingress.tls | list | `[]` | Ingress TLS configuration | +| server.jwt | object | `{"decodingSecret":"","encodingSecret":"","existingSecret":{"decodingSecretKey":"jwtDecodingSecret","encodingSecretKey":"jwtEncodingSecret","name":""}}` | Secrets used to sign and validate HTTP bearer tokens. Left unset, each node generates a random secret on every start, which invalidates tokens across restarts and keeps them node-local. Setting the identical secret on every node makes bearers valid cluster-wide and activates follower to primary HTTP forwarding; `cluster.auth.enabled` derives the same thing from the replica PSK, so it is an alternative rather than an addition. | +| server.jwt.decodingSecret | string | `""` | Decoding secret. Ignored when `existingSecret.name` is set. | +| server.jwt.encodingSecret | string | `""` | Encoding secret. Ignored when `existingSecret.name` is set. | +| server.jwt.existingSecret.decodingSecretKey | string | `"jwtDecodingSecret"` | Key inside that Secret holding the decoding secret | +| server.jwt.existingSecret.encodingSecretKey | string | `"jwtEncodingSecret"` | Key inside that Secret holding the encoding secret | +| server.jwt.existingSecret.name | string | `""` | Name of an existing Secret holding the JWT secrets | | server.nodeSelector | object | `{}` | Node selector for server pods | | server.persistence.accessMode | string | `"ReadWriteOnce"` | PVC access mode | | server.persistence.annotations | object | `{}` | PVC annotations | @@ -332,8 +539,9 @@ pre-commit install | server.persistence.size | string | `"8Gi"` | PVC storage size | | server.persistence.storageClass | string | `""` | Storage class for PVC (empty uses default provisioner) | | server.ports.http | int | `3000` | HTTP API port | -| server.ports.quic | int | `8080` | QUIC protocol port | +| server.ports.quic | int | `8080` | QUIC protocol port (UDP) | | server.ports.tcp | int | `8090` | TCP protocol port | +| server.ports.websocket | int | `8092` | WebSocket protocol port | | server.replicaCount | int | `1` | Number of server replicas | | server.service.port | int | `3000` | Service port for the server | | server.service.type | string | `"ClusterIP"` | Service type for the server | @@ -345,6 +553,7 @@ pre-commit install | server.serviceMonitor.namespace | string | `""` | Namespace to deploy the ServiceMonitor | | server.serviceMonitor.path | string | `"/metrics"` | Path to scrape metrics from | | server.serviceMonitor.scrapeTimeout | string | `"10s"` | Timeout for scrape metrics request | +| server.strategy | object | `{}` | Deployment update strategy. Empty lets the chart choose: `Recreate` when `hostNetwork` is set, because a rolling update would wait forever for a replacement pod that cannot bind host ports the outgoing pod still holds, and the Kubernetes default otherwise. | | server.tolerations | list | `[]` | Tolerations for server pods | | server.users.root.createSecret | bool | `true` | Create a secret for the root user credentials | | server.users.root.existingSecret.name | string | `""` | Name of existing secret for root credentials | diff --git a/helm/charts/iggy/README.md.gotmpl b/helm/charts/iggy/README.md.gotmpl index a30365fe6c..c5ec48e2da 100644 --- a/helm/charts/iggy/README.md.gotmpl +++ b/helm/charts/iggy/README.md.gotmpl @@ -33,8 +33,15 @@ under the License. Iggy server uses `io_uring` for high-performance async I/O. This requires: -1. **IPC_LOCK capability** - For locking memory required by io_uring -2. **Unconfined seccomp profile** - To allow io_uring syscalls +1. **Linux kernel 5.19 or newer on the node** + + * Shard rings require `IORING_SETUP_COOP_TASKRUN` and `IORING_SETUP_TASKRUN_FLAG`. + * Compio's asynchronous socket creation requires `IORING_OP_SOCKET`. + * Mainline Linux provides these features starting in 5.19. + * Older kernels fail during shard startup. The node kernel matters, not the container image. + +2. **IPC_LOCK capability** - For locking memory required by io_uring +3. **Unconfined seccomp profile** - To allow io_uring syscalls These are configured by default for the Iggy server via the chart's root-level `securityContext` and `podSecurityContext`. The web UI uses `ui.securityContext` @@ -94,6 +101,160 @@ If Prometheus Operator is installed and you want monitoring, set `server.serviceMonitor.enabled=true` in `custom-values.yaml` or pass it on the command line with `--set server.serviceMonitor.enabled=true`. +## Cluster Mode + +The server runs a Viewstamped Replication cluster when `cluster.enabled` is set +and each node is told which roster entry it is. The chart models this as **one +release per node**: every release is handed the same roster and overrides only +its own identity. + +`helm/charts/iggy/examples/cluster-3-node.yaml` is a ready three-node roster. +Point the `ip` values at the nodes you will pin the releases to, then: + +```bash +for i in 0 1 2; do + helm upgrade --install "iggy-n$i" ./helm/charts/iggy \ + -f ./helm/charts/iggy/examples/cluster-3-node.yaml \ + --set "server.cluster.selfReplicaId=$i" \ + --set "server.nodeSelector.kubernetes\.io/hostname=node-$i" +done +``` + +The chart turns `server.cluster` into the server's `IGGY_CLUSTER_*` environment +variables and passes `--replica-id`, so no configuration file is mounted. The +values mirror the server's `[[cluster.nodes]]` config one for one. + +### Why hostNetwork and node pinning are required + +The server's consensus listener binds `cluster.nodes[*].ip` **verbatim**, unlike +the client listeners, which keep their own bind address and take only the port +from the roster. A pod therefore has to own the address its roster entry names. +Pod IPs are neither stable nor known before install, and a Service ClusterIP is +not an address a pod can bind, so the workable layout today is `hostNetwork: +true` with each release pinned to a node and its roster `ip` set to that node's +IP. Node IPs are known up front and survive a pod restart, which is what lets a +replica come back and rejoin. + +The cost is real: the server pod shares the node's network namespace and its +ports. Weigh it before running this in a shared cluster. + +If a release lands on a node whose IP is not the one in its roster entry, the +server refuses to start rather than misbehaving quietly: + +```text +Error: ShardJoinFailures { failures: [ShardJoinFailure { shard_id: 0, + kind: Error(Iggy(CannotBindToSocket("10.0.1.11:9090"))) }] } +``` + +Check the `nodeSelector` on that release against the `ip` in its roster entry. + +### Upgrades + +Host ports make a rolling update impossible: the replacement pod cannot bind +ports the outgoing pod still holds, so it stays `Pending` while the Deployment +waits for it to become ready. The chart therefore switches the server to the +`Recreate` strategy whenever `hostNetwork` is set, which takes the node down +for the length of the restart. Roll **one release at a time** and let the +cluster regain quorum before starting the next. + +Turning `hostNetwork` on for a release that already exists moves the Deployment +from `RollingUpdate` to `Recreate`. Under Helm 4's server-side apply that is +rejected, because the patch cannot drop the `rollingUpdate` block the API server +defaulted in: + +```text +Deployment.apps "iggy-n0" is invalid: spec.strategy.rollingUpdate: Forbidden: + may not be specified when strategy `type` is 'Recreate' +``` + +On Helm 4, run that one upgrade with `helm upgrade --server-side=false`, which +replaces the strategy in place. Helm 3 has no such flag, since it does not use +server-side apply. Set `server.strategy` explicitly if you want a different +strategy. + +### Roster rules + +* Every node runs the **identical** `server.cluster.nodes` list. Only + `selfReplicaId` differs between releases. +* `replicaId` values are unique and cover `0..N-1` for an `N`-node roster. +* `ports.tcpReplica` is required on every entry. In cluster mode the server + takes every listener port from the roster and will not fall back to defaults, + because two nodes on one host would otherwise race for the same socket. The + remaining ports default to `server.ports`. +* `server.cluster.name` is hashed into the on-disk cluster id on first boot. + Changing it later makes the server refuse to start against existing data. +* `ip` must be a literal IP. Hostnames are rejected. Use + `advertisedAddress` for the name clients dial, which does accept DNS. + +### Secrets + +Four settings have to carry the byte-identical value on every node, so they +belong in one Secret that all releases reference rather than in each release's +values. Create it in the release namespace before the first install: + +```bash +JWT="$(head -c 32 /dev/urandom | base64)" +kubectl create secret generic iggy-cluster-secrets \ + --from-literal=username=iggy \ + --from-literal=password="$(head -c 24 /dev/urandom | base64)" \ + --from-literal=clusterSharedSecret="$(head -c 32 /dev/urandom | base64)" \ + --from-literal=jwtEncodingSecret="$JWT" \ + --from-literal=jwtDecodingSecret="$JWT" \ + --from-literal=encryptionKey="$(head -c 32 /dev/urandom | base64)" +``` + +`examples/cluster-3-node.yaml` points `server.users.root`, `server.encryption`, +`server.jwt` and `server.cluster.auth` at that one Secret. All four also accept +an inline value, which the chart turns into a Secret of its own: +`-root-credentials` for the root username and password, +`-secrets` for the other three. That is convenient for a single node +and a poor fit for a cluster, since the value then lives in every release's +stored values. + +* **`server.users.root`** seeds the root user. Every node creates it locally + from its own `IGGY_ROOT_USERNAME` and `IGGY_ROOT_PASSWORD`, so a password that + differs on one release logs in on that node only, and a first cluster boot + refuses to start without both. +* **`server.cluster.auth`** makes every replica connection complete an + authenticated handshake or be rejected. The key is at least 32 bytes of + CSPRNG output. Turning it on or off is a coordinated restart of the whole + cluster: a node that authenticates cannot talk to one that does not. +* **`server.encryption`** encrypts message payloads and state commands at rest + with AES-256-GCM, under a 32-byte base64 key. A node holding a different key + cannot read what its peers wrote. The chart selects the legacy configuration + for versioned images through `0.9.0-edge.7`, including its pinned image. + For newer builds with `[encryption]`, set `server.encryption.configVersion=flat`. + Custom tags require an explicit `flat` or `legacy` layout; `legacy` uses + `[system.encryption]`. +* **`server.jwt`** makes HTTP bearer tokens valid on every node and survive a + restart. With `cluster.auth` enabled the server already derives a cluster-wide + key from the replica PSK, so setting the JWT secrets is an alternative to that + rather than an addition; setting them anyway keeps tokens working if auth is + later turned off. + +None of the encryption, JWT and replica-auth values is written to the data +directory, and each is masked as `******` in the startup log. + +### Storage + +A replica cannot move between nodes without invalidating its roster entry, so +give each release storage that stays on its node, and size +`server.persistence` per node rather than for the cluster. A replica that starts +on an empty volume rejoins by state transfer, which is correct but re-reads the +whole dataset from its peers. `server.persistence.enabled: false` puts the +replica on `emptyDir` and forces exactly that on every pod replacement, so the +cluster example enables persistence and leaves `storageClass` for you to point +at a node-local provisioner. + +### What the chart refuses + +`server.replicaCount > 1` fails at render time. Scaling the server Deployment +produces N independent servers behind one Service, all writing the same PVC +subpath with no lock between them, which corrupts the data directory while +`helm --wait` still reports success. The chart ships no HorizontalPodAutoscaler +for the same reason. Cluster size is a roster decision, so add a node to +`server.cluster.nodes` and install another release instead. + ## Uninstallation ```bash @@ -159,13 +320,13 @@ If a previous local smoke install failed and left resources behind, reset the sm scripts/ci/test-helm.sh cleanup-smoke ``` -On Apple Silicon hosts, the released `apache/iggy:0.7.0` `arm64` image may still fail during the runtime smoke path in kind. If your Docker setup supports amd64 emulation well enough, you can try recreating the dedicated smoke cluster with: +On Apple Silicon hosts, the released `arm64` server image may still fail during the runtime smoke path in kind. If your Docker setup supports amd64 emulation well enough, you can try recreating the dedicated smoke cluster with: ```bash HELM_SMOKE_KIND_PLATFORM=linux/amd64 scripts/ci/setup-helm-smoke-cluster.sh ``` -The smoke script defaults `IGGY_SYSTEM_SHARDING_CPU_ALLOCATION=1` for the server pod so the local kind path avoids the chart's `numa:auto` default and keeps the local runtime to a single shard, which has been more reliable on containerized local nodes. If you need a different local override, set `HELM_SMOKE_SERVER_CPU_ALLOCATION` before running `scripts/ci/test-helm.sh smoke`. Pass `--cleanup` to remove the smoke namespace after a successful run; omit it if you want to inspect the deployed resources. +The smoke script defaults `IGGY_SHARDING_CPU_ALLOCATION=1` for the server pod so the local kind path avoids the chart's `numa:auto` default and keeps the local runtime to a single shard, which has been more reliable on containerized local nodes. If you need a different local override, set `HELM_SMOKE_SERVER_CPU_ALLOCATION` before running `scripts/ci/test-helm.sh smoke`. Pass `--cleanup` to remove the smoke namespace after a successful run; omit it if you want to inspect the deployed resources. On smoke-test failures you can collect the same diagnostics as CI with: @@ -228,6 +389,29 @@ Ensure the server binds to `0.0.0.0` instead of `127.0.0.1`. This is configured * `IGGY_TCP_ADDRESS=0.0.0.0:8090` * `IGGY_QUIC_ADDRESS=0.0.0.0:8080` +A wildcard bind says which interfaces accept connections, not where clients +reach the pod, so the server also needs the address to publish in cluster +metadata. Server builds that carry the setting refuse to start without it. +`{{ template "chart.appVersion" . }}`, the image this chart pins, predates it: +that build logs `IGGY_NODE_ADVERTISED_ADDRESS` as an unknown variable and +publishes the bind address, so the chart's default stays inert until the pinned +image moves past it. Either way the chart sets `IGGY_NODE_ADVERTISED_ADDRESS` to +the in-cluster Service DNS name; override it with `server.advertisedAddress` +when clients arrive through a LoadBalancer or an Ingress. + +In cluster mode the server ignores the variable altogether and publishes each +node's roster `ip`, or its `advertisedAddress` when the entry carries one, so +the chart leaves the variable out there and refuses a render that sets +`server.advertisedAddress` alongside `server.cluster.enabled`. + +Declaring `IGGY_NODE_ADVERTISED_ADDRESS` in `server.env` yourself works too: +the chart then leaves its own default out, so the variable is declared once. +Give that entry a non-empty value; an empty one suppresses the chart default +and reaches the server as unset, so the render fails instead. +Setting it in `server.env` and in `server.advertisedAddress` at the same time +is refused at render time rather than resolved silently, since only the +`server.env` entry would take effect. + ## Accessing the Server ### Port Forward diff --git a/helm/charts/iggy/examples/cluster-3-node.yaml b/helm/charts/iggy/examples/cluster-3-node.yaml new file mode 100644 index 0000000000..96a9329ede --- /dev/null +++ b/helm/charts/iggy/examples/cluster-3-node.yaml @@ -0,0 +1,130 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Shared roster for a three-node Iggy cluster. Install one release per node, +# overriding only the identity: +# +# for i in 0 1 2; do +# helm upgrade --install "iggy-n$i" ./helm/charts/iggy \ +# -f ./helm/charts/iggy/examples/cluster-3-node.yaml \ +# --set "server.cluster.selfReplicaId=$i" \ +# --set "server.nodeSelector.kubernetes\.io/hostname=node-$i" +# done +# +# Replace the `ip` values with the IPs of the nodes you pin each release to. +# The server binds this address verbatim for replica traffic, so with +# hostNetwork it must be the node's own IP. +# +# Every secret below has to be byte-identical on every node, so they live in one +# Secret that all three releases reference rather than in this file. Create it +# once, in the release namespace, before the first install: +# +# JWT="$(head -c 32 /dev/urandom | base64)" +# kubectl create secret generic iggy-cluster-secrets \ +# --from-literal=username=iggy \ +# --from-literal=password="$(head -c 24 /dev/urandom | base64)" \ +# --from-literal=clusterSharedSecret="$(head -c 32 /dev/urandom | base64)" \ +# --from-literal=jwtEncodingSecret="$JWT" \ +# --from-literal=jwtDecodingSecret="$JWT" \ +# --from-literal=encryptionKey="$(head -c 32 /dev/urandom | base64)" +# +# The JWT encoding and decoding secrets are one HMAC key, which is why both +# entries carry the same value. Two different values make every token from +# /users/login fail with 401, and the server only warns about the mismatch. + +ui: + enabled: false + +server: + hostNetwork: true + + # The root user is created locally on each node out of that node's own + # IGGY_ROOT_* environment, so the credentials have to match across the + # releases the same way the keys below do. + users: + root: + createSecret: false + existingSecret: + name: iggy-cluster-secrets + + # A release is pinned to one node, so its storage has to stay on that node. + # On emptyDir every pod replacement wipes the replica, which then refetches + # the whole dataset from its peers. + persistence: + enabled: true + # Name a node-local class here (local-path, topolvm, a local volume + # provisioner). Empty takes the default provisioner, which may hand out + # storage that does not stay on the pinned node. + storageClass: "" + size: 8Gi + + # Encrypts message payloads and state commands at rest with AES-256-GCM. The + # key is cluster-wide: a node that holds a different one cannot read what its + # peers wrote. + encryption: + enabled: true + existingSecret: + name: iggy-cluster-secrets + + # Makes bearer tokens valid on every node and survive a restart. Redundant + # while cluster.auth is enabled, which derives the same key from the replica + # PSK, but set explicitly here so tokens keep working if auth is turned off. + jwt: + existingSecret: + name: iggy-cluster-secrets + + cluster: + enabled: true + name: iggy-cluster + + # Every replica connection completes an authenticated handshake or is + # rejected. Turning this on or off is a coordinated restart of the whole + # cluster, not a rolling one. + auth: + enabled: true + existingSecret: + name: iggy-cluster-secrets + nodes: + - name: iggy-node-0 + ip: 10.0.1.10 + replicaId: 0 + ports: + tcpReplica: 9090 + - name: iggy-node-1 + ip: 10.0.1.11 + replicaId: 1 + ports: + tcpReplica: 9090 + - name: iggy-node-2 + ip: 10.0.1.12 + replicaId: 2 + ports: + tcpReplica: 9090 + + env: + - name: RUST_LOG + value: info + # Bind interface only: in cluster mode every listener port comes from the + # roster entry for this node. + - name: IGGY_HTTP_ADDRESS + value: "0.0.0.0:3000" + - name: IGGY_TCP_ADDRESS + value: "0.0.0.0:8090" + - name: IGGY_QUIC_ADDRESS + value: "0.0.0.0:8080" + - name: IGGY_WEBSOCKET_ADDRESS + value: "0.0.0.0:8092" diff --git a/helm/charts/iggy/templates/_helpers.tpl b/helm/charts/iggy/templates/_helpers.tpl index 92f69780e0..65e3e74084 100644 --- a/helm/charts/iggy/templates/_helpers.tpl +++ b/helm/charts/iggy/templates/_helpers.tpl @@ -97,3 +97,252 @@ Create the name of the service account to use {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} + +{{/* +Validate the cluster roster and fail the render with an actionable message. +Every check here is one the server would otherwise only reject at boot, after +the pod is already scheduled. +*/}} +{{- define "iggy.validateCluster" -}} + {{- $cluster := .Values.server.cluster }} + {{- if not $cluster.nodes }} + {{- fail "server.cluster.enabled is true but server.cluster.nodes is empty. Every node runs the identical roster; see the Cluster Mode section of the chart README." }} + {{- end }} + {{- if and $cluster.requireHostNetwork (not .Values.server.hostNetwork) }} + {{- fail "server.cluster.enabled is true but server.hostNetwork is false. The replica listener binds the roster ip verbatim, which is not an address a pod owns on the cluster network, so the pod dies at boot with CannotBindToSocket. Set server.hostNetwork to true, or server.cluster.requireHostNetwork to false if the roster ip really is one this pod owns." }} + {{- end }} + {{- $root := .Values.server.users.root }} + {{- if and (not $root.createSecret) (not $root.existingSecret.name) }} + {{- fail "server.users.root has neither createSecret nor existingSecret.name, so the container receives no IGGY_ROOT_USERNAME or IGGY_ROOT_PASSWORD. A first cluster boot refuses to start without both, because every node creates root locally and the credentials have to come out identical on all of them." }} + {{- end }} + {{- $count := len $cluster.nodes }} + {{- $seen := dict }} + {{- range $cluster.nodes }} + {{- if not .name }} + {{- fail "every server.cluster.nodes entry needs a name" }} + {{- end }} + {{- if not .ip }} + {{- fail (printf "server.cluster.nodes entry %q has no ip. The replica listener binds this address verbatim, so it must be a literal IP the pod owns." .name) }} + {{- end }} + {{- $ip := toString .ip }} + {{- if not (regexMatch "^[0-9a-fA-F.:]+$" $ip) }} + {{- fail (printf "server.cluster.nodes entry %q has ip %q, which the server parses as an IP address and rejects. Use server.cluster.nodes[*].advertisedAddress for the hostname clients dial." .name $ip) }} + {{- end }} + {{- if or (eq $ip "0.0.0.0") (eq $ip "::") }} + {{- fail (printf "server.cluster.nodes entry %q has the wildcard ip %q. Every peer dials this address verbatim, so it has to name one interface." .name $ip) }} + {{- end }} + {{- if kindIs "invalid" .replicaId }} + {{- fail (printf "server.cluster.nodes entry %q has no replicaId" .name) }} + {{- end }} + {{- $id := int .replicaId }} + {{- if or (lt $id 0) (ge $id $count) }} + {{- fail (printf "server.cluster.nodes entry %q has replicaId %d, which is outside 0..%d for a %d-node roster" .name $id (sub $count 1) $count) }} + {{- end }} + {{- if hasKey $seen (printf "%d" $id) }} + {{- fail (printf "server.cluster.nodes has two entries with replicaId %d; ids must be unique" $id) }} + {{- end }} + {{- $_ := set $seen (printf "%d" $id) .name }} + {{- if not (and .ports .ports.tcpReplica) }} + {{- fail (printf "server.cluster.nodes entry %q has no ports.tcpReplica. In cluster mode the server takes every listener port from the roster and refuses to start without it." .name) }} + {{- end }} + {{- end }} + {{- if not (hasKey $seen (printf "%d" (int $cluster.selfReplicaId))) }} + {{- fail (printf "server.cluster.selfReplicaId is %d but no server.cluster.nodes entry declares that replicaId. Each release picks its own identity out of the shared roster." (int $cluster.selfReplicaId)) }} + {{- end }} +{{- end }} + +{{/* +The ports this release's server binds, as a JSON object. In cluster mode the +server takes every listener port from its own roster entry and never falls back +to the top-level ones, so a node whose entry names other ports has to reach the +container ports, the probes and the Service targets as well. `server.ports` +supplies the per-field default there and the whole answer outside cluster mode. +`tcpReplica` has no top-level default because the roster owns it: it is +mandatory on every entry and there is no replica listener outside cluster mode. +*/}} +{{- define "iggy.serverPorts" -}} + {{- $ports := .Values.server.ports }} + {{- $resolved := dict "http" $ports.http "quic" $ports.quic "tcp" $ports.tcp "websocket" $ports.websocket }} + {{- if .Values.server.cluster.enabled }} + {{- $selfReplicaId := int .Values.server.cluster.selfReplicaId }} + {{- range .Values.server.cluster.nodes }} + {{- if eq (int .replicaId) $selfReplicaId }} + {{- range $name, $port := (default (dict) .ports) }} + {{- if $port }} + {{- $_ := set $resolved $name $port }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} + {{- $resolved | toJson }} +{{- end }} + +{{/* +Render the roster as IGGY_CLUSTER_* environment variables. The server accepts +the whole cluster config this way, so the chart needs no config file mount. +*/}} +{{- define "iggy.clusterEnv" -}} + {{- $ports := .Values.server.ports }} +- name: IGGY_CLUSTER_ENABLED + value: "true" +- name: IGGY_CLUSTER_NAME + value: {{ .Values.server.cluster.name | quote }} + {{- range .Values.server.cluster.nodes }} + {{- $id := int .replicaId }} +- name: IGGY_CLUSTER_NODES_{{ $id }}_NAME + value: {{ .name | quote }} +- name: IGGY_CLUSTER_NODES_{{ $id }}_IP + value: {{ .ip | quote }} +- name: IGGY_CLUSTER_NODES_{{ $id }}_REPLICA_ID + value: {{ $id | quote }} + {{- if .advertisedAddress }} +- name: IGGY_CLUSTER_NODES_{{ $id }}_ADVERTISED_ADDRESS + value: {{ .advertisedAddress | quote }} + {{- end }} +- name: IGGY_CLUSTER_NODES_{{ $id }}_PORTS_TCP + value: {{ (default $ports.tcp (and .ports .ports.tcp)) | quote }} +- name: IGGY_CLUSTER_NODES_{{ $id }}_PORTS_QUIC + value: {{ (default $ports.quic (and .ports .ports.quic)) | quote }} +- name: IGGY_CLUSTER_NODES_{{ $id }}_PORTS_HTTP + value: {{ (default $ports.http (and .ports .ports.http)) | quote }} +- name: IGGY_CLUSTER_NODES_{{ $id }}_PORTS_WEBSOCKET + value: {{ (default $ports.websocket (and .ports .ports.websocket)) | quote }} +- name: IGGY_CLUSTER_NODES_{{ $id }}_PORTS_TCP_REPLICA + value: {{ .ports.tcpReplica | quote }} + {{- end }} +{{- end }} + +{{/* +Name of the Secret holding the cluster-wide secrets the chart generates from +inline values. Each of encryption, JWT and replica auth may instead point at a +Secret the operator made, which is the path a multi-node cluster wants: the +values have to be byte-identical on every node, so they belong in one object +every release references rather than in each release's values. +*/}} +{{- define "iggy.secretName" -}} + {{- printf "%s-secrets" (include "iggy.fullname" .) }} +{{- end }} + +{{/* +True when any secret has to be generated by the chart, i.e. an inline value is +set for a feature that is switched on and no existing Secret was named for it. +*/}} +{{- define "iggy.createsSecret" -}} + {{- $server := .Values.server }} + {{- $create := false }} + {{- if and $server.encryption.enabled (not $server.encryption.existingSecret.name) }} + {{- $create = true }} + {{- end }} + {{- if and (or $server.jwt.encodingSecret $server.jwt.decodingSecret) (not $server.jwt.existingSecret.name) }} + {{- $create = true }} + {{- end }} + {{- if and $server.cluster.auth.enabled (not $server.cluster.auth.existingSecret.name) }} + {{- $create = true }} + {{- end }} + {{- if $create }}true{{ end }} +{{- end }} + +{{/* +Validate the secret configuration. The server enforces all of this at boot, so +catching it during render only saves a scheduling round trip, but a cluster +whose PSK differs between nodes fails as a handshake rejection rather than as +anything that names the cause. +*/}} +{{- define "iggy.validateSecrets" -}} + {{- $server := .Values.server }} + {{- if $server.encryption.enabled }} + {{- if and (not $server.encryption.key) (not $server.encryption.existingSecret.name) }} + {{- fail "server.encryption.enabled is true but no key was given. Set server.encryption.key to a base64-encoded 32-byte key, or point server.encryption.existingSecret.name at a Secret holding one." }} + {{- end }} + {{- if and $server.encryption.key (not $server.encryption.existingSecret.name) }} + {{- if ne (len (b64dec $server.encryption.key)) 32 }} + {{- fail (printf "server.encryption.key decodes to %d bytes. AES-256-GCM takes a base64-encoded 32-byte key, and the server fails the boot with 'Invalid encryption key' on anything else." (len (b64dec $server.encryption.key))) }} + {{- end }} + {{- end }} + {{- end }} + {{- if $server.cluster.auth.enabled }} + {{- if not $server.cluster.enabled }} + {{- fail "server.cluster.auth.enabled is true but server.cluster.enabled is false. Replica authentication only applies to the consensus port, which exists in cluster mode." }} + {{- end }} + {{- if and (not $server.cluster.auth.sharedSecret) (not $server.cluster.auth.existingSecret.name) }} + {{- fail "server.cluster.auth.enabled is true but no shared secret was given. Set server.cluster.auth.sharedSecret, or point server.cluster.auth.existingSecret.name at a Secret holding one. Every node needs the byte-identical value." }} + {{- end }} + {{- if and $server.cluster.auth.sharedSecret (lt (len $server.cluster.auth.sharedSecret) 32) }} + {{- fail (printf "server.cluster.auth.sharedSecret is %d bytes; the server requires at least 32 bytes of CSPRNG output." (len $server.cluster.auth.sharedSecret)) }} + {{- end }} + {{- if $server.cluster.auth.previousSharedSecret }} + {{- if lt (len $server.cluster.auth.previousSharedSecret) 32 }} + {{- fail (printf "server.cluster.auth.previousSharedSecret is %d bytes; the server requires at least 32 bytes of CSPRNG output." (len $server.cluster.auth.previousSharedSecret)) }} + {{- end }} + {{- if eq $server.cluster.auth.previousSharedSecret $server.cluster.auth.sharedSecret }} + {{- fail "server.cluster.auth.previousSharedSecret equals server.cluster.auth.sharedSecret, which the server rejects as a no-op rotation window. Leave it empty outside a rotation." }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} + +{{/* +Environment entries for the secret-backed settings, each sourced from whichever +Secret owns it. +*/}} +{{- define "iggy.secretEnv" -}} + {{- $server := .Values.server }} + {{- $generated := include "iggy.secretName" . }} + {{- if $server.encryption.enabled }} + {{- $configVersion := $server.encryption.configVersion | default "auto" }} + {{- if eq $configVersion "auto" }} + {{- $imageTag := $server.image.tag | default .Chart.AppVersion }} + {{- if and (regexMatch "^v?[0-9]+\\.[0-9]+\\.[0-9]+([+-].*)?$" $imageTag) (semverCompare "<=0.9.0-edge.7" $imageTag) }} + {{- $configVersion = "legacy" }} + {{- else }} + {{- fail "Cannot determine the encryption config layout for this server image. Set server.encryption.configVersion to flat for builds using [encryption], or legacy for builds using [system.encryption]." }} + {{- end }} + {{- end }} + {{- $prefix := "IGGY_ENCRYPTION" }} + {{- if eq $configVersion "legacy" }} + {{- $prefix = "IGGY_SYSTEM_ENCRYPTION" }} + {{- else if ne $configVersion "flat" }} + {{- fail "server.encryption.configVersion must be auto, legacy, or flat." }} + {{- end }} +- name: {{ $prefix }}_ENABLED + value: "true" +- name: {{ $prefix }}_KEY + valueFrom: + secretKeyRef: + name: {{ default $generated $server.encryption.existingSecret.name }} + key: {{ ternary $server.encryption.existingSecret.key "encryptionKey" (ne $server.encryption.existingSecret.name "") }} + {{- end }} + {{- if or $server.jwt.existingSecret.name $server.jwt.encodingSecret }} +- name: IGGY_HTTP_JWT_ENCODING_SECRET + valueFrom: + secretKeyRef: + name: {{ default $generated $server.jwt.existingSecret.name }} + key: {{ ternary $server.jwt.existingSecret.encodingSecretKey "jwtEncodingSecret" (ne $server.jwt.existingSecret.name "") }} + {{- end }} + {{- if or $server.jwt.existingSecret.name $server.jwt.decodingSecret }} +- name: IGGY_HTTP_JWT_DECODING_SECRET + valueFrom: + secretKeyRef: + name: {{ default $generated $server.jwt.existingSecret.name }} + key: {{ ternary $server.jwt.existingSecret.decodingSecretKey "jwtDecodingSecret" (ne $server.jwt.existingSecret.name "") }} + optional: true + {{- end }} + {{- if $server.cluster.auth.enabled }} +- name: IGGY_CLUSTER_AUTH_ENABLED + value: "true" +- name: IGGY_CLUSTER_AUTH_SHARED_SECRET + valueFrom: + secretKeyRef: + name: {{ default $generated $server.cluster.auth.existingSecret.name }} + key: {{ ternary $server.cluster.auth.existingSecret.sharedSecretKey "clusterSharedSecret" (ne $server.cluster.auth.existingSecret.name "") }} + {{- if or $server.cluster.auth.previousSharedSecret $server.cluster.auth.existingSecret.name }} +- name: IGGY_CLUSTER_AUTH_PREVIOUS_SHARED_SECRET + valueFrom: + secretKeyRef: + name: {{ default $generated $server.cluster.auth.existingSecret.name }} + key: {{ ternary $server.cluster.auth.existingSecret.previousSharedSecretKey "clusterPreviousSharedSecret" (ne $server.cluster.auth.existingSecret.name "") }} + optional: true + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/charts/iggy/templates/deployment.yaml b/helm/charts/iggy/templates/deployment.yaml index 4f17b2f33d..c99ac9dc98 100644 --- a/helm/charts/iggy/templates/deployment.yaml +++ b/helm/charts/iggy/templates/deployment.yaml @@ -19,6 +19,14 @@ {{- if hasKey .Values.server "podSecurityContext" }} {{- fail "server.podSecurityContext has been moved to podSecurityContext (root level). Please update your values." }} {{- end }} + {{- if gt (int .Values.server.replicaCount) 1 }} + {{- fail "server.replicaCount > 1 is refused: the replicas share one PVC at one subPath and one advertised address, so they would run as independent servers writing the same data directory, and the server takes no lock against that. Run cluster mode instead: one release per node, each with server.cluster.enabled and its own server.cluster.selfReplicaId. See the Cluster Mode section of the chart README." }} + {{- end }} + {{- if .Values.server.cluster.enabled }} + {{- include "iggy.validateCluster" . }} + {{- end }} + {{- include "iggy.validateSecrets" . }} + {{- $ports := include "iggy.serverPorts" . | fromJson }} --- apiVersion: apps/v1 kind: Deployment @@ -27,17 +35,38 @@ metadata: labels: {{- include "iggy.labels" . | nindent 4 }} spec: - {{- if not .Values.autoscaling.enabled }} replicas: {{ .Values.server.replicaCount }} + {{- if .Values.server.strategy }} + strategy: + {{- toYaml .Values.server.strategy | nindent 4 }} + {{- else if .Values.server.hostNetwork }} + strategy: + type: Recreate {{- end }} selector: matchLabels: {{- include "iggy.selectorLabels" . | nindent 6 }} template: metadata: - {{- with .Values.podAnnotations }} + {{- $secretsChecksum := "" }} + {{- if (include "iggy.createsSecret" .) }} + {{- $secretsChecksum = (include (print $.Template.BasePath "/server-secrets.yaml") . | sha256sum) }} + {{- end }} + {{- $rootChecksum := "" }} + {{- if and .Values.server.users.root.createSecret (not .Values.server.users.root.existingSecret.name) }} + {{- $rootChecksum = (include (print $.Template.BasePath "/root-user-credentials.yaml") . | sha256sum) }} + {{- end }} + {{- if or $secretsChecksum $rootChecksum .Values.podAnnotations }} annotations: + {{- if $secretsChecksum }} + checksum/server-secrets: {{ $secretsChecksum }} + {{- end }} + {{- if $rootChecksum }} + checksum/root-user-credentials: {{ $rootChecksum }} + {{- end }} + {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} + {{- end }} {{- end }} labels: {{- include "iggy.labels" . | nindent 8 }} @@ -47,6 +76,11 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} serviceAccountName: {{ include "iggy.serviceAccountName" . }} + enableServiceLinks: false + {{- if .Values.server.hostNetwork }} + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + {{- end }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} containers: @@ -55,16 +89,34 @@ spec: {{- toYaml .Values.securityContext | nindent 12 }} image: "{{ .Values.server.image.repository }}:{{ .Values.server.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.server.image.pullPolicy }} + {{- if or .Values.server.cluster.enabled .Values.server.extraArgs }} + args: + {{- if .Values.server.cluster.enabled }} + - "--replica-id" + - {{ .Values.server.cluster.selfReplicaId | quote }} + {{- end }} + {{- range .Values.server.extraArgs }} + - {{ . | quote }} + {{- end }} + {{- end }} ports: - name: http - containerPort: {{ .Values.server.ports.http }} + containerPort: {{ int $ports.http }} protocol: TCP - name: tcp - containerPort: {{ .Values.server.ports.tcp }} + containerPort: {{ int $ports.tcp }} + protocol: TCP + - name: websocket + containerPort: {{ int $ports.websocket }} protocol: TCP - name: quic - containerPort: {{ .Values.server.ports.quic }} + containerPort: {{ int $ports.quic }} + protocol: UDP + {{- if .Values.server.cluster.enabled }} + - name: tcp-replica + containerPort: {{ int $ports.tcpReplica }} protocol: TCP + {{- end }} env: {{- if .Values.server.users.root.existingSecret.name }} - name: IGGY_ROOT_USERNAME @@ -89,6 +141,31 @@ spec: name: {{ include "iggy.fullname" . }}-root-credentials key: password {{- end }}{{- end}} + {{- $declaredInEnv := false }} + {{- range .Values.server.env }} + {{- if eq .name "IGGY_NODE_ADVERTISED_ADDRESS" }} + {{- if not .value }} + {{- fail "IGGY_NODE_ADVERTISED_ADDRESS is declared in server.env with an empty value. The server reads that as unset and refuses to start behind a wildcard bind. Give the entry the address clients dial, or drop it and set server.advertisedAddress." }} + {{- end }} + {{- $declaredInEnv = true }} + {{- end }} + {{- end }} + {{- if and .Values.server.cluster.enabled .Values.server.advertisedAddress }} + {{- fail "server.advertisedAddress is set together with server.cluster.enabled. In cluster mode the server ignores IGGY_NODE_ADVERTISED_ADDRESS and publishes the roster entry's ip, or its advertisedAddress when one is given, so set it on this node's server.cluster.nodes entry instead." }} + {{- end }} + {{- if and $declaredInEnv .Values.server.advertisedAddress }} + {{- fail "IGGY_NODE_ADVERTISED_ADDRESS is set in server.env and server.advertisedAddress is also set. The server.env entry wins and server.advertisedAddress is ignored. Please set only one." }} + {{- end }} + {{- if and (not $declaredInEnv) (not .Values.server.cluster.enabled) }} + - name: IGGY_NODE_ADVERTISED_ADDRESS + value: {{ .Values.server.advertisedAddress | default (printf "%s.%s.svc.cluster.local" (include "iggy.fullname" .) .Release.Namespace) | quote }} + {{- end }} + {{- if .Values.server.cluster.enabled }} + {{- include "iggy.clusterEnv" . | nindent 12 }} + {{- end }} + {{- with (include "iggy.secretEnv" . | trim) }} + {{- . | nindent 12 }} + {{- end }} {{- if .Values.server.env }} {{- range .Values.server.env }} - name: {{ .name }} @@ -157,7 +234,7 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} labels: - {{- include "iggy-ui.labels" . | nindent 8 }}-ui + {{- include "iggy-ui.labels" . | nindent 8 }} spec: {{- with .Values.imagePullSecrets }} imagePullSecrets: diff --git a/helm/charts/iggy/templates/hpa.yaml b/helm/charts/iggy/templates/hpa.yaml deleted file mode 100644 index d1f6581294..0000000000 --- a/helm/charts/iggy/templates/hpa.yaml +++ /dev/null @@ -1,61 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -{{ if .Values.autoscaling.enabled -}} - {{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion -}} -apiVersion: autoscaling/v2 - {{- else -}} -apiVersion: autoscaling/v2beta2 - {{- end }} -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "iggy.fullname" . }} - labels: - {{- include "iggy.labels" . | nindent 4 }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "iggy.fullname" . }} - minReplicas: {{ .Values.autoscaling.minReplicas }} - maxReplicas: {{ .Values.autoscaling.maxReplicas }} - metrics: - {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} - - type: Resource - resource: - name: cpu - {{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }} - target: - type: Utilization - averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} - {{- else }} - targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} - {{- end }} - {{- end }} - {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} - - type: Resource - resource: - name: memory - {{- if semverCompare ">=1.23-0" .Capabilities.KubeVersion.GitVersion }} - target: - type: Utilization - averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} - {{- else }} - targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} - {{- end }} - {{- end }} -{{- end }} diff --git a/helm/charts/iggy/templates/server-secrets.yaml b/helm/charts/iggy/templates/server-secrets.yaml new file mode 100644 index 0000000000..e75e6e4985 --- /dev/null +++ b/helm/charts/iggy/templates/server-secrets.yaml @@ -0,0 +1,44 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +{{- if and .Values.server.enabled (include "iggy.createsSecret" .) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "iggy.secretName" . }} + labels: + {{- include "iggy.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- if and .Values.server.encryption.enabled (not .Values.server.encryption.existingSecret.name) }} + encryptionKey: {{ .Values.server.encryption.key | quote }} + {{- end }} + {{- if not .Values.server.jwt.existingSecret.name }} + {{- with .Values.server.jwt.encodingSecret }} + jwtEncodingSecret: {{ . | quote }} + {{- end }} + {{- with .Values.server.jwt.decodingSecret }} + jwtDecodingSecret: {{ . | quote }} + {{- end }} + {{- end }} + {{- if and .Values.server.cluster.auth.enabled (not .Values.server.cluster.auth.existingSecret.name) }} + clusterSharedSecret: {{ .Values.server.cluster.auth.sharedSecret | quote }} + {{- with .Values.server.cluster.auth.previousSharedSecret }} + clusterPreviousSharedSecret: {{ . | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/helm/charts/iggy/templates/service.yaml b/helm/charts/iggy/templates/service.yaml index 9820044247..4294e38288 100644 --- a/helm/charts/iggy/templates/service.yaml +++ b/helm/charts/iggy/templates/service.yaml @@ -32,11 +32,15 @@ spec: - name: quic port: {{ .Values.server.ports.quic }} targetPort: quic - protocol: TCP + protocol: UDP - name: tcp port: {{ .Values.server.ports.tcp }} targetPort: tcp protocol: TCP + - name: websocket + port: {{ .Values.server.ports.websocket }} + targetPort: websocket + protocol: TCP selector: {{- include "iggy.selectorLabels" . | nindent 4 }} {{- end }} diff --git a/helm/charts/iggy/values.yaml b/helm/charts/iggy/values.yaml index 7e760ea547..4c80b6b38a 100644 --- a/helm/charts/iggy/values.yaml +++ b/helm/charts/iggy/values.yaml @@ -17,6 +17,12 @@ # -- Iggy server configuration server: + # -- Client-facing address published in cluster metadata. Declaring + # `IGGY_NODE_ADVERTISED_ADDRESS` in `server.env` instead also works, but + # setting both is refused at render time. Empty falls back to the in-cluster + # Service DNS name. Ignored in cluster mode, where the address comes from the + # node's roster entry, so setting both is refused there too. + advertisedAddress: "" # -- Enable the Iggy server deployment enabled: true # -- Number of server replicas @@ -26,15 +32,17 @@ server: repository: apache/iggy # -- Image pull policy pullPolicy: Always - # -- Server image tag (overrides chart appVersion) - tag: "0.7.0" + # -- Server image tag. Empty uses the chart appVersion. + tag: "" ports: # -- HTTP API port http: 3000 - # -- QUIC protocol port + # -- QUIC protocol port (UDP) quic: 8080 # -- TCP protocol port tcp: 8090 + # -- WebSocket protocol port + websocket: 8092 service: # -- Service type for the server @@ -95,6 +103,119 @@ server: # -- PVC storage size size: 8Gi + # -- Server-side encryption of message payloads and state commands, using + # AES-256-GCM. Every node of a cluster must hold the identical key, or it + # cannot read data another node wrote. + encryption: + # -- Enable encryption at rest + enabled: false + # -- Encryption config layout: `auto` selects `legacy` for released image + # versions through 0.9.0-edge.7. Set `flat` for newer server builds, or + # `legacy` for custom builds that still use `[system.encryption]`. + configVersion: auto + # -- 32-byte key, base64 encoded. Ignored when `existingSecret.name` is set. + # Prefer `existingSecret` outside development: a value here is stored in the + # Helm release and readable by anyone who can read it. + key: "" + existingSecret: + # -- Name of an existing Secret holding the encryption key + name: "" + # -- Key inside that Secret + key: encryptionKey + + # -- Secrets used to sign and validate HTTP bearer tokens. Left unset, each + # node generates a random secret on every start, which invalidates tokens + # across restarts and keeps them node-local. Setting the identical secret on + # every node makes bearers valid cluster-wide and activates follower to + # primary HTTP forwarding; `cluster.auth.enabled` derives the same thing from + # the replica PSK, so it is an alternative rather than an addition. + jwt: + # -- Encoding secret. Ignored when `existingSecret.name` is set. + encodingSecret: "" + # -- Decoding secret. Ignored when `existingSecret.name` is set. + decodingSecret: "" + existingSecret: + # -- Name of an existing Secret holding the JWT secrets + name: "" + # -- Key inside that Secret holding the encoding secret + encodingSecretKey: jwtEncodingSecret + # -- Key inside that Secret holding the decoding secret + decodingSecretKey: jwtDecodingSecret + + cluster: + # -- Enable cluster (VSR consensus) mode. One Helm release per node: every + # release shares the same `nodes` roster and overrides only `selfReplicaId`. + # See the Cluster Mode section of the chart README. + enabled: false + # -- Cluster name, byte-identical on every node. Hashed into the on-disk + # cluster id on first boot, so changing it later means starting from an + # empty data directory. + name: iggy-cluster + # -- Which `nodes` entry this release runs, matched against `replicaId`. + selfReplicaId: 0 + # -- Refuse to render a cluster node without `server.hostNetwork`. The + # replica listener binds the roster `ip` verbatim, which no pod owns on the + # cluster network, so the pod would die at boot with `CannotBindToSocket`. + # Set this to false only when the roster `ip` is an address the pod itself + # holds. + requireHostNetwork: true + # -- Replica-to-replica authentication on the consensus port. When enabled + # every peer must complete an authenticated handshake or be rejected, and a + # shared secret becomes mandatory. Enabling it on a running cluster is a + # coordinated-restart change, not a rolling one. + auth: + # -- Require the authenticated replica handshake + enabled: false + # -- Cluster-wide pre-shared key, at least 32 bytes of CSPRNG output, + # byte-identical on every node. Ignored when `existingSecret.name` is set. + sharedSecret: "" + # -- Retiring key, accepted for verification only while a rotation is in + # flight. Leave empty outside a rotation. + previousSharedSecret: "" + existingSecret: + # -- Name of an existing Secret holding the pre-shared keys + name: "" + # -- Key inside that Secret holding the active shared secret + sharedSecretKey: clusterSharedSecret + # -- Key inside that Secret holding the retiring shared secret + previousSharedSecretKey: clusterPreviousSharedSecret + + # -- Cluster roster, mirroring the server's `[[cluster.nodes]]` config. + # Every node runs the identical list. `ip` is the replica-plane address: + # this node's consensus listener binds it verbatim and every peer dials it + # verbatim, so it must be a literal IP that the pod itself owns. With + # `server.hostNetwork` that is the node IP. `ports.tcpReplica` is required + # on every entry; the remaining ports default to `server.ports`. + nodes: [] + # nodes: + # - name: iggy-node-0 + # ip: 10.0.1.5 + # replicaId: 0 + # # -- Optional client-facing address, a literal IP or a DNS hostname. + # advertisedAddress: "" + # ports: + # tcp: 8090 + # quic: 8080 + # http: 3000 + # websocket: 8092 + # tcpReplica: 9090 + + # -- Run the server pod in the host network namespace. Required for cluster + # mode, where the replica listener binds the roster IP verbatim. + hostNetwork: false + + # -- Deployment update strategy. Empty lets the chart choose: `Recreate` when + # `hostNetwork` is set, because a rolling update would wait forever for a + # replacement pod that cannot bind host ports the outgoing pod still holds, + # and the Kubernetes default otherwise. + strategy: {} + + # -- Extra command-line arguments appended to the server entrypoint, e.g. + # `["--with-default-root-credentials"]` for a throwaway development install. + # `--replica-id` is not one of them: the chart passes it already whenever + # `cluster.enabled` is set. + extraArgs: [] + # -- Environment variables for the server container env: - name: RUST_LOG @@ -227,15 +348,3 @@ securityContext: # -- Resource limits and requests for server resources: {} - -autoscaling: - # -- Enable horizontal pod autoscaling - enabled: false - # -- Minimum replicas for autoscaling - minReplicas: 1 - # -- Maximum replicas for autoscaling - maxReplicas: 100 - # -- Target CPU utilization for autoscaling - targetCPUUtilizationPercentage: 80 - # -- Target memory utilization for autoscaling (optional) - # targetMemoryUtilizationPercentage: 80 diff --git a/licenserc.toml b/licenserc.toml index 1005808aa7..7339eac25a 100644 --- a/licenserc.toml +++ b/licenserc.toml @@ -37,6 +37,7 @@ excludes = [ "**/local_data*/**", "**/performance_results*/**", "**/*.lock", + "**/pylock.toml", "**/*.txt", "**/*.tpl", "**/LICENSE", @@ -79,6 +80,7 @@ excludes = [ ".github/config/hawkeye.version", "**/.python-version", "**/gradle/wrapper/**", + "**/.clang-tidy", # cargo-php regenerates this file with an extra separator after the header. "foreign/php/iggy-php.stubs.php", ] diff --git a/scripts/bump-version.sh b/scripts/bump-version.sh index 88413af9d2..d64ffe8c98 100755 --- a/scripts/bump-version.sh +++ b/scripts/bump-version.sh @@ -87,8 +87,8 @@ EOF } RUST_COMPONENTS="rust-sdk rust-common rust-binary-protocol rust-server rust-cli rust-connector-sdk rust-mcp rust-bench rust-bench-dashboard-frontend rust-bench-dashboard-server rust-bench-report" -CONNECTOR_SINK_COMPONENTS="rust-connector-delta-sink rust-connector-elasticsearch-sink rust-connector-http-sink rust-connector-iceberg-sink rust-connector-influxdb-sink rust-connector-mongodb-sink rust-connector-postgres-sink rust-connector-quickwit-sink rust-connector-stdout-sink rust-connector-surrealdb-sink" -CONNECTOR_SOURCE_COMPONENTS="rust-connector-elasticsearch-source rust-connector-influxdb-source rust-connector-postgres-source rust-connector-random-source" +CONNECTOR_SINK_COMPONENTS="rust-connector-clickhouse-sink rust-connector-delta-sink rust-connector-doris-sink rust-connector-elasticsearch-sink rust-connector-http-sink rust-connector-iceberg-sink rust-connector-influxdb-sink rust-connector-meilisearch-sink rust-connector-mongodb-sink rust-connector-postgres-sink rust-connector-quickwit-sink rust-connector-rabbitmq-sink rust-connector-redshift-sink rust-connector-s3-sink rust-connector-stdout-sink rust-connector-surrealdb-sink" +CONNECTOR_SOURCE_COMPONENTS="rust-connector-elasticsearch-source rust-connector-http-source rust-connector-influxdb-source rust-connector-postgres-source rust-connector-random-source" CONNECTOR_COMPONENTS="rust-connector-runtime ${CONNECTOR_SINK_COMPONENTS} ${CONNECTOR_SOURCE_COMPONENTS}" SDK_COMPONENTS="sdk-python sdk-node sdk-go sdk-csharp sdk-java" ALL_COMPONENTS="${RUST_COMPONENTS} ${CONNECTOR_COMPONENTS} ${SDK_COMPONENTS} web-ui" diff --git a/scripts/ci/license-headers.sh b/scripts/ci/license-headers.sh index f5cfb87245..55d79f01b6 100755 --- a/scripts/ci/license-headers.sh +++ b/scripts/ci/license-headers.sh @@ -79,9 +79,11 @@ if ! command -v hawkeye &> /dev/null; then exit 1 fi +# Only the major version must match the pin, so a newer local minor or +# patch release does not block commits. INSTALLED_HAWKEYE_VERSION="$(hawkeye -V | awk '{print $2}')" -if [ "$INSTALLED_HAWKEYE_VERSION" != "$HAWKEYE_VERSION" ]; then - echo "❌ hawkeye $HAWKEYE_VERSION is required, found ${INSTALLED_HAWKEYE_VERSION:-unknown}" +if [ "${INSTALLED_HAWKEYE_VERSION%%.*}" != "${HAWKEYE_VERSION%%.*}" ]; then + echo "❌ hawkeye ${HAWKEYE_VERSION%%.*}.x is required, found ${INSTALLED_HAWKEYE_VERSION:-unknown}" echo "💡 Install HawkEye: cargo install hawkeye --version $HAWKEYE_VERSION --locked --force" exit 1 fi diff --git a/scripts/ci/storage-compat.sh b/scripts/ci/storage-compat.sh new file mode 100755 index 0000000000..edfabe02c9 --- /dev/null +++ b/scripts/ci/storage-compat.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env bash +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +set -euo pipefail + +# shellcheck source-path=SCRIPTDIR +source "$(dirname "${BASH_SOURCE[0]}")/lib/init.sh" + +# storage-compat.sh -- Prove HEAD still reads a data directory written by master. +# +# Builds two iggy-server binaries and hands both to one integration test: +# baseline built from --baseline-ref, copied aside. Default: on a +# pull_request run, the master tip under the checked-out merge ref; +# anywhere else, origin/master. +# HEAD built from the working tree, left at target/debug/iggy-server +# The test boots the baseline, seeds a data directory, swaps the binary to HEAD +# and restarts against that same directory. +# +# The baseline's own core/server/config.toml is extracted next to its binary and +# both boots read it, so the swap changes the binary and nothing else. +# +# Both binaries MUST be built here. `core/integration` has no dependency on the +# `server` package; the harness only LOCATES a binary, through +# `assert_cmd::Command::cargo_bin`, which falls back to whatever file happens to +# sit at target/debug/iggy-server (assert_cmd 2.2.2 `legacy_cargo_bin`). So +# `cargo nextest run -p integration` compiles no server at all, and a lane that +# does not build both halves compares a stale binary against itself and reports +# green forever. +# +# Exit codes: 0 = compatible, non-zero = build failure or format regression. + +# Empty selects the default described above once HEAD is known. +BASELINE_REF="" +REBUILD_BASELINE=0 + +# The single #[ignore]d test this script exists to drive. The integration crate +# is one binary built from tests/mod.rs, so tests are selected by module path. +TEST_FILTER="data_integrity::storage_compat" + +while [[ $# -gt 0 ]]; do + case "$1" in + --baseline-ref) + BASELINE_REF="${2:-}" + if [ -z "${BASELINE_REF}" ]; then + echo "--baseline-ref requires a git ref" + exit 1 + fi + shift 2 + ;; + --rebuild-baseline) + REBUILD_BASELINE=1 + shift + ;; + --help|-h) + echo "Usage: $0 [--baseline-ref ] [--rebuild-baseline]" + echo "" + echo "Options:" + echo " --baseline-ref Ref to build the baseline server from (default: the merge ref's" + echo " first parent on a pull_request run, origin/master otherwise)" + echo " --rebuild-baseline Rebuild the baseline even when its binary is already on disk" + exit 0 + ;; + *) + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# Absolute before any cd: cargo resolves a relative CARGO_TARGET_DIR against +# its own CWD, and the baseline below builds from a worktree elsewhere on +# disk, so a relative value would scatter the two builds and the lookup of +# either binary across three directories. Exported so every cargo call here, +# nextest included, lands in the same place -- except the baseline build, which +# must have a target directory of its own (see the worktree build below). +TARGET_DIR="${CARGO_TARGET_DIR:-${REPO_ROOT}/target}" +mkdir -p "${TARGET_DIR}" +TARGET_DIR="$(cd "${TARGET_DIR}" && pwd)" +export CARGO_TARGET_DIR="${TARGET_DIR}" + +cd "${REPO_ROOT}" + +if ! command -v cargo-nextest &>/dev/null; then + echo "cargo-nextest is not installed" + echo "" + echo "Install with:" + echo " cargo install cargo-nextest --locked" + exit 1 +fi + +HEAD_SERVER="${TARGET_DIR}/debug/iggy-server" + +WORKTREE_DIR="" + +# Every command is guarded: under `set -e` a failure inside an EXIT trap would +# replace the script's real exit status with the trap's. +cleanup() { + if [ -n "${WORKTREE_DIR}" ]; then + git worktree remove --force "${WORKTREE_DIR}" 2>/dev/null || rm -rf "${WORKTREE_DIR}" || true + fi + git worktree prune 2>/dev/null || true +} +trap cleanup EXIT + +HEAD_SHA="$(git rev-parse --verify HEAD)" + +# On a pull_request run actions/checkout leaves HEAD at the synthetic +# refs/pull/N/merge commit, whose first parent is the exact master tip GitHub +# merged the PR onto: an ancestor of HEAD by construction. Live origin/master +# can already be newer by the time this step runs, and a baseline HEAD does +# not contain would blame the PR for master's own changes. Read from the raw +# object: in the depth-1 checkout the parent is a shallow boundary, so +# `HEAD^1` does not resolve. Two parents required, so a checkout pinned to the +# PR head instead of the merge ref falls through rather than picking the PR's +# own parent. +if [ -z "${BASELINE_REF}" ]; then + if [[ "${GITHUB_REF:-}" =~ ^refs/pull/[0-9]+/merge$ ]] \ + && [ "$(git cat-file -p HEAD | grep -c '^parent ')" -eq 2 ]; then + BASELINE_REF="$(git cat-file -p HEAD | awk '/^parent /{print $2; exit}')" + echo "Pull request run: baseline is the master tip under the merge ref" + else + BASELINE_REF="origin/master" + fi +fi + +# The remote side of a fetch takes a branch or tag name (or a reachable SHA), +# never a remote-tracking name, so drop the remote prefix before asking origin. +# +# --depth=1 only where the clone is already shallow (the CI checkout). On a +# full clone that flag does not save anything, it WRITES a shallow boundary at +# the fetched tip and grafts every older commit off the developer's history, +# for every worktree sharing the repository. +FETCH_DEPTH=() +if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then + FETCH_DEPTH=(--depth=1) +fi +FETCHED=0 +if git fetch --no-tags "${FETCH_DEPTH[@]}" origin "${BASELINE_REF#origin/}" 2>/dev/null; then + FETCHED=1 +fi + +BASELINE_SHA="" +if [ "${FETCHED}" -eq 1 ]; then + # FETCH_HEAD in preference to the named ref: actions/checkout narrows + # remote.origin.fetch on a shallow clone, so refs/remotes/origin/master can + # stay absent or stale straight through a successful fetch. + BASELINE_SHA="$(git rev-parse --verify --quiet "FETCH_HEAD^{commit}" || true)" +fi +if [ -z "${BASELINE_SHA}" ]; then + BASELINE_SHA="$(git rev-parse --verify --quiet "${BASELINE_REF}^{commit}" || true)" +fi +if [ -z "${BASELINE_SHA}" ]; then + echo "Could not resolve baseline ref '${BASELINE_REF}' locally or from origin" + exit 1 +fi + +echo "Baseline: ${BASELINE_SHA} (${BASELINE_REF})" +echo "HEAD: ${HEAD_SHA}" +if [ "${BASELINE_SHA}" = "${HEAD_SHA}" ]; then + echo "WARNING: baseline and HEAD are the same commit, this run proves nothing" +fi +# Only decidable with history: a shallow clone answers "no" for every pair. +if [ "$(git rev-parse --is-shallow-repository)" = "false" ] \ + && ! git merge-base --is-ancestor "${BASELINE_SHA}" "${HEAD_SHA}"; then + echo "WARNING: baseline is not an ancestor of HEAD; a failure below may be master's change, not HEAD's" +fi + +# Keyed by baseline commit, which also pins that commit's Cargo.lock and +# rust-toolchain.toml. Only a developer machine ever hits this: hosted runners +# start empty and nothing publishes a baseline binary, so CI rebuilds it on +# every run. +BASELINE_SERVER="${TARGET_DIR}/storage-compat/${BASELINE_SHA}/iggy-server" + +# The baseline's own config file, handed to BOTH boots. Neither binary may fall +# back to the server's relative default path: figment resolves that by walking +# up from the test process's directory, which hands the BASELINE this branch's +# file, and a key added under a `deny_unknown_fields` table (every [cluster] and +# [node] one) then fails its extraction. Written on every run, cached binary or +# not, so the pair can never drift apart. +BASELINE_CONFIG="${TARGET_DIR}/storage-compat/${BASELINE_SHA}/config.toml" +mkdir -p "$(dirname "${BASELINE_CONFIG}")" +if ! git show "${BASELINE_SHA}:core/server/config.toml" >"${BASELINE_CONFIG}"; then + echo "Baseline ${BASELINE_SHA} carries no core/server/config.toml" + exit 1 +fi + +if [ "${REBUILD_BASELINE}" -eq 1 ]; then + rm -f "${BASELINE_SERVER}" +fi + +if [ -x "${BASELINE_SERVER}" ]; then + echo "Reusing baseline server at ${BASELINE_SERVER}" +else + # Outside the repo on purpose: an in-tree worktree gets swept up by the + # repo-wide find(1) in the lint scripts and by cargo's workspace globs. + # A fresh mktemp path per run cannot collide with an aborted run; the only + # residue such a run leaves is an admin entry under .git/worktrees, which + # prune clears. + git worktree prune + WORKTREE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iggy-storage-compat.XXXXXX")" + git worktree add --detach "${WORKTREE_DIR}" "${BASELINE_SHA}" + + echo "Building baseline iggy-server from ${BASELINE_SHA}..." + # Built from the worktree as CWD so the baseline's own rust-toolchain.toml + # applies, and into a target directory of the worktree's own. + # + # The two trees MUST NOT share one. Cargo keys a workspace member's unit hash + # on its manifest path RELATIVE to the workspace root, and records that + # member's sources in the dep-info relative too, so `core/configs` in the + # worktree and `core/configs` here hash identically and both resolve against + # whichever root cargo is invoked from. Sharing a target directory therefore + # makes the second build read the first build's rlibs as fresh: HEAD's + # `server` would compile against MASTER's `configs`, `consensus`, + # `partitions` and `shard`. Any PR touching a crate below `server` fails to + # build here with errors that do not reproduce anywhere else. The duplicated + # dependency compile is the price of the two halves being what they claim. + # + # Inside the worktree so the cleanup trap reclaims it with the worktree; only + # the copied binary below outlives the run. + # + # Debug profile on both sides, and no --all-features: release would compile + # debug_assert! out of the baseline while HEAD still panics on it, and + # --all-features turns on the server's `disable-mimalloc`, so the two halves + # would differ in ways the storage format never changed. + BASELINE_TARGET_DIR="${WORKTREE_DIR}/target" + ( + cd "${WORKTREE_DIR}" + CARGO_TARGET_DIR="${BASELINE_TARGET_DIR}" cargo build --locked -p server --bin iggy-server + ) + + BASELINE_BUILT="${BASELINE_TARGET_DIR}/debug/iggy-server" + if [ ! -x "${BASELINE_BUILT}" ]; then + echo "Baseline build did not produce ${BASELINE_BUILT}" + exit 1 + fi + + mkdir -p "$(dirname "${BASELINE_SERVER}")" + cp "${BASELINE_BUILT}" "${BASELINE_SERVER}" + # Now, not at cleanup: a second full debug dependency graph is several GB, and + # the HEAD build plus the integration test still have to fit on the runner. + rm -rf "${BASELINE_TARGET_DIR}" +fi + +# The baseline never writes here any more, but a binary left by an earlier run +# of this script (or by any other build in this tree) would satisfy the +# existence check below without cargo having produced it now. Delete it so that +# check means what it says. +rm -f "${HEAD_SERVER}" + +echo "Building HEAD iggy-server from ${HEAD_SHA}..." +cargo build --locked -p server --bin iggy-server + +if [ ! -x "${HEAD_SERVER}" ]; then + echo "HEAD build did not produce ${HEAD_SERVER}" + exit 1 +fi + +# Absolute path: ServerHandle only treats this value as a literal path when it +# has more than one component, otherwise it falls back to a cargo_bin lookup. +export COMPAT_BASELINE_SERVER="${BASELINE_SERVER}" +export COMPAT_BASELINE_CONFIG="${BASELINE_CONFIG}" + +echo "Running storage compatibility test..." +# --run-ignored only: the test is #[ignore]d, so the normal lanes skip it. +# --no-tests=fail: a filter matching nothing has to be an error here, unlike the +# main lane's --no-tests=warn, which would report a typo'd filter as green. +# --retries 0: a read-back that only fails sometimes is exactly the signal this +# check exists for, so no retry may hide it. Explicit rather than "not +# --profile ci": a profile default or NEXTEST_RETRIES in the environment would +# otherwise apply. +# --ignore-default-filter: nextest intersects -E with the profile's +# default-filter, so one that excluded this module would turn the run into a +# --no-tests=fail abort instead of running the test. +# The version floor for these flags lives in .config/nextest.toml. +cargo nextest run --locked -p integration \ + --run-ignored only \ + --no-tests=fail \ + --retries 0 \ + --ignore-default-filter \ + -E "test(${TEST_FILTER})" + +echo "Storage format compatible: ${BASELINE_SHA} -> ${HEAD_SHA}" diff --git a/scripts/ci/sync-python-interpreter-version.sh b/scripts/ci/sync-python-interpreter-version.sh index cc3ced1a36..8c074be73f 100755 --- a/scripts/ci/sync-python-interpreter-version.sh +++ b/scripts/ci/sync-python-interpreter-version.sh @@ -24,8 +24,15 @@ source "$(dirname "${BASH_SOURCE[0]}")/lib/init.sh" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' +YELLOW='\033[1;33m' NC='\033[0m' # No Color +# Minor versions deliberately not built as Windows wheels. Every other +# version in the pyproject classifiers must appear in the wheel job +# matrix, so adding a new supported version still fails this check +# until the matrix is updated. +WHEEL_MATRIX_SKIP=("3.10") + # Default mode MODE="" @@ -266,6 +273,12 @@ ensure_lock_python_requirement() { fi } +# Every supported minor version, oldest first, as declared by the +# pyproject classifiers. +read_classifier_versions() { + sed -nE 's/^ "Programming Language :: Python :: ([0-9]+\.[0-9]+)",$/\1/p' "$SOURCE_FILE" +} + ensure_wheel_interpreters() { local file="$1" local classifier_versions=() @@ -284,7 +297,7 @@ ensure_wheel_interpreters() { fi classifier_versions=() - while IFS= read -r _py_cls_tmp; do classifier_versions+=("$_py_cls_tmp"); done < <(sed -nE 's/^ "Programming Language :: Python :: ([0-9]+\.[0-9]+)",$/\1/p' "$SOURCE_FILE") + while IFS= read -r _py_cls_tmp; do classifier_versions+=("$_py_cls_tmp"); done < <(read_classifier_versions) if [ "${#classifier_versions[@]}" -eq 0 ]; then echo -e "${RED}✗${NC} $SOURCE_FILE: could not find Python version classifiers" @@ -327,6 +340,77 @@ ensure_wheel_interpreters() { fi } +# The Windows wheel job builds one interpreter per matrix entry rather +# than passing --interpreter, so its version list needs its own check. +ensure_wheel_matrix() { + local file="$1" + local expected="" + local current + local version + local skipped + local seen=0 + + TOTAL_CHECKS=$((TOTAL_CHECKS + 1)) + + if [ ! -f "$file" ]; then + echo -e "${RED}✗${NC} $file: file does not exist" + FAILED=1 + return + fi + + while IFS= read -r version; do + seen=$((seen + 1)) + for skipped in "${WHEEL_MATRIX_SKIP[@]}"; do + [ "$version" = "$skipped" ] && continue 2 + done + expected+=", \"${version}\"" + done < <(read_classifier_versions) + + if [ "$seen" -eq 0 ]; then + echo -e "${RED}✗${NC} $SOURCE_FILE: could not find Python version classifiers" + FAILED=1 + return + fi + + if [ -z "$expected" ]; then + echo -e "${RED}✗${NC} $SOURCE_FILE: WHEEL_MATRIX_SKIP excludes every classifier version" + FAILED=1 + return + fi + expected="[${expected#, }]" + + current=$(sed -nE 's/^[[:space:]]*python-version: (\[.*\])$/\1/p' "$file") + + if [ -z "$current" ]; then + echo -e "${RED}✗${NC} $file: could not find wheel matrix python-version list" + FAILED=1 + return + fi + + # --fix rewrites every match, so refuse to touch a file that grew a + # second list rather than collapsing both onto the same versions. + if [ "$(printf '%s\n' "$current" | wc -l)" -gt 1 ]; then + echo -e "${RED}✗${NC} $file: multiple python-version lists, cannot pick one" + FAILED=1 + return + fi + + if [ "$current" = "$expected" ]; then + echo -e "${GREEN}✓${NC} $file: wheel matrix Python versions" + return + fi + + if [ "$MODE" = "fix" ]; then + sed -i.bak -E "s|^([[:space:]]*python-version: )\[.*\]$|\\1${expected}|" "$file" + rm -f "$file.bak" + FIXED_CHECKS=$((FIXED_CHECKS + 1)) + echo -e "${GREEN}Fixed${NC} $file: wheel matrix Python versions" + else + echo -e "${RED}✗${NC} $file: wheel matrix Python versions are not $expected" + FAILED=1 + fi +} + ensure_classifiers "$SOURCE_FILE" PYTHON_VERSION_FILES=( @@ -401,6 +485,7 @@ ensure_line \ "wheel workflow setup-python versions" ensure_wheel_interpreters ".github/workflows/_build_python_wheels.yml" +ensure_wheel_matrix ".github/workflows/_build_python_wheels.yml" PYLOCK_FILES=( "foreign/python/pylock.toml" diff --git a/scripts/ci/test-helm.sh b/scripts/ci/test-helm.sh index 370b2f00b2..c3930d2e2c 100755 --- a/scripts/ci/test-helm.sh +++ b/scripts/ci/test-helm.sh @@ -53,6 +53,9 @@ HELM_SMOKE_GATEWAY_NAME="${HELM_SMOKE_GATEWAY_NAME:-iggy-smoke-gateway}" HELM_SMOKE_GATEWAY_PF_PORT="${HELM_SMOKE_GATEWAY_PF_PORT:-8080}" HELM_SMOKE_KIND_NAME="${HELM_SMOKE_KIND_NAME:-iggy-helm-smoke}" HELM_SMOKE_SERVER_CPU_ALLOCATION="${HELM_SMOKE_SERVER_CPU_ALLOCATION:-1}" +# A well-formed 32-byte AES-256-GCM key, base64 encoded. Render-only: it never +# reaches a running server. +HELM_TEST_ENCRYPTION_KEY="AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" # HELM_SMOKE_GATEWAY_NAMESPACE and HELM_SMOKE_GATEWAY_NAME must match the # defaults in scripts/ci/setup-helm-smoke-cluster.sh - the HTTPRoute @@ -197,12 +200,10 @@ validate() { local chart_version local chart_app_version - local server_image_tag local ui_image_tag chart_version="$(extract_chart_field version)" chart_app_version="$(extract_chart_field appVersion)" - server_image_tag="$(extract_values_tag server)" ui_image_tag="$(extract_values_tag ui)" prepare_render_dir @@ -217,19 +218,16 @@ validate() { grep -q "helm.sh/chart: iggy-${chart_version}" "$HELM_RENDER_DIR/default.yaml" grep -q "helm.sh/chart: iggy-ui-${chart_version}" "$HELM_RENDER_DIR/default.yaml" grep -q "app.kubernetes.io/version: \"${chart_app_version}\"" "$HELM_RENDER_DIR/default.yaml" - grep -q "image: \"apache/iggy:${server_image_tag}\"" "$HELM_RENDER_DIR/default.yaml" + grep -q "image: \"apache/iggy:${chart_app_version}\"" "$HELM_RENDER_DIR/default.yaml" grep -q "image: \"apache/iggy-web-ui:${ui_image_tag}\"" "$HELM_RENDER_DIR/default.yaml" helm template iggy "$CHART_DIR" \ --set server.persistence.enabled=true \ - --set autoscaling.enabled=true \ - --set autoscaling.targetCPUUtilizationPercentage=80 \ --set server.ingress.enabled=true \ --set ui.ingress.enabled=true \ --set server.serviceMonitor.enabled=true \ > "$HELM_RENDER_DIR/all-features.yaml" grep -q '^kind: PersistentVolumeClaim$' "$HELM_RENDER_DIR/all-features.yaml" - grep -q '^kind: HorizontalPodAutoscaler$' "$HELM_RENDER_DIR/all-features.yaml" test "$(grep -c '^kind: Ingress$' "$HELM_RENDER_DIR/all-features.yaml")" -eq 2 test "$(extract_kind_names "$HELM_RENDER_DIR/all-features.yaml" Ingress | sort -u | wc -l | tr -d ' ')" -eq 2 extract_kind_names "$HELM_RENDER_DIR/all-features.yaml" Ingress | grep -qx 'iggy' @@ -239,14 +237,10 @@ validate() { helm template iggy "$CHART_DIR" \ --kube-version 1.18.0 \ --api-versions networking.k8s.io/v1beta1 \ - --api-versions autoscaling/v2beta2 \ - --set autoscaling.enabled=true \ - --set autoscaling.targetCPUUtilizationPercentage=80 \ --set server.ingress.enabled=true \ --set ui.ingress.enabled=true \ > "$HELM_RENDER_DIR/legacy-k8s-1.18.yaml" test "$(grep -c '^apiVersion: networking.k8s.io/v1beta1$' "$HELM_RENDER_DIR/legacy-k8s-1.18.yaml")" -eq 2 - grep -q '^apiVersion: autoscaling/v2beta2$' "$HELM_RENDER_DIR/legacy-k8s-1.18.yaml" helm template iggy "$CHART_DIR" --set ui.enabled=false > "$HELM_RENDER_DIR/server-only.yaml" test "$(grep -c '^kind: Deployment$' "$HELM_RENDER_DIR/server-only.yaml")" -eq 1 @@ -266,11 +260,114 @@ validate() { fi grep -q 'name: supersecret' "$HELM_RENDER_DIR/existing-secret.yaml" + helm template iggy "$CHART_DIR" \ + -f "$CHART_DIR/examples/cluster-3-node.yaml" \ + --set server.cluster.selfReplicaId=1 \ + > "$HELM_RENDER_DIR/cluster.yaml" + grep -q 'name: IGGY_CLUSTER_ENABLED' "$HELM_RENDER_DIR/cluster.yaml" + grep -q 'name: IGGY_CLUSTER_NODES_2_PORTS_TCP_REPLICA' "$HELM_RENDER_DIR/cluster.yaml" + grep -q -- '- "--replica-id"' "$HELM_RENDER_DIR/cluster.yaml" + grep -q '^ hostNetwork: true$' "$HELM_RENDER_DIR/cluster.yaml" + grep -q 'name: tcp-replica' "$HELM_RENDER_DIR/cluster.yaml" + + grep -q 'name: IGGY_CLUSTER_AUTH_SHARED_SECRET' "$HELM_RENDER_DIR/cluster.yaml" + grep -q 'name: IGGY_SYSTEM_ENCRYPTION_KEY' "$HELM_RENDER_DIR/cluster.yaml" + grep -q 'name: IGGY_HTTP_JWT_ENCODING_SECRET' "$HELM_RENDER_DIR/cluster.yaml" + if grep -qE '^ +(encryptionKey|clusterSharedSecret|jwtEncodingSecret):' "$HELM_RENDER_DIR/cluster.yaml"; then + echo "Error: cluster render inlined a secret value instead of referencing the existing Secret" >&2 + exit 1 + fi + + helm template iggy "$CHART_DIR" \ + --set server.encryption.enabled=true \ + --set-string server.encryption.key="$HELM_TEST_ENCRYPTION_KEY" \ + > "$HELM_RENDER_DIR/generated-secret.yaml" + grep -q "^ encryptionKey: \"${HELM_TEST_ENCRYPTION_KEY}\"$" "$HELM_RENDER_DIR/generated-secret.yaml" + grep -q '^ name: iggy-secrets$' "$HELM_RENDER_DIR/generated-secret.yaml" + grep -q '^ name: iggy-secrets$' "$HELM_RENDER_DIR/generated-secret.yaml" + grep -q '^ key: encryptionKey$' "$HELM_RENDER_DIR/generated-secret.yaml" + test "$(grep -c '^kind: Secret$' "$HELM_RENDER_DIR/generated-secret.yaml")" -eq 2 + grep -q 'name: IGGY_SYSTEM_ENCRYPTION_ENABLED' "$HELM_RENDER_DIR/generated-secret.yaml" + grep -q 'name: IGGY_SYSTEM_ENCRYPTION_KEY' "$HELM_RENDER_DIR/generated-secret.yaml" + if grep -q 'name: IGGY_ENCRYPTION_' "$HELM_RENDER_DIR/generated-secret.yaml"; then + echo "Error: the pinned server image requires the legacy encryption variables" >&2 + exit 1 + fi + + helm template iggy "$CHART_DIR" \ + --set-string server.image.tag=0.9.0-edge.7 \ + --set server.encryption.enabled=true \ + --set server.encryption.existingSecret.name=shared-key \ + > "$HELM_RENDER_DIR/legacy-encryption.yaml" + grep -q 'name: IGGY_SYSTEM_ENCRYPTION_KEY' "$HELM_RENDER_DIR/legacy-encryption.yaml" + + helm template iggy "$CHART_DIR" \ + --set-string server.image.tag=custom-flat \ + --set server.encryption.enabled=true \ + --set server.encryption.configVersion=flat \ + --set server.encryption.existingSecret.name=shared-key \ + > "$HELM_RENDER_DIR/flat-encryption.yaml" + grep -q 'name: IGGY_ENCRYPTION_ENABLED' "$HELM_RENDER_DIR/flat-encryption.yaml" + grep -q 'name: IGGY_ENCRYPTION_KEY' "$HELM_RENDER_DIR/flat-encryption.yaml" + if grep -q 'name: IGGY_SYSTEM_ENCRYPTION_' "$HELM_RENDER_DIR/flat-encryption.yaml"; then + echo "Error: a server with flat encryption config rejects legacy variables" >&2 + exit 1 + fi + + helm template iggy "$CHART_DIR" \ + --set-string server.image.tag=custom-legacy \ + --set server.encryption.enabled=true \ + --set server.encryption.configVersion=legacy \ + --set server.encryption.existingSecret.name=shared-key \ + > "$HELM_RENDER_DIR/custom-legacy-encryption.yaml" + grep -q 'name: IGGY_SYSTEM_ENCRYPTION_KEY' "$HELM_RENDER_DIR/custom-legacy-encryption.yaml" + + for image_tag in custom-unknown 0.9.0-edge.8; do + assert_render_rejected "encryption config layout unknown for ${image_tag}" \ + --set-string server.image.tag="$image_tag" \ + --set server.encryption.enabled=true \ + --set server.encryption.existingSecret.name=shared-key + done + assert_render_rejected "invalid encryption config layout" \ + --set server.encryption.enabled=true \ + --set server.encryption.configVersion=invalid \ + --set server.encryption.existingSecret.name=shared-key + + assert_render_rejected "server.replicaCount=3" --set server.replicaCount=3 + assert_render_rejected "encryption without a key" --set server.encryption.enabled=true + assert_render_rejected "an encryption key that is not 32 bytes" \ + --set server.encryption.enabled=true \ + --set-string server.encryption.key=bm90LTMyLWJ5dGVz + assert_render_rejected "replica auth without a secret" \ + -f "$CHART_DIR/examples/cluster-3-node.yaml" \ + --set server.cluster.auth.existingSecret.name="" \ + --set server.cluster.selfReplicaId=0 + assert_render_rejected "a shared secret under 32 bytes" \ + -f "$CHART_DIR/examples/cluster-3-node.yaml" \ + --set server.cluster.auth.existingSecret.name="" \ + --set-string server.cluster.auth.sharedSecret=tooshort \ + --set server.cluster.selfReplicaId=0 + assert_render_rejected "cluster without a roster" --set server.cluster.enabled=true + assert_render_rejected "selfReplicaId outside the roster" \ + -f "$CHART_DIR/examples/cluster-3-node.yaml" --set server.cluster.selfReplicaId=9 + validate_yamllint validate_helmfmt validate_helm_docs } +# Assert that `helm template` refuses a values combination the chart guards +# against. A guard that stops failing is a silent data-corruption regression, +# so the render succeeding is the error case here. +assert_render_rejected() { + local description="$1" + shift + if helm template iggy "$CHART_DIR" "$@" > /dev/null 2>&1; then + echo "Error: chart rendered ${description}, but that combination must be refused" >&2 + exit 1 + fi +} + # PID of the kubectl port-forward process started by smoke(). # Stored at script scope so the EXIT trap can kill it on any code path. HELM_SMOKE_GW_PF_PID="" @@ -403,7 +500,7 @@ server: value: "0.0.0.0:8080" - name: IGGY_WEBSOCKET_ADDRESS value: "0.0.0.0:8092" - - name: IGGY_SYSTEM_SHARDING_CPU_ALLOCATION + - name: IGGY_SHARDING_CPU_ALLOCATION value: "${HELM_SMOKE_SERVER_CPU_ALLOCATION}" ui: image: diff --git a/scripts/performance/run-standard-performance-suite.sh b/scripts/performance/run-standard-performance-suite.sh index 10244a5443..4de8dd242c 100755 --- a/scripts/performance/run-standard-performance-suite.sh +++ b/scripts/performance/run-standard-performance-suite.sh @@ -59,32 +59,11 @@ source "$(dirname "$0")/utils.sh" trap on_exit_bench SIGINT trap on_exit_bench EXIT -# Function to get environment variables based on benchmark type +# Cache and no_wait names below are historical scenario labels. They do not +# change server storage settings and must not be compared as cache policies. +# Each scenario uses the same explicit server credentials. get_env_vars() { - local bench_type="$1" - local env_vars=() - - env_vars+=("IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy") - - # Specific env vars based on bench type - case "$bench_type" in - # fsync is a topic creation option (`enforce_fsync`) now, not server config, - # so the bench command carries `--enforce-fsync` (added by - # `construct_bench_command` off the same remark) and only the cache setting - # is left to the server environment. - *"no_cache_fsync"*) - env_vars+=("IGGY_SYSTEM_CACHE_ENABLED=false") - ;; - *"only_cache"*) - env_vars+=("IGGY_SYSTEM_CACHE_SIZE=9GB") - ;; - *"no_cache"*) - env_vars+=("IGGY_SYSTEM_CACHE_ENABLED=false") - ;; - *"no_wait"*) - env_vars+=("IGGY_SYSTEM_SEGMENT_SERVER_CONFIRMATION=no_wait") - ;; - esac + local env_vars=("IGGY_ROOT_USERNAME=iggy IGGY_ROOT_PASSWORD=iggy") # Convert array to env var string local env_string="" @@ -106,34 +85,34 @@ RUSTFLAGS="-C target-cpu=native" cargo build --release ############################## # Large batch tests with cache enabled -NORMAL_BATCH_ONLY_CACHE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 8 8 1000 1000 1000 tcp "send_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with forced cache -NORMAL_BATCH_ONLY_CACHE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 8 8 1000 1000 1000 tcp "poll_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with forced cache +NORMAL_BATCH_ONLY_CACHE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 8 8 1000 1000 1000 tcp "send_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with default storage settings +NORMAL_BATCH_ONLY_CACHE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 8 8 1000 1000 1000 tcp "poll_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with default storage settings # Large batch tests with cache disabled -NORMAL_BATCH_NO_CACHE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 8 8 1000 1000 1000 tcp "send_no_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with disabled cache -NORMAL_BATCH_NO_CACHE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 8 8 1000 1000 1000 tcp "send_no_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with disabled cache +NORMAL_BATCH_NO_CACHE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 8 8 1000 1000 1000 tcp "send_no_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with default storage settings +NORMAL_BATCH_NO_CACHE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 8 8 1000 1000 1000 tcp "send_no_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with default storage settings # Large batch tests with no wait and with cache configuration -NORMAL_BATCH_NO_WAIT_ONLY_CACHE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 8 8 1000 1000 1000 tcp "send_no_wait_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with no_wait config -NORMAL_BATCH_NO_WAIT_ONLY_CACHE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 8 8 1000 1000 1000 tcp "send_no_wait_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with no_wait config +NORMAL_BATCH_NO_WAIT_ONLY_CACHE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 8 8 1000 1000 1000 tcp "send_no_wait_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with default storage settings +NORMAL_BATCH_NO_WAIT_ONLY_CACHE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 8 8 1000 1000 1000 tcp "send_no_wait_only_cache" "$IDENTIFIER") # 8GB data, 1KB messages, 1000 msgs/batch with default storage settings # Single actor tests with cache disabled # shellcheck disable=SC2034 -NO_CACHE_SINGLE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 1 1 1000 1000 5000 tcp "1_producer_no_cache" "$IDENTIFIER") # 5GB data, 1KB messages, 100 msgs/batch with forced cache +NO_CACHE_SINGLE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 1 1 1000 1000 5000 tcp "1_producer_no_cache" "$IDENTIFIER") # 5GB data, 1KB messages, 100 msgs/batch with default storage settings # shellcheck disable=SC2034 -NO_CACHE_SINGLE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 1 1 1000 1000 5000 tcp "1_consumer_no_cache" "$IDENTIFIER") # 5GB data, 1KB messages, 100 msgs/batch with forced cache +NO_CACHE_SINGLE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 1 1 1000 1000 5000 tcp "1_consumer_no_cache" "$IDENTIFIER") # 5GB data, 1KB messages, 100 msgs/batch with default storage settings # Consumer group tests with cache enabled BALANCED_ONLY_CACHE_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "balanced-producer" 1 8 1000 1000 1000 tcp "only_cache" "$IDENTIFIER") # Balanced producer benchmark BALANCED_ONLY_CACHE_CONSUMER_GROUP=$(construct_bench_command "$IGGY_BENCH_CMD" "balanced-consumer-group" 1 8 1000 1000 1000 tcp "only_cache" "$IDENTIFIER") # Consumer group benchmark # Single actor tests with cache disabled and rate limit 100 MB/s -NO_CACHE_RL_SINGLE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 1 1 1000 1000 2000 tcp "1_producer_no_cache_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with forced cache -NO_CACHE_RL_SINGLE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 1 1 1000 1000 2000 tcp "1_consumer_no_cache_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with forced cache +NO_CACHE_RL_SINGLE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 1 1 1000 1000 2000 tcp "1_producer_no_cache_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with default storage settings +NO_CACHE_RL_SINGLE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 1 1 1000 1000 2000 tcp "1_consumer_no_cache_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with default storage settings # Single actor tests with cache disabled, fsync enabled and rate limit 100 MB/s -NO_CACHE_FSYNC_RL_SINGLE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 1 1 1000 1000 2000 tcp "1_producer_no_cache_fsync_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with forced cache -NO_CACHE_FSYNC_RL_SINGLE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 1 1 1000 1000 2000 tcp "1_consumer_no_cache_fsync_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with forced cache +NO_CACHE_FSYNC_RL_SINGLE_PINNED_PRODUCER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-producer" 1 1 1000 1000 2000 tcp "1_producer_no_cache_fsync_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with default storage settings +NO_CACHE_FSYNC_RL_SINGLE_PINNED_CONSUMER=$(construct_bench_command "$IGGY_BENCH_CMD" "pinned-consumer" 1 1 1000 1000 2000 tcp "1_consumer_no_cache_fsync_rl_100MB" "$IDENTIFIER" "100MB") # 2GB data, 1KB messages, 100 msgs/batch with default storage settings ############################### # Single benchmarks # diff --git a/scripts/performance/utils.sh b/scripts/performance/utils.sh index f710b5b80a..8967e9e69e 100755 --- a/scripts/performance/utils.sh +++ b/scripts/performance/utils.sh @@ -148,12 +148,11 @@ function construct_bench_command() { ;; esac - # fsync is a per-topic option now, not server config, so the fsync variants - # have to ask for it on the bench command line rather than via server env. - local fsync_arg="" + # The persisted variants select the topic policy at creation. + local durability_arg="" case "$remark" in *"no_cache_fsync"*) - fsync_arg="--enforce-fsync" + durability_arg="--durability persisted" ;; esac @@ -168,7 +167,7 @@ function construct_bench_command() { exit 1 } - echo "$bench_command ${rate_limit:+ --rate-limit ${rate_limit}} ${fsync_arg} \ + echo "$bench_command ${rate_limit:+ --rate-limit ${rate_limit}} ${durability_arg} \ --message-size ${message_size} \ --messages-per-batch ${messages_per_batch} \ --message-batches ${message_batches} \ diff --git a/scripts/prepare-release.sh b/scripts/prepare-release.sh index 0d270e285b..835aaf9620 100755 --- a/scripts/prepare-release.sh +++ b/scripts/prepare-release.sh @@ -56,10 +56,12 @@ RELEASE_PATHS=( "about.toml" "docker-compose.yml" "justfile" + "rust-toolchain.toml" "bdd" "core" "examples" "foreign" + "gateways" "helm" "scripts" "web" diff --git a/scripts/utils.sh b/scripts/utils.sh index 0c77704877..cc5d4399c3 100755 --- a/scripts/utils.sh +++ b/scripts/utils.sh @@ -343,9 +343,45 @@ function portable_timeout() { fi } +# Grep must see complete shell commands instead of individual README lines. +function read_readme_logical_lines() { + local readme_file="$1" + + awk ' + function has_line_continuation(line, position, count) { + count = 0 + for (position = length(line); position > 0; position--) { + if (substr(line, position, 1) != "\\") { + break + } + count++ + } + return count % 2 == 1 + } + + { + if (has_line_continuation($0)) { + pending = pending substr($0, 1, length($0) - 1) + continued = 1 + next + } + + print pending $0 + pending = "" + continued = 0 + } + + END { + if (continued) { + print pending "\\" + } + } + ' "${readme_file}" +} + # Run commands extracted from a README file. # Usage: run_readme_commands readme_file grep_pattern [cmd_timeout [grep_exclude]] -# Reads matching lines, strips backticks/comments, executes each. +# Reads matching logical lines, strips backticks/comments, executes each. # Calls TRANSFORM_COMMAND function on each command if defined. # Returns: sets global EXAMPLES_EXIT_CODE and README_COMMANDS_EXECUTED. # Zero matches is not an error here: callers iterate multiple README @@ -364,7 +400,7 @@ function run_readme_commands() { fi local commands - commands=$(grep -E "${grep_pattern}" "${readme_file}" || true) + commands=$(read_readme_logical_lines "${readme_file}" | grep -E "${grep_pattern}" || true) if [ -n "${grep_exclude}" ]; then commands=$(echo "${commands}" | grep -v -e "${grep_exclude}" || true) fi diff --git a/web/README.md b/web/README.md index f2ef7cdbc3..ef213b7d7a 100644 --- a/web/README.md +++ b/web/README.md @@ -8,11 +8,11 @@ This project hosts the web user interface for Apache Iggy. The web UI is built u The Iggy Web UI provides a user-friendly panel for managing various aspects of the Iggy platform, including streams, topics, partitions, and more. -The [docker image](https://hub.docker.com/r/apache/iggy-web-ui) is available, and can be fetched via `docker pull apache/iggy-web-ui`. +The [docker image](https://hub.docker.com/r/apache/iggy-web-ui) is available, and can be fetched via `docker pull apache/iggy-web-ui:edge`. ## Tooling -- Node.js: use a version supported by the current frontend toolchain, `^20.19.0 || ^22.13.0 || >=24`. `22.13+` LTS is the safest default. +- Node.js: use a version supported by the current frontend toolchain, `^20.19.0 || ^22.13.0 || >=24`. - Package manager: `npm` - `pnpm` and `yarn` are not part of the supported workflow for this package. CI, Docker builds, and the committed lockfile use `npm`. @@ -21,11 +21,14 @@ The [docker image](https://hub.docker.com/r/apache/iggy-web-ui) is available, an 1. **Run Iggy server first** ```sh - docker pull apache/iggy:latest + docker pull apache/iggy:edge ``` ```sh - docker run -p 3000:3000 -p 8090:8090 apache/iggy:latest + docker run --security-opt seccomp=unconfined -p 3000:3000 -p 8090:8090 \ + -e IGGY_ROOT_USERNAME=iggy -e IGGY_ROOT_PASSWORD=iggy \ + -e IGGY_HTTP_ADDRESS=0.0.0.0:3000 -e IGGY_TCP_ADDRESS=0.0.0.0:8090 \ + -e IGGY_NODE_ADVERTISED_ADDRESS=localhost apache/iggy:edge ``` 2. **Clone the repository:** @@ -37,7 +40,7 @@ The [docker image](https://hub.docker.com/r/apache/iggy-web-ui) is available, an 3. **Build the project:** ```sh - cd web + cd iggy/web npm ci ``` @@ -53,17 +56,13 @@ The [docker image](https://hub.docker.com/r/apache/iggy-web-ui) is available, an npm run dev -- --host --port 3333 ``` - **If Iggy server was run using cargo directly we need to change PUBLIC_IGGY_API_URL env in web ui root folder to:** + Set `PUBLIC_IGGY_API_URL` in `web/.env` to the HTTP address reachable by your browser, for example: ```sh PUBLIC_IGGY_API_URL=http://127.0.0.1:3000 ``` - **instead of** - - ```sh - PUBLIC_IGGY_API_URL=http://localhost:3000 - ``` + This applies to both container and source-built servers. Use the host-published HTTP port for a container. ## Roadmap diff --git a/web/docker-compose.yml b/web/docker-compose.yml index dca555202b..e8e9a2cf13 100644 --- a/web/docker-compose.yml +++ b/web/docker-compose.yml @@ -24,6 +24,7 @@ services: IGGY_HTTP_ADDRESS: 0.0.0.0:3000 IGGY_QUIC_ADDRESS: 0.0.0.0:8080 IGGY_TCP_ADDRESS: 0.0.0.0:8090 + IGGY_NODE_ADVERTISED_ADDRESS: localhost IGGY_WEBSOCKET_ADDRESS: 0.0.0.0:8092 cap_add: - SYS_NICE diff --git a/web/package-lock.json b/web/package-lock.json index 5d19d523be..e1e76b8cef 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "iggy-web-ui", - "version": "0.4.0-edge.3", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "iggy-web-ui", - "version": "0.4.0-edge.3", + "version": "0.4.0", "dependencies": { "@floating-ui/dom": "^1.8.0", "@tailwindcss/postcss": "^4.3.3", @@ -21,7 +21,7 @@ "uuid": "^14.0.2" }, "devDependencies": { - "@eslint/compat": "^2.1.0", + "@eslint/compat": "^2.1.1", "@eslint/js": "^10.0.1", "@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-static": "^3.0.10", @@ -29,27 +29,27 @@ "@sveltejs/vite-plugin-svelte": "^7.3.0", "@types/d3-interpolate": "^3.0.4", "@types/json-bigint": "^1.0.4", - "@types/node": "^26.2.0", - "autoprefixer": "^10.5.4", + "@types/node": "^26.5.1", + "autoprefixer": "^10.5.6", "esbuild": "^0.28.2", - "eslint": "^10.8.1", + "eslint": "^10.10.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.23.0", - "globals": "^17.11.0", + "globals": "^17.12.0", "jwt-decode": "^4.0.0", - "postcss": "^8.5.26", + "postcss": "^8.5.28", "prettier": "^3.9.6", "prettier-plugin-svelte": "^4.1.1", - "svelte": "^5.56.10", + "svelte": "^5.57.0", "svelte-check": "^4.7.6", "sveltekit-superforms": "^2.30.2", "tailwindcss": "^4.2.4", "ts-toolbelt": "^9.6.0", "tslib": "^2.8.1", "typescript": "^6.0.3", - "typescript-eslint": "^8.67.0", - "vite": "^8.2.2", - "zod": "^4.4.3" + "typescript-eslint": "^8.70.0", + "vite": "^8.3.0", + "zod": "^4.6.1" } }, "node_modules/@alloc/quick-lru": { @@ -94,6 +94,30 @@ "node": ">=6.9.0" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -579,9 +603,9 @@ } }, "node_modules/@eslint/compat": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.0.tgz", - "integrity": "sha512-LgaSCymEpw7tF53xvDw9SNsraPb1IBHxpdABIOM0hW8UAlP8znrjYtuxfR58FSJ3L9BhwD+FaPRFQpZq84Nh6g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-2.1.1.tgz", + "integrity": "sha512-rMcy8GSrwNzcISX/BlTDY/GLB4eCopEuy9woIls3To+15OLxykZrxxq+WUcylCPCQ6F4MujjBM1DX5V1aqI3Vw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -672,9 +696,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -738,29 +762,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -834,14 +872,38 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-project/types": { - "version": "0.146.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", - "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@polka/url": { @@ -871,9 +933,9 @@ "optional": true }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", - "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", "cpu": [ "arm" ], @@ -888,9 +950,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", - "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ "arm64" ], @@ -905,9 +967,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", - "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ "arm64" ], @@ -922,9 +984,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", - "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ "x64" ], @@ -939,9 +1001,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", - "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ "x64" ], @@ -956,9 +1018,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", - "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "cpu": [ "arm" ], @@ -973,9 +1035,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", - "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "cpu": [ "arm64" ], @@ -993,9 +1055,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", - "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "cpu": [ "arm64" ], @@ -1013,9 +1075,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", - "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "cpu": [ "ppc64" ], @@ -1033,9 +1095,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", - "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ "s390x" ], @@ -1053,9 +1115,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", - "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "cpu": [ "x64" ], @@ -1073,9 +1135,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", - "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "cpu": [ "x64" ], @@ -1093,9 +1155,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", - "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "cpu": [ "arm64" ], @@ -1110,9 +1172,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", - "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "cpu": [ "arm64" ], @@ -1127,9 +1189,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", - "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "cpu": [ "x64" ], @@ -2150,13 +2212,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/resolve": { @@ -2166,12 +2228,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" - }, "node_modules/@types/validator": { "version": "13.15.10", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", @@ -2216,17 +2272,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", - "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/type-utils": "8.67.0", - "@typescript-eslint/utils": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2239,15 +2295,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.67.0", + "@typescript-eslint/parser": "^8.70.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", "dev": true, "license": "MIT", "engines": { @@ -2255,16 +2311,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", - "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3" }, "engines": { @@ -2280,14 +2336,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", - "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.67.0", - "@typescript-eslint/types": "^8.67.0", + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", "debug": "^4.4.3" }, "engines": { @@ -2302,14 +2358,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", - "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0" + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2320,9 +2376,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", - "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", "dev": true, "license": "MIT", "engines": { @@ -2337,15 +2393,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", - "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2362,9 +2418,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", - "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", "devOptional": true, "license": "MIT", "engines": { @@ -2376,16 +2432,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", - "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.67.0", - "@typescript-eslint/tsconfig-utils": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/visitor-keys": "8.67.0", + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2404,16 +2460,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", - "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.67.0", - "@typescript-eslint/types": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0" + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2428,13 +2484,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", - "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/types": "8.70.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -2561,9 +2617,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.4", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", - "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "version": "10.5.6", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.6.tgz", + "integrity": "sha512-HiH4oYNc5+DQEx/b8FPfMj+WHH/WWUBmbN5r1Uf/ixtfyFkk+wGIlfJT/RD5eAyzlwkeYRtZUCI8qrEi26pl2g==", "dev": true, "funding": [ { @@ -2581,8 +2637,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.6", - "caniuse-lite": "^1.0.30001806", + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -2617,9 +2673,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.11.tgz", - "integrity": "sha512-/yImnXwyTvgMkhgekLHok/Rx5vO6E0BmStWlSqKWMVm2a2ITuZ1Tn+9bgLS+gZRdZmWtd8nxuhHpdmCUOWsTQQ==", + "version": "2.11.22", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", + "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2652,9 +2708,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -2672,11 +2728,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -2685,6 +2741,20 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/camelcase": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", @@ -2700,9 +2770,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -2887,9 +2957,9 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", "license": "MIT" }, "node_modules/dlv": { @@ -2913,9 +2983,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.399", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", - "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", + "version": "1.5.426", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.426.tgz", + "integrity": "sha512-2Gcq6inCQs/AqfHP3f5ftCzk+pqeW2VlA1LgmPqEj2hfBO8HiZRspNYkD4HzFBe4u6lGqca4BspFr6Ix4Q400g==", "dev": true, "license": "ISC" }, @@ -2998,9 +3068,9 @@ } }, "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", + "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", "dev": true, "license": "MIT", "workspaces": [ @@ -3012,7 +3082,7 @@ "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -3027,7 +3097,7 @@ "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", @@ -3309,16 +3379,13 @@ } }, "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" + "flat-cache": "^6.1.23" } }, "node_modules/find-up": { @@ -3339,23 +3406,21 @@ } }, "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -3412,9 +3477,9 @@ } }, "node_modules/globals": { - "version": "17.11.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", - "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", "dev": true, "license": "MIT", "engines": { @@ -3430,6 +3495,19 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -3443,6 +3521,13 @@ "node": ">= 0.4" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3536,9 +3621,9 @@ } }, "node_modules/joi": { - "version": "17.13.4", - "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", - "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", + "version": "17.13.7", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.7.tgz", + "integrity": "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==", "dev": true, "license": "BSD-3-Clause", "optional": true, @@ -3559,13 +3644,6 @@ "bignumber.js": "^9.0.0" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-to-ts": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", @@ -3606,13 +3684,13 @@ } }, "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "@keyv/serialize": "^1.1.1" } }, "node_modules/kleur": { @@ -4020,9 +4098,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", "dev": true, "license": "MIT", "engines": { @@ -4160,9 +4238,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -4179,7 +4257,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4378,6 +4456,26 @@ "license": "MIT", "optional": true }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4414,13 +4512,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", - "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.146.0", + "@oxc-project/types": "=0.149.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4430,21 +4528,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.5", - "@rolldown/binding-android-arm64": "1.2.5", - "@rolldown/binding-darwin-arm64": "1.2.5", - "@rolldown/binding-darwin-x64": "1.2.5", - "@rolldown/binding-freebsd-x64": "1.2.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", - "@rolldown/binding-linux-arm64-gnu": "1.2.5", - "@rolldown/binding-linux-arm64-musl": "1.2.5", - "@rolldown/binding-linux-ppc64-gnu": "1.2.5", - "@rolldown/binding-linux-s390x-gnu": "1.2.5", - "@rolldown/binding-linux-x64-gnu": "1.2.5", - "@rolldown/binding-linux-x64-musl": "1.2.5", - "@rolldown/binding-openharmony-arm64": "1.2.5", - "@rolldown/binding-win32-arm64-msvc": "1.2.5", - "@rolldown/binding-win32-x64-msvc": "1.2.5" + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, "node_modules/rollup": { @@ -4597,16 +4695,15 @@ } }, "node_modules/svelte": { - "version": "5.56.10", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz", - "integrity": "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g==", + "version": "5.57.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz", + "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", @@ -5028,16 +5125,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.67.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", - "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.67.0", - "@typescript-eslint/parser": "8.67.0", - "@typescript-eslint/typescript-estree": "8.67.0", - "@typescript-eslint/utils": "8.67.0" + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5052,16 +5149,16 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -5147,16 +5244,16 @@ } }, "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "bin": { @@ -5173,7 +5270,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5577,9 +5674,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz", + "integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==", "dev": true, "license": "MIT", "funding": { diff --git a/web/package.json b/web/package.json index 3217b3ec3d..f619daacbc 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "iggy-web-ui", - "version": "0.4.0-edge.3", + "version": "0.4.0", "private": true, "scripts": { "dev": "vite dev --port 3050", @@ -10,11 +10,12 @@ "server": "vite server", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "node --experimental-strip-types --test", "lint": "eslint . && prettier --check .", "format": "prettier --write ." }, "devDependencies": { - "@eslint/compat": "^2.1.0", + "@eslint/compat": "^2.1.1", "@eslint/js": "^10.0.1", "@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-static": "^3.0.10", @@ -22,27 +23,27 @@ "@sveltejs/vite-plugin-svelte": "^7.3.0", "@types/d3-interpolate": "^3.0.4", "@types/json-bigint": "^1.0.4", - "@types/node": "^26.2.0", - "autoprefixer": "^10.5.4", + "@types/node": "^26.5.1", + "autoprefixer": "^10.5.6", "esbuild": "^0.28.2", - "eslint": "^10.8.1", + "eslint": "^10.10.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.23.0", - "globals": "^17.11.0", + "globals": "^17.12.0", "jwt-decode": "^4.0.0", - "postcss": "^8.5.26", + "postcss": "^8.5.28", "prettier": "^3.9.6", "prettier-plugin-svelte": "^4.1.1", - "svelte": "^5.56.10", + "svelte": "^5.57.0", "svelte-check": "^4.7.6", "sveltekit-superforms": "^2.30.2", "tailwindcss": "^4.2.4", "ts-toolbelt": "^9.6.0", "tslib": "^2.8.1", "typescript": "^6.0.3", - "typescript-eslint": "^8.67.0", - "vite": "^8.2.2", - "zod": "^4.4.3" + "typescript-eslint": "^8.70.0", + "vite": "^8.3.0", + "zod": "^4.6.1" }, "dependencies": { "@floating-ui/dom": "^1.8.0", @@ -60,7 +61,7 @@ "overrides": { "cookie": "^0.7.0", "valibot": "^1.2.0", - "devalue": "^5.8.1", + "devalue": "^5.9.2", "flatted": "^3.4.2", "effect": "^3.20.0", "picomatch": "^4.0.4" diff --git a/web/src/lib/api/ApiSchema.ts b/web/src/lib/api/ApiSchema.ts index cc6c3cdd1e..8af3153403 100644 --- a/web/src/lib/api/ApiSchema.ts +++ b/web/src/lib/api/ApiSchema.ts @@ -123,7 +123,6 @@ type Topics = message_expiry: number; name: string; partitions_count: number; - stream_id: number; }; } | { @@ -131,9 +130,9 @@ type Topics = path: `/streams/${number}/topics/${number}`; body: { name: string; - message_expiry: number; - compression_algorithm: number; - max_topic_size: number; + message_expiry?: number; + compression_algorithm?: 'none' | 'gzip'; + max_topic_size?: number; }; } | { diff --git a/web/src/lib/components/Breadcrumbs.svelte b/web/src/lib/components/Breadcrumbs.svelte index 09150949f1..e697376183 100644 --- a/web/src/lib/components/Breadcrumbs.svelte +++ b/web/src/lib/components/Breadcrumbs.svelte @@ -24,9 +24,10 @@ under the License. import { isNumber } from '$lib/utils/parsers'; import { twMerge } from 'tailwind-merge'; import { resolve } from '$app/paths'; + import type { Pathname } from '$app/types'; type Crumb = { - path: string; + path: Pathname; label: string; }; @@ -50,7 +51,7 @@ under the License. } function formatPathSegment(segment: string, index: number, parts: string[]): Crumb { - const path = `/dashboard/${parts.slice(0, index + 1).join('/')}`; + const path = `/dashboard/${parts.slice(0, index + 1).join('/')}` as Pathname; if (isNumber(segment)) { const prevSegment = parts[index - 1]; diff --git a/web/src/lib/components/Modals/DeletePartitionsModal.svelte b/web/src/lib/components/Modals/DeletePartitionsModal.svelte index 31bcd76660..53b0f65f09 100644 --- a/web/src/lib/components/Modals/DeletePartitionsModal.svelte +++ b/web/src/lib/components/Modals/DeletePartitionsModal.svelte @@ -130,7 +130,7 @@ under the License. }); let messagesToDelete = $derived( - arraySum(topic.partitions.slice($form.partitions_count).map((p) => p.messagesCount)) + arraySum(topic.partitions.slice(-$form.partitions_count).map((p) => p.messagesCount)) ); diff --git a/web/src/lib/components/Modals/StreamSettingsModal.svelte b/web/src/lib/components/Modals/StreamSettingsModal.svelte index 1b63b1fe36..8d8c6fcc6c 100644 --- a/web/src/lib/components/Modals/StreamSettingsModal.svelte +++ b/web/src/lib/components/Modals/StreamSettingsModal.svelte @@ -127,7 +127,7 @@ under the License. confirmationOpen = false; if (result) { - const { ok } = await fetchRouteApi({ + const { data, ok } = await fetchRouteApi({ method: 'DELETE', path: `/streams/${stream.id}` }); diff --git a/web/src/lib/components/Modals/TopicSettingsModal.svelte b/web/src/lib/components/Modals/TopicSettingsModal.svelte index 8fcd0b5fbf..fc962d265c 100644 --- a/web/src/lib/components/Modals/TopicSettingsModal.svelte +++ b/web/src/lib/components/Modals/TopicSettingsModal.svelte @@ -31,6 +31,7 @@ under the License. import { fetchRouteApi } from '$lib/api/fetchRouteApi'; import { goto } from '$app/navigation'; import { resolve } from '$app/paths'; + import type { Pathname } from '$app/types'; import { showToast } from '../AppToasts.svelte'; import ModalConfirmation from '../ModalConfirmation.svelte'; import { browser } from '$app/environment'; @@ -41,7 +42,7 @@ under the License. interface Props { topic: TopicDetails; closeModal: CloseModalFn; - onDeleteRedirectPath: string; + onDeleteRedirectPath: Pathname; } let { topic, closeModal, onDeleteRedirectPath }: Props = $props(); @@ -71,9 +72,7 @@ under the License. path: `/streams/${+page.params.streamId}/topics/${topic.id}`, body: { name: form.data.name, - message_expiry: form.data.message_expiry, - compression_algorithm: topic.compressionAlgorithm, - max_topic_size: 0 + message_expiry: form.data.message_expiry } }); diff --git a/web/src/lib/utils/base64Utils.test.mjs b/web/src/lib/utils/base64Utils.test.mjs new file mode 100644 index 0000000000..316a94a276 --- /dev/null +++ b/web/src/lib/utils/base64Utils.test.mjs @@ -0,0 +1,32 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { decodeBase64 } from './base64Utils.ts'; + +test('decode message payloads as UTF-8 text', () => { + for (const payload of ['plain text', 'Kraków 🦀', '{"city":"Kraków"}', 'Kraków']) { + const encoded = Buffer.from(payload, 'utf8').toString('base64'); + assert.equal(decodeBase64(encoded), payload); + } +}); + +test('reject invalid Base64 and preserve empty text', () => { + assert.equal(decodeBase64('%%%'), null); + assert.equal(decodeBase64(''), ''); +}); diff --git a/web/src/lib/utils/base64Utils.ts b/web/src/lib/utils/base64Utils.ts index 325d650ae8..9b5988900f 100644 --- a/web/src/lib/utils/base64Utils.ts +++ b/web/src/lib/utils/base64Utils.ts @@ -17,7 +17,8 @@ export function decodeBase64(str: string): string | null { try { - return atob(str); + const bytes = Uint8Array.from(atob(str), (character) => character.charCodeAt(0)); + return new TextDecoder().decode(bytes); } catch { return null; } diff --git a/web/src/routes/dashboard/streams/[streamId=i32]/topics/[topicId=i32]/+page.svelte b/web/src/routes/dashboard/streams/[streamId=i32]/topics/[topicId=i32]/+page.svelte index 05fbe8469a..c151d11574 100644 --- a/web/src/routes/dashboard/streams/[streamId=i32]/topics/[topicId=i32]/+page.svelte +++ b/web/src/routes/dashboard/streams/[streamId=i32]/topics/[topicId=i32]/+page.svelte @@ -39,7 +39,7 @@ under the License. let { data }: Props = $props(); let topic = $derived(data.topic); - let prevPage = $derived(`/dashboard/streams/${page.params.streamId}/`); + let prevPage = $derived(typedRoute(`/dashboard/streams/${+(page.params.streamId || '')}`));