diff --git a/Cargo.lock b/Cargo.lock index f1e318a8ef7..c5482cf5e74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5264,6 +5264,7 @@ dependencies = [ "anyhow", "apache-avro 0.18.0", "arrow", + "async-channel 2.5.0", "aws-sdk-dynamodb", "bytemuck", "chrono", @@ -5275,8 +5276,10 @@ dependencies = [ "feldera-sqllib", "feldera-storage", "feldera-types", + "futures", "num-derive", "num-traits", + "rand 0.8.6", "rmp-serde", "rmpv", "serde", @@ -5370,6 +5373,8 @@ name = "feldera-iceberg" version = "0.324.0" dependencies = [ "anyhow", + "atomic", + "bytemuck", "chrono", "datafusion", "dbsp", @@ -5383,6 +5388,7 @@ dependencies = [ "iceberg-datafusion", "iceberg-storage-opendal", "log", + "serde", "serde_json", "tokio", "url", diff --git a/crates/adapterlib/Cargo.toml b/crates/adapterlib/Cargo.toml index 031a23575c0..a02fbab9e84 100644 --- a/crates/adapterlib/Cargo.toml +++ b/crates/adapterlib/Cargo.toml @@ -39,7 +39,7 @@ rmpv = { workspace = true, features = ["with-serde"] } bytemuck = { workspace = true } num-traits = { workspace = true } num-derive = { workspace = true } -tokio = { workspace = true, features = ["sync"] } +tokio = { workspace = true, features = ["sync", "rt"] } xxhash-rust = { workspace = true, features = ["xxh3"] } datafusion = { workspace = true } datafusion-functions-json = { workspace = true } @@ -47,6 +47,9 @@ thiserror = { workspace = true } serde_json_path_to_error = { workspace = true } chrono = { workspace = true } tracing = { workspace = true } +async-channel = { workspace = true } +futures = { workspace = true } +rand = { workspace = true } [package.metadata.cargo-machete] ignored = ["num-traits"] diff --git a/crates/adapterlib/src/utils.rs b/crates/adapterlib/src/utils.rs index c482fb9dce2..195532116f8 100644 --- a/crates/adapterlib/src/utils.rs +++ b/crates/adapterlib/src/utils.rs @@ -1 +1,3 @@ +pub mod backoff; pub mod datafusion; +pub mod job_queue; diff --git a/crates/adapterlib/src/utils/backoff.rs b/crates/adapterlib/src/utils/backoff.rs new file mode 100644 index 00000000000..6bf50af2998 --- /dev/null +++ b/crates/adapterlib/src/utils/backoff.rs @@ -0,0 +1,46 @@ +//! Exponential backoff with jitter, shared by connectors that retry +//! intermittent object-store and metadata failures. + +use rand::Rng; +use std::cmp::min; +use std::time::Duration; + +/// Calculate exponential backoff delay for retrying an operation. +/// +/// Starts at 0.5s, doubles each retry, caps at 32s, plus uniform jitter up to 25% of that delay +/// (capped at the 32s ceiling) to reduce synchronized retries across connectors. +pub fn calculate_backoff_delay(retry_count: u32) -> Duration { + let base_delay_ms: u64 = 500; // 0.5 seconds + let max_delay_ms: u64 = 32_000; // 32 seconds + let delay_ms = min( + base_delay_ms.checked_shl(retry_count).unwrap_or(u64::MAX), + max_delay_ms, + ); + let jitter_span = (delay_ms / 4).max(1); + let jitter_ms = rand::thread_rng().gen_range(0..jitter_span); + Duration::from_millis(min(delay_ms + jitter_ms, max_delay_ms)) +} + +#[cfg(test)] +mod tests { + use super::calculate_backoff_delay; + use std::time::Duration; + + #[test] + fn backoff_grows_and_caps() { + // Delay lower bound doubles with each retry until the 32s cap. + // Upper bound adds up to 25% jitter, itself capped at 32s. + for (retry, min_ms) in [(0u32, 500u64), (1, 1000), (2, 2000), (6, 32000)] { + let d = calculate_backoff_delay(retry); + assert!(d >= Duration::from_millis(min_ms), "retry {retry}: {d:?}"); + assert!(d <= Duration::from_millis(32_000), "retry {retry}: {d:?}"); + } + } + + #[test] + fn backoff_saturates_on_large_retry_count() { + // `checked_shl` overflow must not panic; it saturates to the cap. + let d = calculate_backoff_delay(1000); + assert!(d <= Duration::from_millis(32_000)); + } +} diff --git a/crates/adapterlib/src/utils/job_queue.rs b/crates/adapterlib/src/utils/job_queue.rs new file mode 100644 index 00000000000..bf433961d16 --- /dev/null +++ b/crates/adapterlib/src/utils/job_queue.rs @@ -0,0 +1,205 @@ +//! A job queue that dispatches work to a pool of tokio tasks while preserving +//! output ordering. +//! +//! Connectors use this to parse record batches in parallel: several worker +//! tasks parse concurrently, but their results are consumed in the order the +//! jobs were enqueued, so records reach the circuit in their original order. + +use std::future::Future; +use std::pin::Pin; + +use futures::channel::oneshot; +use tokio::{spawn, task::JoinHandle}; + +/// A job queue that dispatches work to a pool of tokio tasks. +/// +/// While the jobs can complete out-of-order, their outputs are consumed in the same order +/// they were enqueued. This is useful for implementing parallel parsing, where parsed +/// records must be fed into the circuit in the order they were received. +/* + sync + ┌─────────────────────────────────────────────────────────┐ + │ ┌───────┐ │ + │ ┌─►│worker1├────────┐ │ + │ jobs │ └───────┘ │ │ + ▼ ┌─┬─┬─┬─┬─┬─┐ │ ┌───────┐ │ ┌────┴───┐ +producer ─┬─►│ │ │ │ │ │ ├─┼─►│worker2├────┐ │ │consumer│ + │ └─┴─┴─┴─┴─┴─┘ │ └───────┘ │ │ └────────┘ + │ │ ┌───────┐ │ │ ▲ + │ └─►│worker3├──┐ │ │ │ + │ └───────┘ ▼ ▼ ▼ │ + │ ┌─┬─┬─┬─┬─┬─┐ │ + └────────────────────────────►│ │ │ │ │ │ ├───────┘ + └─┴─┴─┴─┴─┴─┘ + completions + +* Producer adds jobs to the job queue. A job consists of an input value and + a completion one-shot channel where the worker will send the output of the job. + +* The receiving side of the channel is pushed to the completions queue. + +* Worker tasks dequeue jobs from the jobs queue and send the result to the + one-shot channel associated with each job. The consumer receives the next + item from the completion queue and waits for the output of the job. + +* When the producer needs to wait for all jobs in the queue to complete, it + sends a special Sync message to the completions queue. Upon receiving + this message, the consumer sends an acknowledgement to the sync channel. +*/ +pub struct JobQueue { + /// The producer side of the job queue. + job_sender: async_channel::Sender<(I, oneshot::Sender)>, + + /// The producer side of the completions queue. + completion_sender: async_channel::Sender>, + + /// The receiving side of the sync channel. + sync_receiver: async_channel::Receiver<()>, + + /// Worker tasks. + workers: Vec>, + + /// Consumer task. + consumer: JoinHandle<()>, +} + +// Every message in the completions channel contains the receiving side of the +// oneshot channel that will contain the result of the completed job, or a special +// Sync message. +enum Completion { + Completion(oneshot::Receiver), + Sync, +} + +impl JobQueue +where + I: Send + 'static, + O: Send + 'static, +{ + /// Create a job queue. + /// + /// # Arguments + /// + /// * `num_workers` - the number of threads in the worker pool. Must be >0. + /// * `worker_builder` - a closure that returns a closure that each worker will execute for each job. + /// The outer closure is needed to allocate any resources needed by the worker. + /// * `consumer_func` - closure that the consumer will execute for each completed job. + pub fn new( + num_workers: usize, + worker_builder: impl Fn() -> Box Pin + Send>> + Send>, + mut consumer_func: impl FnMut(O) + Send + 'static, + ) -> Self { + assert_ne!(num_workers, 0); + + // The jobs queue length is equal to the number of workers. This way, workers don't get + // starved, but we also don't queue more data than necessary to keep the workers busy. + // TODO: does it make sense to make queue length separately configurable? + let (job_sender, job_receiver) = + async_channel::bounded::<(I, oneshot::Sender)>(num_workers); + + // The completion queue can contain at most one message per worker + one message per + // job in the jobs queue plus the Sync message. + let (completion_sender, completion_receiver) = async_channel::bounded(2 * num_workers + 1); + let (sync_sender, sync_receiver) = async_channel::bounded(1); + + let workers = (0..num_workers) + .map(move |_| { + let mut worker_fn = worker_builder(); + + let job_receiver = job_receiver.clone(); + spawn(async move { + loop { + let Ok((input, completion_sender)) = job_receiver.recv().await else { + return; + }; + let result = worker_fn(input).await; + if completion_sender.send(result).is_err() { + return; + }; + } + }) + }) + .collect(); + + let consumer = spawn(async move { + loop { + match completion_receiver.recv().await { + Err(_) => { + return; + } + Ok(Completion::Completion(receiver)) => { + let Ok(v) = receiver.await else { + continue; + }; + consumer_func(v); + } + Ok(Completion::Sync) => { + if sync_sender.send(()).await.is_err() { + return; + } + } + } + } + }); + + Self { + job_sender, + completion_sender, + sync_receiver, + workers, + consumer, + } + } + + /// Push a new job to the queue. Blocks until there is space in the queue. + pub async fn push_job(&self, job: I) { + let (completion_sender, completion_receiver) = oneshot::channel(); + let _ = self.job_sender.send((job, completion_sender)).await; + let _ = self + .completion_sender + .send(Completion::Completion(completion_receiver)) + .await; + } + + /// Wait for all previously queued jobs to complete. + pub async fn flush(&self) { + let _ = self.completion_sender.send(Completion::Sync).await; + let _ = self.sync_receiver.recv().await; + } +} + +impl Drop for JobQueue { + fn drop(&mut self) { + self.consumer.abort(); + for worker in self.workers.drain(..) { + worker.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_job_queue() { + let result = Arc::new(Mutex::new(Vec::new())); + let result_clone = result.clone(); + + let job_queue = super::JobQueue::new( + 6, + || Box::new(|i: u32| Box::pin(async move { i })), + move |i| result_clone.lock().unwrap().push(i), + ); + + for i in 0..1000000 { + job_queue.push_job(i).await; + } + + job_queue.flush().await; + + let expected: Vec = (0..1000000).collect(); + + assert_eq!(&*result.lock().unwrap(), &*expected); + } +} diff --git a/crates/adapters/src/integrated/delta_table/input.rs b/crates/adapters/src/integrated/delta_table/input.rs index 5d1b1148c15..95311ee90c0 100644 --- a/crates/adapters/src/integrated/delta_table/input.rs +++ b/crates/adapters/src/integrated/delta_table/input.rs @@ -5,7 +5,6 @@ use crate::integrated::delta_table::deletion_vector::{ }; use crate::integrated::delta_table::{delta_input_serde_config, register_storage_handlers}; use crate::transport::{InputEndpoint, InputQueue, InputReaderCommand, IntegratedInputEndpoint}; -use crate::util::JobQueue; use crate::{ControllerError, InputConsumer, InputReader, PipelineState}; use anyhow::{Error as AnyError, Result as AnyResult, anyhow, bail}; use arrow::array::BooleanArray; @@ -39,19 +38,20 @@ use deltalake::{DeltaTable, DeltaTableBuilder, Path, datafusion}; use feldera_adapterlib::format::{ParseError, StagedInputBuffer}; use feldera_adapterlib::metrics::{ConnectorMetrics, ValueType}; use feldera_adapterlib::transport::{InputQueueEntry, Resume, Watermark, parse_resume_info}; +use feldera_adapterlib::utils::backoff::calculate_backoff_delay; use feldera_adapterlib::utils::datafusion::{ ColumnNameSet, array_to_string, columns_referenced_by_expression, columns_referenced_by_order_by, create_session_context_with, execute_query_collect, execute_singleton_query, quote_sql_identifier, timestamp_to_sql_expression, validate_sql_expression, validate_sql_order_by, validate_timestamp_column, }; +use feldera_adapterlib::utils::job_queue::JobQueue; use feldera_storage::tokio::TOKIO_DEDICATED_IO; use feldera_types::adapter_stats::ConnectorHealth; use feldera_types::config::{FtModel, PipelineConfig}; use feldera_types::program_schema::{Field, Relation}; use feldera_types::transport::delta_table::{DeltaTableReaderConfig, DeltaTableTransactionMode}; use futures_util::StreamExt; -use rand::Rng; use roaring::RoaringTreemap; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; @@ -72,21 +72,6 @@ use tracing::{debug, info, trace, warn}; /// Polling interval when following a delta table. const POLL_INTERVAL: Duration = Duration::from_millis(1000); -/// Calculate exponential backoff delay for retrying delta log reads. -/// Starts at 0.5s, doubles each retry, caps at 32s, plus uniform jitter up to 25% of that delay -/// (capped at `max_delay_ms`) to reduce synchronized retries. -fn calculate_backoff_delay(retry_count: u32) -> Duration { - let base_delay_ms: u64 = 500; // 0.5 seconds - let max_delay_ms: u64 = 32_000; // 32 seconds - let delay_ms = min( - base_delay_ms.checked_shl(retry_count).unwrap_or(u64::MAX), - max_delay_ms, - ); - let jitter_span = (delay_ms / 4).max(1); - let jitter_ms = rand::thread_rng().gen_range(0..jitter_span); - Duration::from_millis(min(delay_ms + jitter_ms, max_delay_ms)) -} - /// Default object store timeout. When not explicitly set by the user, /// we use a large timeout value to avoid this issue: /// https://github.com/delta-io/delta-rs/issues/2595, which is common for diff --git a/crates/adapters/src/test/iceberg.rs b/crates/adapters/src/test/iceberg.rs index f596071d9fb..cb5a31b7d1d 100644 --- a/crates/adapters/src/test/iceberg.rs +++ b/crates/adapters/src/test/iceberg.rs @@ -65,15 +65,39 @@ fn data_to_ndjson(data: Vec) -> NamedTempFile { file } +/// Read the Iceberg connector's custom metrics into a `name -> value` map. +fn iceberg_connector_metrics(pipeline: &Controller) -> HashMap { + let endpoint_id = pipeline + .input_endpoint_id_by_name("test_input1") + .expect("iceberg input endpoint must exist"); + pipeline + .status() + .input_status() + .get(&endpoint_id) + .and_then(|status| status.custom_metrics.clone()) + .map(|metrics| { + metrics + .metrics() + .into_iter() + .map(|(name, _, _, value)| (name.to_string(), value)) + .collect() + }) + .unwrap_or_default() +} + /// Read a snapshot of an Iceberg table with records of type `T` to a temporary JSON file. /// /// `table_properties` are set on the input relation, the way table-level SQL /// `WITH` properties (e.g., `skip_unused_columns`) reach the connector. +/// +/// `config` is the connector's transport config as a JSON object. This function +/// forces `mode = snapshot`. Returns the output file and the connector's custom +/// metrics captured just before the pipeline is stopped. fn iceberg_snapshot_to_json( schema: &[Field], table_properties: &[(&str, &str)], - config: &HashMap, -) -> NamedTempFile + config: serde_json::Value, +) -> (NamedTempFile, HashMap) where T: DBData + SerializeWithContext @@ -87,13 +111,16 @@ where json_file.path().display() ); - let mut config = config.clone(); - config.insert("mode".to_string(), "snapshot".to_string()); + let mut config = config; + config + .as_object_mut() + .expect("iceberg connector config must be a JSON object") + .insert("mode".to_string(), json!("snapshot")); let (input_pipeline, err_receiver) = iceberg_input_pipeline::( schema, table_properties, - &config, + config, &json_file.path().display().to_string(), ); input_pipeline.start(); @@ -105,18 +132,21 @@ where assert!(err_receiver.is_empty()); + // Read metrics before stopping, while the connector status is still live. + let metrics = iceberg_connector_metrics(&input_pipeline); + input_pipeline.stop().unwrap(); info!("Read Iceberg snapshot in {:?}", start.elapsed()); - json_file + (json_file, metrics) } /// Build a pipeline that reads from an Iceberg table and writes to a JSON file. fn iceberg_input_pipeline( schema: &[Field], table_properties: &[(&str, &str)], - config: &HashMap, + config: serde_json::Value, output_file_path: &str, ) -> (Controller, Receiver) where @@ -228,30 +258,75 @@ fn data(n_records: usize) -> Vec { #[test] #[cfg(feature = "iceberg-tests-fs")] fn iceberg_localfs_input_test_unordered() { - iceberg_localfs_input_test(&[], &|_| true); + iceberg_localfs_input_test(1_000_000, json!({}), &|_| true); } #[test] #[cfg(feature = "iceberg-tests-fs")] fn iceberg_localfs_input_test_ordered() { - iceberg_localfs_input_test( - &[("timestamp_column".to_string(), "ts".to_string())], - &|_| true, - ); + iceberg_localfs_input_test(1_000_000, json!({ "timestamp_column": "ts" }), &|_| true); } #[test] #[cfg(feature = "iceberg-tests-fs")] fn iceberg_localfs_input_test_ordered_with_filter() { iceberg_localfs_input_test( - &[ - ("timestamp_column".to_string(), "ts".to_string()), - ("snapshot_filter".to_string(), "i >= 10000".to_string()), - ], + 1_000_000, + json!({ "timestamp_column": "ts", "snapshot_filter": "i >= 10000" }), &|x| x.i >= 10000, ); } +/// A single parser task must ingest the whole snapshot correctly (the parallel +/// path defaults to 4 parsers and is covered by the tests above). +#[test] +#[cfg(feature = "iceberg-tests-fs")] +fn iceberg_localfs_input_test_single_parser() { + iceberg_localfs_input_test(100_000, json!({ "num_parsers": 1 }), &|_| true); +} + +/// `transaction_mode = snapshot` on an unordered read ingests the whole snapshot +/// in exactly one Feldera transaction; the ingested data must be identical to a +/// non-transactional read. +#[test] +#[cfg(feature = "iceberg-tests-fs")] +fn iceberg_localfs_input_test_transactional() { + let metrics = + iceberg_localfs_input_test(100_000, json!({ "transaction_mode": "snapshot" }), &|_| { + true + }); + // Unordered snapshot: exactly one transaction. (Reverting the transaction + // wiring drops this to 0.) + assert_eq!( + metrics + .get("input_connector_iceberg_snapshot_transaction_starts") + .copied(), + Some(1.0) + ); +} + +/// `transaction_mode = snapshot` on an ordered read ingests one Feldera +/// transaction per lateness range; the ingested data must still be complete. +#[test] +#[cfg(feature = "iceberg-tests-fs")] +fn iceberg_localfs_input_test_ordered_transactional() { + let metrics = iceberg_localfs_input_test( + 100_000, + json!({ "timestamp_column": "ts", "transaction_mode": "snapshot" }), + &|_| true, + ); + // Ordered snapshot: one transaction per non-empty lateness range, so at + // least one, and (with data spanning multiple ranges) typically several. + let starts = metrics + .get("input_connector_iceberg_snapshot_transaction_starts") + .copied() + .unwrap_or(0.0); + assert!( + starts >= 1.0, + "expected >= 1 snapshot transaction, got {starts}" + ); +} + /// Create a local Iceberg table populated with `data` and return its metadata /// location. With `extra_columns`, the table gets columns that no test SQL /// schema declares (see `--extra-columns` in `create_test_table_s3.py`). @@ -303,22 +378,33 @@ fn create_localfs_table(data: &[IcebergTestStruct], extra_columns: bool) -> Stri .to_string() } +/// Ingest a local-FS Iceberg table in snapshot mode and assert the ingested +/// data matches `data(n_records)` filtered by `filter`. `extra_config` is +/// merged into the connector's transport config as JSON. Returns the +/// connector's custom metrics so callers can make mode-specific assertions. #[cfg(feature = "iceberg-tests-fs")] fn iceberg_localfs_input_test( - extra_config: &[(String, String)], + n_records: usize, + extra_config: serde_json::Value, filter: &dyn Fn(&IcebergTestStruct) -> bool, -) { - let data = data(1_000_000); +) -> HashMap { + let data = data(n_records); let metadata_path = create_localfs_table(&data, false); - let mut json_file = iceberg_snapshot_to_json::( + let mut config = json!({ "metadata_location": metadata_path }); + let config_obj = config.as_object_mut().unwrap(); + for (key, value) in extra_config + .as_object() + .expect("extra_config must be a JSON object") + { + config_obj.insert(key.clone(), value.clone()); + } + + let (mut json_file, metrics) = iceberg_snapshot_to_json::( &IcebergTestStruct::schema_with_lateness(), &[], - &[("metadata_location".to_string(), metadata_path.to_string())] - .into_iter() - .chain(extra_config.into_iter().cloned()) - .collect::>(), + config, ); let expected_zset = dbsp::OrdZSet::from_tuples( @@ -332,6 +418,23 @@ fn iceberg_localfs_input_test( let zset = file_to_zset::(json_file.as_file_mut()); assert_eq!(zset, expected_zset); + + // A snapshot-only connector must reach the completed phase (2). + assert_eq!( + metrics.get("input_connector_iceberg_phase").copied(), + Some(2.0) + ); + + // The test table is built with a single append, i.e. the ingested snapshot + // has sequence number 1. (An unset gauge would read -1.) + assert_eq!( + metrics + .get("input_connector_iceberg_last_ingested_sequence_number") + .copied(), + Some(1.0) + ); + + metrics } /// Read a table through a SQL declaration that names only a few of its @@ -355,12 +458,10 @@ fn iceberg_localfs_input_subset_test(skip_unused: bool) { &[] }; - let mut json_file = iceberg_snapshot_to_json::( + let (mut json_file, _metrics) = iceberg_snapshot_to_json::( &IcebergSubsetTestStruct::schema(), table_properties, - &[("metadata_location".to_string(), metadata_path)] - .into_iter() - .collect::>(), + json!({ "metadata_location": metadata_path }), ); let expected_zset = dbsp::OrdZSet::from_tuples( @@ -394,45 +495,145 @@ fn iceberg_localfs_input_test_skip_unused_columns() { iceberg_localfs_input_subset_test(true); } +/// Build an input-only pipeline that reads a local-FS Iceberg snapshot with +/// at-least-once fault tolerance, checkpointing to `storage_dir`. Rebuilding a +/// pipeline with the same `storage_dir` resumes from the latest checkpoint. +#[cfg(feature = "iceberg-tests-fs")] +fn iceberg_ft_pipeline( + extra_config: serde_json::Value, + storage_dir: &std::path::Path, +) -> Controller { + init_logging(); + + let mut config = json!({ "mode": "snapshot" }); + let config_obj = config.as_object_mut().unwrap(); + for (key, value) in extra_config + .as_object() + .expect("extra_config must be a JSON object") + { + config_obj.insert(key.clone(), value.clone()); + } + + let config: feldera_types::config::PipelineConfig = serde_json::from_value(json!({ + "name": "test", + "workers": 4, + "storage_config": { "path": storage_dir }, + "fault_tolerance": { "model": "at_least_once" }, + "inputs": { + "test_input1": { + "stream": "test_input1", + "transport": { + "name": "iceberg_input", + "config": config, + } + } + } + })) + .unwrap(); + + Controller::with_test_config( + move |workers| { + // A concrete persistent output id is required for checkpointing. + Ok(test_circuit_with_properties::( + workers, + &IcebergTestStruct::schema_with_lateness(), + &[], + &[Some("output")], + )) + }, + &config, + Box::new(|e, _| panic!("iceberg ft pipeline: error: {e}")), + ) + .unwrap() +} + +/// Checkpoint-and-suspend the pipeline, then stop it. +#[cfg(feature = "iceberg-tests-fs")] +fn suspend_and_stop(pipeline: Controller) { + let (sender, receiver) = std::sync::mpsc::channel(); + pipeline.start_suspend(Box::new(move |result| { + let _ = sender.send(result.map(|_| ()).map_err(|e| e.to_string())); + })); + receiver + .recv_timeout(std::time::Duration::from_secs(100)) + .expect("suspend timed out") + .expect("suspend failed"); + pipeline.stop().unwrap(); +} + +/// A snapshot fully ingested before a checkpoint must not be re-read after a +/// suspend/resume: the resumed connector reaches the completed phase (2) and +/// reads zero records. This is what lets a large Iceberg table survive a +/// restart without re-ingesting all of its rows. +/// +/// To confirm the assertion catches a regression, drop the terminal eoi +/// boundary (or the `resume_info.eoi` short-circuit) in `input.rs`: the resumed +/// run then re-reads the whole snapshot and `snapshot_records_total` is nonzero. +#[test] +#[cfg(feature = "iceberg-tests-fs")] +fn iceberg_localfs_input_test_resume_completed_snapshot() { + let data = data(100_000); + let metadata_path = create_localfs_table(&data, false); + let storage_dir = tempfile::TempDir::new().unwrap(); + + // Ordered snapshot so the read is resumable per lateness range. + let config = json!({ "metadata_location": metadata_path, "timestamp_column": "ts" }); + + // First run: ingest the whole snapshot, then checkpoint and suspend. + let pipeline = iceberg_ft_pipeline(config.clone(), storage_dir.path()); + pipeline.start(); + wait(|| pipeline.pipeline_complete(), 400_000).expect("timeout waiting for snapshot"); + let first = iceberg_connector_metrics(&pipeline); + assert!( + first + .get("input_connector_iceberg_snapshot_records_total") + .copied() + .unwrap_or(0.0) + > 0.0, + "the first run should ingest the snapshot" + ); + suspend_and_stop(pipeline); + + // Second run: resume from the checkpoint. The snapshot is complete, so the + // connector reaches the completed phase without reading any records. + let pipeline = iceberg_ft_pipeline(config, storage_dir.path()); + pipeline.start(); + wait(|| pipeline.pipeline_complete(), 60_000).expect("timeout waiting for resume"); + let second = iceberg_connector_metrics(&pipeline); + assert_eq!( + second + .get("input_connector_iceberg_snapshot_records_total") + .copied(), + Some(0.0), + "a resumed, already-completed snapshot must not be re-read" + ); + assert_eq!( + second.get("input_connector_iceberg_phase").copied(), + Some(2.0), + "the resumed connector must reach the completed phase" + ); + pipeline.stop().unwrap(); +} + #[test] #[cfg(feature = "iceberg-tests-glue")] fn iceberg_glue_s3_input_test() { use dbsp::trace::BatchReader; // Read delta table unordered. - let mut json_file = iceberg_snapshot_to_json::( + let (mut json_file, _metrics) = iceberg_snapshot_to_json::( &IcebergTestStruct::schema_with_lateness(), &[], - &[ - ("catalog_type".to_string(), "glue".to_string()), - ( - "glue.warehouse".to_string(), - "s3://feldera-iceberg-test/".to_string(), - ), - ( - "table_name".to_string(), - "iceberg_test.test_table_v2".to_string(), - ), - ( - "glue.access-key-id".to_string(), - std::env::var("ICEBERG_TEST_AWS_ACCESS_KEY_ID").unwrap(), - ), - ( - "glue.secret-access-key".to_string(), - std::env::var("ICEBERG_TEST_AWS_SECRET_ACCESS_KEY").unwrap(), - ), - ("glue.region".to_string(), "us-east-1".to_string()), - ( - "s3.access-key-id".to_string(), - std::env::var("ICEBERG_TEST_AWS_ACCESS_KEY_ID").unwrap(), - ), - ( - "s3.secret-access-key".to_string(), - std::env::var("ICEBERG_TEST_AWS_SECRET_ACCESS_KEY").unwrap(), - ), - ("s3.region".to_string(), "us-east-1".to_string()), - ] - .into_iter() - .collect::>(), + json!({ + "catalog_type": "glue", + "glue.warehouse": "s3://feldera-iceberg-test/", + "table_name": "iceberg_test.test_table_v2", + "glue.access-key-id": std::env::var("ICEBERG_TEST_AWS_ACCESS_KEY_ID").unwrap(), + "glue.secret-access-key": std::env::var("ICEBERG_TEST_AWS_SECRET_ACCESS_KEY").unwrap(), + "glue.region": "us-east-1", + "s3.access-key-id": std::env::var("ICEBERG_TEST_AWS_ACCESS_KEY_ID").unwrap(), + "s3.secret-access-key": std::env::var("ICEBERG_TEST_AWS_SECRET_ACCESS_KEY").unwrap(), + "s3.region": "us-east-1", + }), ); let zset = file_to_zset::(json_file.as_file_mut()); @@ -457,21 +658,16 @@ fn iceberg_s3tables_input_test() { // `s3tables:GetTableData` (the FileIO reads the metadata and data files). // Run with AWS credentials configured, e.g. `AWS_PROFILE=` or // `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`(/`AWS_SESSION_TOKEN`) exported. - let mut json_file = iceberg_snapshot_to_json::( + let (mut json_file, _metrics) = iceberg_snapshot_to_json::( &S3TablesTestStruct::schema(), &[], - &[ - ("catalog_type".to_string(), "s3tables".to_string()), - ( - "s3tables.table-bucket-arn".to_string(), - "arn:aws:s3tables:us-west-1:737834633458:bucket/iceberg-test".to_string(), - ), - ("table_name".to_string(), "dev.test_table".to_string()), - ("s3tables.region".to_string(), "us-west-1".to_string()), - ("s3.region".to_string(), "us-west-1".to_string()), - ] - .into_iter() - .collect::>(), + json!({ + "catalog_type": "s3tables", + "s3tables.table-bucket-arn": "arn:aws:s3tables:us-west-1:737834633458:bucket/iceberg-test", + "table_name": "dev.test_table", + "s3tables.region": "us-west-1", + "s3.region": "us-west-1", + }), ); let zset = file_to_zset::(json_file.as_file_mut()); @@ -485,32 +681,18 @@ fn iceberg_rest_s3_input_test() { use dbsp::trace::BatchReader; // Read delta table unordered. - let mut json_file = iceberg_snapshot_to_json::( + let (mut json_file, _metrics) = iceberg_snapshot_to_json::( &IcebergTestStruct::schema_with_lateness(), &[], - &[ - ("catalog_type".to_string(), "rest".to_string()), - ("rest.uri".to_string(), "http://localhost:8181".to_string()), - ( - "rest.warehouse".to_string(), - "s3://feldera-iceberg-test/".to_string(), - ), - ( - "table_name".to_string(), - "iceberg_test.test_table_v2".to_string(), - ), - ( - "s3.access-key-id".to_string(), - std::env::var("ICEBERG_TEST_AWS_ACCESS_KEY_ID").unwrap(), - ), - ( - "s3.secret-access-key".to_string(), - std::env::var("ICEBERG_TEST_AWS_SECRET_ACCESS_KEY").unwrap(), - ), - ("s3.region".to_string(), "us-east-1".to_string()), - ] - .into_iter() - .collect::>(), + json!({ + "catalog_type": "rest", + "rest.uri": "http://localhost:8181", + "rest.warehouse": "s3://feldera-iceberg-test/", + "table_name": "iceberg_test.test_table_v2", + "s3.access-key-id": std::env::var("ICEBERG_TEST_AWS_ACCESS_KEY_ID").unwrap(), + "s3.secret-access-key": std::env::var("ICEBERG_TEST_AWS_SECRET_ACCESS_KEY").unwrap(), + "s3.region": "us-east-1", + }), ); let zset = file_to_zset::(json_file.as_file_mut()); diff --git a/crates/adapters/src/util.rs b/crates/adapters/src/util.rs index 8495da65ffb..9c94c1cead7 100644 --- a/crates/adapters/src/util.rs +++ b/crates/adapters/src/util.rs @@ -6,18 +6,11 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::mpsc::RecvTimeoutError; use std::time::{Duration, Instant}; -#[cfg(feature = "with-deltalake")] -use std::{future::Future, pin::Pin}; - use anyhow::{Result as AnyResult, bail}; use dashmap::DashMap; use feldera_adapterlib::catalog::SerCursor; use feldera_types::program_schema::SqlIdentifier; -#[cfg(feature = "with-deltalake")] -use futures::channel::oneshot; use itertools::Itertools; -#[cfg(feature = "with-deltalake")] -use tokio::{spawn, task::JoinHandle}; use tracing::warn; /// Operations over an indexed view. @@ -197,176 +190,6 @@ macro_rules! dyn_event { }; } -/// A job queue that dispatches work to a pool of tokio tasks. -/// -/// While the jobs can complete out-of-order, their outputs are consumed in the same order -/// they were enqueued. This is useful for implementing parallel parsing, where parsed -/// records must be fed into the circuit in the order they were received. -/* - sync - ┌─────────────────────────────────────────────────────────┐ - │ ┌───────┐ │ - │ ┌─►│worker1├────────┐ │ - │ jobs │ └───────┘ │ │ - ▼ ┌─┬─┬─┬─┬─┬─┐ │ ┌───────┐ │ ┌────┴───┐ -producer ─┬─►│ │ │ │ │ │ ├─┼─►│worker2├────┐ │ │consumer│ - │ └─┴─┴─┴─┴─┴─┘ │ └───────┘ │ │ └────────┘ - │ │ ┌───────┐ │ │ ▲ - │ └─►│worker3├──┐ │ │ │ - │ └───────┘ ▼ ▼ ▼ │ - │ ┌─┬─┬─┬─┬─┬─┐ │ - └────────────────────────────►│ │ │ │ │ │ ├───────┘ - └─┴─┴─┴─┴─┴─┘ - completions - -* Producer adds jobs to the job queue. A job consists of an input value and - a completion one-shot channel where the worker will send the output of the job. - -* The receiving side of the channel is pushed to the completions queue. - -* Worker tasks dequeue jobs from the jobs queue and send the result to the - one-shot channel associated with each job. The consumer receives the next - item from the completion queue and waits for the output of the job. - -* When the producer needs to wait for all jobs in the queue to complete, it - sends a special Sync message to the completions queue. Upon receiving - this message, the consumer sends an acknowledgement to the sync channel. -*/ -#[cfg(feature = "with-deltalake")] -pub(crate) struct JobQueue { - /// The producer side of the job queue. - job_sender: async_channel::Sender<(I, oneshot::Sender)>, - - /// The producer side of the completions queue. - completion_sender: async_channel::Sender>, - - /// The receiving side of the sync channel. - sync_receiver: async_channel::Receiver<()>, - - /// Worker tasks. - workers: Vec>, - - /// Consumer task. - consumer: JoinHandle<()>, -} - -// Every message in the completions channel contains the receiving side of the -// oneshot channel that will contain the result of the completed job, or a special -// Sync message. -#[cfg(feature = "with-deltalake")] -enum Completion { - Completion(oneshot::Receiver), - Sync, -} - -#[cfg(feature = "with-deltalake")] -impl JobQueue -where - I: Send + 'static, - O: Send + 'static, -{ - /// Create a job queue. - /// - /// # Arguments - /// - /// * `num_workers` - the number of threads in the worker pool. Must be >0. - /// * `worker_builder` - a closure that returns a closure that each worker will execute for each job. - /// The outer closure is needed to allocate any resources needed by the worker. - /// * `consumer_func` - closure that the consumer will execute for each completed job. - pub(crate) fn new( - num_workers: usize, - worker_builder: impl Fn() -> Box Pin + Send>> + Send>, - mut consumer_func: impl FnMut(O) + Send + 'static, - ) -> Self { - assert_ne!(num_workers, 0); - - // The jobs queue length is equal to the number of workers. This way, workers don't get - // starved, but we also don't queue more data than necessary to keep the workers busy. - // TODO: does it make sense to make queue length separately configurable? - let (job_sender, job_receiver) = - async_channel::bounded::<(I, oneshot::Sender)>(num_workers); - - // The completion queue can contain at most one message per worker + one message per - // job in the jobs queue plus the Sync message. - let (completion_sender, completion_receiver) = async_channel::bounded(2 * num_workers + 1); - let (sync_sender, sync_receiver) = async_channel::bounded(1); - - let workers = (0..num_workers) - .map(move |_| { - let mut worker_fn = worker_builder(); - - let job_receiver = job_receiver.clone(); - spawn(async move { - loop { - let Ok((input, completion_sender)) = job_receiver.recv().await else { - return; - }; - let result = worker_fn(input).await; - if completion_sender.send(result).is_err() { - return; - }; - } - }) - }) - .collect(); - - let consumer = spawn(async move { - loop { - match completion_receiver.recv().await { - Err(_) => { - return; - } - Ok(Completion::Completion(receiver)) => { - let Ok(v) = receiver.await else { - continue; - }; - consumer_func(v); - } - Ok(Completion::Sync) => { - if sync_sender.send(()).await.is_err() { - return; - } - } - } - } - }); - - Self { - job_sender, - completion_sender, - sync_receiver, - workers, - consumer, - } - } - - /// Push a new job to the queue. Blocks until there is space in the queue. - pub(crate) async fn push_job(&self, job: I) { - let (completion_sender, completion_receiver) = oneshot::channel(); - let _ = self.job_sender.send((job, completion_sender)).await; - let _ = self - .completion_sender - .send(Completion::Completion(completion_receiver)) - .await; - } - - /// Wait for all previously queued jobs to complete. - pub(crate) async fn flush(&self) { - let _ = self.completion_sender.send(Completion::Sync).await; - let _ = self.sync_receiver.recv().await; - } -} - -#[cfg(feature = "with-deltalake")] -impl Drop for JobQueue { - fn drop(&mut self) { - self.consumer.abort(); - for worker in self.workers.drain(..) { - worker.abort(); - } - } -} - /// Execute a set of `tasks` on a thread pool with `num_threads`. /// /// Each of the `tasks` is a `(name, closure)` pair. The names allow the names @@ -758,8 +581,6 @@ pub(crate) fn run_in_posix_runtime( #[cfg(test)] mod test { - #[cfg(feature = "with-deltalake")] - use std::sync::Mutex; use std::{ sync::{Arc, Barrier}, thread, @@ -768,29 +589,6 @@ mod test { use crate::util::{RateLimitCheckResult, TokenBucketRateLimiter}; - #[cfg(feature = "with-deltalake")] - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn test_job_queue() { - let result = Arc::new(Mutex::new(Vec::new())); - let result_clone = result.clone(); - - let job_queue = super::JobQueue::new( - 6, - || Box::new(|i: u32| Box::pin(async move { i })), - move |i| result_clone.lock().unwrap().push(i), - ); - - for i in 0..1000000 { - job_queue.push_job(i).await; - } - - job_queue.flush().await; - - let expected: Vec = (0..1000000).collect(); - - assert_eq!(&*result.lock().unwrap(), &*expected); - } - // -------- Basic single-thread tests -------- // #[test] diff --git a/crates/feldera-types/src/transport/iceberg.rs b/crates/feldera-types/src/transport/iceberg.rs index da84a6527a0..9234879f2b6 100644 --- a/crates/feldera-types/src/transport/iceberg.rs +++ b/crates/feldera-types/src/transport/iceberg.rs @@ -48,6 +48,29 @@ pub enum IcebergCatalogType { S3Tables, } +/// Iceberg table transaction mode. +/// +/// Determines how the connector breaks up its input into Feldera transactions. +/// +/// * `none` - the connector does not break up its input into transactions. +/// * `snapshot` - ingest the initial snapshot of the table in one or several transactions. +/// +/// # How the table snapshot is ingested using transactions +/// +/// When `transaction_mode` is set to `snapshot`, the connector ingests the snapshot in one +/// or several transactions, depending on `timestamp_column`. If `timestamp_column` is not set, +/// the whole snapshot is ingested in a single Feldera transaction. If `timestamp_column` is set, +/// the connector ingests the snapshot in a series of timestamp ranges of width equal to the +/// `LATENESS` attribute of the column, each range in a separate transaction. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema, Default)] +pub enum IcebergTransactionMode { + #[default] + #[serde(rename = "none")] + None, + #[serde(rename = "snapshot")] + Snapshot, +} + /// AWS Glue catalog config. #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)] pub struct GlueCatalogConfig { @@ -176,12 +199,23 @@ pub struct S3TablesCatalogConfig { pub region: Option, } +fn default_num_parsers() -> u32 { + 4 +} + /// Iceberg input connector configuration. #[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, ToSchema)] pub struct IcebergReaderConfig { /// Table read mode. pub mode: IcebergIngestMode, + /// Transaction mode. + /// + /// Determines how the connector breaks up its input into Feldera transactions. + /// See [`IcebergTransactionMode`]. Defaults to [`IcebergTransactionMode::None`]. + #[serde(default)] + pub transaction_mode: IcebergTransactionMode, + /// Table column that serves as an event timestamp. /// /// When this option is specified, and `mode` is one of `snapshot` or `snapshot_and_follow`, @@ -263,6 +297,24 @@ pub struct IcebergReaderConfig { /// exclusive with `metadata_location`. pub catalog_type: Option, + /// The number of parallel parsing tasks the connector uses to process data read from the + /// table. Increasing this value can enhance performance by allowing more concurrent processing. + /// Recommended range: 1-10. The default is 4. + #[serde(default = "default_num_parsers")] + #[schema(minimum = 1)] + pub num_parsers: u32, + + /// Maximum number of retries for reading the table snapshot. + /// + /// When reading the snapshot fails partway through, for example because an + /// object-store read times out or is throttled, the connector retries the + /// entire read with exponential backoff. This is in addition to the + /// lower-level retries performed by the object-store client. + /// + /// Defaults to unlimited retries. Set to 0 to disable retries. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_retries: Option, + #[serde(flatten)] pub glue_catalog_config: GlueCatalogConfig, @@ -426,6 +478,11 @@ fn ensure_s3tables_property_not_set(property: &Option, name: &str) -> Resu } impl IcebergReaderConfig { + /// Maximum number of high-level operation retries. Defaults to unlimited. + pub fn max_retries(&self) -> u32 { + self.max_retries.unwrap_or(u32::MAX) + } + /// `true` if the configuration requires taking an initial snapshot of the table. pub fn snapshot(&self) -> bool { matches!( @@ -523,4 +580,58 @@ mod test { .unwrap_err(); assert!(err.contains("s3tables.table-bucket-arn"), "{err}"); } + + #[test] + fn num_parsers_and_max_retries_defaults() { + // With neither field set, num_parsers defaults to 4 and retries are unlimited. + let config: IcebergReaderConfig = serde_json::from_str( + r#"{"mode":"snapshot","metadata_location":"file:///tmp/t/metadata.json"}"#, + ) + .unwrap(); + assert_eq!(config.num_parsers, 4); + assert_eq!(config.max_retries, None); + assert_eq!(config.max_retries(), u32::MAX); + } + + #[test] + fn num_parsers_and_max_retries_explicit() { + let config: IcebergReaderConfig = serde_json::from_str( + r#"{"mode":"snapshot","metadata_location":"file:///tmp/t/metadata.json","num_parsers":8,"max_retries":0}"#, + ) + .unwrap(); + assert_eq!(config.num_parsers, 8); + assert_eq!(config.max_retries, Some(0)); + // 0 disables retries: the first attempt is the last. + assert_eq!(config.max_retries(), 0); + } + + #[test] + fn transaction_mode_defaults_to_none() { + let config: IcebergReaderConfig = serde_json::from_str( + r#"{"mode":"snapshot","metadata_location":"file:///tmp/t/metadata.json"}"#, + ) + .unwrap(); + assert_eq!(config.transaction_mode, IcebergTransactionMode::None); + } + + #[test] + fn transaction_mode_snapshot_parses() { + let config: IcebergReaderConfig = serde_json::from_str( + r#"{"mode":"snapshot","metadata_location":"file:///tmp/t/metadata.json","transaction_mode":"snapshot"}"#, + ) + .unwrap(); + assert_eq!(config.transaction_mode, IcebergTransactionMode::Snapshot); + } + + #[test] + fn reader_config_roundtrips() { + let config: IcebergReaderConfig = serde_json::from_str( + r#"{"mode":"snapshot","metadata_location":"file:///tmp/t/metadata.json","num_parsers":2}"#, + ) + .unwrap(); + let serialized = serde_json::to_string(&config).unwrap(); + let reparsed: IcebergReaderConfig = serde_json::from_str(&serialized).unwrap(); + assert_eq!(config, reparsed); + assert_eq!(reparsed.num_parsers, 2); + } } diff --git a/crates/iceberg/Cargo.toml b/crates/iceberg/Cargo.toml index deda2cf94ce..49a41751646 100644 --- a/crates/iceberg/Cargo.toml +++ b/crates/iceberg/Cargo.toml @@ -15,7 +15,9 @@ feldera-types = { workspace = true } feldera-adapterlib = { workspace = true } dbsp = { workspace = true } anyhow = { workspace = true, features = ["backtrace"] } -tokio = { workspace = true, features = ["sync", "rt"] } +atomic = { workspace = true } +bytemuck = { workspace = true } +tokio = { workspace = true, features = ["sync", "rt", "time"] } datafusion = { workspace = true } log = { workspace = true } iceberg = { workspace = true } @@ -26,5 +28,6 @@ iceberg-catalog-s3tables = { workspace = true } iceberg-storage-opendal = { workspace = true } url = { workspace = true } chrono = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } futures-util = { workspace = true } diff --git a/crates/iceberg/src/input.rs b/crates/iceberg/src/input.rs index b2bfe46f292..7c35e78b52d 100644 --- a/crates/iceberg/src/input.rs +++ b/crates/iceberg/src/input.rs @@ -1,35 +1,43 @@ use crate::iceberg_input_serde_config; use anyhow::{anyhow, bail, Error as AnyError, Result as AnyResult}; +use atomic::Atomic; +use bytemuck::NoUninit; use chrono::{DateTime, Utc}; use datafusion::arrow::datatypes::Schema as ArrowSchema; use datafusion::catalog::TableProvider; +use datafusion::common::arrow::array::RecordBatch; use datafusion::prelude::{DataFrame, SQLOptions, SessionContext}; use dbsp::circuit::tokio::TOKIO; use feldera_adapterlib::{ catalog::{ArrowStream, InputCollectionHandle}, errors::journal::ControllerError, - format::ParseError, + format::{InputBuffer, ParseError, StagedInputBuffer}, + metrics::{ConnectorMetrics, ValueType}, transport::{ - InputConsumer, InputEndpoint, InputQueue, InputReader, InputReaderCommand, - IntegratedInputEndpoint, NonFtInputReaderCommand, + parse_resume_info, InputConsumer, InputEndpoint, InputQueue, InputQueueEntry, InputReader, + InputReaderCommand, IntegratedInputEndpoint, Resume, Watermark, }, + utils::backoff::calculate_backoff_delay, utils::datafusion::{ array_to_string, columns_referenced_by_expression, create_session_context, execute_query_collect, execute_singleton_query, quote_sql_identifier, timestamp_to_sql_expression, validate_sql_expression, validate_timestamp_column, ColumnNameSet, }, + utils::job_queue::JobQueue, PipelineState, }; +use feldera_types::adapter_stats::ConnectorHealth; use feldera_types::{ config::{FtModel, PipelineConfig}, program_schema::{Field, Relation}, - transport::iceberg::{IcebergCatalogType, IcebergReaderConfig}, + transport::iceberg::{IcebergCatalogType, IcebergReaderConfig, IcebergTransactionMode}, }; use futures_util::StreamExt; use iceberg::CatalogBuilder; use iceberg::{ io::{FileIO, FileIOBuilder, StorageFactory}, + spec::SnapshotRef, table::{StaticTable, Table as IcebergTable}, Catalog, TableIdent, }; @@ -47,7 +55,12 @@ use iceberg_catalog_s3tables::{ }; use iceberg_datafusion::IcebergStaticTableProvider; use iceberg_storage_opendal::OpenDalResolvingStorageFactory; -use log::{debug, info, trace}; +use log::{debug, info, trace, warn}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; use std::{collections::BTreeSet, sync::Arc, thread}; use tokio::{ select, @@ -55,6 +68,7 @@ use tokio::{ mpsc, watch::{channel, Receiver, Sender}, }, + time::sleep, }; use url::Url; @@ -157,6 +171,135 @@ fn used_column_list(columns: &[String]) -> String { .join(", ") } +/// Current phase of an Iceberg table input connector. +// repr(u64) so the phase can live in an `Atomic` gauge and read +// out as an f64 metric. +#[derive(Copy, Clone, NoUninit)] +#[repr(u64)] +enum IcebergPhase { + LoadingSnapshot = 0, + // Reserved so the gauge encoding stays stable when follow mode adds a + // streaming phase; see #6165. + #[allow(dead_code)] + Follow = 1, + Completed = 2, +} + +/// Prometheus-style metrics exported by the Iceberg input connector. +// TODO(#6165): follow-mode metrics land with follow mode. +struct IcebergMetrics { + /// Current phase of the connector (see [`IcebergPhase`]). + phase: Atomic, + /// Unix epoch seconds when the snapshot phase finished; 0 if not yet complete. + snapshot_completed_ts: AtomicU64, + /// Total records loaded during the snapshot phase. + snapshot_records_total: AtomicU64, + /// Number of Feldera snapshot transactions started by this connector. + snapshot_transaction_starts: AtomicU64, + /// Sequence number of the ingested Iceberg snapshot; + /// [`SEQUENCE_METRIC_UNSET`] until the snapshot has been read. + last_ingested_sequence_number: AtomicU64, +} + +/// Sentinel stored in the sequence-number gauge before a value is available. +const SEQUENCE_METRIC_UNSET: u64 = u64::MAX; + +impl IcebergMetrics { + fn new() -> Self { + Self { + phase: Atomic::new(IcebergPhase::LoadingSnapshot), + snapshot_completed_ts: AtomicU64::new(0), + snapshot_records_total: AtomicU64::new(0), + snapshot_transaction_starts: AtomicU64::new(0), + last_ingested_sequence_number: AtomicU64::new(SEQUENCE_METRIC_UNSET), + } + } + + fn set_phase(&self, phase: IcebergPhase) { + self.phase.store(phase, Ordering::Relaxed); + } + + fn set_last_ingested_sequence_number(&self, sequence_number: i64) { + debug_assert!( + sequence_number >= 0, + "Iceberg sequence number must be non-negative" + ); + self.last_ingested_sequence_number + .store(sequence_number as u64, Ordering::Relaxed); + } + + fn last_ingested_sequence_number_metric(&self) -> f64 { + match self.last_ingested_sequence_number.load(Ordering::Relaxed) { + SEQUENCE_METRIC_UNSET => -1.0, + sequence_number => sequence_number as f64, + } + } +} + +impl ConnectorMetrics for IcebergMetrics { + fn metrics(&self) -> Vec<(&'static str, &'static str, ValueType, f64)> { + vec![ + ( + "input_connector_iceberg_phase", + "Current phase: 0=loading_snapshot, 2=completed (1 reserved for follow mode).", + ValueType::Gauge, + self.phase.load(Ordering::Relaxed) as u64 as f64, + ), + ( + "input_connector_iceberg_snapshot_completed_seconds", + "Unix epoch seconds when the snapshot phase finished (0 if not yet complete).", + ValueType::Gauge, + self.snapshot_completed_ts.load(Ordering::Relaxed) as f64, + ), + ( + "input_connector_iceberg_snapshot_records_total", + "Total records loaded during the snapshot phase.", + ValueType::Counter, + self.snapshot_records_total.load(Ordering::Relaxed) as f64, + ), + ( + "input_connector_iceberg_snapshot_transaction_starts", + "Number of Feldera snapshot transactions started by this connector.", + ValueType::Counter, + self.snapshot_transaction_starts.load(Ordering::Relaxed) as f64, + ), + ( + "input_connector_iceberg_last_ingested_sequence_number", + "Sequence number of the Iceberg snapshot ingested by this connector (-1 if none yet).", + ValueType::Gauge, + self.last_ingested_sequence_number_metric(), + ), + ] + } +} + +fn now_unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Whether a failed table open is a transient throttling error worth retrying, +/// as opposed to a permanent one. +/// +/// iceberg-rust wraps the AWS SDK error as `ErrorKind::Unexpected` and exposes +/// the rate-limit code only in the message text, so match on that text. +fn is_retryable_open_error(message: &str) -> bool { + // Lower-cased rate-limit codes/messages across catalogs (Glue, REST, S3 + // Tables). Full codes, not stems, so prose like "not throttled" won't match. + const SIGNALS: [&str; 6] = [ + "throttlingexception", // Glue / DynamoDB throttling + "rate exceeded", // Glue throttling message + "slowdown", // S3 "SlowDown" + "too many requests", // HTTP 429 (REST catalog) + "requestlimitexceeded", // generic AWS rate limit + "provisionedthroughputexceeded", // DynamoDB-backed catalog + ]; + let message = message.to_lowercase(); + SIGNALS.iter().any(|signal| message.contains(signal)) +} + enum SnapshotDescr { /// Open the latest snapshot (default) Latest, @@ -166,6 +309,85 @@ enum SnapshotDescr { Timestamp(DateTime), } +/// Resume info persisted in each checkpoint for the Iceberg input connector. +/// +/// The same JSON shape serves as both resume metadata and per-checkpoint +/// watermark metadata. Modeled on the Delta connector's `DeltaResumeInfo`: +/// `snapshot_id` plays the role Delta's table `version` does, pinning the +/// connector to one immutable snapshot across restarts, and is where a future +/// follow mode will record its position. +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +struct IcebergResumeInfo { + /// Id of the pinned Iceberg snapshot. `None` before the snapshot is + /// resolved. + snapshot_id: Option, + + /// Exclusive upper bound of event timestamps already ingested from the + /// pinned snapshot. Set only during an ordered read (`timestamp_column`); + /// the connector resumes reading from this timestamp. + /// + /// Stored as the raw `cast( as string)` string; + /// `timestamp_to_sql_expression` wraps it back into a SQL expression on + /// resume. + snapshot_timestamp: Option, + + /// True once the pinned snapshot has been fully read. + eoi: bool, +} + +impl IcebergResumeInfo { + /// Checkpoint taken before the connector started ingesting. + fn initial() -> Self { + Self { + snapshot_id: None, + snapshot_timestamp: None, + eoi: false, + } + } + + /// Checkpoint taken after fully ingesting snapshot `snapshot_id`. + fn eoi(snapshot_id: Option) -> Self { + Self { + snapshot_id, + snapshot_timestamp: None, + eoi: true, + } + } + + /// Checkpoint taken mid-way through an ordered snapshot read: `timestamp` is + /// the exclusive upper bound of ingested event times in snapshot + /// `snapshot_id`. + fn snapshot_progress(snapshot_id: i64, timestamp: &str) -> Self { + Self { + snapshot_id: Some(snapshot_id), + snapshot_timestamp: Some(timestamp.to_string()), + eoi: false, + } + } + + fn to_resume(&self) -> Resume { + Resume::Seek { + seek: serde_json::to_value(self).unwrap(), + } + } +} + +/// Auxiliary data attached to input-queue entries so the connector can align +/// checkpoints with snapshot read boundaries. +#[derive(Debug, Clone)] +enum QueueEntry { + /// Resume info describing the connector state after this entry is flushed. + /// `Some` marks a checkpointable boundary (a committed range, or eoi); `None` + /// rides along with data buffers that carry no independent resume point. + ResumeInfo(Option), + + /// Pushed after a failed read, before retrying or declaring a fatal error, + /// so the connector stays checkpointable between retries. Any in-progress + /// transaction is committed (not rolled back), so a retry may re-emit rows + /// (at-least-once). + Rollback, +} + /// Integrated input connector that reads from an Iceberg table. pub struct IcebergInputEndpoint { inner: Arc, @@ -193,7 +415,11 @@ impl IcebergInputEndpoint { impl InputEndpoint for IcebergInputEndpoint { fn fault_tolerance(&self) -> Option { - None + // The connector reads an immutable, pinned table snapshot and records a + // seekable resume point (the ingested timestamp upper bound) at each + // lateness range, so a restart re-reads at most the range in flight. + // Records are never deduplicated, hence at-least-once, not exactly-once. + Some(FtModel::AtLeastOnce) } } @@ -201,11 +427,12 @@ impl IntegratedInputEndpoint for IcebergInputEndpoint { fn open( self: Box, input_handle: &InputCollectionHandle, - _seek: Option, + resume_info: Option, ) -> AnyResult> { Ok(Box::new(IcebergInputReader::new( &self.inner, input_handle, + resume_info, )?)) } } @@ -219,6 +446,7 @@ impl IcebergInputReader { fn new( endpoint: &Arc, input_handle: &InputCollectionHandle, + resume_info: Option, ) -> AnyResult { // TODO: perform validation as part of config deserialization. endpoint @@ -226,11 +454,87 @@ impl IcebergInputReader { .validate_catalog_config() .map_err(|e| anyhow!(e))?; + if endpoint.config.num_parsers == 0 { + bail!("invalid Iceberg connector configuration: 'num_parsers' must be greater than 0"); + } + if endpoint.config.follow() { bail!("'{}' mode is not yet supported", endpoint.config.mode); } + // Register metrics here rather than at endpoint construction: the + // controller inserts this endpoint's status entry after constructing the + // endpoint but before calling `open` (which builds this reader), and + // `set_custom_metrics` is dropped if the status entry does not yet exist. + endpoint + .consumer + .set_custom_metrics(Arc::clone(&endpoint.metrics) as Arc); + + // Seed resume state from the checkpoint, if any. + let resume_info = match resume_info { + Some(resume_info) => { + let resume_info = parse_resume_info::(&resume_info)?; + match &resume_info { + IcebergResumeInfo { eoi: true, .. } => info!( + "iceberg {}: resuming in the end-of-input state; nothing left to read", + &endpoint.endpoint_name + ), + IcebergResumeInfo { + snapshot_id: Some(snapshot_id), + snapshot_timestamp: Some(ts), + .. + } => info!( + "iceberg {}: resuming the initial snapshot {snapshot_id} from timestamp {ts}", + &endpoint.endpoint_name + ), + IcebergResumeInfo { + snapshot_id: Some(snapshot_id), + .. + } => info!( + "iceberg {}: resuming with pinned snapshot {snapshot_id}", + &endpoint.endpoint_name + ), + IcebergResumeInfo { + snapshot_id: None, .. + } => info!( + "iceberg {}: resuming from a clean state", + &endpoint.endpoint_name + ), + } + Some(resume_info) + } + None => None, + }; + + let eoi = resume_info.as_ref().is_some_and(|r| r.eoi); + + // Prime the resume state so the worker resumes from the checkpointed + // position and the next checkpoint reports at least the resumed status. + if let Some(resume_info) = &resume_info { + *endpoint.last_resume_status.lock().unwrap() = Some(resume_info.clone()); + *endpoint.last_checkpointable_status.lock().unwrap() = resume_info.clone(); + + // Seed the queue so the connector's completed frontier is + // initialized from the resumed position. (The completion timestamp + // is approximate; the frontier is not itself checkpointed.) + endpoint.queue.push_with_aux( + (None, Vec::new()), + Utc::now(), + QueueEntry::ResumeInfo(Some(resume_info.clone())), + ); + } + let (sender, receiver) = channel(PipelineState::Paused); + + if eoi { + endpoint.metrics.set_phase(IcebergPhase::Completed); + endpoint.consumer.eoi(); + return Ok(Self { + sender, + inner: endpoint.clone(), + }); + } + let endpoint_clone = endpoint.clone(); let receiver_clone = receiver.clone(); @@ -271,9 +575,22 @@ impl InputReader for IcebergInputReader { } fn request(&self, command: InputReaderCommand) { - match command.as_nonft().unwrap() { - NonFtInputReaderCommand::Queue => self.inner.queue.queue(), - NonFtInputReaderCommand::Transition(state) => drop(self.sender.send_replace(state)), + match command { + InputReaderCommand::Replay { .. } => panic!( + "replay command is not supported by the Iceberg input connector, which only offers at-least-once fault tolerance; this is a bug, please report it to developers" + ), + InputReaderCommand::Extend => { + let _ = self.sender.send_replace(PipelineState::Running); + } + InputReaderCommand::Pause => { + let _ = self.sender.send_replace(PipelineState::Paused); + } + InputReaderCommand::Queue { + checkpoint_requested, + } => self.queue(checkpoint_requested), + InputReaderCommand::Disconnect => { + let _ = self.sender.send_replace(PipelineState::Terminated); + } } } @@ -282,6 +599,66 @@ impl InputReader for IcebergInputReader { } } +impl IcebergInputReader { + /// Flush queued records to the circuit and, when a checkpoint is requested, + /// stop at a snapshot read boundary so the reported resume point is + /// consistent with the data flushed. + fn queue(&self, checkpoint_requested: bool) { + let stop_at: &dyn Fn(&QueueEntry) -> bool = if checkpoint_requested { + &|entry: &QueueEntry| { + matches!( + entry, + QueueEntry::ResumeInfo(Some(_)) | QueueEntry::Rollback + ) + } + } else { + &|_: &QueueEntry| false + }; + + let (total, _, consumed_aux) = self.inner.queue.flush_with_aux_until(stop_at); + + // The resume point is that of the last checkpointable boundary consumed; + // if none was consumed, keep the previously reported status. + let resume_status = match consumed_aux.last() { + None => self.inner.last_resume_status.lock().unwrap().clone(), + Some((_ts, QueueEntry::ResumeInfo(resume_info))) => resume_info.clone(), + Some((_ts, QueueEntry::Rollback)) => Some( + self.inner + .last_checkpointable_status + .lock() + .unwrap() + .clone(), + ), + }; + + *self.inner.last_resume_status.lock().unwrap() = resume_status.clone(); + if let Some(resume_status) = &resume_status { + *self.inner.last_checkpointable_status.lock().unwrap() = resume_status.clone(); + } + + let resume = match resume_status { + None => Resume::Barrier, + Some(resume_info) => resume_info.to_resume(), + }; + + // Resume info and watermark metadata share the `IcebergResumeInfo` format. + self.inner.consumer.extended( + total, + Some(resume), + consumed_aux + .into_iter() + .filter_map(|(timestamp, aux)| match aux { + QueueEntry::ResumeInfo(resume_info) => Some(Watermark::new( + timestamp, + resume_info.map(|m| serde_json::to_value(m).unwrap()), + )), + QueueEntry::Rollback => None, + }) + .collect(), + ); + } +} + impl Drop for IcebergInputReader { fn drop(&mut self) { self.disconnect(); @@ -293,7 +670,19 @@ struct IcebergInputEndpointInner { config: IcebergReaderConfig, consumer: Box, datafusion: SessionContext, - queue: InputQueue, + queue: Arc>, + /// Monotonic counter used to label snapshot transactions for observability. + transaction_index: AtomicUsize, + metrics: Arc, + + /// Resume point reported at the most recent checkpoint. Seeded from the + /// checkpoint on resume and advanced as the connector consumes snapshot read + /// boundaries. `None` means the connector cannot resume (report a barrier). + last_resume_status: Mutex>, + + /// Most recent resume point that is safe to check point at. Used to answer a + /// checkpoint that stopped on a [`QueueEntry::Rollback`] boundary. + last_checkpointable_status: Mutex, } impl IcebergInputEndpointInner { @@ -304,17 +693,44 @@ impl IcebergInputEndpointInner { runtime_env: Arc, consumer: Box, ) -> Self { - let queue = InputQueue::new(consumer.clone()); + let queue = Arc::new(InputQueue::new(consumer.clone())); // Share the pipeline-wide `RuntimeEnv` so that scans against the // iceberg table spill to the bounded memory pool and on-disk scratch // dir alongside every other datafusion user in the pipeline. let datafusion = create_session_context(pipeline_config, runtime_env); + + // Note: metrics are registered with the consumer in `IcebergInputReader::new` + // (the `open` path), not here. The controller inserts this endpoint's status + // entry only after constructing the endpoint but before `open`, and + // `set_custom_metrics` is silently dropped if the status entry does not yet + // exist. + let metrics = Arc::new(IcebergMetrics::new()); + Self { endpoint_name: endpoint_name.to_string(), config, consumer, datafusion, queue, + transaction_index: AtomicUsize::new(0), + metrics, + last_resume_status: Mutex::new(Some(IcebergResumeInfo::initial())), + last_checkpointable_status: Mutex::new(IcebergResumeInfo::initial()), + } + } + + /// Allocate a transaction for the next snapshot chunk. + /// + /// Returns `None` when `transaction_mode` is `none`, meaning the chunk is not + /// wrapped in a Feldera transaction. Otherwise returns `Some(Some(label))`, + /// where the label identifies the transaction in logs and metrics. + fn allocate_snapshot_transaction(&self) -> Option> { + match self.config.transaction_mode { + IcebergTransactionMode::None => None, + IcebergTransactionMode::Snapshot => { + let index = self.transaction_index.fetch_add(1, Ordering::AcqRel); + Some(Some(format!("snapshot-{index}"))) + } } } @@ -425,11 +841,12 @@ impl IcebergInputEndpointInner { async fn read_ordered_snapshot( &self, used_columns: &[String], + snapshot_id: Option, input_stream: &mut dyn ArrowStream, schema: &Relation, receiver: &mut Receiver, ) { - self.read_ordered_snapshot_inner(used_columns, input_stream, schema, receiver) + self.read_ordered_snapshot_inner(used_columns, snapshot_id, input_stream, schema, receiver) .await .unwrap_or_else(|e| self.consumer.error(true, e, None)); } @@ -437,6 +854,7 @@ impl IcebergInputEndpointInner { async fn read_ordered_snapshot_inner( &self, used_columns: &[String], + snapshot_id: Option, input_stream: &mut dyn ArrowStream, schema: &Relation, receiver: &mut Receiver, @@ -466,7 +884,7 @@ impl IcebergInputEndpointInner { if bounds.len() != 1 || bounds[0].num_rows() != 1 { info!( - "iceberg {}: initial snapshot is empty; the Delta table contains no records{}", + "iceberg {}: initial snapshot is empty; the Iceberg table contains no records{}", &self.endpoint_name, if let Some(filter) = &self.config.snapshot_filter { format!(" that satisfy the filter condition '{filter}'") @@ -485,39 +903,58 @@ impl IcebergInputEndpointInner { )); } - let min = array_to_string(bounds[0].column(0)).ok_or_else(|| { - anyhow!( - "internal error: cannot retrieve the first column in the output of query '{bounds_query}' as a string" - ) - })?; + // When resuming a partially ingested snapshot, start from the + // checkpointed timestamp upper bound instead of the table minimum, so + // ranges already ingested are not read again. + let resume_timestamp = match &*self.last_resume_status.lock().unwrap() { + Some(IcebergResumeInfo { + snapshot_timestamp: Some(timestamp), + .. + }) => Some(timestamp.clone()), + _ => None, + }; - let max = array_to_string(bounds[0].column(1)).ok_or_else(|| { + let min_raw = match &resume_timestamp { + Some(timestamp) => timestamp.clone(), + None => array_to_string(bounds[0].column(0)).ok_or_else(|| { + anyhow!( + "internal error: cannot retrieve the first column in the output of query '{bounds_query}' as a string" + ) + })?, + }; + + let max_raw = array_to_string(bounds[0].column(1)).ok_or_else(|| { anyhow!( "internal error: cannot retrieve the second column in the output of query '{bounds_query}' as a string" ) })?; info!( - "iceberg {}: reading table snapshot in the range '{min} <= {timestamp_column} <= {max}'", + "iceberg {}: reading table snapshot in the range '{min_raw} <= {timestamp_column} <= {max_raw}'{}", &self.endpoint_name, + if resume_timestamp.is_some() { + " (resumed from a checkpoint)" + } else { + "" + }, ); - let min = timestamp_to_sql_expression(×tamp_field.columntype, &min); - let max = timestamp_to_sql_expression(×tamp_field.columntype, &max); + let max = timestamp_to_sql_expression(×tamp_field.columntype, &max_raw); let column_names = used_column_list(used_columns); - let mut start = min.clone(); - let mut done = "false".to_string(); + // `start` is the wrapped SQL expression fed into range queries; the raw + // string version is what we record as the resume point. + let mut start = timestamp_to_sql_expression(×tamp_field.columntype, &min_raw); - while &done != "true" { + loop { // Evaluate SQL expression for the new end of the interval. - let end = execute_singleton_query( + let end_raw = execute_singleton_query( &self.datafusion, &format!("select cast(({start} + {lateness}) as string)"), ) .await?; - let end = timestamp_to_sql_expression(×tamp_field.columntype, &end); + let end = timestamp_to_sql_expression(×tamp_field.columntype, &end_raw); // Query the table for the range. let mut range_query = @@ -531,16 +968,40 @@ impl IcebergInputEndpointInner { start = end.clone(); - done = execute_singleton_query( + let done = execute_singleton_query( &self.datafusion, &format!("select cast({start} > {max} as string)"), ) - .await?; + .await? + == "true"; + + // Commit the range's transaction and record a checkpointable resume + // point: every record with timestamp < `end_raw` has been ingested, + // so a resumed read starts from `end_raw`. Recording the raw + // (unwrapped) timestamp lets the resume path wrap it exactly once. + self.push_snapshot_boundary(snapshot_id, &end_raw); + + if done { + break; + } } Ok(()) } + /// Push an aux-only queue entry that commits the current snapshot + /// transaction (if any) and records a checkpointable resume point: the + /// connector has ingested every record with an event timestamp earlier than + /// `timestamp` from snapshot `snapshot_id`. + fn push_snapshot_boundary(&self, snapshot_id: Option, timestamp: &str) { + let resume_info = snapshot_id.map(|id| IcebergResumeInfo::snapshot_progress(id, timestamp)); + self.queue.push_entry( + InputQueueEntry::new_with_aux(Utc::now(), QueueEntry::ResumeInfo(resume_info)) + .with_commit_transaction(true), + Vec::new(), + ); + } + async fn worker_task_inner( self: Arc, mut input_stream: Box, @@ -548,7 +1009,7 @@ impl IcebergInputEndpointInner { mut receiver: Receiver, init_status_sender: mpsc::Sender>, ) { - let table = match self.open_table().await { + let table = match self.open_table_with_retries().await { Err(e) => { let _ = init_status_sender.send(Err(e)).await; return; @@ -558,7 +1019,32 @@ impl IcebergInputEndpointInner { let table = Arc::new(table); - let used_columns = match self.prepare_snapshot_query(&table, &schema).await { + // Pin the snapshot the connector reads (resolving `latest` to a concrete + // id) and record it in the reported resume state, so every checkpoint + // keeps the connector on the same immutable snapshot across restarts. + let snapshot_id = match self.resolved_snapshot_id(&table) { + Err(e) => { + let _ = init_status_sender.send(Err(e)).await; + return; + } + Ok(snapshot_id) => snapshot_id, + }; + if let Some(snapshot_id) = snapshot_id { + let mut status = self.last_resume_status.lock().unwrap(); + let snapshot_timestamp = status + .as_ref() + .and_then(|status| status.snapshot_timestamp.clone()); + *status = Some(IcebergResumeInfo { + snapshot_id: Some(snapshot_id), + snapshot_timestamp, + eoi: false, + }); + } + + let used_columns = match self + .prepare_snapshot_query(&table, snapshot_id, &schema) + .await + { Err(e) => { let _ = init_status_sender.send(Err(e)).await; return; @@ -572,13 +1058,18 @@ impl IcebergInputEndpointInner { let _ = init_status_sender.send(Ok(())).await; if self.config.snapshot() && self.config.timestamp_column.is_none() { - // Read snapshot chunk-by-chunk. + // Unordered read: the whole snapshot is one query with no seekable + // interior boundary, so a checkpoint taken mid-read resumes by + // re-reading the whole snapshot. Set `timestamp_column` for + // incremental, bounded-re-read checkpointing on large tables. self.read_unordered_snapshot(&used_columns, input_stream.as_mut(), &mut receiver) .await; } else if self.config.snapshot() { - // Read the entire snapshot in one query. + // Ordered read: one lateness range per Feldera transaction, each a + // checkpointable resume point. self.read_ordered_snapshot( &used_columns, + snapshot_id, input_stream.as_mut(), &schema, &mut receiver, @@ -586,9 +1077,74 @@ impl IcebergInputEndpointInner { .await; }; + if self.config.snapshot() { + self.metrics + .snapshot_completed_ts + .store(now_unix_secs(), Ordering::Relaxed); + if let Some(snapshot) = self.ingested_snapshot(&table, snapshot_id) { + self.metrics + .set_last_ingested_sequence_number(snapshot.sequence_number()); + info!( + "iceberg {}: ingested snapshot {} (sequence number {})", + &self.endpoint_name, + snapshot.snapshot_id(), + snapshot.sequence_number(), + ); + } + + // Terminal checkpoint boundary: the snapshot is fully ingested. + // Committing any in-progress transaction and recording the + // end-of-input state means a checkpoint taken after completion + // resumes straight into the eoi state, never re-reading the snapshot. + self.queue.push_entry( + InputQueueEntry::new_with_aux( + Utc::now(), + QueueEntry::ResumeInfo(Some(IcebergResumeInfo::eoi(snapshot_id))), + ) + .with_commit_transaction(true), + Vec::new(), + ); + } + + // Snapshot-only connector: nothing follows the snapshot, so the + // connector is done once the snapshot has been read. + self.metrics.set_phase(IcebergPhase::Completed); + self.consumer.eoi(); } + /// Open the table, retrying transient catalog throttling with backoff. + /// + /// Under load the catalog control plane (notably AWS Glue `GetTable`) can + /// reject an open with a rate-limit error that clears on retry. + /// + /// Only rate-limit errors are retried. A permanent error (missing table, bad + /// credentials, wrong region) is surfaced immediately: `max_retries` defaults + /// to unlimited, so retrying it would loop forever. + async fn open_table_with_retries(&self) -> Result { + let max_retries = self.config.max_retries(); + let mut retry_count = 0; + loop { + match self.open_table().await { + Ok(table) => return Ok(table), + Err(e) + if retry_count >= max_retries || !is_retryable_open_error(&e.to_string()) => + { + return Err(e); + } + Err(e) => { + let backoff_delay = calculate_backoff_delay(retry_count); + retry_count += 1; + warn!( + "iceberg {}: error opening table: '{e}'; retrying in {backoff_delay:?} (attempt {retry_count})", + &self.endpoint_name + ); + sleep(backoff_delay).await; + } + } + } + } + /// Open existing iceberg table. Use snapshot id or timestamp specified in the configuration, if any. async fn open_table(&self) -> Result { debug!("iceberg {}: opening iceberg table", &self.endpoint_name); @@ -931,6 +1487,7 @@ impl IcebergInputEndpointInner { async fn prepare_snapshot_query( &self, table: &IcebergTable, + snapshot_id: Option, schema: &Relation, ) -> Result, ControllerError> { if !self.config.snapshot() { @@ -947,29 +1504,6 @@ impl IcebergInputEndpointInner { &self.endpoint_name, ); - let snapshot_id = match self.snapshot_descr()? { - SnapshotDescr::SnapshotId(snapshot_id) => Some(snapshot_id), - SnapshotDescr::Timestamp(ts) => { - let ts_ms = ts.timestamp_millis(); - let snapshot_log = table - .metadata() - .history() - .iter() - .rev() - .find(|log| log.timestamp_ms() <= ts_ms); - if let Some(snapshot_log) = snapshot_log { - Some(snapshot_log.snapshot_id) - } else { - return Err(ControllerError::input_transport_error( - &self.endpoint_name, - true, - anyhow!("Iceberg connector configuration specifies timestamp {ts}; however Iceberg table does not contain a snapshot with the same or earlier timestamp"), - )); - } - } - SnapshotDescr::Latest => None, - }; - let provider = match snapshot_id { Some(snapshot_id) => { IcebergStaticTableProvider::try_new_from_table_snapshot(table.clone(), snapshot_id) @@ -1016,6 +1550,56 @@ impl IcebergInputEndpointInner { Ok(used_columns) } + /// The concrete id of the snapshot the connector reads, pinned across + /// restarts. `Ok(None)` when the table has no snapshot (empty table read in + /// `Latest` mode). + /// + /// On resume the id recorded in the checkpoint wins, so the connector reads + /// the same immutable snapshot even if the table advanced. Otherwise it + /// resolves the configured selector: `Latest` becomes the table's current + /// snapshot id, so the choice is frozen at the first read and every later + /// checkpoint pins it. + fn resolved_snapshot_id(&self, table: &IcebergTable) -> Result, ControllerError> { + if let Some(IcebergResumeInfo { + snapshot_id: Some(snapshot_id), + .. + }) = &*self.last_resume_status.lock().unwrap() + { + return Ok(Some(*snapshot_id)); + } + + let metadata = table.metadata(); + match self.snapshot_descr()? { + SnapshotDescr::SnapshotId(snapshot_id) => Ok(Some(snapshot_id)), + SnapshotDescr::Timestamp(ts) => { + let ts_ms = ts.timestamp_millis(); + match metadata + .history() + .iter() + .rev() + .find(|log| log.timestamp_ms() <= ts_ms) + { + Some(snapshot_log) => Ok(Some(snapshot_log.snapshot_id)), + None => Err(ControllerError::input_transport_error( + &self.endpoint_name, + true, + anyhow!("Iceberg connector configuration specifies timestamp {ts}; however Iceberg table does not contain a snapshot with the same or earlier timestamp"), + )), + } + } + SnapshotDescr::Latest => Ok(metadata.current_snapshot().map(|s| s.snapshot_id())), + } + } + + /// The Iceberg snapshot the connector reads, `None` if the table has no matching snapshot. + fn ingested_snapshot( + &self, + table: &IcebergTable, + snapshot_id: Option, + ) -> Option { + table.metadata().snapshot_by_id(snapshot_id?).cloned() + } + /// Execute a SQL query to load a complete or partial snapshot of the table. async fn execute_snapshot_query( &self, @@ -1043,17 +1627,37 @@ impl IcebergInputEndpointInner { } }; - self.execute_df(df, true, &descr, input_stream, receiver) + // Each snapshot chunk is its own Feldera transaction (or none, depending on + // `transaction_mode`): the whole snapshot for an unordered read, one range + // for an ordered read. + let transaction = self.allocate_snapshot_transaction(); + + // On terminal failure `execute_df` has already reported the error to the + // consumer, which stops ingestion; nothing more to do here. + let _ = self + .execute_df(df, true, &descr, transaction, input_stream, receiver) .await; } - /// Execute a prepared dataframe and push data from it to the circuit. + /// Execute a prepared dataframe and push data from it to the circuit, + /// retrying the whole dataframe on transient failures. + /// + /// The object-store reads underlying an Iceberg scan can fail intermittently + /// (timeouts, throttling). Since a partially consumed dataframe stream cannot + /// be resumed mid-flight, we retry the entire dataframe with exponential + /// backoff. On terminal failure the error is reported to the consumer and + /// returned. /// /// * `polarity` - determines whether records in the dataframe should be /// inserted to or deleted from the table. /// /// * `descr` - dataframe description used to construct error message. /// + /// * `transaction` - when `Some`, the dataframe's records are wrapped in a + /// Feldera transaction: entries carry the start label; the transaction is + /// committed by the caller's snapshot-boundary entry once the chunk + /// completes. + /// /// * `input_stream` - handle to push updates to. /// /// * `receiver` - used to block the function until the endpoint is unpaused. @@ -1062,61 +1666,192 @@ impl IcebergInputEndpointInner { dataframe: DataFrame, polarity: bool, descr: &str, + transaction: Option>, input_stream: &mut dyn ArrowStream, receiver: &mut Receiver, - ) { - wait_running(receiver).await; + ) -> Result { + let max_retries = self.config.max_retries(); + let mut retry_count = 0; + loop { + match self + .execute_df_inner( + dataframe.clone(), + polarity, + transaction.clone(), + input_stream, + receiver, + ) + .await + { + Ok(total_records) => { + self.metrics + .snapshot_records_total + .fetch_add(total_records as u64, Ordering::Relaxed); + self.consumer + .update_connector_health(ConnectorHealth::healthy()); + return Ok(total_records); + } + Err(e) => { + // Commit any transaction started by this attempt and mark a + // checkpointable boundary between retries. The partial data + // already queued is not rolled back, so a retry re-reads the + // whole dataframe and may re-emit those rows (at-least-once). + self.queue.push_entry( + InputQueueEntry::new_with_aux(Utc::now(), QueueEntry::Rollback) + .with_commit_transaction(true), + Vec::new(), + ); - let mut stream = match dataframe.execute_stream().await { - Err(e) => { - self.consumer - .error(true, anyhow!("error retrieving {descr}: {e:?}"), None); - return; + retry_count += 1; + if retry_count > max_retries { + let message = + format!("error retrieving {descr} after {retry_count} attempt(s): {e}"); + self.consumer + .update_connector_health(ConnectorHealth::unhealthy(&message)); + self.consumer + .error(true, anyhow!(message.clone()), Some("iceberg-read")); + return Err(anyhow!(message)); + } + let backoff_delay = calculate_backoff_delay(retry_count - 1); + let message = format!( + "error retrieving {descr} after {retry_count} attempt(s): {e}; retrying in {backoff_delay:?}" + ); + self.consumer + .update_connector_health(ConnectorHealth::unhealthy(&message)); + warn!("iceberg {}: {message}", &self.endpoint_name); + sleep(backoff_delay).await; + } } - Ok(stream) => stream, - }; + } + } + + /// A single attempt of the `execute_df` retry loop. + /// + /// Record batches are parsed by a pool of `num_parsers` tasks. Parsing runs + /// concurrently, but [`JobQueue`] preserves ordering, so parsed buffers reach + /// the input queue in the same order the batches were read. + async fn execute_df_inner( + &self, + dataframe: DataFrame, + polarity: bool, + transaction: Option>, + input_stream: &mut dyn ArrowStream, + receiver: &mut Receiver, + ) -> Result { + wait_running(receiver).await; + + if transaction.is_some() { + self.metrics + .snapshot_transaction_starts + .fetch_add(1, Ordering::Relaxed); + } + + let mut stream = dataframe + .execute_stream() + .await + .map_err(|e| format!("{e:?}"))?; + + // The dataframe compiled and started streaming: the connector is healthy. + self.consumer + .update_connector_health(ConnectorHealth::healthy()); let mut num_batches = 0; + let mut total_records = 0usize; + + let queue = self.queue.clone(); + let num_parsers = self.config.num_parsers as usize; + + // Job queue that parses record batches on a pool of tasks and pushes the + // resulting buffers to the input queue in enqueue order. + let job_queue = JobQueue::< + (RecordBatch, DateTime), + (Option, Vec, DateTime), + >::new( + num_parsers, + // Both the worker closure and each per-job future need an owned + // (`'static`) stream, so each level forks once: + // - the outer fork gives every worker its own stream, since the + // closure can't capture the borrowed `&mut input_stream`; + // - the inner fork produces a fresh stream to move into each job's + // future, since an `FnMut` can't move its captured stream out + // more than once. + move || { + let input_stream = input_stream.fork(); + Box::new(move |(batch, timestamp)| { + Box::pin({ + let mut input_stream = input_stream.fork(); + async move { + let (buffer, errors) = + Self::parse_record_batch(batch, polarity, input_stream.as_mut()) + .await; + // Stage the parsed buffer so the flush cost is paid + // here, ahead of circuit demand, rather than when the + // controller drains the queue. + let staged_buffer = buffer.map(|buffer| { + let len = buffer.len(); + StagedInputBuffer::new(input_stream.stage(vec![buffer]), len) + }); + (staged_buffer, errors, timestamp) + } + }) + }) + }, + move |(buffer, errors, timestamp)| { + // Data buffers carry no independent resume point, so their aux is + // `ResumeInfo(None)`; the caller's snapshot-boundary entry + // records the checkpointable position. Setting the start label on + // every entry is idempotent: the input queue starts the + // transaction on the first flushed entry and ignores the label + // thereafter. + queue.push_entry( + InputQueueEntry::new_with_aux(timestamp, QueueEntry::ResumeInfo(None)) + .with_buffer(buffer) + .with_start_transaction(transaction.clone()), + errors, + ); + }, + ); - // Use the timestamp when we start retrieving the next batch as the ingestion timestamp. + // Use the timestamp when the batch was retrieved as the ingestion timestamp. let mut timestamp = Utc::now(); while let Some(batch) = stream.next().await { wait_running(receiver).await; - - let batch = match batch { - Ok(batch) => batch, - Err(e) => { - self.consumer.error( - false, - anyhow!("error retrieving batch {num_batches} of {descr}: {e:?}"), - Some("iceberg-batch"), - ); - continue; - } - }; - // info!("schema: {}", batch.schema()); + let batch = + batch.map_err(|e| format!("error retrieving batch {num_batches}: {e:?}"))?; num_batches += 1; - let result = if polarity { - input_stream.insert(&batch, &None) - } else { - input_stream.delete(&batch, &None) - }; - let errors = result.map_or_else( - |e| { - vec![ParseError::bin_envelope_error( - format!("error deserializing table records from Parquet data: {e}"), - &[], - None, - )] - }, - |()| Vec::new(), - ); - self.queue - .push((input_stream.take_all(), errors), timestamp); - + total_records += batch.num_rows(); + job_queue.push_job((batch, timestamp)).await; timestamp = Utc::now(); } + + job_queue.flush().await; + Ok(total_records) + } + + /// Parse a single record batch into an input buffer. + async fn parse_record_batch( + batch: RecordBatch, + polarity: bool, + input_stream: &mut dyn ArrowStream, + ) -> (Option>, Vec) { + let result = if polarity { + input_stream.insert(&batch, &None) + } else { + input_stream.delete(&batch, &None) + }; + let errors = result.map_or_else( + |e| { + vec![ParseError::bin_envelope_error( + format!("error deserializing records read from the Iceberg table: {e}"), + &[], + None, + )] + }, + |()| Vec::new(), + ); + + (input_stream.take_all(), errors) } } @@ -1144,6 +1879,26 @@ mod tests { let _factory = storage_factory(); } + #[test] + fn retryable_open_error_matches_only_throttling() { + // A real Glue throttling rejection is retried. + let glue_throttle = "error loading Iceberg table: Unexpected => Operation failed \ + for hitting aws sdk error, source: aws sdk error: ServiceError { source: \ + Unhandled { source: ErrorMetadata { code: Some(\"ThrottlingException\"), \ + message: Some(\"Rate exceeded\") } } }"; + assert!(is_retryable_open_error(glue_throttle)); + + // Permanent failures must fail fast, not retry. + for permanent in [ + "error loading Iceberg table: table `db.t` not found", + "error creating Glue catalog client: missing credentials", + "request throttling is disabled for this method", + "the table was not throttled; it does not exist", + ] { + assert!(!is_retryable_open_error(permanent), "{permanent}"); + } + } + fn config(value: serde_json::Value) -> IcebergReaderConfig { serde_json::from_value(value).unwrap() } @@ -1288,4 +2043,54 @@ mod tests { r#""simple", "with""quote", "With Space""# ); } + + /// Resume metadata must survive a JSON round trip unchanged: the connector + /// serializes it into the checkpoint and deserializes it back on resume. + #[test] + fn resume_info_json_round_trip() { + for resume_info in [ + IcebergResumeInfo::initial(), + IcebergResumeInfo::eoi(Some(555)), + IcebergResumeInfo::snapshot_progress(1234567890123456789, "2024-01-02 00:00:00"), + ] { + let json = serde_json::to_value(&resume_info).unwrap(); + let parsed = parse_resume_info::(&json).unwrap(); + assert_eq!(parsed, resume_info); + } + } + + /// Every resume point the connector records is seekable, so it must map to + /// `Resume::Seek` (at-least-once), never `Resume::Replay` (exactly-once, + /// which the connector does not support). + #[test] + fn resume_info_maps_to_seek() { + for resume_info in [ + IcebergResumeInfo::initial(), + IcebergResumeInfo::eoi(None), + IcebergResumeInfo::snapshot_progress(42, "2024-01-02 00:00:00"), + ] { + let resume = resume_info.to_resume(); + let Resume::Seek { seek } = resume else { + panic!("expected Resume::Seek, got {resume:?}"); + }; + // The seek payload deserializes back into the same resume info. + assert_eq!( + parse_resume_info::(&seek).unwrap(), + resume_info + ); + } + } + + /// A large i64 snapshot id must round-trip exactly through the resume info. + /// This is why the opaque snapshot id lives in resume metadata (JSON, exact) + /// rather than in the f64 sequence-number gauge, which loses precision above + /// 2^53. + #[test] + fn resume_info_preserves_large_snapshot_id() { + let snapshot_id = 8_070_808_040_601_692_143_i64; + let resume_info = IcebergResumeInfo::snapshot_progress(snapshot_id, "2024-01-02 00:00:00"); + let json = serde_json::to_value(&resume_info).unwrap(); + let parsed = parse_resume_info::(&json).unwrap(); + assert_eq!(parsed.snapshot_id, Some(snapshot_id)); + } } diff --git a/crates/iceberg/src/lib.rs b/crates/iceberg/src/lib.rs index c1e278af11e..267a4c8e1b9 100644 --- a/crates/iceberg/src/lib.rs +++ b/crates/iceberg/src/lib.rs @@ -3,7 +3,8 @@ mod input; pub use input::IcebergInputEndpoint; use feldera_types::serde_with_context::{ - serde_config::DecimalFormat, DateFormat, SqlSerdeConfig, TimestampFormat, + serde_config::{DecimalFormat, UuidFormat}, + DateFormat, SqlSerdeConfig, TimestampFormat, }; pub fn iceberg_input_serde_config() -> SqlSerdeConfig { @@ -19,4 +20,7 @@ pub fn iceberg_input_serde_config() -> SqlSerdeConfig { .with_timestamp_format(TimestampFormat::String("%Y-%m-%dT%H:%M:%S%.f%Z")) .with_date_format(DateFormat::DaysSinceEpoch) .with_decimal_format(DecimalFormat::String) + // Iceberg stores UUID as a physical 16-byte fixed binary, so the + // deserializer must read it as bytes, not a string. + .with_uuid_format(UuidFormat::Binary) } diff --git a/crates/pipeline-manager/src/api/main.rs b/crates/pipeline-manager/src/api/main.rs index 56327f6e32a..30a1b946f28 100644 --- a/crates/pipeline-manager/src/api/main.rs +++ b/crates/pipeline-manager/src/api/main.rs @@ -423,6 +423,7 @@ It contains the following fields: feldera_types::transport::iceberg::RestCatalogConfig, feldera_types::transport::iceberg::GlueCatalogConfig, feldera_types::transport::iceberg::S3TablesCatalogConfig, + feldera_types::transport::iceberg::IcebergTransactionMode, feldera_types::transport::postgres::PostgresReaderConfig, feldera_types::transport::postgres::PostgresCdcReaderConfig, feldera_types::transport::postgres::PostgresWriterConfig, diff --git a/docs.feldera.com/docs/connectors/sources/iceberg.md b/docs.feldera.com/docs/connectors/sources/iceberg.md index 7f48f9280d2..230c2aa63c6 100644 --- a/docs.feldera.com/docs/connectors/sources/iceberg.md +++ b/docs.feldera.com/docs/connectors/sources/iceberg.md @@ -21,7 +21,9 @@ The connector is compatible with REST, AWS Glue, and Amazon S3 Tables catalogs a supports direct table reads without a catalog, provided the location of the metadata file. Supported storage systems include S3, GCS, and local file systems. -The Iceberg input connector does not yet support [fault tolerance](/pipelines/fault-tolerance). +The Iceberg input connector supports [fault tolerance](/pipelines/fault-tolerance) at the +[at-least-once](/pipelines/fault-tolerance#fault-tolerance-guarantees) level: see +[Fault tolerance](#fault-tolerance) below. ## Configuration @@ -29,15 +31,16 @@ The Iceberg input connector does not yet support [fault tolerance](/pipelines/fa | Property | Type | Description | |-----------------------------|--------|---------------| | `mode`* | enum | Table read mode. Currently, the only supported mode is `snapshot`, in which the connector reads a snapshot of the table and stops.| +| `transaction_mode` | enum | Determines how the connector breaks up its input into transactions. Supported values are `none` (default) and `snapshot`. See [below](#transactions) for details. | +| `timestamp_column` | string | Table column that serves as an event timestamp. When this option is specified, table rows are ingested in the timestamp order, respecting the [`LATENESS`](/sql/streaming#lateness-expressions) property of the column: each ingested row has a timestamp no more than `LATENESS` time units earlier than the most recent timestamp of any previously ingested row. See details [below](#ingesting-time-series-data-from-iceberg). | | `snapshot_filter` | string |

Optional row filter. When specified, only rows that satisfy the filter condition are included in the snapshot. The condition must be a valid SQL Boolean expression that can be used in the `where` clause of the `select * from snapshot where ..` query.

This option can be used to specify the range of event times to include in the snapshot, e.g.: `ts BETWEEN TIMESTAMP '2005-01-01 00:00:00' AND TIMESTAMP '2010-12-31 23:59:59'`.

| `snapshot_id` | integer|

Optional table snapshot id. When this option is set, the connector reads the specified snapshot of the table.

Note: at most one of `version` and `datetime` options can be specified. When neither of the two options is specified, the latest snapshot of the table is used.

| `datetime` | string |

Optional timestamp for the snapshot in the ISO-8601/RFC-3339 format, e.g., "2024-12-09T16:09:53+00:00". When this option is set, the connector reads the version of the table as of the specified point in time (based on the server time recorded in the transaction log, not the event time encoded in the data).

Note: at most one of `version` and `datetime` options can be specified. When neither of the two options is specified, the latest committed version of the table is used.

| | `metadata_location` | string | Location of the table metadata JSON file. This property is used to access an Iceberg table directly, without a catalog. It is mutually exclusive with the `catalog_type` property.| | `table_name` | string | Specifies the Iceberg table name within the catalog in the `namespace.table` format. This option is applicable when an Iceberg catalog is configured using the `catalog_type` property.| | `catalog_type` | enum | Type of the Iceberg catalog used to access the table. Supported options include `rest`, `glue`, and `s3tables`. This property is mutually exclusive with `metadata_location`.| - - - +| `num_parsers` | integer| Number of parallel parsing tasks used to process data read from the table. Increasing this value can improve throughput by parsing record batches concurrently. Recommended range: 1-10. Default: `4`.| +| `max_retries` | integer|

Maximum number of retries for reading the table snapshot. When reading the snapshot fails partway through, for example because an object store read times out or is throttled, the connector retries the entire read with exponential backoff. This is in addition to the lower-level retries performed by the object store client.

Defaults to unlimited retries. Set to `0` to disable retries.

| [*]: Required fields @@ -146,15 +149,18 @@ The following table lists supported Iceberg data types and corresponding Feldera | `time` | `TIME` | | | `timestamp` | `TIMESTAMP` | Timestamp values are rounded to the nearest millisecond.| | `timestamp_ns` | `TIMESTAMP` | Timestamp values are rounded to the nearest millisecond.| +| `timestamptz` | `TIMESTAMP WITH TIME ZONE` | Timestamp values are rounded to the nearest millisecond.| +| `timestamptz_ns` | `TIMESTAMP WITH TIME ZONE` | Timestamp values are rounded to the nearest millisecond.| | `string` | `STRING` | | | `fixed(L)` | `BINARY(L)` | | | `binary` | `VARBINARY` | | +| `uuid` | `UUID` | | +| `struct` | `ROW(...)` | Read as a whole column; nested fields map by name.| +| `list` | ` ARRAY`| | +| `map` | `MAP<, >` | | - - - -Types that are currently not supported include Iceberg's nested data types (`struct`s, -`list`s and `map`s), `uuid`, and timestamps with time zone. +All Iceberg data types are supported, including the nested types (`struct`, `list`, +and `map`), which the connector reads as whole columns. ## Column selection @@ -163,8 +169,54 @@ declaration. Other columns of the Iceberg table are never read. In addition, when the table declaration sets the [`skip_unused_columns` property](/sql/grammar#ignoring-unused-columns), the connector skips declared columns that no view uses, provided they are nullable or have default values. - - - + }]' +); +``` ## Examples diff --git a/docs.feldera.com/docs/operations/metrics.md b/docs.feldera.com/docs/operations/metrics.md index 4106a14ff60..b748637d99e 100644 --- a/docs.feldera.com/docs/operations/metrics.md +++ b/docs.feldera.com/docs/operations/metrics.md @@ -163,6 +163,11 @@ invisible to users unless a pause or checkpoint happens mid-batch. | `input_connector_errors_parse_total` |counter | Total number of errors encountered parsing records received by the input connector. | | `input_connector_errors_transport_total` |counter | Total number of errors encountered by the input connector at the transport layer. | | `input_connector_extra_memory_bytes` |gauge | Additional memory used by an input connector beyond that used for buffered records. | +| `input_connector_iceberg_last_ingested_sequence_number` |gauge | Sequence number of the Iceberg snapshot ingested by this connector (-1 if none yet). | +| `input_connector_iceberg_phase` |gauge | Current phase: 0=loading_snapshot, 2=completed (1 reserved for follow mode). | +| `input_connector_iceberg_snapshot_completed_seconds` |gauge | Unix epoch seconds when the snapshot phase finished (0 if not yet complete). | +| `input_connector_iceberg_snapshot_records_total` |counter | Total records loaded during the snapshot phase. | +| `input_connector_iceberg_snapshot_transaction_starts` |counter | Number of Feldera snapshot transactions started by this connector. | | `input_connector_processing_latency_seconds` |histogram | Time between when the connector receives new data and when the pipeline processes this data and computes output updates, over the last 600 seconds or 10,000 samples. | | `input_connector_records_total` |counter | Total number of records received by an input connector. | | `input_connector_running` |gauge | Whether the input connector is running (1) or paused by the user (0). | diff --git a/openapi.json b/openapi.json index 6e03d64aef1..1b0bf2676a1 100644 --- a/openapi.json +++ b/openapi.json @@ -10055,6 +10055,13 @@ "description": "Optional timestamp for the snapshot in the ISO-8601/RFC-3339 format, e.g.,\n\"2024-12-09T16:09:53+00:00\".\n\nWhen this option is set, the connector finds and opens the snapshot of the table as of the\nspecified point in time (based on the server time recorded in the transaction\nlog, not the event time encoded in the data). In `snapshot` and `snapshot_and_follow`\nmodes, it retrieves this snapshot. In `follow` and `snapshot_and_follow` modes, it\nfollows transaction log records **after** this snapshot.\n\nNote: at most one of `snapshot_id` and `datetime` options can be specified.\nWhen neither of the two options is specified, the latest committed version of the table\nis used.", "nullable": true }, + "max_retries": { + "type": "integer", + "format": "int32", + "description": "Maximum number of retries for reading the table snapshot.\n\nWhen reading the snapshot fails partway through, for example because an\nobject-store read times out or is throttled, the connector retries the\nentire read with exponential backoff. This is in addition to the\nlower-level retries performed by the object-store client.\n\nDefaults to unlimited retries. Set to 0 to disable retries.", + "nullable": true, + "minimum": 0 + }, "metadata_location": { "type": "string", "description": "Location of the table metadata JSON file.\n\nThis propery is used to access an Iceberg table without a catalog. It is mutually\nexclusive with the `catalog_type` property.", @@ -10063,6 +10070,12 @@ "mode": { "$ref": "#/components/schemas/IcebergIngestMode" }, + "num_parsers": { + "type": "integer", + "format": "int32", + "description": "The number of parallel parsing tasks the connector uses to process data read from the\ntable. Increasing this value can enhance performance by allowing more concurrent processing.\nRecommended range: 1-10. The default is 4.", + "minimum": 1 + }, "snapshot_filter": { "type": "string", "description": "Optional row filter.\n\nThis option is only valid when `mode` is set to `snapshot` or `snapshot_and_follow`.\n\nWhen specified, only rows that satisfy the filter condition are included in the\nsnapshot. The condition must be a valid SQL Boolean expression that can be used in\nthe `where` clause of the `select * from snapshot where ...` query.\n\nThis option can be used to specify the range of event times to include in the snapshot,\ne.g.: `ts BETWEEN '2005-01-01 00:00:00' AND '2010-12-31 23:59:59'`.", @@ -10083,6 +10096,9 @@ "type": "string", "description": "Table column that serves as an event timestamp.\n\nWhen this option is specified, and `mode` is one of `snapshot` or `snapshot_and_follow`,\ntable rows are ingested in the timestamp order, respecting the\n[`LATENESS`](https://docs.feldera.com/sql/streaming#lateness-expressions)\nproperty of the column: each ingested row has a timestamp no more than `LATENESS`\ntime units earlier than the most recent timestamp of any previously ingested row.\nThe ingestion is performed by partitioning the table into timestamp ranges of width\n`LATENESS`. Each range is processed sequentially, in increasing timestamp order.\n\n# Example\n\nConsider a table with timestamp column of type `TIMESTAMP` and lateness attribute\n`INTERVAL 1 DAY`. Assuming that the oldest timestamp in the table is\n`2024-01-01T00:00:00``, the connector will fetch all records with timestamps\nfrom `2024-01-01`, then all records for `2024-01-02`, `2024-01-03`, etc., until all records\nin the table have been ingested.\n\n# Requirements\n\n* The timestamp column must be of a supported type: integer, `DATE`, or `TIMESTAMP`.\n* The timestamp column must be declared with non-zero `LATENESS`.\n* For efficient ingest, the table must be optimized for timestamp-based\nqueries using partitioning, Z-ordering, or liquid clustering.", "nullable": true + }, + "transaction_mode": { + "$ref": "#/components/schemas/IcebergTransactionMode" } }, "additionalProperties": { @@ -10093,6 +10109,14 @@ ], "description": "Iceberg input connector configuration." }, + "IcebergTransactionMode": { + "type": "string", + "description": "Iceberg table transaction mode.\n\nDetermines how the connector breaks up its input into Feldera transactions.\n\n* `none` - the connector does not break up its input into transactions.\n* `snapshot` - ingest the initial snapshot of the table in one or several transactions.\n\n# How the table snapshot is ingested using transactions\n\nWhen `transaction_mode` is set to `snapshot`, the connector ingests the snapshot in one\nor several transactions, depending on `timestamp_column`. If `timestamp_column` is not set,\nthe whole snapshot is ingested in a single Feldera transaction. If `timestamp_column` is set,\nthe connector ingests the snapshot in a series of timestamp ranges of width equal to the\n`LATENESS` attribute of the column, each range in a separate transaction.", + "enum": [ + "none", + "snapshot" + ] + }, "InputEndpointConfig": { "allOf": [ { diff --git a/python/pyproject.toml b/python/pyproject.toml index f084ae6d04f..39e66b83f5c 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -48,6 +48,7 @@ dev = [ "simplejson==3.20.1", "confluent-kafka>=2.2.0", "deltalake>=1.6.0", + "pyiceberg[sql-sqlite]>=0.9.0", ] [tool.pytest.ini_options] diff --git a/python/tests/platform/test_iceberg_input.py b/python/tests/platform/test_iceberg_input.py new file mode 100644 index 00000000000..9a33059c5f3 --- /dev/null +++ b/python/tests/platform/test_iceberg_input.py @@ -0,0 +1,586 @@ +""" +Iceberg input connector: end-to-end platform tests. + +These exercise the connector against a live pipeline manager, which the +in-crate Rust tests (that drive a `Controller` in-process) cannot cover: + +* ``test_iceberg_snapshot_all_types`` — the connector ingests a snapshot + spanning every Feldera SQL type Iceberg can represent, including nested + list/map/struct columns, and every column round-trips by value, NULLs + included. +* ``test_iceberg_snapshot_uuid`` — Iceberg's fixed[16] UUID reads back as a + SQL UUID, values and NULLs intact. +* ``test_iceberg_snapshot_resume_no_reingest`` — with at-least-once fault + tolerance, suspending after a completed snapshot and resuming re-reads + nothing: the data survives and the connector reports zero re-ingested + records. +* ``test_iceberg_ordered_snapshot_ingests_all_rows`` — a ``timestamp_column`` + with ``LATENESS`` ingests the snapshot as several timestamp-ordered + transactions, and every row lands exactly once. +* ``test_iceberg_ordered_snapshot_resume_skips_ingested`` — suspending an + ordered read once it has committed a range, then resuming, re-reads fewer + than all rows: the seek point skips already-ingested ranges. + +The table backend toggles automatically via :class:`IcebergTestLocation`: +local runs use a filesystem warehouse under ``/tmp``; CI runs use the +in-cluster MinIO bucket over S3 so the pipeline and the test runner share +storage. +""" + +from __future__ import annotations + +import json +import re +import uuid as uuidlib +from datetime import date, datetime, time, timezone +from decimal import Decimal +from http import HTTPStatus + +import pytest + +from feldera import PipelineBuilder +from feldera.enums import FaultToleranceModel +from feldera.runtime_config import RuntimeConfig +from feldera.testutils import FELDERA_TEST_NUM_HOSTS, FELDERA_TEST_NUM_WORKERS +from tests import TEST_CLIENT, enterprise_only +from tests.platform.helper import api_url, get +from tests.utils import IcebergTestLocation, wait_for_condition + +TABLE = "t" +CONNECTOR = "iceberg_in" +ENDPOINT = f"{TABLE}.{CONNECTOR}" + + +# ─── schema shared by every test ──────────────────────────────────────── +# +# One column per Feldera SQL type that Iceberg can represent. The Iceberg, +# Arrow, and SQL descriptions are kept side by side so a type is added in +# exactly one place. + +_SQL_COLUMNS = [ + "id BIGINT NOT NULL", + "b BOOLEAN NOT NULL", + "i INT NOT NULL", + "l BIGINT NOT NULL", + "r REAL NOT NULL", + "d DOUBLE NOT NULL", + "dec DECIMAL(10, 3) NOT NULL", + "dt DATE NOT NULL", + "tm TIME NOT NULL", + "ts TIMESTAMP NOT NULL", + "s VARCHAR NOT NULL", + "fixed BINARY(5) NOT NULL", + "varbin VARBINARY NOT NULL", + "tstz TIMESTAMP WITH TIME ZONE NOT NULL", + # Nullable columns, one per distinct Arrow layout, to exercise the + # connector's NULL-decoding path (primitive, string, decimal, timestamp, + # binary). + "n_i INT", + "n_s VARCHAR", + "n_dec DECIMAL(10, 3)", + "n_ts TIMESTAMP", + "n_varbin VARBINARY", + # Nested types: list, map, struct. (UUID has its own test.) + "arr INT ARRAY", + "m MAP", + "st ROW(a INT NOT NULL, b VARCHAR NULL)", +] + + +def _iceberg_schema(): + from pyiceberg.schema import Schema + from pyiceberg.types import ( + BinaryType, + BooleanType, + DateType, + DecimalType, + DoubleType, + FixedType, + FloatType, + IntegerType, + ListType, + LongType, + MapType, + NestedField, + StringType, + StructType, + TimestampType, + TimestamptzType, + TimeType, + ) + + return Schema( + NestedField(1, "id", LongType(), required=True), + NestedField(2, "b", BooleanType(), required=True), + NestedField(3, "i", IntegerType(), required=True), + NestedField(4, "l", LongType(), required=True), + NestedField(5, "r", FloatType(), required=True), + NestedField(6, "d", DoubleType(), required=True), + NestedField(7, "dec", DecimalType(10, 3), required=True), + NestedField(8, "dt", DateType(), required=True), + NestedField(9, "tm", TimeType(), required=True), + NestedField(10, "ts", TimestampType(), required=True), + NestedField(11, "s", StringType(), required=True), + NestedField(12, "fixed", FixedType(5), required=True), + NestedField(13, "varbin", BinaryType(), required=True), + NestedField(14, "tstz", TimestamptzType(), required=True), + NestedField(15, "n_i", IntegerType(), required=False), + NestedField(16, "n_s", StringType(), required=False), + NestedField(17, "n_dec", DecimalType(10, 3), required=False), + NestedField(18, "n_ts", TimestampType(), required=False), + NestedField(19, "n_varbin", BinaryType(), required=False), + NestedField( + 20, + "arr", + ListType(element_id=30, element_type=IntegerType(), element_required=False), + required=False, + ), + NestedField( + 21, + "m", + MapType( + key_id=31, + key_type=StringType(), + value_id=32, + value_type=IntegerType(), + value_required=False, + ), + required=False, + ), + NestedField( + 22, + "st", + StructType( + NestedField(33, "a", IntegerType(), required=True), + NestedField(34, "b", StringType(), required=False), + ), + required=False, + ), + ) + + +def _arrow_schema(): + import pyarrow as pa + + return pa.schema( + [ + pa.field("id", pa.int64(), nullable=False), + pa.field("b", pa.bool_(), nullable=False), + pa.field("i", pa.int32(), nullable=False), + pa.field("l", pa.int64(), nullable=False), + pa.field("r", pa.float32(), nullable=False), + pa.field("d", pa.float64(), nullable=False), + pa.field("dec", pa.decimal128(10, 3), nullable=False), + pa.field("dt", pa.date32(), nullable=False), + pa.field("tm", pa.time64("us"), nullable=False), + pa.field("ts", pa.timestamp("us"), nullable=False), + pa.field("s", pa.string(), nullable=False), + pa.field("fixed", pa.binary(5), nullable=False), + pa.field("varbin", pa.binary(), nullable=False), + pa.field("tstz", pa.timestamp("us", tz="UTC"), nullable=False), + pa.field("n_i", pa.int32(), nullable=True), + pa.field("n_s", pa.string(), nullable=True), + pa.field("n_dec", pa.decimal128(10, 3), nullable=True), + pa.field("n_ts", pa.timestamp("us"), nullable=True), + pa.field("n_varbin", pa.binary(), nullable=True), + pa.field( + "arr", + pa.list_(pa.field("element", pa.int32(), nullable=True)), + nullable=True, + ), + pa.field( + "m", + pa.map_(pa.string(), pa.field("value", pa.int32(), nullable=True)), + nullable=True, + ), + pa.field( + "st", + pa.struct( + [ + pa.field("a", pa.int32(), nullable=False), + pa.field("b", pa.string(), nullable=True), + ] + ), + nullable=True, + ), + ] + ) + + +# Base timestamp for the generated rows: 2024-01-01T00:00:00Z. Rows step +# `ts` forward so a `LATENESS` window splits them into ordered ranges. +_BASE_TS = datetime(2024, 1, 1, tzinfo=timezone.utc) + + +def _row(row_id: int, *, ts_hours: int, nulls: bool = False) -> dict: + """One deterministic row keyed by ``row_id``. + + ``ts_hours`` sets both the naive ``ts`` and the tz-aware ``tstz`` so the + ordered-ingest test can spread rows across day-wide windows. When ``nulls`` + is set, the optional columns are NULL, exercising the null-decoding path. + """ + event = _BASE_TS + _hours(ts_hours) + return { + "id": row_id, + "b": row_id % 2 == 0, + "i": row_id, + "l": row_id * 1_000_000, + "r": float(row_id) + 0.5, + "d": float(row_id) * 1.25, + "dec": Decimal(f"{row_id}.125"), + "dt": date(2024, 1, 1), + "tm": time(12, 0, 0), + "ts": event.replace(tzinfo=None), + "s": f"row_{row_id}", + "fixed": bytes([row_id % 256]) * 5, + "varbin": bytes([row_id % 256, (row_id + 1) % 256]), + "tstz": event, + "n_i": None if nulls else row_id, + "n_s": None if nulls else f"n_{row_id}", + "n_dec": None if nulls else Decimal(f"{row_id}.500"), + "n_ts": None if nulls else event.replace(tzinfo=None), + "n_varbin": None if nulls else bytes([row_id % 256]), + # Nested columns: the whole column is NULL when `nulls`. The struct's + # `a` is NOT NULL; `b` is nullable and NULL on some rows, so the nested + # null-decoding path is covered even when the struct itself is present. + "arr": None if nulls else [row_id, row_id + 1], + "m": None if nulls else [(f"k{row_id}", row_id)], + "st": None + if nulls + else {"a": row_id, "b": None if row_id % 3 == 0 else f"s{row_id}"}, + } + + +def _hours(n: int): + from datetime import timedelta + + return timedelta(hours=n) + + +def _arrow_table(rows: list[dict]): + import pyarrow as pa + + return pa.Table.from_pylist(rows, schema=_arrow_schema()) + + +def _build_sql(loc: IcebergTestLocation, *, lateness: bool = False, **extra) -> str: + columns = list(_SQL_COLUMNS) + if lateness: + # Replace the plain `ts` column with a lateness-annotated one. + columns = [ + c + if not c.startswith("ts ") + else "ts TIMESTAMP NOT NULL LATENESS INTERVAL 1 DAY" + for c in columns + ] + connector = { + "name": CONNECTOR, + "transport": { + "name": "iceberg_input", + "config": loc.connector_config(**extra), + }, + } + connectors = json.dumps([connector]).replace("'", "''") + cols = ",\n ".join(columns) + return ( + f"CREATE TABLE {TABLE} (\n {cols}\n) " + f"WITH ('materialized' = 'true', 'connectors' = '{connectors}');" + ) + + +def _build_pipeline(name: str, sql: str, *, fault_tolerant: bool = False): + kwargs = dict( + workers=FELDERA_TEST_NUM_WORKERS, + hosts=FELDERA_TEST_NUM_HOSTS, + logging="debug", + ) + if fault_tolerant: + kwargs["storage"] = True + kwargs["fault_tolerance_model"] = FaultToleranceModel.AtLeastOnce + return PipelineBuilder( + TEST_CLIENT, + name, + sql=sql, + runtime_config=RuntimeConfig(**kwargs), + ).create_or_replace() + + +# ─── metric helpers (Prometheus scrape, filtered by endpoint) ──────────── + + +def _metric(pipeline_name: str, metric_name: str) -> float: + response = get(api_url(f"/pipelines/{pipeline_name}/metrics?format=prometheus")) + assert response.status_code == HTTPStatus.OK, response.text + pattern = rf'^{re.escape(metric_name)}\{{[^}}]*endpoint="{re.escape(ENDPOINT)}"[^}}]*\}}\s+(\S+)' + for line in response.text.splitlines(): + match = re.match(pattern, line) + if match: + return float(match.group(1)) + return -1.0 + + +def _metric_int(pipeline_name: str, metric_name: str) -> int: + """Integer value of a counter/gauge (cf. ``_delta_counter``). -1 if absent.""" + value = _metric(pipeline_name, metric_name) + return -1 if value < 0 else int(value) + + +def _phase_from_metrics(pipeline_name: str) -> int: + """Connector phase gauge: 2 == the snapshot read completed.""" + return _metric_int(pipeline_name, "input_connector_iceberg_phase") + + +def _row_count(pipeline) -> int: + rows = list(pipeline.query(f"SELECT COUNT(*) AS c FROM {TABLE}")) + return int(rows[0]["c"]) + + +def _wait_for_completed(pipeline, pipeline_name: str, timeout_s: float = 120.0) -> None: + wait_for_condition( + "iceberg snapshot completed (phase == 2)", + lambda: _phase_from_metrics(pipeline_name) == 2, + timeout_s=timeout_s, + poll_interval_s=0.2, + ) + + +# ─── tests ─────────────────────────────────────────────────────────────── + + +@enterprise_only +def test_iceberg_snapshot_all_types(pipeline_name): + """A snapshot spanning every Iceberg-representable SQL type (scalars and + nested list/map/struct) ingests completely, and every column round-trips + by value, NULLs included.""" + loc = IcebergTestLocation.create(pipeline_name) + try: + # Every fifth row NULLs its optional columns to cover null decoding. + rows = [_row(i, ts_hours=i, nulls=(i % 5 == 0)) for i in range(20)] + loc.create_table(_iceberg_schema()) + loc.append(_arrow_table(rows)) + + pipeline = _build_pipeline(pipeline_name, _build_sql(loc, mode="snapshot")) + pipeline.start() + _wait_for_completed(pipeline, pipeline_name) + + assert _row_count(pipeline) == len(rows) + + # `query_arrow_dicts` returns native Python values (bytes, datetimes, + # decimals, and None for SQL NULL), so a plain equality check verifies + # value correctness and null decoding for every declared type -- not + # just the scalars a JSON query round-trips cleanly. + got = list(pipeline.query_arrow_dicts(f"SELECT * FROM {TABLE} ORDER BY id")) + assert got == rows, "every column, including NULLs, must round-trip" + + pipeline.stop(force=True) + finally: + loc.remove_if_local() + + +@enterprise_only +def test_iceberg_snapshot_uuid(pipeline_name): + """Iceberg stores UUID as a 16-byte fixed binary. The connector reads it as + a SQL UUID (its serde config sets ``UuidFormat::Binary``); values and NULLs + round-trip. Without that config the read fails, dropping every row.""" + from pyiceberg.schema import Schema + from pyiceberg.types import LongType, NestedField, UUIDType + import pyarrow as pa + + loc = IcebergTestLocation.create(pipeline_name) + try: + ids = list(range(8)) + uuids = {i: uuidlib.UUID(int=i * 0x1111_1111_1111_1111) for i in ids} + # Every fourth row is NULL to cover null decoding of a UUID column. + rows = [{"id": i, "u": None if i % 4 == 0 else uuids[i].bytes} for i in ids] + + loc.create_table( + Schema( + NestedField(1, "id", LongType(), required=True), + NestedField(2, "u", UUIDType(), required=False), + ) + ) + loc.append( + pa.Table.from_pylist( + rows, + schema=pa.schema( + [ + pa.field("id", pa.int64(), nullable=False), + pa.field("u", pa.binary(16), nullable=True), + ] + ), + ) + ) + + connector = { + "name": CONNECTOR, + "transport": { + "name": "iceberg_input", + "config": loc.connector_config(mode="snapshot"), + }, + } + connectors = json.dumps([connector]).replace("'", "''") + sql = ( + f"CREATE TABLE {TABLE} (id BIGINT NOT NULL, u UUID) " + f"WITH ('materialized' = 'true', 'connectors' = '{connectors}');" + ) + pipeline = _build_pipeline(pipeline_name, sql) + pipeline.start() + _wait_for_completed(pipeline, pipeline_name) + + # The ad-hoc query returns a UUID as its canonical string form. + got = list(pipeline.query_arrow_dicts(f"SELECT * FROM {TABLE} ORDER BY id")) + expected = [{"id": i, "u": None if i % 4 == 0 else str(uuids[i])} for i in ids] + assert got == expected, "UUID values and NULLs must round-trip" + + pipeline.stop(force=True) + finally: + loc.remove_if_local() + + +@enterprise_only +def test_iceberg_snapshot_resume_no_reingest(pipeline_name): + """At-least-once FT: suspending after a completed snapshot and resuming + re-reads nothing. The rows survive and the connector reports zero + re-ingested records — a restart never re-reads a finished table.""" + loc = IcebergTestLocation.create(pipeline_name) + try: + rows = [_row(i, ts_hours=i) for i in range(50)] + loc.create_table(_iceberg_schema()) + loc.append(_arrow_table(rows)) + + sql = _build_sql(loc, mode="snapshot") + pipeline = _build_pipeline(pipeline_name, sql, fault_tolerant=True) + pipeline.start() + _wait_for_completed(pipeline, pipeline_name) + assert _row_count(pipeline) == len(rows) + assert _metric_int( + pipeline_name, "input_connector_iceberg_snapshot_records_total" + ) == len(rows) + + # Checkpoint, suspend, and resume the SAME pipeline incarnation. + pipeline.checkpoint(wait=True) + pipeline.stop(force=False) + pipeline.start() + + # Data intact and the connector jumps straight back to completed + # without re-reading a single record. + assert _row_count(pipeline) == len(rows) + _wait_for_completed(pipeline, pipeline_name) + assert ( + _metric_int(pipeline_name, "input_connector_iceberg_snapshot_records_total") + == 0 + ), "a resumed, already-completed snapshot must re-read no records" + + pipeline.stop(force=True) + finally: + loc.remove_if_local() + + +@enterprise_only +def test_iceberg_ordered_snapshot_ingests_all_rows(pipeline_name): + """A ``timestamp_column`` with ``LATENESS`` ingests the snapshot as + several timestamp-ordered transactions, and every row lands once.""" + loc = IcebergTestLocation.create(pipeline_name) + try: + # 60 rows, one per hour → 3 distinct days → several 1-day windows. + rows = [_row(i, ts_hours=i) for i in range(60)] + loc.create_table(_iceberg_schema()) + loc.append(_arrow_table(rows)) + + sql = _build_sql( + loc, + lateness=True, + mode="snapshot", + timestamp_column="ts", + transaction_mode="snapshot", + ) + pipeline = _build_pipeline(pipeline_name, sql) + pipeline.start() + _wait_for_completed(pipeline, pipeline_name) + + assert _row_count(pipeline) == len(rows) + # Ordered ingest breaks the snapshot into per-window transactions. + assert ( + _metric_int( + pipeline_name, + "input_connector_iceberg_snapshot_transaction_starts", + ) + > 1 + ), "a lateness-ordered snapshot must span more than one transaction" + + pipeline.stop(force=True) + finally: + loc.remove_if_local() + + +_RECORDS_METRIC = "input_connector_iceberg_snapshot_records_total" + + +@enterprise_only +def test_iceberg_ordered_snapshot_resume_skips_ingested(pipeline_name): + """Resuming an ordered read skips ranges it already ingested. + + Suspend the read once it has committed at least one range, resume, and + check the second incarnation re-reads fewer than all rows. The exact + suspend point is not controlled, so the assertion is deterministic across + every point it can land on: + + * suspended mid-read: the checkpointed timestamp skips the committed + ranges, so the resumed read ingests only the remainder (< N). + * already completed when suspended: the end-of-input state resumes into + completion and re-reads nothing (0 < N). + + Either way the resumed read must be < N; a broken seek that re-read the + whole snapshot would ingest N and fail here. The final table always holds + every row. + """ + loc = IcebergTestLocation.create(pipeline_name) + try: + # 240 hourly rows over 10 days -> ~10 one-day ranges, so a suspend has + # several committed range boundaries to land after. + rows = [_row(i, ts_hours=i) for i in range(240)] + n = len(rows) + loc.create_table(_iceberg_schema()) + loc.append(_arrow_table(rows)) + + sql = _build_sql( + loc, + lateness=True, + mode="snapshot", + timestamp_column="ts", + transaction_mode="snapshot", + ) + pipeline = _build_pipeline(pipeline_name, sql, fault_tolerant=True) + pipeline.start() + + # Wait until at least one range has been ingested, then suspend. This + # gate is always reached (the snapshot has rows) and removes the race + # on ingest speed: whether the value is partial or already N, the + # resume assertion below holds. + wait_for_condition( + "iceberg ordered read ingested at least one record", + lambda: _metric_int(pipeline_name, _RECORDS_METRIC) > 0, + timeout_s=120.0, + poll_interval_s=0.05, + ) + + pipeline.checkpoint(wait=True) + pipeline.stop(force=False) + pipeline.start() + + _wait_for_completed(pipeline, pipeline_name) + + reingested = _metric_int(pipeline_name, _RECORDS_METRIC) + assert reingested < n, ( + f"resumed read re-ingested {reingested} of {n} rows; the seek point " + "must skip already-ingested ranges" + ) + assert _row_count(pipeline) == n, "every row must survive the resume" + + pipeline.stop(force=True) + finally: + loc.remove_if_local() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/python/tests/utils.py b/python/tests/utils.py index cb95be5e540..08ea2cc9e3e 100644 --- a/python/tests/utils.py +++ b/python/tests/utils.py @@ -303,6 +303,173 @@ def cleanup(self) -> None: self.local_dir = None +@dataclass +class IcebergTestLocation: + """Where an Iceberg test table lives and how to read/write it. + + A test writes rows with ``pyiceberg`` (the ``iceberg-rust`` connector + cannot write yet) and then points the connector at the table via + ``metadata_location`` — the catalog-free access path. Both sides reach + the same storage: + + * Local runs put the warehouse under ``/tmp`` and use bare filesystem + paths (no ``file://`` scheme), matching the Rust FS harness and the + fork's local ``FileIO`` resolver. + * CI runs put the warehouse in the in-cluster MinIO bucket over S3, so + the pipeline pod and the test runner reach the table through the same + object store, exactly like :class:`DeltaTestLocation`. + + The ``pyiceberg`` SQL catalog metadata (a SQLite file) always lives on + local disk; it is used only by the writer (the test runner), never by + the connector. + """ + + warehouse: str + table_location: str + catalog_db: str + namespace: str + table_name: str + fileio_config: dict[str, str] + # Local directory removed on teardown. In local runs it is the whole + # warehouse (data + SQLite catalog); in CI it is only the catalog dir (the + # warehouse lives in S3/MinIO). `None` leaves nothing to remove. + cleanup_dir: pathlib.Path | None = None + + NAMESPACE = "iceberg_test" + TABLE = "test_table" + + @classmethod + def create(cls, pipeline_name: str) -> "IcebergTestLocation": + """Local filesystem for local runs, MinIO-backed S3 in CI.""" + if runs_in_ci(): + access_key = required_env("CI_K8S_MINIO_ACCESS_KEY_ID") + secret_key = required_env("CI_K8S_MINIO_SECRET_ACCESS_KEY") + prefix = f"{pipeline_name}/{uuid.uuid4().hex}" + root = f"{MINIO_BUCKET}/{prefix}" + endpoint = MINIO_ENDPOINT.rstrip("/") + parsed = urlparse(endpoint) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError( + "CI_MINIO_ENDPOINT must be a full URL, e.g. " + "'http://minio.minio.svc.cluster.local:9000'" + ) + fileio_config = { + "s3.endpoint": endpoint, + "s3.access-key-id": access_key, + "s3.secret-access-key": secret_key, + "s3.region": MINIO_REGION, + "s3.path-style-access": "true", + } + # The SQLite catalog is only touched by the writer, so it stays + # on local disk even when the warehouse is remote. + catalog_dir = pathlib.Path( + tempfile.mkdtemp(prefix=f"{pipeline_name}_iceberg_cat_", dir="/tmp") + ) + return cls( + warehouse=f"s3://{root}", + table_location=f"s3://{root}/{cls.TABLE}", + catalog_db=str(catalog_dir / "catalog.db"), + namespace=cls.NAMESPACE, + table_name=cls.TABLE, + fileio_config=fileio_config, + cleanup_dir=catalog_dir, + ) + + local_dir = pathlib.Path( + tempfile.mkdtemp(prefix=f"{pipeline_name}_iceberg_", dir="/tmp") + ) + # Bare (scheme-less) table path: the Rust FS harness reads tables + # written this way, and the connector's local resolver expects it. + return cls( + warehouse=f"file://{local_dir}", + table_location=f"{local_dir}/{cls.TABLE}", + catalog_db=str(local_dir / "catalog.db"), + namespace=cls.NAMESPACE, + table_name=cls.TABLE, + fileio_config={}, + cleanup_dir=local_dir, + ) + + @property + def qualified_name(self) -> str: + return f"{self.namespace}.{self.table_name}" + + def _catalog(self): + """Build the ``pyiceberg`` SQL catalog. Deferred import keeps module + collection cheap on hosts that never touch Iceberg.""" + from pyiceberg.catalog.sql import SqlCatalog + + return SqlCatalog( + "test", + **{ + "uri": f"sqlite:///{self.catalog_db}", + "warehouse": self.warehouse, + **self.fileio_config, + }, + ) + + def create_table(self, schema, partition_spec=None): + """Create (replacing any prior) the test table and return it.""" + catalog = self._catalog() + try: + catalog.create_namespace(self.namespace) + except Exception: + pass # Already exists. + try: + catalog.drop_table(self.qualified_name) + except Exception: + pass # Nothing to drop. + + from pyiceberg.partitioning import UNPARTITIONED_PARTITION_SPEC + + return catalog.create_table( + self.qualified_name, + schema, + location=self.table_location, + partition_spec=partition_spec or UNPARTITIONED_PARTITION_SPEC, + ) + + def append(self, arrow_table) -> None: + """Append one Arrow batch as a new Iceberg snapshot.""" + table = self._catalog().load_table(self.qualified_name) + table.append(arrow_table) + + def metadata_location(self) -> str: + """Return the current table metadata file location. + + This is the value handed to the connector's ``metadata_location`` + option, so a reload picks up the latest snapshot after appends. + """ + return self._catalog().load_table(self.qualified_name).metadata_location + + def connector_config(self, **extra) -> dict[str, object]: + """Transport config pointing the connector at this table. + + Merges ``metadata_location`` and any storage (``s3.*``) options with + caller-supplied ``extra`` fields (e.g. ``mode``, ``timestamp_column``). + """ + config: dict[str, object] = {"metadata_location": self.metadata_location()} + config.update(self.fileio_config) + config.update(extra) + return config + + def row_count(self) -> int: + table = self._catalog().load_table(self.qualified_name) + return table.scan().to_arrow().num_rows + + def remove_if_local(self) -> None: + """Remove the local temp directory, if any. + + A no-op on the S3 data itself (as with :class:`DeltaTestLocation`): + objects this run wrote to the shared MinIO bucket use a unique + prefix and are left in place. In CI only the local SQLite catalog dir + is removed; in local runs the whole warehouse dir is removed. + """ + if self.cleanup_dir is not None: + shutil.rmtree(self.cleanup_dir, ignore_errors=True) + self.cleanup_dir = None + + def ensure_delta_spark_fixture( loc: DeltaTestLocation, builder_script: str | os.PathLike[str], diff --git a/python/uv.lock b/python/uv.lock index 5889e4e911a..15ac33962fa 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -21,6 +21,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "arro3-core" version = "0.8.0" @@ -92,6 +101,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "cachetools" +version = "6.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -174,6 +192,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, ] +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -255,7 +285,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -293,6 +323,7 @@ dependencies = [ dev = [ { name = "confluent-kafka" }, { name = "deltalake" }, + { name = "pyiceberg", extra = ["sql-sqlite"] }, { name = "pytest" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, @@ -318,6 +349,7 @@ requires-dist = [ dev = [ { name = "confluent-kafka", specifier = ">=2.2.0" }, { name = "deltalake", specifier = ">=1.6.0" }, + { name = "pyiceberg", extras = ["sql-sqlite"], specifier = ">=0.9.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-timeout", specifier = ">=2.3.1" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, @@ -326,6 +358,54 @@ dev = [ { name = "sphinx-rtd-theme", specifier = "==2.0.0" }, ] +[[package]] +name = "fsspec" +version = "2026.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/a1/1f7f0c555f5858fd2906fe9f7b0a3554fddb85cb70df7a6aaec41dc292c2/greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c", size = 285838, upload-time = "2026-06-26T18:21:05.167Z" }, + { url = "https://files.pythonhosted.org/packages/0a/29/be9f43ed61677a5759b38c8a9389248133c8c731bbfc0574ecdff66c99fc/greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04", size = 602342, upload-time = "2026-06-26T19:07:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/ba41c97ec36aa4b3ec25e5aa691d79561254805fad7f2f826dd6770587e2/greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce", size = 615541, upload-time = "2026-06-26T19:10:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c7/28747042e1df8a9cd120a1ebe15529fc4be3b486e13e8d551ff307a82412/greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec", size = 615675, upload-time = "2026-06-26T18:32:14.444Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a8/b85525a6c8fba9f009a5f7c8df1545de8fb0f0bf3e0179194ef4e500317f/greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8", size = 1575057, upload-time = "2026-06-26T19:09:00.264Z" }, + { url = "https://files.pythonhosted.org/packages/03/79/fb76edb218fe6735ab0edeba176c7ab80df9618f7c02ce4208979f3ae7db/greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702", size = 1641692, upload-time = "2026-06-26T18:31:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/6b/79/86fe3ee50ed55d9b3907eecd3208b5c3fe8a79515519aae98b4753c3fa1d/greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db", size = 238742, upload-time = "2026-06-26T18:20:40.758Z" }, + { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, + { url = "https://files.pythonhosted.org/packages/fb/96/b9820295576ef18c9edc404f10e260ae7215ceaf3781a54b720ed2627862/greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44", size = 237630, upload-time = "2026-06-26T18:24:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, +] + [[package]] name = "idna" version = "3.15" @@ -365,6 +445,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -428,6 +520,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mmh3" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/bb/88ee54afa5644b0f35ab5b435f208394feb963e5bb47c4e404deb625ffa4/mmh3-5.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5d87a3584093e1a89987e3d36d82c98d9621b2cb944e22a420aa1401e096758f", size = 56080, upload-time = "2026-03-05T15:53:40.452Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bf/5404c2fd6ac84819e8ff1b7e34437b37cf55a2b11318894909e7bb88de3f/mmh3-5.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:30e4d2084df019880d55f6f7bea35328d9b464ebee090baa372c096dc77556fb", size = 40462, upload-time = "2026-03-05T15:53:41.751Z" }, + { url = "https://files.pythonhosted.org/packages/de/0b/52bffad0b52ae4ea53e222b594bd38c08ecac1fc410323220a7202e43da5/mmh3-5.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bbc17250b10d3466875a40a52520a6bac3c02334ca709207648abd3c223ed5c", size = 40077, upload-time = "2026-03-05T15:53:42.753Z" }, + { url = "https://files.pythonhosted.org/packages/a0/9e/326c93d425b9fa4cbcdc71bc32aaba520db37577d632a24d25d927594eca/mmh3-5.2.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:76219cd1eefb9bf4af7856e3ae563d15158efa145c0aab01e9933051a1954045", size = 95302, upload-time = "2026-03-05T15:53:43.867Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b1/e20d5f0d19c4c0f3df213fa7dcfa0942c4fb127d38e11f398ae8ddf6cccc/mmh3-5.2.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb9d44c25244e11c8be3f12c938ca8ba8404620ef8092245d2093c6ab3df260f", size = 101174, upload-time = "2026-03-05T15:53:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/7f/4a/1a9bb3e33c18b1e1cee2c249a3053c4d4d9c93ecb30738f39a62249a7e86/mmh3-5.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d5d542bf2abd0fd0361e8017d03f7cb5786214ceb4a40eef1539d6585d93386", size = 103979, upload-time = "2026-03-05T15:53:46.334Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/dab9ee7545429e7acdd38d23d0104471d31de09a0c695f1b751e0ff34532/mmh3-5.2.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:08043f7cb1fb9467c3fbbbaea7896986e7fbc81f4d3fd9289a73d9110ab6207a", size = 110898, upload-time = "2026-03-05T15:53:47.443Z" }, + { url = "https://files.pythonhosted.org/packages/72/08/408f11af7fe9e76b883142bb06536007cc7f237be2a5e9ad4e837716e627/mmh3-5.2.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:add7ac388d1e0bf57259afbcf9ed05621a3bf11ce5ee337e7536f1e1aaf056b0", size = 118308, upload-time = "2026-03-05T15:53:49.1Z" }, + { url = "https://files.pythonhosted.org/packages/86/2d/0551be7fe0000736d9ad12ffa1f130d7a0c17b49193d6dc41c82bd9404c6/mmh3-5.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41105377f6282e8297f182e393a79cfffd521dde37ace52b106373bdcd9ca5cb", size = 101671, upload-time = "2026-03-05T15:53:50.317Z" }, + { url = "https://files.pythonhosted.org/packages/44/17/6e4f80c4e6ad590139fa2017c3aeca54e7cc9ef68e08aa142a0c90f40a97/mmh3-5.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3cb61db880ec11e984348227b333259994c2c85caa775eb7875decb3768db890", size = 96682, upload-time = "2026-03-05T15:53:51.48Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a7/b82fccd38c1fa815de72e94ebe9874562964a10e21e6c1bc3b01d3f15a0e/mmh3-5.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b5378de2b139c3a830f0209c1e91f7705919a4b3e563a10955104f5097a70a", size = 110287, upload-time = "2026-03-05T15:53:52.68Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a1/2644069031c8cec0be46f0346f568a53f42fddd843f03cc890306699c1e2/mmh3-5.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e904f2417f0d6f6d514f3f8b836416c360f306ddaee1f84de8eef1e722d212e5", size = 111899, upload-time = "2026-03-05T15:53:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/51/7b/6614f3eb8fb33f931fa7616c6d477247e48ec6c5082b02eeeee998cffa94/mmh3-5.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1fbb0a99125b1287c6d9747f937dc66621426836d1a2d50d05aecfc81911b57", size = 100078, upload-time = "2026-03-05T15:53:55.234Z" }, + { url = "https://files.pythonhosted.org/packages/27/9a/dd4d5a5fb893e64f71b42b69ecae97dd78db35075412488b24036bc5599c/mmh3-5.2.1-cp310-cp310-win32.whl", hash = "sha256:b4cce60d0223074803c9dbe0721ad3fa51dafe7d462fee4b656a1aa01ee07518", size = 40756, upload-time = "2026-03-05T15:53:56.319Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/0b25889450f8aeffcec840aa73251e853f059c1b72ed1d1c027b956f95f5/mmh3-5.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:6f01f044112d43a20be2f13a11683666d87151542ad627fe41a18b9791d2802f", size = 41519, upload-time = "2026-03-05T15:53:57.41Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/8fd42e3c526d0bcb1db7f569c0de6729e180860a0495e387a53af33c2043/mmh3-5.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:7501e9be34cb21e72fcfe672aafd0eee65c16ba2afa9dcb5500a587d3a0580f0", size = 39285, upload-time = "2026-03-05T15:53:58.697Z" }, + { url = "https://files.pythonhosted.org/packages/65/d7/3312a59df3c1cdd783f4cf0c4ee8e9decff9c5466937182e4cc7dbbfe6c5/mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450", size = 56082, upload-time = "2026-03-05T15:53:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/61/96/6f617baa098ca0d2989bfec6d28b5719532cd8d8848782662f5b755f657f/mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0", size = 40458, upload-time = "2026-03-05T15:54:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b4/9cd284bd6062d711e13d26c04d4778ab3f690c1c38a4563e3c767ec8802e/mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082", size = 40079, upload-time = "2026-03-05T15:54:02.743Z" }, + { url = "https://files.pythonhosted.org/packages/f6/09/a806334ce1d3d50bf782b95fcee8b3648e1e170327d4bb7b4bad2ad7d956/mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997", size = 97242, upload-time = "2026-03-05T15:54:04.536Z" }, + { url = "https://files.pythonhosted.org/packages/ee/93/723e317dd9e041c4dc4566a2eb53b01ad94de31750e0b834f1643905e97c/mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d", size = 103082, upload-time = "2026-03-05T15:54:06.387Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/f96121e69cc48696075071531cf574f112e1ffd08059f4bffb41210e6fc5/mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e", size = 106054, upload-time = "2026-03-05T15:54:07.506Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/192b987ec48d0b2aecf8ac285a9b11fbc00030f6b9c694664ae923458dde/mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d", size = 112910, upload-time = "2026-03-05T15:54:09.403Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/03e91fd334ed0144b83343a76eb11f17434cd08f746401488cfeafb2d241/mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4", size = 120551, upload-time = "2026-03-05T15:54:10.587Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/b89a71d2ff35c3a764d1c066c7313fc62c7cc48fa48a4b3b0304a4a0146f/mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15", size = 99096, upload-time = "2026-03-05T15:54:11.76Z" }, + { url = "https://files.pythonhosted.org/packages/36/b5/613772c1c6ed5f7b63df55eb131e887cc43720fec392777b95a79d34e640/mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503", size = 98524, upload-time = "2026-03-05T15:54:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/1524566fe8eaf871e4f7bc44095929fcd2620488f402822d848df19d679c/mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2", size = 106239, upload-time = "2026-03-05T15:54:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/04/94/21adfa7d90a7a697137ad6de33eeff6445420ca55e433a5d4919c79bc3b5/mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1", size = 109797, upload-time = "2026-03-05T15:54:15.819Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e6/1aacc3a219e1aa62fa65669995d4a3562b35be5200ec03680c7e4bec9676/mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38", size = 97228, upload-time = "2026-03-05T15:54:16.992Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b9/5e4cca8dcccf298add0a27f3c357bc8cf8baf821d35cdc6165e4bd5a48b0/mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728", size = 40751, upload-time = "2026-03-05T15:54:18.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/fc/5b11d49247f499bcda591171e9cf3b6ee422b19e70aa2cef2e0ae65ca3b9/mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a", size = 41517, upload-time = "2026-03-05T15:54:19.764Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/2a511ee8a1c2a527c77726d5231685b72312c5a1a1b7639ad66a9652aa84/mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f", size = 39287, upload-time = "2026-03-05T15:54:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, + { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, + { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, + { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, + { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, + { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, + { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, + { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, + { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, +] + [[package]] name = "numpy" version = "2.2.6" @@ -573,10 +751,10 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -626,9 +804,9 @@ resolution-markers = [ "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } wheels = [ @@ -729,6 +907,107 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/1a/8dd5cafab7b66573fa91c03d06d213356ad4edd71813aa75e08ce2b3a844/pyarrow-24.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d", size = 27388127, upload-time = "2026-04-21T10:49:37.334Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -738,6 +1017,61 @@ 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" }, ] +[[package]] +name = "pyiceberg" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "click" }, + { name = "fsspec" }, + { name = "mmh3" }, + { name = "pydantic" }, + { name = "pyparsing" }, + { name = "pyroaring" }, + { name = "requests" }, + { name = "rich" }, + { name = "strictyaml" }, + { name = "tenacity" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/f0/7616676603fdbd05ab97816337a9b31be08a5f9e1ffd636260812b217e0f/pyiceberg-0.11.1.tar.gz", hash = "sha256:366fe0d5a74e3cf1d4e7cbf3c49e308da60e7835ea268667be9185388f05d7a5", size = 1076075, upload-time = "2026-03-03T00:10:27.61Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/40/ea9cc312ea8ad76d94c57f921be19256c3d07354056c68e64601ff43f98d/pyiceberg-0.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:89c347170a691e3b8d072519f36790b9c19a2263ec0af0144c873994ecfe55eb", size = 532405, upload-time = "2026-03-03T00:09:49.364Z" }, + { url = "https://files.pythonhosted.org/packages/97/e9/fbcd52d19aa2a362ba7fd971b1721c57f8c0fd37b5bb418ac283dde9637e/pyiceberg-0.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:138983e96a5c473aae1e1cd6143aacf02ccc9bbbf6180f15fa2588f99531188b", size = 533282, upload-time = "2026-03-03T00:09:50.712Z" }, + { url = "https://files.pythonhosted.org/packages/1d/43/a0722863f821e96eed8ff5753a7146dd5b1537668d6684ac650a48647669/pyiceberg-0.11.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e08c268e12d54ea629a1f123bf7cb9587bdd57fd7bcdac22394dc6272a18668f", size = 708618, upload-time = "2026-03-03T00:09:52.427Z" }, + { url = "https://files.pythonhosted.org/packages/72/76/3f4aa7f8fe99292b8074720f5726040f6c7528209666444f1d6166c17aeb/pyiceberg-0.11.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23ed91f8d2fa75a109708338f80a7addd2f37c93a8b0e6c9d220f1427939b81f", size = 705581, upload-time = "2026-03-03T00:09:54.389Z" }, + { url = "https://files.pythonhosted.org/packages/27/12/85543a838b2b3f4a74dfd8de470f6d88f6ba5d287231b6ea4e8765df04f6/pyiceberg-0.11.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4b91f1b1bac135c077326d30a1876f917e6c36b844a2e329b148fb9cb7aa660", size = 704197, upload-time = "2026-03-03T00:09:55.809Z" }, + { url = "https://files.pythonhosted.org/packages/f6/20/7a37d331722a4cfda0e782ec583aa28af412f8564ac937733eac440aa7b0/pyiceberg-0.11.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7770e7bdee34549e8baf28b8626e6552de7894d869465a7c95fd9af33b4cbeb0", size = 703982, upload-time = "2026-03-03T00:09:57.098Z" }, + { url = "https://files.pythonhosted.org/packages/65/16/61c2af5ab30f61f3b523346d109bc52b035bb26ecdfa58524c63fac190c4/pyiceberg-0.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:ccac408873f65b8954858ef08d4b098841301e232afc1b14e3eada489d303a7f", size = 530720, upload-time = "2026-03-03T00:09:58.548Z" }, + { url = "https://files.pythonhosted.org/packages/62/f7/3b7fee2ecc021f0526f23ef4ae5dcc8e0ed26062c35890ad25d39c53fb3b/pyiceberg-0.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ba98d6a41ec0b7c81dd85d764f15653d6abbbbd69d92630677c43f92dd50d924", size = 532406, upload-time = "2026-03-03T00:09:59.647Z" }, + { url = "https://files.pythonhosted.org/packages/94/25/324030b13d91b7b564fb7342bf3fcdbf76eed2672964b273b156bf84d6e5/pyiceberg-0.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6400774e6820760eb6c322f6feace43fe7267deb9f8d508f10bf258887a9c4d5", size = 533368, upload-time = "2026-03-03T00:10:01.264Z" }, + { url = "https://files.pythonhosted.org/packages/1c/83/6a43d06a079292c4fc7815b4de3e90a05ded90031c35d0a1b037659f722b/pyiceberg-0.11.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1663d79fc8400903992c63f79b3908b9298c623138e8929bf36c559231e082d3", size = 722886, upload-time = "2026-03-03T00:10:02.558Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5c/53807036b63bb810f2c56b4b5576e79b721ba93f1c16bd0dd49ecfe41055/pyiceberg-0.11.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:856c7fca5ed780ed44f60bceb92d6b311ebc008a2249415b8f6045201d4f5530", size = 721212, upload-time = "2026-03-03T00:10:03.694Z" }, + { url = "https://files.pythonhosted.org/packages/db/11/65e25d6016e3844c516c9f04041853115711f64af1ee184a2320c9ceab4c/pyiceberg-0.11.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6cf94756fb6a822d20a5a64f44840e6633ebf8b1deb3ce01057bff1cc03b01c2", size = 717978, upload-time = "2026-03-03T00:10:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/33b4ea9dc7f0c496900f5fc6da79e8587e94b88e2244ff02b786016cc649/pyiceberg-0.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8bd52c1891ae74cee21a4ebe8325953310a2e0af5352d70f47f5461422fcce2d", size = 718747, upload-time = "2026-03-03T00:10:06.269Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/5e133c435efc577afea5be303d7123264a5176a3ce1e2d3dc3a691049eaf/pyiceberg-0.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:65a7ad892a570045b0de2db6af17119162880aebc05a0c125ce2db7dab36f17e", size = 531081, upload-time = "2026-03-03T00:10:07.352Z" }, + { url = "https://files.pythonhosted.org/packages/8f/84/a140466b7e0841207e6b77042e03d4ab3a4f9d47e00f0bbbcc5420792bbb/pyiceberg-0.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd423b8ee2f75fc9db09158875abe5e2c952a26ae5e521c3265ab2f9d3511ddf", size = 532981, upload-time = "2026-03-03T00:10:08.906Z" }, + { url = "https://files.pythonhosted.org/packages/17/10/6bedd784010f707680ffd0606d4d11394cf915f4f9f54ae16e8007e00ad4/pyiceberg-0.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e273242cdca56029af694d7ce18075d47a74d034326d663ff6dd2655a6f44825", size = 533188, upload-time = "2026-03-03T00:10:10.086Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a3/79db617c3cffc963efa8a332707079d3f22fd58067b31a208d358dd89b39/pyiceberg-0.11.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b347d3cc8510f8fbe191956fcda7da372ebb3302789acefca08e352345959003", size = 729546, upload-time = "2026-03-03T00:10:11.413Z" }, + { url = "https://files.pythonhosted.org/packages/06/64/acc11d230c33817bced80d9d947bb49e7bb3a429d76d906523e3df86faf8/pyiceberg-0.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba3a35b4648694783aeae5b77c235a57191c8b1b375c8602b03ae56a6cf4fe7", size = 730263, upload-time = "2026-03-03T00:10:13.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1a/fb067d5150c7309fbf5dd126c648a6afed6259e7bc924ba3c65d0f87a333/pyiceberg-0.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0f958cbca18d05846e3081dfff8575e73d45595441d659847479656dc76f91d", size = 724064, upload-time = "2026-03-03T00:10:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/c1/71/103fdba5b144d55f3bb07347893737cc1d8fd71308108a77b7817c92c544/pyiceberg-0.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c62636a1e9d8a1fc74ffb70383939b9cd93f2c9ee8e12015a50dd75c98a989e", size = 727239, upload-time = "2026-03-03T00:10:16.204Z" }, + { url = "https://files.pythonhosted.org/packages/18/c3/4db64429304c58c039f8e842cd37a9a1c472f596c2868ed2a5d2907b17ed/pyiceberg-0.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:1d6b6f0c1e7dd8357f1ba56524bfc870d04ad3c00979db291784a7145497ad3b", size = 531309, upload-time = "2026-03-03T00:10:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/4c/a122d80d98cb6125d87024681263406433f0c25c699d503f5633521e6809/pyiceberg-0.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b7ec5db19feab98a31fcd5caccf4a9a4e83f96933d1ca393ba7aea665710c2bb", size = 532644, upload-time = "2026-03-03T00:10:18.574Z" }, + { url = "https://files.pythonhosted.org/packages/10/94/9a8fa5fc580e6dccd34bbbf51e7658cd7b49540e2458783addeff5e22a91/pyiceberg-0.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cec0616d2ba6e7dda6327089a2f34ec723aa9ac2c389857ef0b83f65fb135dd6", size = 532787, upload-time = "2026-03-03T00:10:19.656Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ab/ab7c88828bc17d77dbbc5a765419dfec2135629e1d74cdd0762cd38ad867/pyiceberg-0.11.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddb360da76c62c7c23ec3da40e1af48e6712a563905fea2d1a8911ff7a3b6c4d", size = 722202, upload-time = "2026-03-03T00:10:21.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/38/079cf1c0bf86da315472a926eec0dba10135f43374a2e267336eb98d8c76/pyiceberg-0.11.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d8790f420ebc484236017edba59182cf2a21bd3e4224a0bd0760a9c7268e96a", size = 724037, upload-time = "2026-03-03T00:10:22.176Z" }, + { url = "https://files.pythonhosted.org/packages/08/6b/08eaef477debb110438d943ef3f5985096f660ccb735d6344701cbd075a9/pyiceberg-0.11.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae27ba4d37925d5b2cff192acaa70c8bb114d632bbc527cc91fea0370702b866", size = 716035, upload-time = "2026-03-03T00:10:23.789Z" }, + { url = "https://files.pythonhosted.org/packages/0b/59/7671d6a630ab1d85c6e7ca8ddf438dc63a0b0dd183bc4be69bf25c0fa5f6/pyiceberg-0.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db66a4e0fdfbf4090631d59c3f65e960d9a5561e9259f6f3993cbe91e396837e", size = 720887, upload-time = "2026-03-03T00:10:24.824Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/5c8ad37807efaedb14b20f01f36462684468c80da5b74f4018fb4c1804b5/pyiceberg-0.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb3a0a3e630ee89758eb96b39b456f4697732351fb0c080e9498ea578f9b71f9", size = 530923, upload-time = "2026-03-03T00:10:26.196Z" }, +] + +[package.optional-dependencies] +sql-sqlite = [ + { name = "sqlalchemy" }, +] + [[package]] name = "pyjwt" version = "2.13.0" @@ -750,6 +1084,71 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyroaring" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/46/a50510d080f8cb089303ec0f7cd80736b2949ca3d148f48f1cc90c49e345/pyroaring-1.1.0.tar.gz", hash = "sha256:f02e4021397ae02a139defdc6813b9942ab163de90affddd4ce4efbac299f619", size = 200298, upload-time = "2026-04-24T21:29:25.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/b0/ab1c3b9227aadc326138cbbb973549399ece103d35b8c11952405a252a4d/pyroaring-1.1.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:19c9bb8b32f17fe63e54e78f88a7a67fe2dca352bf79d7d3b43f0a901db5ea3b", size = 332486, upload-time = "2026-04-24T21:27:35.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/02/a3a916ac9b6de70ac8696f767eb3487c41ca37b7f991216abbebe528045c/pyroaring-1.1.0-cp310-cp310-macosx_14_0_universal2.whl", hash = "sha256:212f471da113eba24a0fedbd1ef078bfed34b792e8f48dba3f7301f1bca2678a", size = 707625, upload-time = "2026-04-24T21:27:36.809Z" }, + { url = "https://files.pythonhosted.org/packages/22/53/e845f27b6e6e23cb322f834fcf871e27e4e2e9284360d63e68bfcf9088dd/pyroaring-1.1.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:a31276323fdd355505883162f1dca4d7acc3d6ec2159c85ddef863f1fe9e8f05", size = 382766, upload-time = "2026-04-24T21:27:38.337Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/4c9439ff556091d93c591ac466ab526991fa8745c1a15e9ca49081f215da/pyroaring-1.1.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55c9d0b708a65308eaa95c53d9de56a2b4425812395f6e57a4d9ad8efb081bf9", size = 1970617, upload-time = "2026-04-24T21:27:39.722Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4a/568684cc6d5294f0ec183bacf38f5a27b2c37908d0e0ac6f4423ae7c7c5f/pyroaring-1.1.0-cp310-cp310-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5599b691e5e993f7fdd9880429d1c485eae03dc392ba5b2dbe2eef0434bea1a5", size = 1853502, upload-time = "2026-04-24T21:27:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/82c4335651ae243eed411daa8e5acc1c62179a3e90350e4785c487e643a7/pyroaring-1.1.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8376c62e60a49e8fe51ffce569f74beed6e53fb58e5d6739f954c8c51ec0bec3", size = 2171600, upload-time = "2026-04-24T21:27:42.79Z" }, + { url = "https://files.pythonhosted.org/packages/62/b6/e6859ba41ad4c421248c2baaa4aa6c434369991200c4809cd389a4b1688f/pyroaring-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cc998e81748b72659802a7b434b39292308a5f4ab8dd4bf4739d65de1d4ed2bf", size = 2887912, upload-time = "2026-04-24T21:27:43.864Z" }, + { url = "https://files.pythonhosted.org/packages/07/c2/3f31ebc60ab094c1f2ff72f60aa0b5650d49c7653b5b575648cfb22b5441/pyroaring-1.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:fe0b9f0baf190663df37571cbb8a77370e7bd3a66068276d48f813c53ceba530", size = 2691997, upload-time = "2026-04-24T21:27:44.996Z" }, + { url = "https://files.pythonhosted.org/packages/00/6e/c1d09230ebe0088ca011be2879e7c2f956cbdbc530c6427f6d046658b1df/pyroaring-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83815b3a4cb9c3b17096c70833373610d5c93e89c134548cf43436c90326bfb3", size = 3143377, upload-time = "2026-04-24T21:27:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/cd/fa/665ad8ce092e24b4d3b1f5e34b032d73c6a3f4792f229a70b702cd050408/pyroaring-1.1.0-cp310-cp310-win32.whl", hash = "sha256:e9caf67c0c05d27bab0d88cf193e73023f624c6714f1ff807c7d4b135b19d36a", size = 210064, upload-time = "2026-04-24T21:27:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/802d6edca2dfdca841ecaafb1d60c05657f2d3948b7b78eb892cf8195dac/pyroaring-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a6343249ce9401dd90c3b934fd9a4a618b6fc455f27a9aa11d198eebd4743137", size = 259507, upload-time = "2026-04-24T21:27:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/b57b59a7d7f913eb6d1ce2578fbd82a3209805a33a673f562ca1035839c1/pyroaring-1.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:8f5194c0544b08e56e108dad096c468c67f8ac60eec42763aed36ccbd3565346", size = 217549, upload-time = "2026-04-24T21:27:50.129Z" }, + { url = "https://files.pythonhosted.org/packages/4e/aa/574f153feb89856010092c33cafe4a52f4da8cea19d441cbc4cbcb8ccb1f/pyroaring-1.1.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:462b0952277d3100a90ae890ae641d3fb3561b10cfea542e02468f0bef7700a5", size = 332142, upload-time = "2026-04-24T21:27:51.258Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/938a9fbdd41699ac9419ca922fcf80eb58c0c62aa34539aa540abaee9a63/pyroaring-1.1.0-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:9cf8608c9d6cb6bff9c624744f7a2ba8ab12276f097a07930490fe0e2219e9be", size = 706441, upload-time = "2026-04-24T21:27:52.226Z" }, + { url = "https://files.pythonhosted.org/packages/06/06/5120acc9918223ccb647dfadb430da1ec08d7ada818469ad3a2f1574b8c7/pyroaring-1.1.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:44a9f9719ed18e86627286f90057f9b7e22f6ba1d952c9793f9600b5c14e8680", size = 382003, upload-time = "2026-04-24T21:27:53.322Z" }, + { url = "https://files.pythonhosted.org/packages/88/73/adc7951e0d4e65ab7a84fa5562bc28ba2dcc6f461a9156edc0d5aefda7f6/pyroaring-1.1.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c14fc6bd65e5624f76b90297b081222261476978f795f60d48745553617ddceb", size = 2034493, upload-time = "2026-04-24T21:27:54.739Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/992f2f2ef573b54559ed4bc62b5cf0ea095a59173521cfbe78dfefcc08ad/pyroaring-1.1.0-cp311-cp311-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:01212da3752d6486adcca98c9d353f8fb8e36513e05062cdd0feebc4211dbe70", size = 1901479, upload-time = "2026-04-24T21:27:57.115Z" }, + { url = "https://files.pythonhosted.org/packages/10/4b/38bded4ca17af6359c1766c25e942435332d26e2cb3321d9d081546b75f8/pyroaring-1.1.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:100585f438b293112e2c52e45a442835837c8a0267dd1e513bafec35628f8ecb", size = 2235338, upload-time = "2026-04-24T21:27:58.66Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ff/8e7da41468a33fc9d0fbee949e8f3f734930b5173020a478686558388f31/pyroaring-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:532e53191d8dd29dedfc5202cbb45632f7df751b207a7f6d6860fb7067c7fe11", size = 2951300, upload-time = "2026-04-24T21:28:00.12Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/f8a4b55b0817aac3caf6b3b51f2e93cf152f32784949919512bd38feb0f6/pyroaring-1.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:731a7a9e050758986d5757eea10f9ccb08f9c3ef514ce0335f4a90e126f81131", size = 2752747, upload-time = "2026-04-24T21:28:01.281Z" }, + { url = "https://files.pythonhosted.org/packages/b7/26/b5a29c0f38c581ac290245150d4d14b627f110eac208f3569d5d8739c78f/pyroaring-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e9864e19109e76111befc75d799d334e7365eb4189607aa734053c12e7840fa5", size = 3203933, upload-time = "2026-04-24T21:28:02.595Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/2b3661d9a9e12dac02d2c3ef4545bd2236fcd964ba8fdd96e308ca621153/pyroaring-1.1.0-cp311-cp311-win32.whl", hash = "sha256:7caf95de39ce869ea0978068521cf6faa7350574fd1734ad6c63e5ed8cd06baa", size = 209205, upload-time = "2026-04-24T21:28:03.811Z" }, + { url = "https://files.pythonhosted.org/packages/be/1f/416a8cf29738d2e8c552fcaff7951c2a9a3bac0faffbb88b888287665834/pyroaring-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:a23cd023985b5f2ba23e84e1fadaeacde3c8a59e1d2adb3fe782e99db1e22387", size = 260338, upload-time = "2026-04-24T21:28:04.752Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0d/fd3f57014e98ffd20a3db7fc07157be8abb6cc5a356ccb20e1ea2493c397/pyroaring-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:a98d1147fe1d3195053b67b474bccc0be5021506765d27f613a943c8c99f9e4c", size = 217570, upload-time = "2026-04-24T21:28:06.126Z" }, + { url = "https://files.pythonhosted.org/packages/96/e9/d8dcccfdac1657e6a53b6ade0c0c71d59244316810c82537a5d634f8b7fd/pyroaring-1.1.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:fdf484d26016e0c016f23f2b635d2899daec034565fdcc062ed6b10f3b26a3f4", size = 334166, upload-time = "2026-04-24T21:28:07.184Z" }, + { url = "https://files.pythonhosted.org/packages/1a/16/70f8268c9bac4f6d91a82df254332edfa07020fe02e97c6d3d0295ee4db3/pyroaring-1.1.0-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:e9c2b9aa8decdcf40ed8f4c887092c20a272f8c32215c3fee65e9db92ecf418e", size = 711970, upload-time = "2026-04-24T21:28:08.929Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a6/0b88a8a8e4ffdd0bf53765c3ea17ce3b747f7de42b1f10d5c50a13ba3ecc/pyroaring-1.1.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:5eb031237e9d39cbdfc9276facacdd88e27aefb58940bd8b56b878dfd38d6022", size = 385825, upload-time = "2026-04-24T21:28:10.077Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a7/f06a899b896bb74a031201fbba707abf23e2485f44ead28d2e91977ee204/pyroaring-1.1.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66aa6a321fbc598f26d5e66050a7f145c2253f3fe5737b589841ff0cbe5cb177", size = 2000799, upload-time = "2026-04-24T21:28:11.276Z" }, + { url = "https://files.pythonhosted.org/packages/eb/16/b13e0727ef5c91c84ac5d9b5c4af43cb3f28d8822d96d44aa4aeefc10b37/pyroaring-1.1.0-cp312-cp312-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cff06d18c9a30f8547a92757078aa345db1ba5b22e3082a05f64e50b384e27a", size = 1843405, upload-time = "2026-04-24T21:28:12.394Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4b/ff77d6eb4747c65aba49d345318059f1ccfbd206bbcd34686cea819187e9/pyroaring-1.1.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:043dbbaa905f7288c515ac06a96b67a3763f35e9ae06f0c0278c0d9964d16760", size = 2224307, upload-time = "2026-04-24T21:28:13.569Z" }, + { url = "https://files.pythonhosted.org/packages/50/d1/9b4e2175a9bd07592ef1c6f4692ba0cfe3abd54f33ebd574aa2b2ab88c2c/pyroaring-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71fec09bd42f8c33ac3b762cd00c5db842eb583ffd0e361739ce1c17ad078a6a", size = 2902586, upload-time = "2026-04-24T21:28:15.485Z" }, + { url = "https://files.pythonhosted.org/packages/67/24/904952d6bfe2e4946f361958157774230cb1ac171d306e2460620274c58a/pyroaring-1.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c9f30ca28b991a920b446ed3ee19c7ecafcc49c46db592abf89cf239a7bb45f4", size = 2741581, upload-time = "2026-04-24T21:28:17.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f5/8ad5605870fbdcb568f0b847e1fea24adea15e07c90231fb62f339c08b14/pyroaring-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:67650460c65bdd7b4f5078d9c955aa38f64627d02cb48f9cfb24eae84bca2aba", size = 3182906, upload-time = "2026-04-24T21:28:18.435Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5d/264414a1b1bea72b2a7756e2b1dca709e5c695b0cd332a8f90c297ae3b33/pyroaring-1.1.0-cp312-cp312-win32.whl", hash = "sha256:61a8eabee99104ca197b6e7cce05dc4f27f503be52881800cd370eb5a5152d3f", size = 211231, upload-time = "2026-04-24T21:28:20.414Z" }, + { url = "https://files.pythonhosted.org/packages/ea/39/dd3341e235a3794c613ea32bd35618a88ff2ae067dbe9dd7c382c8c146d2/pyroaring-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:51ebe5e6f48e3dc9df91a4cb62137ef72e1469acd6f37479abd9991f6d945cc9", size = 263337, upload-time = "2026-04-24T21:28:21.371Z" }, + { url = "https://files.pythonhosted.org/packages/12/5d/bb8e93dd7412180c621086ed46014a0f09f9a71d9370ce8cf607c5a2cf00/pyroaring-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:9882a204178cc8c915e0ce30abb4bdd1668e383c571b06649d5ed272d9625877", size = 216727, upload-time = "2026-04-24T21:28:22.536Z" }, + { url = "https://files.pythonhosted.org/packages/7d/75/1d39ecb04e6cd96d191eb8884864355051df80928dd5096a9dea43fbf63b/pyroaring-1.1.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:72f68a16b00b35481d9b3bfe897ecd8a1f7da69efd92ba5b17347ca11c21cb0d", size = 333363, upload-time = "2026-04-24T21:28:23.838Z" }, + { url = "https://files.pythonhosted.org/packages/20/3e/65cd0871e86d11c5c5cfd0f5abb0ca80eb2b6b5dbe5a2433f315a9ebd90c/pyroaring-1.1.0-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:4c443e9f942b6089efe8c9b264576e9d116f90be28a315679375bba2d8a915d6", size = 710573, upload-time = "2026-04-24T21:28:24.884Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a2/f8f23515f41414332e60cd86e4957e2a6838070b2ad5fe25e80f136de635/pyroaring-1.1.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:3beb40eb1220d1ce4fb3661bb019e9a21857e5bb294fe8c1c5016aeb6e82318c", size = 384880, upload-time = "2026-04-24T21:28:25.864Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5b/82dc44b5074a1ff62e702d12611272d1711a60d5518dab23f94e1f7a9b3d/pyroaring-1.1.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f1f56004e8f1c1489bf279c25f1fa4764252cd9af5fb35675774268a4a615ba", size = 1999529, upload-time = "2026-04-24T21:28:26.859Z" }, + { url = "https://files.pythonhosted.org/packages/11/40/b07bac8cdc4b709a05f5c55bb52d4f684e5ea1fadfa0b6d9decf477a9d2a/pyroaring-1.1.0-cp313-cp313-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13660386ea8905ee4d42c21a6275463e2dc7d31e0b5d65eec210aa7043ad96f4", size = 1842927, upload-time = "2026-04-24T21:28:28.056Z" }, + { url = "https://files.pythonhosted.org/packages/0d/60/c4b511965802dfc77978a9e16f2813f47fb3083db1822019ba1bb169c685/pyroaring-1.1.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0dfb6cf50fd8898179e460e699a6b8326ca508c627d083f7bf62f769fe1717d5", size = 2199538, upload-time = "2026-04-24T21:28:29.425Z" }, + { url = "https://files.pythonhosted.org/packages/e8/12/38f6b50b3f3f41a8b752d3e9efcf105b18eb2c66811831059f25613734ac/pyroaring-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81ebbc0c880c8a10f13118632e5c0d59159ceada8b651bba18f2e6dc70efdeda", size = 2896904, upload-time = "2026-04-24T21:28:30.67Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b6/b5436e4b93c6bf2bd3dd6ccb88cbdc64b12084151a43e2f5c94be50eb710/pyroaring-1.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:370d191b0d1b32bbd99452ef5f0485f22fcc4bf7404d33b821d0ce2459951152", size = 2733819, upload-time = "2026-04-24T21:28:31.882Z" }, + { url = "https://files.pythonhosted.org/packages/ab/8f/f392f268de9607a5c7a95aaed6b9c8a81f00c14d85c33855e9f492095478/pyroaring-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8b3bfad0ae3ef0e67b40c193863dce8b7d79de545dadbe53c19acc3ace38f66", size = 3161730, upload-time = "2026-04-24T21:28:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a1/03250fd4834b6a5c13e6600bca47ea20fda579f80bce3551d4985185d164/pyroaring-1.1.0-cp313-cp313-win32.whl", hash = "sha256:eead129046822cb0fd47c78740b81bdaffd0515c0bb0306a2318acf0f0540b58", size = 211194, upload-time = "2026-04-24T21:28:35.001Z" }, + { url = "https://files.pythonhosted.org/packages/70/63/d9b307462cddc82fe94a67d6810e5c802818690e131ba690c1de674d8558/pyroaring-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:90ab2f00c09eed5bd986a80c8641e2dc10e7aca1a2d892d89a44b396e39c08ea", size = 263110, upload-time = "2026-04-24T21:28:35.976Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4a/aa6e9833a6ba9a630efdbec8783b63da6602f763b37a5b5fbc01d73a1af1/pyroaring-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:51dd2490a64ad4ed53c4fb58ef1ee3f84f6cbd97cdb47abd9065c9f714ab72ef", size = 216546, upload-time = "2026-04-24T21:28:37.065Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -829,6 +1228,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] +[[package]] +name = "rich" +version = "14.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" }, +] + [[package]] name = "ruff" version = "0.15.8" @@ -1041,6 +1453,59 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/76/b3ea1d8842e7b62c718a88d302809003d65ed82011460ca48907dde658c4/sqlalchemy-2.0.51-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e8203d2fbd5c6254692ef0a72c740d75b2f3c7ca345404f4c1a4604813c77c0", size = 2162087, upload-time = "2026-06-15T16:05:15.795Z" }, + { url = "https://files.pythonhosted.org/packages/6c/22/f19552eb7876774d50cfd025337ef5d67acc10cd8f29adab7716cf47c352/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1af05726b3d0cdba1c55284bf408fd3b792e690fe2399bfb8304565551cda652", size = 3244579, upload-time = "2026-06-15T16:10:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e4a2eb5a8ec5cd3c2a0615a2f15f0afca89ac039229599b9ed0c0ed28e5e/sqlalchemy-2.0.51-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e54ff2dd657f2e3e0fbf2b097db1182f7bfea263eca4353f00065bae2a67c3d", size = 3243515, upload-time = "2026-06-15T16:12:22.627Z" }, + { url = "https://files.pythonhosted.org/packages/74/c6/5900ec624fab3360aa2ec59b99bb2046dd79799e310bb78a0514eaa4038e/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1e47b1199c2e832e325eacabc8d32d2487f58c9358f97e9a00f5eb93c5680d84", size = 3195492, upload-time = "2026-06-15T16:10:38.097Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2ee3c4e1ac4fd22309349823fe13f33febeab1a71db1d7e9d60293a07dcb/sqlalchemy-2.0.51-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c68568f3facf8f66fa76c60e0ced69b67666ffa9941d1d0a3756fda196049080", size = 3215782, upload-time = "2026-06-15T16:12:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/ce/1c/3bd72c341f1cb5faed5a7457ea840228a46be51cfbaf31a9db72fc963f11/sqlalchemy-2.0.51-cp310-cp310-win32.whl", hash = "sha256:0592bdadf86ddcabfd72d9ab66ea8a5d8d2cc6be1cc51fa7e66c03868ac5eac1", size = 2122119, upload-time = "2026-06-15T16:13:26.915Z" }, + { url = "https://files.pythonhosted.org/packages/2a/63/b6dfdd646abf91c3bedb13727226a5e765e5f8365e898d43818e6672fa46/sqlalchemy-2.0.51-cp310-cp310-win_amd64.whl", hash = "sha256:740cf6f35351b1ac3d82369152acf1d51d37e3dcf85d4dc0a22ca01410eabe2a", size = 2145158, upload-time = "2026-06-15T16:13:28.386Z" }, + { url = "https://files.pythonhosted.org/packages/3a/69/a67c69e5f28fc9c99d6f7bd60bd50e91f2fed2423e3b30fb228fa00e51f3/sqlalchemy-2.0.51-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1aa10c0daee6705294d181daadaa793221e1a59ed55000a3fab1d42b088ce4ba", size = 2161838, upload-time = "2026-06-15T16:05:17.144Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/c8c22b8438bddc0a030157c6ec0f6ef97b3c38effa444bdab2a27af04090/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5b2ed6d828f1f09bd812861f4f59ca3bc3803f9df871f4555187f0faf018604", size = 3319402, upload-time = "2026-06-15T16:10:40.002Z" }, + { url = "https://files.pythonhosted.org/packages/90/54/44012d32fd77d991256d2ff793ba3807c51d40cb27a85b4796224f6744df/sqlalchemy-2.0.51-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:436728ce18a80f6951a1e11cc6112c2ede9faf20766f1a26195a7c441ca12dbd", size = 3319675, upload-time = "2026-06-15T16:12:25.658Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/de0592acaf5906cd7430874392d6f7e8b4a7c8437610953ee2d1501c0b44/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dc261707bf5739aea8a541593f3cc1d463c2701fb05fbcbba0ce031b69a21260", size = 3270777, upload-time = "2026-06-15T16:10:42.125Z" }, + { url = "https://files.pythonhosted.org/packages/cb/14/a44c90739c780b362238e4ac3cb19dd0ca40d13e6ddc5daa112166ddab4f/sqlalchemy-2.0.51-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a6d26094615306d116dd5e4a51b0304c99dd2356fc569eed6922a80a6bd3b265", size = 3293940, upload-time = "2026-06-15T16:12:27.156Z" }, + { url = "https://files.pythonhosted.org/packages/65/eb/fbd0f206a330e66f8c602a99c37c4e731f107faed62954b41b01f16dd9d9/sqlalchemy-2.0.51-cp311-cp311-win32.whl", hash = "sha256:ca8435d13829b92f4a97362d91975154a4015db3a2634154e1754e9a915e6b86", size = 2121183, upload-time = "2026-06-15T16:13:29.905Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fd/005bf80f3cf6e5c62b5dd68616280f51cd012c60840fa74781b3ed7b1623/sqlalchemy-2.0.51-cp311-cp311-win_amd64.whl", hash = "sha256:4a011ea4510683319ce4ed274b56ee05194b39b6da9d09ca7a39388f0fa84dcc", size = 2145796, upload-time = "2026-06-15T16:13:31.283Z" }, + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "strictyaml" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/08/efd28d49162ce89c2ad61a88bd80e11fb77bc9f6c145402589112d38f8af/strictyaml-1.7.3.tar.gz", hash = "sha256:22f854a5fcab42b5ddba8030a0e4be51ca89af0267961c8d6cfa86395586c407", size = 115206, upload-time = "2023-03-10T12:50:27.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7c/a81ef5ef10978dd073a854e0fa93b5d8021d0594b639cc8f6453c3c78a1d/strictyaml-1.7.3-py3-none-any.whl", hash = "sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7", size = 123917, upload-time = "2023-03-10T12:50:17.242Z" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -1095,6 +1560,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2025.3" @@ -1176,3 +1653,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/7a/28efd1d371f1acd037ac64ed1c5e2b41514a6cc937dd6ab6a13ab9f0702f/zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd", size = 795256, upload-time = "2025-09-14T22:15:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/ef34ef77f1ee38fc8e4f9775217a613b452916e633c4f1d98f31db52c4a5/zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7", size = 640565, upload-time = "2025-09-14T22:15:58.177Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1b/4fdb2c12eb58f31f28c4d28e8dc36611dd7205df8452e63f52fb6261d13e/zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550", size = 5345306, upload-time = "2025-09-14T22:16:00.165Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/a44bdece01bca027b079f0e00be3b6bd89a4df180071da59a3dd7381665b/zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d", size = 5055561, upload-time = "2025-09-14T22:16:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/e9/74/68341185a4f32b274e0fc3410d5ad0750497e1acc20bd0f5b5f64ce17785/zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b", size = 5402214, upload-time = "2025-09-14T22:16:04.109Z" }, + { url = "https://files.pythonhosted.org/packages/8b/67/f92e64e748fd6aaffe01e2b75a083c0c4fd27abe1c8747fee4555fcee7dd/zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0", size = 5449703, upload-time = "2025-09-14T22:16:06.312Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e5/6d36f92a197c3c17729a2125e29c169f460538a7d939a27eaaa6dcfcba8e/zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0", size = 5556583, upload-time = "2025-09-14T22:16:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/d7/83/41939e60d8d7ebfe2b747be022d0806953799140a702b90ffe214d557638/zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd", size = 5045332, upload-time = "2025-09-14T22:16:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/b3/87/d3ee185e3d1aa0133399893697ae91f221fda79deb61adbe998a7235c43f/zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701", size = 5572283, upload-time = "2025-09-14T22:16:12.128Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/58635ae6104df96671076ac7d4ae7816838ce7debd94aecf83e30b7121b0/zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1", size = 4959754, upload-time = "2025-09-14T22:16:14.225Z" }, + { url = "https://files.pythonhosted.org/packages/75/d6/57e9cb0a9983e9a229dd8fd2e6e96593ef2aa82a3907188436f22b111ccd/zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150", size = 5266477, upload-time = "2025-09-14T22:16:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a9/ee891e5edf33a6ebce0a028726f0bbd8567effe20fe3d5808c42323e8542/zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab", size = 5440914, upload-time = "2025-09-14T22:16:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/58/08/a8522c28c08031a9521f27abc6f78dbdee7312a7463dd2cfc658b813323b/zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e", size = 5819847, upload-time = "2025-09-14T22:16:20.559Z" }, + { url = "https://files.pythonhosted.org/packages/6f/11/4c91411805c3f7b6f31c60e78ce347ca48f6f16d552fc659af6ec3b73202/zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74", size = 5363131, upload-time = "2025-09-14T22:16:22.206Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d6/8c4bd38a3b24c4c7676a7a3d8de85d6ee7a983602a734b9f9cdefb04a5d6/zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa", size = 436469, upload-time = "2025-09-14T22:16:25.002Z" }, + { url = "https://files.pythonhosted.org/packages/93/90/96d50ad417a8ace5f841b3228e93d1bb13e6ad356737f42e2dde30d8bd68/zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e", size = 506100, upload-time = "2025-09-14T22:16:23.569Z" }, + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, +]