diff --git a/datafusion/core/tests/physical_optimizer/projection_pushdown.rs b/datafusion/core/tests/physical_optimizer/projection_pushdown.rs index 95cd34b6e4553..6018d714c5955 100644 --- a/datafusion/core/tests/physical_optimizer/projection_pushdown.rs +++ b/datafusion/core/tests/physical_optimizer/projection_pushdown.rs @@ -46,7 +46,7 @@ use datafusion_physical_optimizer::output_requirements::OutputRequirementExec; use datafusion_physical_optimizer::projection_pushdown::ProjectionPushdown; use datafusion_physical_plan::coalesce_partitions::CoalescePartitionsExec; use datafusion_physical_plan::coop::CooperativeExec; -use datafusion_physical_plan::filter::FilterExec; +use datafusion_physical_plan::filter::{FilterExec, FilterExecBuilder}; use datafusion_physical_plan::joins::utils::{ColumnIndex, JoinFilter}; use datafusion_physical_plan::joins::{ HashJoinExec, NestedLoopJoinExec, PartitionMode, StreamJoinPartitionMode, @@ -1754,3 +1754,121 @@ fn test_hash_join_empty_projection_embeds() -> Result<()> { Ok(()) } + +/// Regression test for +/// +/// When a `ProjectionExec` sits on top of a `FilterExec` that already carries +/// an embedded projection, the `ProjectionPushdown` optimizer must not panic. +/// +/// Before the fix, `FilterExecBuilder::from(self)` copied stale projection +/// indices (e.g. `[0, 1, 2]`). After swapping, the new input was narrower +/// (2 columns), so `.build()` panicked with "project index out of bounds". +#[test] +fn test_filter_with_embedded_projection_after_projection() -> Result<()> { + // DataSourceExec: [a, b, c, d, e] + let csv = create_simple_csv_exec(); + + // FilterExec: a > 0, projection=[0, 1, 2] → output: [a, b, c] + let predicate = Arc::new(BinaryExpr::new( + Arc::new(Column::new("a", 0)), + Operator::Gt, + Arc::new(Literal::new(ScalarValue::Int32(Some(0)))), + )); + let filter: Arc = Arc::new( + FilterExecBuilder::new(predicate, csv) + .apply_projection(Some(vec![0, 1, 2]))? + .build()?, + ); + + // ProjectionExec: narrows [a, b, c] → [a, b] + let projection: Arc = Arc::new(ProjectionExec::try_new( + vec![ + ProjectionExpr::new(Arc::new(Column::new("a", 0)), "a"), + ProjectionExpr::new(Arc::new(Column::new("b", 1)), "b"), + ], + filter, + )?); + + let initial = displayable(projection.as_ref()).indent(true).to_string(); + let actual = initial.trim(); + assert_snapshot!( + actual, + @r" + ProjectionExec: expr=[a@0 as a, b@1 as b] + FilterExec: a@0 > 0, projection=[a@0, b@1, c@2] + DataSourceExec: file_groups={1 group: [[x]]}, projection=[a, b, c, d, e], file_type=csv, has_header=false + " + ); + + // This must not panic + let after_optimize = + ProjectionPushdown::new().optimize(projection, &ConfigOptions::new())?; + let after_optimize_string = displayable(after_optimize.as_ref()) + .indent(true) + .to_string(); + let actual = after_optimize_string.trim(); + assert_snapshot!( + actual, + @r" + FilterExec: a@0 > 0 + DataSourceExec: file_groups={1 group: [[x]]}, projection=[a, b], file_type=csv, has_header=false + " + ); + + Ok(()) +} + +/// Same as above, but the outer ProjectionExec also renames columns. +/// Ensures the rename is preserved after the projection pushdown swap. +#[test] +fn test_filter_with_embedded_projection_after_renaming_projection() -> Result<()> { + let csv = create_simple_csv_exec(); + + // FilterExec: b > 10, projection=[0, 1, 2, 3] → output: [a, b, c, d] + let predicate = Arc::new(BinaryExpr::new( + Arc::new(Column::new("b", 1)), + Operator::Gt, + Arc::new(Literal::new(ScalarValue::Int32(Some(10)))), + )); + let filter: Arc = Arc::new( + FilterExecBuilder::new(predicate, csv) + .apply_projection(Some(vec![0, 1, 2, 3]))? + .build()?, + ); + + // ProjectionExec: [a as x, b as y] — narrows and renames + let projection: Arc = Arc::new(ProjectionExec::try_new( + vec![ + ProjectionExpr::new(Arc::new(Column::new("a", 0)), "x"), + ProjectionExpr::new(Arc::new(Column::new("b", 1)), "y"), + ], + filter, + )?); + + let initial = displayable(projection.as_ref()).indent(true).to_string(); + let actual = initial.trim(); + assert_snapshot!( + actual, + @r" + ProjectionExec: expr=[a@0 as x, b@1 as y] + FilterExec: b@1 > 10, projection=[a@0, b@1, c@2, d@3] + DataSourceExec: file_groups={1 group: [[x]]}, projection=[a, b, c, d, e], file_type=csv, has_header=false + " + ); + + let after_optimize = + ProjectionPushdown::new().optimize(projection, &ConfigOptions::new())?; + let after_optimize_string = displayable(after_optimize.as_ref()) + .indent(true) + .to_string(); + let actual = after_optimize_string.trim(); + assert_snapshot!( + actual, + @r" + FilterExec: y@1 > 10 + DataSourceExec: file_groups={1 group: [[x]]}, projection=[a@0 as x, b@1 as y], file_type=csv, has_header=false + " + ); + + Ok(()) +} diff --git a/datafusion/expr/src/logical_plan/plan.rs b/datafusion/expr/src/logical_plan/plan.rs index 07e0eb1a77aa9..4f73169ad2827 100644 --- a/datafusion/expr/src/logical_plan/plan.rs +++ b/datafusion/expr/src/logical_plan/plan.rs @@ -45,7 +45,7 @@ use crate::utils::{ grouping_set_expr_count, grouping_set_to_exprlist, split_conjunction, }; use crate::{ - BinaryExpr, CreateMemoryTable, CreateView, Execute, Expr, ExprSchemable, + BinaryExpr, CreateMemoryTable, CreateView, Execute, Expr, ExprSchemable, GroupingSet, LogicalPlanBuilder, Operator, Prepare, TableProviderFilterPushDown, TableSource, WindowFunctionDefinition, build_join_schema, expr_vec_fmt, requalify_sides_if_needed, }; @@ -3595,11 +3595,12 @@ impl Aggregate { .into_iter() .map(|(q, f)| (q, f.as_ref().clone().with_nullable(true).into())) .collect::>(); + let max_ordinal = max_grouping_set_duplicate_ordinal(&group_expr); qualified_fields.push(( None, Field::new( Self::INTERNAL_GROUPING_ID, - Self::grouping_id_type(qualified_fields.len()), + Self::grouping_id_type(qualified_fields.len(), max_ordinal), false, ) .into(), @@ -3685,15 +3686,24 @@ impl Aggregate { } /// Returns the data type of the grouping id. - /// The grouping ID value is a bitmask where each set bit - /// indicates that the corresponding grouping expression is - /// null - pub fn grouping_id_type(group_exprs: usize) -> DataType { - if group_exprs <= 8 { + /// + /// The grouping ID packs two pieces of information into a single integer: + /// - The low `group_exprs` bits are the semantic bitmask (a set bit means the + /// corresponding grouping expression is NULL for this grouping set). + /// - The bits above position `group_exprs` encode a duplicate ordinal that + /// distinguishes multiple occurrences of the same grouping set pattern. + /// + /// `max_ordinal` is the highest ordinal value that will appear (0 when there + /// are no duplicate grouping sets). The type is chosen to be the smallest + /// unsigned integer that can represent both parts. + pub fn grouping_id_type(group_exprs: usize, max_ordinal: usize) -> DataType { + let ordinal_bits = usize::BITS as usize - max_ordinal.leading_zeros() as usize; + let total_bits = group_exprs + ordinal_bits; + if total_bits <= 8 { DataType::UInt8 - } else if group_exprs <= 16 { + } else if total_bits <= 16 { DataType::UInt16 - } else if group_exprs <= 32 { + } else if total_bits <= 32 { DataType::UInt32 } else { DataType::UInt64 @@ -3702,21 +3712,36 @@ impl Aggregate { /// Internal column used when the aggregation is a grouping set. /// - /// This column contains a bitmask where each bit represents a grouping - /// expression. The least significant bit corresponds to the rightmost - /// grouping expression. A bit value of 0 indicates that the corresponding - /// column is included in the grouping set, while a value of 1 means it is excluded. + /// This column packs two values into a single unsigned integer: + /// + /// - **Low bits (positions 0 .. n-1)**: a semantic bitmask where each bit + /// represents one of the `n` grouping expressions. The least significant + /// bit corresponds to the rightmost grouping expression. A `1` bit means + /// the corresponding column is replaced with `NULL` for this grouping set; + /// a `0` bit means it is included. + /// - **High bits (positions n and above)**: a *duplicate ordinal* that + /// distinguishes multiple occurrences of the same semantic grouping set + /// pattern within a single query. The ordinal is `0` for the first + /// occurrence, `1` for the second, and so on. + /// + /// The integer type is chosen by [`Self::grouping_id_type`] to be the + /// smallest `UInt8 / UInt16 / UInt32 / UInt64` that can represent both + /// parts. /// - /// For example, for the grouping expressions CUBE(a, b), the grouping ID - /// column will have the following values: + /// For example, for the grouping expressions CUBE(a, b) (no duplicates), + /// the grouping ID column will have the following values: /// 0b00: Both `a` and `b` are included /// 0b01: `b` is excluded /// 0b10: `a` is excluded /// 0b11: Both `a` and `b` are excluded /// - /// This internal column is necessary because excluded columns are replaced - /// with `NULL` values. To handle these cases correctly, we must distinguish - /// between an actual `NULL` value in a column and a column being excluded from the set. + /// When the same set appears twice and `n = 2`, the duplicate ordinal is + /// packed into bit 2: + /// first occurrence: `0b0_01` (ordinal = 0, mask = 0b01) + /// second occurrence: `0b1_01` (ordinal = 1, mask = 0b01) + /// + /// The GROUPING function always masks the value with `(1 << n) - 1` before + /// interpreting it so the ordinal bits are invisible to user-facing SQL. pub const INTERNAL_GROUPING_ID: &'static str = "__grouping_id"; } @@ -3737,6 +3762,24 @@ impl PartialOrd for Aggregate { } } +/// Returns the highest duplicate ordinal across all grouping sets in `group_expr`. +/// +/// The ordinal for each occurrence of a grouping set pattern is its 0-based +/// index among identical entries. For example, if the same set appears three +/// times, the ordinals are 0, 1, 2 and this function returns 2. +/// Returns 0 when no grouping set is duplicated. +fn max_grouping_set_duplicate_ordinal(group_expr: &[Expr]) -> usize { + if let Some(Expr::GroupingSet(GroupingSet::GroupingSets(sets))) = group_expr.first() { + let mut counts: HashMap<&[Expr], usize> = HashMap::new(); + for set in sets { + *counts.entry(set).or_insert(0) += 1; + } + counts.into_values().max().unwrap_or(0).saturating_sub(1) + } else { + 0 + } +} + /// Checks whether any expression in `group_expr` contains `Expr::GroupingSet`. fn contains_grouping_set(group_expr: &[Expr]) -> bool { group_expr @@ -5053,6 +5096,14 @@ mod tests { ); } + #[test] + fn grouping_id_type_accounts_for_duplicate_ordinal_bits() { + // 8 grouping columns fit in UInt8 when there are no duplicate ordinals, + // but adding one duplicate ordinal bit widens the type to UInt16. + assert_eq!(Aggregate::grouping_id_type(8, 0), DataType::UInt8); + assert_eq!(Aggregate::grouping_id_type(8, 1), DataType::UInt16); + } + #[test] fn test_filter_is_scalar() { // test empty placeholder diff --git a/datafusion/optimizer/src/analyzer/resolve_grouping_function.rs b/datafusion/optimizer/src/analyzer/resolve_grouping_function.rs index 6b8ae3e8531bc..c12d7fd2ec2f6 100644 --- a/datafusion/optimizer/src/analyzer/resolve_grouping_function.rs +++ b/datafusion/optimizer/src/analyzer/resolve_grouping_function.rs @@ -99,10 +99,17 @@ fn replace_grouping_exprs( { match expr { Expr::AggregateFunction(ref function) if is_grouping_function(&expr) => { + let grouping_id_type = is_grouping_set + .then(|| { + schema + .field_with_name(None, Aggregate::INTERNAL_GROUPING_ID) + .map(|f| f.data_type().clone()) + }) + .transpose()?; let grouping_expr = grouping_function_on_id( function, &group_expr_to_bitmap_index, - is_grouping_set, + grouping_id_type, )?; projection_exprs.push(Expr::Alias(Alias::new( grouping_expr, @@ -184,40 +191,44 @@ fn validate_args( fn grouping_function_on_id( function: &AggregateFunction, group_by_expr: &HashMap<&Expr, usize>, - is_grouping_set: bool, + // None means not a grouping set (result is always 0). + grouping_id_type: Option, ) -> Result { validate_args(function, group_by_expr)?; let args = &function.params.args; // Postgres allows grouping function for group by without grouping sets, the result is then // always 0 - if !is_grouping_set { + let Some(grouping_id_type) = grouping_id_type else { return Ok(Expr::Literal(ScalarValue::from(0i32), None)); - } - - let group_by_expr_count = group_by_expr.len(); - let literal = |value: usize| { - if group_by_expr_count < 8 { - Expr::Literal(ScalarValue::from(value as u8), None) - } else if group_by_expr_count < 16 { - Expr::Literal(ScalarValue::from(value as u16), None) - } else if group_by_expr_count < 32 { - Expr::Literal(ScalarValue::from(value as u32), None) - } else { - Expr::Literal(ScalarValue::from(value as u64), None) - } }; + // Use the actual __grouping_id column type to size literals correctly. This + // accounts for duplicate-ordinal bits that `Aggregate::grouping_id_type` + // packs into the high bits of the column, which a simple count of grouping + // expressions would miss. + let literal = |value: usize| match &grouping_id_type { + DataType::UInt8 => Expr::Literal(ScalarValue::from(value as u8), None), + DataType::UInt16 => Expr::Literal(ScalarValue::from(value as u16), None), + DataType::UInt32 => Expr::Literal(ScalarValue::from(value as u32), None), + DataType::UInt64 => Expr::Literal(ScalarValue::from(value as u64), None), + other => panic!("unexpected __grouping_id type: {other}"), + }; let grouping_id_column = Expr::Column(Column::from(Aggregate::INTERNAL_GROUPING_ID)); - // The grouping call is exactly our internal grouping id - if args.len() == group_by_expr_count + if args.len() == group_by_expr.len() && args .iter() .rev() .enumerate() .all(|(idx, expr)| group_by_expr.get(expr) == Some(&idx)) { - return Ok(cast(grouping_id_column, DataType::Int32)); + let n = group_by_expr.len(); + // Mask the ordinal bits above position `n` so only the semantic bitmask is visible. + // checked_shl returns None when n >= 64 (all bits are semantic), mapping to u64::MAX. + let semantic_mask: u64 = 1u64.checked_shl(n as u32).map_or(u64::MAX, |m| m - 1); + let masked_id = + bitwise_and(grouping_id_column.clone(), literal(semantic_mask as usize)); + return Ok(cast(masked_id, DataType::Int32)); } args.iter() diff --git a/datafusion/optimizer/src/propagate_empty_relation.rs b/datafusion/optimizer/src/propagate_empty_relation.rs index da18d9071869c..6565d4f187339 100644 --- a/datafusion/optimizer/src/propagate_empty_relation.rs +++ b/datafusion/optimizer/src/propagate_empty_relation.rs @@ -21,9 +21,9 @@ use std::sync::Arc; use datafusion_common::JoinType; use datafusion_common::tree_node::Transformed; -use datafusion_common::{Result, plan_err}; +use datafusion_common::{Column, DFSchemaRef, Result, ScalarValue, plan_err}; use datafusion_expr::logical_plan::LogicalPlan; -use datafusion_expr::{EmptyRelation, Projection, Union}; +use datafusion_expr::{EmptyRelation, Expr, Projection, Union, cast, lit}; use crate::optimizer::ApplyOrder; use crate::{OptimizerConfig, OptimizerRule}; @@ -73,12 +73,8 @@ impl OptimizerRule for PropagateEmptyRelation { Ok(Transformed::no(plan)) } LogicalPlan::Join(ref join) => { - // TODO: For Join, more join type need to be careful: - // For LeftOut/Full Join, if the right side is empty, the Join can be eliminated with a Projection with left side - // columns + right side columns replaced with null values. - // For RightOut/Full Join, if the left side is empty, the Join can be eliminated with a Projection with right side - // columns + left side columns replaced with null values. let (left_empty, right_empty) = binary_plan_children_is_empty(&plan)?; + let left_field_count = join.left.schema().fields().len(); match join.join_type { // For Full Join, only both sides are empty, the Join result is empty. @@ -88,6 +84,24 @@ impl OptimizerRule for PropagateEmptyRelation { schema: Arc::clone(&join.schema), }), )), + // For Full Join, if one side is empty, replace with a + // Projection that null-pads the empty side's columns. + JoinType::Full if right_empty => { + Ok(Transformed::yes(build_null_padded_projection( + Arc::clone(&join.left), + &join.schema, + left_field_count, + true, + )?)) + } + JoinType::Full if left_empty => { + Ok(Transformed::yes(build_null_padded_projection( + Arc::clone(&join.right), + &join.schema, + left_field_count, + false, + )?)) + } JoinType::Inner if left_empty || right_empty => Ok(Transformed::yes( LogicalPlan::EmptyRelation(EmptyRelation { produce_one_row: false, @@ -100,12 +114,32 @@ impl OptimizerRule for PropagateEmptyRelation { schema: Arc::clone(&join.schema), }), )), + // Left Join with empty right: all left rows survive + // with NULLs for right columns. + JoinType::Left if right_empty => { + Ok(Transformed::yes(build_null_padded_projection( + Arc::clone(&join.left), + &join.schema, + left_field_count, + true, + )?)) + } JoinType::Right if right_empty => Ok(Transformed::yes( LogicalPlan::EmptyRelation(EmptyRelation { produce_one_row: false, schema: Arc::clone(&join.schema), }), )), + // Right Join with empty left: all right rows survive + // with NULLs for left columns. + JoinType::Right if left_empty => { + Ok(Transformed::yes(build_null_padded_projection( + Arc::clone(&join.right), + &join.schema, + left_field_count, + false, + )?)) + } JoinType::LeftSemi if left_empty || right_empty => Ok( Transformed::yes(LogicalPlan::EmptyRelation(EmptyRelation { produce_one_row: false, @@ -230,6 +264,57 @@ fn empty_child(plan: &LogicalPlan) -> Result> { } } +/// Builds a Projection that replaces one side of an outer join with NULL literals. +/// +/// When one side of an outer join is an `EmptyRelation`, the join can be eliminated +/// by projecting the surviving side's columns as-is and replacing the empty side's +/// columns with `CAST(NULL AS )`. +/// +/// The join schema is used as the projection's output schema to preserve nullability +/// guarantees (important for FULL JOIN where the surviving side's columns are marked +/// nullable in the join schema even if they aren't in the source schema). +/// +/// # Example +/// +/// For a `LEFT JOIN` where the right side is empty: +/// ```text +/// Left Join (orders.id = returns.order_id) Projection(orders.id, orders.amount, +/// ├── TableScan: orders => CAST(NULL AS Int64) AS order_id, +/// └── EmptyRelation CAST(NULL AS Utf8) AS reason) +/// └── TableScan: orders +/// ``` +fn build_null_padded_projection( + surviving_plan: Arc, + join_schema: &DFSchemaRef, + left_field_count: usize, + empty_side_is_right: bool, +) -> Result { + let exprs = join_schema + .iter() + .enumerate() + .map(|(i, (qualifier, field))| { + let on_empty_side = if empty_side_is_right { + i >= left_field_count + } else { + i < left_field_count + }; + + if on_empty_side { + cast(lit(ScalarValue::Null), field.data_type().clone()) + .alias_qualified(qualifier.cloned(), field.name()) + } else { + Expr::Column(Column::new(qualifier.cloned(), field.name())) + } + }) + .collect::>(); + + Ok(LogicalPlan::Projection(Projection::try_new_with_schema( + exprs, + surviving_plan, + Arc::clone(join_schema), + )?)) +} + #[cfg(test)] mod tests { @@ -570,6 +655,111 @@ mod tests { assert_empty_left_empty_right_lp(true, false, JoinType::RightAnti, false) } + #[test] + fn test_left_join_right_empty_null_pad() -> Result<()> { + let left = + LogicalPlanBuilder::from(test_table_scan_with_name("left")?).build()?; + let right_empty = LogicalPlanBuilder::from(test_table_scan_with_name("right")?) + .filter(lit(false))? + .build()?; + + let plan = LogicalPlanBuilder::from(left) + .join_using( + right_empty, + JoinType::Left, + vec![Column::from_name("a".to_string())], + )? + .build()?; + + let expected = "Projection: left.a, left.b, left.c, CAST(NULL AS UInt32) AS a, CAST(NULL AS UInt32) AS b, CAST(NULL AS UInt32) AS c\n TableScan: left"; + assert_together_optimized_plan(plan, expected, true) + } + + #[test] + fn test_right_join_left_empty_null_pad() -> Result<()> { + let left_empty = LogicalPlanBuilder::from(test_table_scan_with_name("left")?) + .filter(lit(false))? + .build()?; + let right = + LogicalPlanBuilder::from(test_table_scan_with_name("right")?).build()?; + + let plan = LogicalPlanBuilder::from(left_empty) + .join_using( + right, + JoinType::Right, + vec![Column::from_name("a".to_string())], + )? + .build()?; + + let expected = "Projection: CAST(NULL AS UInt32) AS a, CAST(NULL AS UInt32) AS b, CAST(NULL AS UInt32) AS c, right.a, right.b, right.c\n TableScan: right"; + assert_together_optimized_plan(plan, expected, true) + } + + #[test] + fn test_full_join_right_empty_null_pad() -> Result<()> { + let left = + LogicalPlanBuilder::from(test_table_scan_with_name("left")?).build()?; + let right_empty = LogicalPlanBuilder::from(test_table_scan_with_name("right")?) + .filter(lit(false))? + .build()?; + + let plan = LogicalPlanBuilder::from(left) + .join_using( + right_empty, + JoinType::Full, + vec![Column::from_name("a".to_string())], + )? + .build()?; + + let expected = "Projection: left.a, left.b, left.c, CAST(NULL AS UInt32) AS a, CAST(NULL AS UInt32) AS b, CAST(NULL AS UInt32) AS c\n TableScan: left"; + assert_together_optimized_plan(plan, expected, true) + } + + #[test] + fn test_full_join_left_empty_null_pad() -> Result<()> { + let left_empty = LogicalPlanBuilder::from(test_table_scan_with_name("left")?) + .filter(lit(false))? + .build()?; + let right = + LogicalPlanBuilder::from(test_table_scan_with_name("right")?).build()?; + + let plan = LogicalPlanBuilder::from(left_empty) + .join_using( + right, + JoinType::Full, + vec![Column::from_name("a".to_string())], + )? + .build()?; + + let expected = "Projection: CAST(NULL AS UInt32) AS a, CAST(NULL AS UInt32) AS b, CAST(NULL AS UInt32) AS c, right.a, right.b, right.c\n TableScan: right"; + assert_together_optimized_plan(plan, expected, true) + } + + #[test] + fn test_left_join_complex_on_right_empty_null_pad() -> Result<()> { + let left = + LogicalPlanBuilder::from(test_table_scan_with_name("left")?).build()?; + let right_empty = LogicalPlanBuilder::from(test_table_scan_with_name("right")?) + .filter(lit(false))? + .build()?; + + // Complex ON condition: left.a = right.a AND left.b > right.b + let plan = LogicalPlanBuilder::from(left) + .join( + right_empty, + JoinType::Left, + ( + vec![Column::from_name("a".to_string())], + vec![Column::from_name("a".to_string())], + ), + Some(col("left.b").gt(col("right.b"))), + )? + .build()?; + + let expected = "Projection: left.a, left.b, left.c, CAST(NULL AS UInt32) AS a, CAST(NULL AS UInt32) AS b, CAST(NULL AS UInt32) AS c\n TableScan: left"; + assert_together_optimized_plan(plan, expected, true) + } + #[test] fn test_empty_with_non_empty() -> Result<()> { let table_scan = test_table_scan()?; diff --git a/datafusion/physical-plan/src/aggregates/mod.rs b/datafusion/physical-plan/src/aggregates/mod.rs index a3f0f568616e5..41782330c39da 100644 --- a/datafusion/physical-plan/src/aggregates/mod.rs +++ b/datafusion/physical-plan/src/aggregates/mod.rs @@ -37,7 +37,7 @@ use crate::{ use datafusion_common::config::ConfigOptions; use datafusion_physical_expr::utils::collect_columns; use parking_lot::Mutex; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use arrow::array::{ArrayRef, UInt8Array, UInt16Array, UInt32Array, UInt64Array}; use arrow::datatypes::{DataType, Field, Schema, SchemaRef}; @@ -396,6 +396,15 @@ impl PhysicalGroupBy { self.expr.len() + usize::from(self.has_grouping_set) } + /// Returns the Arrow data type of the `__grouping_id` column. + /// + /// The type is chosen to be wide enough to hold both the semantic bitmask + /// (in the low `n` bits, where `n` is the number of grouping expressions) + /// and the duplicate ordinal (in the high bits). + fn grouping_id_data_type(&self) -> DataType { + Aggregate::grouping_id_type(self.expr.len(), max_duplicate_ordinal(&self.groups)) + } + pub fn group_schema(&self, schema: &Schema) -> Result { Ok(Arc::new(Schema::new(self.group_fields(schema)?))) } @@ -420,7 +429,7 @@ impl PhysicalGroupBy { fields.push( Field::new( Aggregate::INTERNAL_GROUPING_ID, - Aggregate::grouping_id_type(self.expr.len()), + self.grouping_id_data_type(), false, ) .into(), @@ -2039,27 +2048,72 @@ fn evaluate_optional( .collect() } -fn group_id_array(group: &[bool], batch: &RecordBatch) -> Result { - if group.len() > 64 { +/// Builds the internal `__grouping_id` array for a single grouping set. +/// +/// The returned array packs two values into a single integer: +/// +/// - Low `n` bits (positions 0 .. n-1): the semantic bitmask. A `1` bit +/// at position `i` means that the `i`-th grouping column (counting from the +/// least significant bit, i.e. the *last* column in the `group` slice) is +/// `NULL` for this grouping set. +/// - High bits (positions n and above): the duplicate `ordinal`, which +/// distinguishes multiple occurrences of the same grouping-set pattern. The +/// ordinal is `0` for the first occurrence, `1` for the second, and so on. +/// +/// The integer type is chosen to be the smallest `UInt8 / UInt16 / UInt32 / +/// UInt64` that can represent both parts. It matches the type returned by +/// [`Aggregate::grouping_id_type`]. +fn group_id_array( + group: &[bool], + ordinal: usize, + max_ordinal: usize, + batch: &RecordBatch, +) -> Result { + let n = group.len(); + if n > 64 { return not_impl_err!( "Grouping sets with more than 64 columns are not supported" ); } - let group_id = group.iter().fold(0u64, |acc, &is_null| { + let ordinal_bits = usize::BITS as usize - max_ordinal.leading_zeros() as usize; + let total_bits = n + ordinal_bits; + if total_bits > 64 { + return not_impl_err!( + "Grouping sets with {n} columns and a maximum duplicate ordinal of \ + {max_ordinal} require {total_bits} bits, which exceeds 64" + ); + } + let semantic_id = group.iter().fold(0u64, |acc, &is_null| { (acc << 1) | if is_null { 1 } else { 0 } }); + let full_id = semantic_id | ((ordinal as u64) << n); let num_rows = batch.num_rows(); - if group.len() <= 8 { - Ok(Arc::new(UInt8Array::from(vec![group_id as u8; num_rows]))) - } else if group.len() <= 16 { - Ok(Arc::new(UInt16Array::from(vec![group_id as u16; num_rows]))) - } else if group.len() <= 32 { - Ok(Arc::new(UInt32Array::from(vec![group_id as u32; num_rows]))) + if total_bits <= 8 { + Ok(Arc::new(UInt8Array::from(vec![full_id as u8; num_rows]))) + } else if total_bits <= 16 { + Ok(Arc::new(UInt16Array::from(vec![full_id as u16; num_rows]))) + } else if total_bits <= 32 { + Ok(Arc::new(UInt32Array::from(vec![full_id as u32; num_rows]))) } else { - Ok(Arc::new(UInt64Array::from(vec![group_id; num_rows]))) + Ok(Arc::new(UInt64Array::from(vec![full_id; num_rows]))) } } +/// Returns the highest duplicate ordinal across all grouping sets. +/// +/// At the call-site, the ordinal is the 0-based index assigned to each +/// occurrence of a repeated grouping-set pattern: the first occurrence gets +/// ordinal 0, the second gets 1, and so on. If the same `Vec` appears +/// three times the ordinals are 0, 1, 2 and this function returns 2. +/// Returns 0 when no grouping set is duplicated. +fn max_duplicate_ordinal(groups: &[Vec]) -> usize { + let mut counts: HashMap<&[bool], usize> = HashMap::new(); + for group in groups { + *counts.entry(group).or_insert(0) += 1; + } + counts.into_values().max().unwrap_or(0).saturating_sub(1) +} + /// Evaluate a group by expression against a `RecordBatch` /// /// Arguments: @@ -2074,6 +2128,8 @@ pub fn evaluate_group_by( group_by: &PhysicalGroupBy, batch: &RecordBatch, ) -> Result>> { + let max_ordinal = max_duplicate_ordinal(&group_by.groups); + let mut ordinal_per_pattern: HashMap<&[bool], usize> = HashMap::new(); let exprs = evaluate_expressions_to_arrays( group_by.expr.iter().map(|(expr, _)| expr), batch, @@ -2087,6 +2143,10 @@ pub fn evaluate_group_by( .groups .iter() .map(|group| { + let ordinal = ordinal_per_pattern.entry(group).or_insert(0); + let current_ordinal = *ordinal; + *ordinal += 1; + let mut group_values = Vec::with_capacity(group_by.num_group_exprs()); group_values.extend(group.iter().enumerate().map(|(idx, is_null)| { if *is_null { @@ -2096,7 +2156,12 @@ pub fn evaluate_group_by( } })); if !group_by.is_single() { - group_values.push(group_id_array(group, batch)?); + group_values.push(group_id_array( + group, + current_ordinal, + max_ordinal, + batch, + )?); } Ok(group_values) }) diff --git a/datafusion/physical-plan/src/filter.rs b/datafusion/physical-plan/src/filter.rs index afe2b0ae810a3..8720d5f7d223b 100644 --- a/datafusion/physical-plan/src/filter.rs +++ b/datafusion/physical-plan/src/filter.rs @@ -568,6 +568,10 @@ impl ExecutionPlan for FilterExec { return FilterExecBuilder::from(self) .with_input(make_with_child(projection, self.input())?) .with_predicate(new_predicate) + // The original FilterExec projection referenced columns from its old + // input. After the swap the new input is the ProjectionExec which + // already handles column selection, so clear the projection here. + .apply_projection(None)? .build() .map(|e| Some(Arc::new(e) as _)); } @@ -2572,4 +2576,67 @@ mod tests { ); Ok(()) } + + /// Regression test: ProjectionExec on top of a FilterExec that already has + /// an explicit projection must not panic when `try_swapping_with_projection` + /// attempts to swap the two nodes. + /// + /// Before the fix, `FilterExecBuilder::from(self)` copied the old projection + /// (e.g. `[0, 1, 2]`) from the FilterExec. After `.with_input` replaced the + /// input with the narrower ProjectionExec (2 columns), `.build()` tried to + /// validate the stale `[0, 1, 2]` projection against the 2-column schema and + /// panicked with "project index 2 out of bounds, max field 2". + #[test] + fn test_filter_with_projection_swap_does_not_panic() -> Result<()> { + use crate::projection::ProjectionExpr; + use datafusion_physical_expr::expressions::col; + + // Schema: [ts: Int64, tokens: Int64, svc: Utf8] + let schema = Arc::new(Schema::new(vec![ + Field::new("ts", DataType::Int64, false), + Field::new("tokens", DataType::Int64, false), + Field::new("svc", DataType::Utf8, false), + ])); + let input = Arc::new(EmptyExec::new(Arc::clone(&schema))); + + // FilterExec: ts > 0, projection=[ts@0, tokens@1, svc@2] (all 3 cols) + let predicate = Arc::new(BinaryExpr::new( + Arc::new(Column::new("ts", 0)), + Operator::Gt, + Arc::new(Literal::new(ScalarValue::Int64(Some(0)))), + )); + let filter = Arc::new( + FilterExecBuilder::new(predicate, input) + .apply_projection(Some(vec![0, 1, 2]))? + .build()?, + ); + + // ProjectionExec: narrows to [ts, tokens] (drops svc) + let proj_exprs = vec![ + ProjectionExpr { + expr: col("ts", &filter.schema())?, + alias: "ts".to_string(), + }, + ProjectionExpr { + expr: col("tokens", &filter.schema())?, + alias: "tokens".to_string(), + }, + ]; + let projection = Arc::new(ProjectionExec::try_new( + proj_exprs, + Arc::clone(&filter) as _, + )?); + + // This must not panic + let result = filter.try_swapping_with_projection(&projection)?; + assert!(result.is_some(), "swap should succeed"); + + let new_plan = result.unwrap(); + // Output schema must still be [ts, tokens] + let out_schema = new_plan.schema(); + assert_eq!(out_schema.fields().len(), 2); + assert_eq!(out_schema.field(0).name(), "ts"); + assert_eq!(out_schema.field(1).name(), "tokens"); + Ok(()) + } } diff --git a/datafusion/sqllogictest/test_files/group_by.slt b/datafusion/sqllogictest/test_files/group_by.slt index 59db63ba420e9..b313424951532 100644 --- a/datafusion/sqllogictest/test_files/group_by.slt +++ b/datafusion/sqllogictest/test_files/group_by.slt @@ -5203,6 +5203,41 @@ NULL NULL 1 statement ok drop table t; +# regression: duplicate grouping sets must not be collapsed into one +statement ok +create table duplicate_grouping_sets(deptno int, job varchar, sal int, comm int) as values +(10, 'CLERK', 1300, null), +(20, 'MANAGER', 3000, null); + +query ITIIIII +select deptno, job, sal, sum(comm), grouping(deptno), grouping(job), grouping(sal) +from duplicate_grouping_sets +group by grouping sets ((deptno, job), (deptno, sal), (deptno, job)) +order by deptno, job, sal, grouping(deptno), grouping(job), grouping(sal); +---- +10 CLERK NULL NULL 0 0 1 +10 CLERK NULL NULL 0 0 1 +10 NULL 1300 NULL 0 1 0 +20 MANAGER NULL NULL 0 0 1 +20 MANAGER NULL NULL 0 0 1 +20 NULL 3000 NULL 0 1 0 + +query ITII +select deptno, job, sal, grouping(deptno, job, sal) +from duplicate_grouping_sets +group by grouping sets ((deptno, job), (deptno, sal), (deptno, job)) +order by deptno, job, sal, grouping(deptno, job, sal); +---- +10 CLERK NULL 1 +10 CLERK NULL 1 +10 NULL 1300 2 +20 MANAGER NULL 1 +20 MANAGER NULL 1 +20 NULL 3000 2 + +statement ok +drop table duplicate_grouping_sets; + # test multi group by for binary type without nulls statement ok create table t(a int, b bytea) as values (1, 0xa), (1, 0xa), (2, 0xb), (3, 0xb), (3, 0xb); diff --git a/datafusion/sqllogictest/test_files/propagate_empty_relation_outer_join.slt b/datafusion/sqllogictest/test_files/propagate_empty_relation_outer_join.slt new file mode 100644 index 0000000000000..016a3108e509b --- /dev/null +++ b/datafusion/sqllogictest/test_files/propagate_empty_relation_outer_join.slt @@ -0,0 +1,155 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Test PropagateEmptyRelation rule: outer joins where one side is +# an EmptyRelation should be replaced with a null-padded projection. + +statement ok +create table t1(a int, b varchar, c double); + +statement ok +create table t2(x int, y varchar, z double); + +statement ok +insert into t1 values (1, 'a', 10.0), (2, 'b', 20.0), (3, 'c', 30.0); + +statement ok +insert into t2 values (1, 'p', 100.0), (2, 'q', 200.0); + +statement ok +set datafusion.explain.logical_plan_only = true; + +### +### LEFT JOIN with empty right (WHERE false subquery) +### + +# The join should be eliminated — no join operator in the plan +query TT +explain select * from t1 left join (select * from t2 where false) r on t1.a = r.x; +---- +logical_plan +01)Projection: t1.a, t1.b, t1.c, Int32(NULL) AS x, Utf8View(NULL) AS y, Float64(NULL) AS z +02)--TableScan: t1 projection=[a, b, c] + +# Verify result correctness — all left rows with NULLs on right +query ITRITR rowsort +select * from t1 left join (select * from t2 where false) r on t1.a = r.x; +---- +1 a 10 NULL NULL NULL +2 b 20 NULL NULL NULL +3 c 30 NULL NULL NULL + +### +### RIGHT JOIN with empty left +### + +query TT +explain select * from (select * from t1 where false) l right join t2 on l.a = t2.x; +---- +logical_plan +01)Projection: Int32(NULL) AS a, Utf8View(NULL) AS b, Float64(NULL) AS c, t2.x, t2.y, t2.z +02)--TableScan: t2 projection=[x, y, z] + +query ITRITR rowsort +select * from (select * from t1 where false) l right join t2 on l.a = t2.x; +---- +NULL NULL NULL 1 p 100 +NULL NULL NULL 2 q 200 + +### +### FULL JOIN with empty right +### + +query TT +explain select * from t1 full join (select * from t2 where false) r on t1.a = r.x; +---- +logical_plan +01)Projection: t1.a, t1.b, t1.c, Int32(NULL) AS x, Utf8View(NULL) AS y, Float64(NULL) AS z +02)--TableScan: t1 projection=[a, b, c] + +query ITRITR rowsort +select * from t1 full join (select * from t2 where false) r on t1.a = r.x; +---- +1 a 10 NULL NULL NULL +2 b 20 NULL NULL NULL +3 c 30 NULL NULL NULL + +### +### FULL JOIN with empty left +### + +query TT +explain select * from (select * from t1 where false) l full join t2 on l.a = t2.x; +---- +logical_plan +01)Projection: Int32(NULL) AS a, Utf8View(NULL) AS b, Float64(NULL) AS c, t2.x, t2.y, t2.z +02)--TableScan: t2 projection=[x, y, z] + +query ITRITR rowsort +select * from (select * from t1 where false) l full join t2 on l.a = t2.x; +---- +NULL NULL NULL 1 p 100 +NULL NULL NULL 2 q 200 + +### +### Filter on top of optimized join +### + +query TT +explain select * from t1 left join (select * from t2 where false) r on t1.a = r.x where t1.a > 1; +---- +logical_plan +01)Projection: t1.a, t1.b, t1.c, Int32(NULL) AS x, Utf8View(NULL) AS y, Float64(NULL) AS z +02)--Filter: t1.a > Int32(1) +03)----TableScan: t1 projection=[a, b, c] + +query ITRITR rowsort +select * from t1 left join (select * from t2 where false) r on t1.a = r.x where t1.a > 1; +---- +2 b 20 NULL NULL NULL +3 c 30 NULL NULL NULL + +### +### LEFT JOIN with complex ON condition and empty right +### + +query TT +explain select * from t1 left join (select * from t2 where false) r on t1.a = r.x and t1.c > r.z; +---- +logical_plan +01)Projection: t1.a, t1.b, t1.c, Int32(NULL) AS x, Utf8View(NULL) AS y, Float64(NULL) AS z +02)--TableScan: t1 projection=[a, b, c] + +query ITRITR rowsort +select * from t1 left join (select * from t2 where false) r on t1.a = r.x and t1.c > r.z; +---- +1 a 10 NULL NULL NULL +2 b 20 NULL NULL NULL +3 c 30 NULL NULL NULL + +### +### Cleanup +### + +statement ok +set datafusion.explain.logical_plan_only = false; + +statement ok +drop table t1; + +statement ok +drop table t2; diff --git a/datafusion/sqllogictest/test_files/subquery.slt b/datafusion/sqllogictest/test_files/subquery.slt index 5d54de6dbce2d..368d252e25006 100644 --- a/datafusion/sqllogictest/test_files/subquery.slt +++ b/datafusion/sqllogictest/test_files/subquery.slt @@ -688,10 +688,8 @@ query TT explain SELECT t1_id, (SELECT t2_id FROM t2 limit 0) FROM t1 ---- logical_plan -01)Projection: t1.t1_id, __scalar_sq_1.t2_id AS t2_id -02)--Left Join: -03)----TableScan: t1 projection=[t1_id] -04)----EmptyRelation: rows=0 +01)Projection: t1.t1_id, Int32(NULL) AS t2_id +02)--TableScan: t1 projection=[t1_id] query II rowsort SELECT t1_id, (SELECT t2_id FROM t2 limit 0) FROM t1 diff --git a/uv.lock b/uv.lock index 925d850bba42d..d8d2cc13cf72b 100644 --- a/uv.lock +++ b/uv.lock @@ -240,61 +240,61 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.6" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" }, - { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" }, - { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" }, - { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" }, - { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" }, - { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" }, - { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" }, - { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" }, - { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" }, - { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" }, - { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" }, - { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" }, - { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" }, - { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" }, - { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" }, - { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" }, - { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" }, - { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" }, - { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" }, - { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" }, - { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" }, - { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" }, - { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" }, - { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" }, - { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" }, - { url = "https://files.pythonhosted.org/packages/2e/84/7ccff00ced5bac74b775ce0beb7d1be4e8637536b522b5df9b73ada42da2/cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead", size = 3475444, upload-time = "2026-03-25T23:34:38.944Z" }, - { url = "https://files.pythonhosted.org/packages/bc/1f/4c926f50df7749f000f20eede0c896769509895e2648db5da0ed55db711d/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8", size = 4218227, upload-time = "2026-03-25T23:34:40.871Z" }, - { url = "https://files.pythonhosted.org/packages/c6/65/707be3ffbd5f786028665c3223e86e11c4cda86023adbc56bd72b1b6bab5/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0", size = 4381399, upload-time = "2026-03-25T23:34:42.609Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6d/73557ed0ef7d73d04d9aba745d2c8e95218213687ee5e76b7d236a5030fc/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b", size = 4217595, upload-time = "2026-03-25T23:34:44.205Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c5/e1594c4eec66a567c3ac4400008108a415808be2ce13dcb9a9045c92f1a0/cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a", size = 4380912, upload-time = "2026-03-25T23:34:46.328Z" }, - { url = "https://files.pythonhosted.org/packages/1a/89/843b53614b47f97fe1abc13f9a86efa5ec9e275292c457af1d4a60dc80e0/cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e", size = 3409955, upload-time = "2026-03-25T23:34:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" }, + { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" }, + { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] [[package]]