From a1573e76d56ca15a9e4c4dc77282b144cc5d25f9 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:43:35 +0530 Subject: [PATCH 001/167] Add S3-Snowflake stage operations for large-scale data transfers - Add query_pandas_from_snowflake_via_s3_stage() for efficient large query results (>10M rows) - Add publish_pandas_via_s3_stage() for efficient large DataFrame writes (>10M rows) - Add make_batch_predictions_from_snowflake_via_s3_stage() for batch ML predictions - Support dev/prod environment switching via current.is_production - Add helper functions for S3 operations and SQL generation - Add metaflow_s3/utils.py with S3 utility functions - Add comprehensive functional tests - Integrate with existing Metaflow card system and cost tracking --- src/ds_platform_utils/metaflow/__init__.py | 8 + .../metaflow/pandas_via_s3_stage.py | 663 ++++++++++++++++++ src/ds_platform_utils/metaflow_s3/utils.py | 80 +++ .../metaflow/test_pandas_via_s3_stage.py | 287 ++++++++ 4 files changed, 1038 insertions(+) create mode 100644 src/ds_platform_utils/metaflow/pandas_via_s3_stage.py create mode 100644 src/ds_platform_utils/metaflow_s3/utils.py create mode 100644 tests/functional_tests/metaflow/test_pandas_via_s3_stage.py diff --git a/src/ds_platform_utils/metaflow/__init__.py b/src/ds_platform_utils/metaflow/__init__.py index c22e587..eb9065e 100644 --- a/src/ds_platform_utils/metaflow/__init__.py +++ b/src/ds_platform_utils/metaflow/__init__.py @@ -1,12 +1,20 @@ from .pandas import publish_pandas, query_pandas_from_snowflake +from .pandas_via_s3_stage import ( + make_batch_predictions_from_snowflake_via_s3_stage, + publish_pandas_via_s3_stage, + query_pandas_from_snowflake_via_s3_stage, +) from .restore_step_state import restore_step_state from .validate_config import make_pydantic_parser_fn from .write_audit_publish import publish __all__ = [ + "make_batch_predictions_from_snowflake_via_s3_stage", "make_pydantic_parser_fn", "publish", "publish_pandas", + "publish_pandas_via_s3_stage", "query_pandas_from_snowflake", + "query_pandas_from_snowflake_via_s3_stage", "restore_step_state", ] diff --git a/src/ds_platform_utils/metaflow/pandas_via_s3_stage.py b/src/ds_platform_utils/metaflow/pandas_via_s3_stage.py new file mode 100644 index 0000000..08265ec --- /dev/null +++ b/src/ds_platform_utils/metaflow/pandas_via_s3_stage.py @@ -0,0 +1,663 @@ +"""Pandas operations for Snowflake via S3 stage - optimized for large-scale data transfers. + +This module provides efficient data transfer between Snowflake and Pandas DataFrames using S3 as +an intermediate staging area. This approach is significantly faster for large datasets compared +to direct database connections. + +Use these functions when: +- Querying large result sets (>10M rows) from Snowflake +- Writing large DataFrames (>10M rows) to Snowflake +- Processing batch predictions with large datasets + +The functions automatically handle: +- Dev/prod environment switching via current.is_production +- Temporary S3 folder creation with timestamps +- Parquet file chunking for optimal performance +- Metaflow card integration for visibility +""" + +import json +import os +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +import pandas as pd +from metaflow import S3, current +from metaflow.cards import Markdown, Table + +from ds_platform_utils._snowflake.run_query import _execute_sql +from ds_platform_utils.metaflow._consts import NON_PROD_SCHEMA, PROD_SCHEMA +from ds_platform_utils.metaflow.get_snowflake_connection import get_snowflake_connection +from ds_platform_utils.metaflow.pandas import TWarehouse +from ds_platform_utils.metaflow.write_audit_publish import ( + add_comment_to_each_sql_statement, + get_select_dev_query_tags, +) + +# S3 Stage Configuration +# Dev environment +DEV_S3_BUCKET = "s3://dev-outerbounds-snowflake-stage" +DEV_SNOWFLAKE_STAGE = "DEV_OUTERBOUNDS_S3_STAGE" + +# Prod environment +PROD_S3_BUCKET = "s3://prod-outerbounds-snowflake-stage" +PROD_SNOWFLAKE_STAGE = "PROD_OUTERBOUNDS_S3_STAGE" + +# IAM Role for S3 access (same for both environments) +S3_IAM_ROLE = "arn:aws:iam::209479263910:role/outerbounds_iam_role" + + +def _get_metaflow_s3_client() -> S3: + """Get Metaflow S3 client with configured IAM role.""" + return S3(role=S3_IAM_ROLE) + + +def _get_s3_config(is_production: bool = False) -> Tuple[str, str]: + """Get S3 bucket and Snowflake stage name based on environment. + + :param is_production: If True, use production S3 bucket and stage. + If False, use dev S3 bucket and stage. + :return: Tuple of (s3_bucket_path, snowflake_stage_name) + """ + if is_production: + return PROD_S3_BUCKET, PROD_SNOWFLAKE_STAGE + return DEV_S3_BUCKET, DEV_SNOWFLAKE_STAGE + + +def _list_files_in_s3_folder(path: str) -> List[str]: + """List all files in an S3 folder. + + :param path: S3 URI path (must start with 's3://') + :return: List of file URLs + """ + if not path.startswith("s3://"): + raise ValueError("Invalid S3 URI. Must start with 's3://'.") + + with _get_metaflow_s3_client() as s3: + return [file_path.url for file_path in s3.list_paths([path])] + + +def _get_df_from_s3_files(paths: List[str]) -> pd.DataFrame: + """Read multiple parquet files from S3 and return a single DataFrame. + + :param paths: List of S3 URIs to parquet files + :return: Combined DataFrame + """ + if any(not path.startswith("s3://") for path in paths): + raise ValueError("Invalid S3 URI. All paths must start with 's3://'.") + + with _get_metaflow_s3_client() as s3: + df_paths = [obj.path for obj in s3.get_many(paths)] + return pd.read_parquet(df_paths) + + +def _get_df_from_s3_folder(path: str) -> pd.DataFrame: + """Read all parquet files from an S3 folder and return a single DataFrame. + + :param path: S3 URI folder path + :return: Combined DataFrame + """ + if not path.startswith("s3://"): + raise ValueError("Invalid S3 URI. Must start with 's3://'.") + + files = _list_files_in_s3_folder(path) + if not files: + # Return empty DataFrame if no files found + return pd.DataFrame() + return _get_df_from_s3_files(files) + + +def _put_df_to_s3_as_parquet_files( + df: pd.DataFrame, + s3_base_path: str, + batch_size: Optional[int] = None, + file_prefix: str = "data_part", +) -> int: + """Write DataFrame to S3 as parquet files in batches. + + This helper function handles the complete workflow of: + 1. Writing DataFrame to local parquet files in batches + 2. Uploading all files to S3 using put_files + 3. Cleaning up local temporary files + + :param df: DataFrame to write to S3 + :param s3_base_path: Base S3 path (without trailing slash). Files will be written as + {s3_base_path}/{file_prefix}_0.parquet, {file_prefix}_1.parquet, etc. + :param batch_size: Number of rows per parquet file. If None, writes entire DataFrame to single file. + :param file_prefix: Prefix for output files. Default "data_part" + :return: Number of parquet files created + """ + if not s3_base_path.startswith("s3://"): + raise ValueError("Invalid S3 URI. Must start with 's3://'.") + + # Create unique temporary directory + timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") + local_temp_dir = f"/tmp/s3_upload_{timestamp}" + os.makedirs(local_temp_dir, exist_ok=True) + + try: + key_paths = [] + + if batch_size is None: + # Write entire DataFrame to single file + batch_num = 0 + local_file_path = f"{local_temp_dir}/{file_prefix}_{batch_num}.parquet" + s3_file_path = f"{s3_base_path}/{file_prefix}_{batch_num}.parquet" + + df.to_parquet(local_file_path, index=False, engine="pyarrow") + key_paths.append([s3_file_path, local_file_path]) + else: + # Write DataFrame in batches + for i in range(0, len(df), batch_size): + batch_num = i // batch_size + local_file_path = f"{local_temp_dir}/{file_prefix}_{batch_num}.parquet" + s3_file_path = f"{s3_base_path}/{file_prefix}_{batch_num}.parquet" + + df.iloc[i : i + batch_size].to_parquet(local_file_path, index=False, engine="pyarrow") + key_paths.append([s3_file_path, local_file_path]) + + # Upload all files to S3 using put_files + with _get_metaflow_s3_client() as s3: + s3.put_files(key_paths=key_paths) + + num_files = len(key_paths) + + finally: + # Clean up local files + for _, local_path in key_paths: + if os.path.exists(local_path): + os.remove(local_path) + if os.path.exists(local_temp_dir): + os.rmdir(local_temp_dir) + + return num_files + + +def _generate_snowflake_to_s3_copy_query( + query: str, + snowflake_stage: str, + s3_folder_path: str, + file_name: str = "data.parquet", +) -> str: + """Generate SQL COPY INTO command to export Snowflake query results to S3. + + :param query: SQL query to execute + :param snowflake_stage: Snowflake stage name (e.g., 'DEV_OUTERBOUNDS_S3_STAGE') + :param s3_folder_path: Relative S3 folder path within the stage (e.g., 'temp/query_20260205_123456') + :param file_name: Output file name. Default 'data.parquet' + :return: COPY INTO SQL command + """ + copy_query = f""" + COPY INTO @{snowflake_stage}/{s3_folder_path}/{file_name} + FROM ({query}) + OVERWRITE = TRUE + FILE_FORMAT = (TYPE = 'parquet') + HEADER = TRUE; + """ + return copy_query + + +def _generate_s3_to_snowflake_copy_query( + database: str, + schema: str, + table_name: str, + snowflake_stage: str, + s3_folder_path: str, + table_schema: List[Tuple[str, str]], + overwrite: bool = True, + auto_create_table: bool = True, +) -> str: + """Generate SQL commands to load data from S3 to Snowflake table. + + This function generates a complete SQL script that includes: + 1. DROP TABLE IF EXISTS (if overwrite=True) + 2. CREATE TABLE IF NOT EXISTS (if auto_create_table=True or overwrite=True) + 3. COPY INTO command to load data from S3 + + :param database: Snowflake database name (e.g., 'PATTERN_DB') + :param schema: Snowflake schema name (e.g., 'DATA_SCIENCE' or 'DATA_SCIENCE_STAGE') + :param table_name: Target table name + :param snowflake_stage: Snowflake stage name (e.g., 'DEV_OUTERBOUNDS_S3_STAGE') + :param s3_folder_path: Relative S3 folder path within the stage + :param table_schema: List of tuples with column names and types + :param overwrite: If True, drop and recreate the table. Default True + :param auto_create_table: If True, create the table if it doesn't exist. Default True + :return: Complete SQL script with table management and COPY INTO commands + """ + sql_statements = [] + + # Step 1: Drop table if overwrite is True + if overwrite: + sql_statements.append(f"DROP TABLE IF EXISTS {database}.{schema}.{table_name};") + + # Step 2: Create table if auto_create_table or overwrite + if auto_create_table or overwrite: + table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_schema]) + create_table_query = f""" +CREATE TABLE IF NOT EXISTS {database}.{schema}.{table_name} ( + {table_create_columns_str} +);""" + sql_statements.append(create_table_query) + + # Step 3: Generate COPY INTO command + columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_schema]) + + copy_query = f""" +COPY INTO {database}.{schema}.{table_name} +FROM ( + SELECT {columns_str} + FROM @{snowflake_stage}/{s3_folder_path}/ +) +FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = TRUE);""" + sql_statements.append(copy_query) + + # Combine all statements + return "\n\n".join(sql_statements) + + +def query_pandas_from_snowflake_via_s3_stage( + query: Union[str, Path], + warehouse: Optional[TWarehouse] = None, + ctx: Optional[Dict[str, Any]] = None, + use_utc: bool = True, +) -> pd.DataFrame: + """Query Snowflake and return large result sets efficiently via S3 stage. + + This function is optimized for large query results (>10M rows). It uses Snowflake's + COPY INTO command to export query results to S3, then reads the parquet files from S3. + This is significantly faster than using cursor.fetch_pandas_all() for large datasets. + + The function automatically: + - Creates a timestamp-based temporary folder in S3 + - Exports query results to parquet files in S3 + - Reads and combines all parquet files into a single DataFrame + - Uses the appropriate S3 bucket/stage based on current.is_production + + :param query: SQL query string or path to a .sql file + :param warehouse: The Snowflake warehouse to use. Defaults to shared warehouse based on environment. + :param ctx: Context dictionary to substitute into the query string + :param use_utc: Whether to set the Snowflake session to use UTC time zone. Default is True. + :return: DataFrame containing the results of the query + + Example: + >>> df = query_pandas_from_snowflake_via_s3_stage( + ... query="SELECT * FROM LARGE_TABLE LIMIT 100000000", + ... warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH" + ... ) + + """ + from ds_platform_utils._snowflake.write_audit_publish import ( + get_query_from_string_or_fpath, + substitute_map_into_string, + ) + + # Determine environment + is_production = current.is_production if hasattr(current, "is_production") else False + s3_bucket, snowflake_stage = _get_s3_config(is_production) + schema = PROD_SCHEMA if is_production else NON_PROD_SCHEMA + + # Process query + query = get_query_from_string_or_fpath(query) + + # Add query tags for cost tracking + tags = get_select_dev_query_tags() + query_comment_str = f"\n\n/* {json.dumps(tags)} */" + query = add_comment_to_each_sql_statement(query, query_comment_str) + + # Handle schema substitution + if "{{schema}}" in query or "{{ schema }}" in query: + query = substitute_map_into_string(query, {"schema": schema}) + + # Handle additional context substitution + if ctx: + query = substitute_map_into_string(query, ctx) + + # Create timestamp-based temporary folder + timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") + temp_folder = f"temp/query_{timestamp}" + s3_path = f"{s3_bucket}/{temp_folder}/" + + # Build COPY INTO query to export results to S3 + copy_query = _generate_snowflake_to_s3_copy_query( + query=query, + snowflake_stage=snowflake_stage, + s3_folder_path=temp_folder, + file_name="data.parquet", + ) + + # Add to Metaflow card + environment = "PROD" if is_production else "DEV" + current.card.append(Markdown(f"## Querying Snowflake via S3 Stage ({environment})")) + if warehouse is not None: + current.card.append(Markdown(f"### Using Warehouse: `{warehouse}`")) + current.card.append(Markdown(f"### S3 Staging Path: `{s3_path}`")) + current.card.append(Markdown(f"### Query:\n```sql\n{query}\n```")) + + # Execute query + conn = get_snowflake_connection(use_utc) + + if warehouse is not None: + _execute_sql(conn, f"USE WAREHOUSE {warehouse};") + + # Set query tag for cost tracking + tags_json = json.dumps(tags) + _execute_sql(conn, f"ALTER SESSION SET QUERY_TAG = '{tags_json}';") + + # Copy data to S3 + _execute_sql(conn, copy_query) + conn.close() + + # Read data from S3 + df = _get_df_from_s3_folder(s3_path) + + # Lowercase column names for consistency + df.columns = df.columns.str.lower() + + # Add preview to card + current.card.append(Markdown("### Query Result Preview")) + current.card.append(Table.from_dataframe(df.head(10))) + current.card.append(Markdown(f"**Total rows:** {len(df):,}")) + + return df + + +def publish_pandas_via_s3_stage( # noqa: PLR0913 (too many arguments) + table_name: str, + df: pd.DataFrame, + table_schema: List[Tuple[str, str]], + batch_size: int = 100000, + warehouse: Optional[TWarehouse] = None, + overwrite: bool = True, + auto_create_table: bool = True, + use_utc: bool = True, +) -> None: + """Write large DataFrame to Snowflake table efficiently via S3 stage. + + This function is optimized for large DataFrames (>10M rows). It uploads the DataFrame + as parquet files to S3, then uses Snowflake's COPY INTO command to load the data. + This is significantly faster than using write_pandas() for large datasets. + + The function automatically: + - Chunks the DataFrame into batches and writes to S3 as parquet files + - Creates or overwrites the target table based on parameters + - Loads all parquet files from S3 into Snowflake + - Uses the appropriate S3 bucket/stage based on current.is_production + + :param table_name: Name of the Snowflake table to create/update + :param df: DataFrame to write to Snowflake + :param table_schema: List of tuples defining column names and types. + Example: [("col1", "VARCHAR(255)"), ("col2", "INTEGER")] + :param batch_size: Number of rows per parquet file. Default 100,000 + :param warehouse: The Snowflake warehouse to use. Defaults to shared warehouse based on environment. + :param overwrite: If True, drop and recreate the table. Default True + :param auto_create_table: If True, create the table if it doesn't exist. Default True + :param use_utc: Whether to set the Snowflake session to use UTC time zone. Default is True + + Example: + >>> schema = [ + ... ("asin", "VARCHAR(255)"), + ... ("date", "DATE"), + ... ("forecast", "FLOAT") + ... ] + >>> publish_pandas_via_s3_stage( + ... table_name="FORECAST_RESULTS", + ... df=large_df, + ... table_schema=schema, + ... warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" + ... ) + + """ + if not isinstance(df, pd.DataFrame): + raise TypeError("df must be a pandas DataFrame.") + + if df.empty: + raise ValueError("DataFrame is empty.") + + # Determine environment + is_production = current.is_production if hasattr(current, "is_production") else False + s3_bucket, snowflake_stage = _get_s3_config(is_production) + schema = PROD_SCHEMA if is_production else NON_PROD_SCHEMA + + table_name = table_name.upper() + + # Create timestamp-based temporary folder + timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") + temp_folder = f"temp/publish_{timestamp}" + s3_path = f"{s3_bucket}/{temp_folder}" + + # Add to Metaflow card + environment = "PROD" if is_production else "DEV" + current.card.append(Markdown(f"## Publishing DataFrame to Snowflake via S3 Stage ({environment})")) + if warehouse is not None: + current.card.append(Markdown(f"### Using Warehouse: `{warehouse}`")) + current.card.append(Markdown(f"### Target Table: `{schema}.{table_name}`")) + current.card.append(Markdown(f"### S3 Staging Path: `{s3_path}`")) + current.card.append(Markdown(f"### Rows: {len(df):,} | Columns: {len(df.columns)}")) + current.card.append(Table.from_dataframe(df.head())) + + # Upload DataFrame to S3 as parquet files + num_files = _put_df_to_s3_as_parquet_files( + df=df, + s3_base_path=s3_path, + batch_size=batch_size, + file_prefix="data_part", + ) + + current.card.append(Markdown(f"### Uploaded {num_files} parquet file(s) to S3")) + + # Connect to Snowflake + conn = get_snowflake_connection(use_utc) + + if warehouse is not None: + _execute_sql(conn, f"USE WAREHOUSE {warehouse};") + + # Set query tag for cost tracking + tags = get_select_dev_query_tags() + tags_json = json.dumps(tags) + _execute_sql(conn, f"ALTER SESSION SET QUERY_TAG = '{tags_json}';") + + # Generate and execute SQL to create table and load data from S3 + sql_commands = _generate_s3_to_snowflake_copy_query( + database="PATTERN_DB", + schema=schema, + table_name=table_name, + snowflake_stage=snowflake_stage, + s3_folder_path=temp_folder, + table_schema=table_schema, + overwrite=overwrite, + auto_create_table=auto_create_table, + ) + + current.card.append(Markdown("### Loading data from S3 to Snowflake...")) + + # Execute all SQL commands + _execute_sql(conn, sql_commands) + conn.close() + + # Add success message to card + from ds_platform_utils.metaflow.write_audit_publish import _make_snowflake_table_url + + table_url = _make_snowflake_table_url( + database="PATTERN_DB", + schema=schema, + table=table_name, + ) + current.card.append(Markdown(f"### ✅ Successfully published {len(df):,} rows")) + current.card.append(Markdown(f"[View table in Snowflake]({table_url})")) + + +def make_batch_predictions_from_snowflake_via_s3_stage( # noqa: PLR0913 (too many arguments) + input_query: Union[str, Path], + output_table_name: str, + output_table_schema: List[Tuple[str, str]], + model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], + warehouse: Optional[TWarehouse] = None, + ctx: Optional[Dict[str, Any]] = None, + use_utc: bool = True, +) -> None: + """Process large datasets through a model/function using S3 for efficient batch processing. + + This function implements an end-to-end pipeline for batch predictions: + 1. Query data from Snowflake → Export to S3 + 2. Read data from S3 file by file + 3. Process each file through the model_predictor_function + 4. Write predictions to S3 + 5. Load all predictions from S3 to Snowflake table + + This approach is memory-efficient for very large datasets as it processes data file by file + rather than loading everything into memory at once. + + :param input_query: SQL query to fetch input data from Snowflake + :param output_table_name: Name of the Snowflake table to write predictions to + :param output_table_schema: Schema for the output table. + Example: [("col1", "VARCHAR(255)"), ("col2", "FLOAT")] + :param model_predictor_function: Function that takes a DataFrame and returns a DataFrame of predictions. + Signature: fn(df: pd.DataFrame) -> pd.DataFrame + :param warehouse: The Snowflake warehouse to use. Defaults to shared warehouse based on environment. + :param ctx: Context dictionary to substitute into the input query + :param use_utc: Whether to set the Snowflake session to use UTC time zone. Default is True + + Example: + >>> def predict_fn(input_df: pd.DataFrame) -> pd.DataFrame: + ... # Your model prediction logic here + ... predictions = model.predict(input_df) + ... return pd.DataFrame({"asin": input_df["asin"], "forecast": predictions}) + ... + >>> output_schema = [("asin", "VARCHAR(255)"), ("forecast", "FLOAT")] + >>> make_batch_predictions_from_snowflake_via_s3_stage( + ... input_query="SELECT * FROM INPUT_TABLE", + ... output_table_name="PREDICTIONS", + ... output_table_schema=output_schema, + ... model_predictor_function=predict_fn + ... ) + + """ + from ds_platform_utils._snowflake.write_audit_publish import ( + get_query_from_string_or_fpath, + substitute_map_into_string, + ) + + # Determine environment + is_production = current.is_production if hasattr(current, "is_production") else False + s3_bucket, snowflake_stage = _get_s3_config(is_production) + schema = PROD_SCHEMA if is_production else NON_PROD_SCHEMA + + output_table_name = output_table_name.upper() + + # Process input query + query = get_query_from_string_or_fpath(input_query) + + # Handle schema substitution + if "{{schema}}" in query or "{{ schema }}" in query: + query = substitute_map_into_string(query, {"schema": schema}) + + # Handle additional context substitution + if ctx: + query = substitute_map_into_string(query, ctx) + + # Create timestamps for input and output folders + input_timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") + output_timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") + input_temp_folder = f"temp/batch_input_{input_timestamp}" + output_temp_folder = f"temp/batch_output_{output_timestamp}" + input_s3_path = f"{s3_bucket}/{input_temp_folder}/" + output_s3_path = f"{s3_bucket}/{output_temp_folder}/" + + # Add to Metaflow card + environment = "PROD" if is_production else "DEV" + current.card.append(Markdown(f"## Batch Predictions Pipeline via S3 Stage ({environment})")) + if warehouse is not None: + current.card.append(Markdown(f"### Using Warehouse: `{warehouse}`")) + current.card.append(Markdown(f"### Output Table: `{schema}.{output_table_name}`")) + current.card.append(Markdown(f"### Input Query:\n```sql\n{query}\n```")) + + # Step 1: Export input data from Snowflake to S3 + current.card.append(Markdown("### Step 1: Exporting input data from Snowflake to S3...")) + + # Add query tags for cost tracking + tags = get_select_dev_query_tags() + query_comment_str = f"\n\n/* {json.dumps(tags)} */" + query_with_tags = add_comment_to_each_sql_statement(query, query_comment_str) + + # Build COPY INTO query to export data from Snowflake to S3 + copy_to_s3_query = _generate_snowflake_to_s3_copy_query( + query=query_with_tags, + snowflake_stage=snowflake_stage, + s3_folder_path=input_temp_folder, + file_name="data.parquet", + ) + + conn = get_snowflake_connection(use_utc) + + if warehouse is not None: + _execute_sql(conn, f"USE WAREHOUSE {warehouse};") + + # Set query tag for cost tracking + tags_json = json.dumps(tags) + _execute_sql(conn, f"ALTER SESSION SET QUERY_TAG = '{tags_json}';") + + _execute_sql(conn, copy_to_s3_query) + + # Step 2: Get list of input files from S3 + input_files = _list_files_in_s3_folder(input_s3_path) + + if not input_files: + raise ValueError(f"No input files found in S3 path: {input_s3_path}") + + current.card.append(Markdown(f"### Step 2: Processing {len(input_files)} file(s)...")) + + # Step 3: Process each file through the model and write predictions to S3 + total_predictions = 0 + for file_idx, input_file in enumerate(input_files): + current.card.append(Markdown(f"#### Processing file {file_idx + 1}/{len(input_files)}...")) + + # Read single file + input_df = _get_df_from_s3_files([input_file]) + + # Run predictions + predictions_df = model_predictor_function(input_df) + + # Write predictions to S3 + _put_df_to_s3_as_parquet_files( + df=predictions_df, + s3_base_path=output_s3_path.rstrip("/"), + batch_size=None, # Write each prediction result as single file + file_prefix=f"predictions_part_{file_idx}", + ) + + total_predictions += len(predictions_df) + current.card.append( + Markdown(f" - Processed {len(input_df):,} rows → Generated {len(predictions_df):,} predictions") + ) + + current.card.append(Markdown(f"### Step 3: Total predictions generated: {total_predictions:,}")) + + # Step 4: Create output table and load predictions from S3 to Snowflake + current.card.append(Markdown("### Step 4: Creating table and loading predictions from S3 to Snowflake...")) + + # Generate and execute SQL to create table and load data from S3 + sql_commands = _generate_s3_to_snowflake_copy_query( + database="PATTERN_DB", + schema=schema, + table_name=output_table_name, + snowflake_stage=snowflake_stage, + s3_folder_path=output_temp_folder, + table_schema=output_table_schema, + overwrite=False, # Don't overwrite for batch predictions + auto_create_table=True, # Create table if it doesn't exist + ) + + _execute_sql(conn, sql_commands) + conn.close() + + # Add success message to card + from ds_platform_utils.metaflow.write_audit_publish import _make_snowflake_table_url + + table_url = _make_snowflake_table_url( + database="PATTERN_DB", + schema=schema, + table=output_table_name, + ) + current.card.append(Markdown("### ✅ Successfully completed batch predictions")) + current.card.append(Markdown(f"**Total predictions:** {total_predictions:,}")) + current.card.append(Markdown(f"[View results in Snowflake]({table_url})")) diff --git a/src/ds_platform_utils/metaflow_s3/utils.py b/src/ds_platform_utils/metaflow_s3/utils.py new file mode 100644 index 0000000..f1856ca --- /dev/null +++ b/src/ds_platform_utils/metaflow_s3/utils.py @@ -0,0 +1,80 @@ +import pandas as pd +from metaflow import S3 + + +def get_metaflow_s3_client(): + return S3(role="arn:aws:iam::209479263910:role/outerbounds_iam_role") + + +def list_files_in_s3_folder(path: str) -> list: + if not path.startswith("s3://"): + raise ValueError("Invalid S3 URI. Must start with 's3://'.") + + with get_metaflow_s3_client() as s3: + return [path.url for path in s3.list_paths([path])] + + +def get_df_from_s3_file(path: str) -> pd.DataFrame: + if not path.startswith("s3://"): + raise ValueError("Invalid S3 URI. Must start with 's3://'.") + + with get_metaflow_s3_client() as s3: + return pd.read_parquet(s3.get(path).path) + + +def get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: + if any(not path.startswith("s3://") for path in paths): + raise ValueError("Invalid S3 URI. All paths must start with 's3://'.") + + with get_metaflow_s3_client() as s3: + df_paths = [obj.path for obj in s3.get_many(paths)] + return pd.read_parquet(df_paths) + + +def get_df_from_s3_folder(path: str) -> pd.DataFrame: + if not path.startswith("s3://"): + raise ValueError("Invalid S3 URI. Must start with 's3://'.") + + files = list_files_in_s3_folder(path) + return get_df_from_s3_files(files) + + +def put_df_to_s3_file(df: pd.DataFrame, path: str) -> None: + if not path.startswith("s3://"): + raise ValueError("Invalid S3 URI. Must start with 's3://'.") + + with get_metaflow_s3_client() as s3: + local_path = "/tmp/temp_parquet_file.parquet" + df.to_parquet(local_path) + s3.put_files(key_paths=[[path, local_path]]) + + +def put_df_to_s3_folder(df: pd.DataFrame, path: str, chunk_size=None) -> None: + if not path.startswith("s3://"): + raise ValueError("Invalid S3 URI. Must start with 's3://'.") + + if not path.endswith("/"): + path += "/" + + target_chunk_size_mb = 50 + target_chunk_size_bytes = target_chunk_size_mb * 1024 * 1024 + + def estimate_bytes_per_row(df_sample): + return df_sample.memory_usage(deep=True).sum() / len(df_sample) + + if chunk_size is None: + sample = df.head(10000) + bytes_per_row = estimate_bytes_per_row(sample) + chunk_size = int(target_chunk_size_bytes / bytes_per_row) + chunk_size = max(1, chunk_size) + + with get_metaflow_s3_client() as s3: + local_path_template = "/tmp/temp_parquet_file_part_{}.parquet" + key_paths = [] + num_rows = df.shape[0] + for i in range(0, num_rows, chunk_size): + local_path = local_path_template.format(i // chunk_size) + df.iloc[i : i + chunk_size].to_parquet(local_path) + s3_path = f"{path}part_{i // chunk_size}.parquet" + key_paths.append([s3_path, local_path]) + s3.put_files(key_paths=key_paths) diff --git a/tests/functional_tests/metaflow/test_pandas_via_s3_stage.py b/tests/functional_tests/metaflow/test_pandas_via_s3_stage.py new file mode 100644 index 0000000..778c1ca --- /dev/null +++ b/tests/functional_tests/metaflow/test_pandas_via_s3_stage.py @@ -0,0 +1,287 @@ +"""Functional tests for Snowflake S3 stage operations. + +These tests verify the S3-Snowflake bridge functionality for large-scale data operations. +""" + +from unittest.mock import MagicMock, Mock, patch + +import pandas as pd +import pytest + +from ds_platform_utils.metaflow.pandas_via_s3_stage import ( + DEV_S3_BUCKET, + DEV_SNOWFLAKE_STAGE, + PROD_S3_BUCKET, + PROD_SNOWFLAKE_STAGE, + _get_s3_config, +) + + +class TestS3Config: + """Test S3 configuration selection based on environment.""" + + def test_get_s3_config_dev(self): + """Test that dev configuration is returned when is_production=False.""" + bucket, stage = _get_s3_config(is_production=False) + assert bucket == DEV_S3_BUCKET + assert stage == DEV_SNOWFLAKE_STAGE + + def test_get_s3_config_prod(self): + """Test that prod configuration is returned when is_production=True.""" + bucket, stage = _get_s3_config(is_production=True) + assert bucket == PROD_S3_BUCKET + assert stage == PROD_SNOWFLAKE_STAGE + + +class TestQueryPandasFromSnowflakeViaS3Stage: + """Test query_pandas_from_snowflake_via_s3_stage function.""" + + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.get_snowflake_connection") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._execute_sql") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._get_df_from_s3_folder") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.current") + def test_query_via_s3_stage_basic(self, mock_current, mock_get_df, mock_execute_sql, mock_get_conn): + """Test basic query execution via S3 stage.""" + from ds_platform_utils.metaflow.pandas_via_s3_stage import ( + query_pandas_from_snowflake_via_s3_stage, + ) + + # Setup mocks + mock_current.is_production = False + mock_current.card = MagicMock() + + mock_conn = MagicMock() + mock_get_conn.return_value = mock_conn + + expected_df = pd.DataFrame({"col1": [1, 2, 3], "col2": ["a", "b", "c"]}) + mock_get_df.return_value = expected_df + + # Execute + query = "SELECT * FROM TEST_TABLE" + result_df = query_pandas_from_snowflake_via_s3_stage(query=query) + + # Verify + assert isinstance(result_df, pd.DataFrame) + assert len(result_df) == 3 + assert list(result_df.columns) == ["col1", "col2"] + + # Verify connection was closed + mock_conn.close.assert_called_once() + + # Verify COPY INTO was executed + assert mock_execute_sql.called + + +class TestPublishPandasViaS3Stage: + """Test publish_pandas_via_s3_stage function.""" + + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.get_snowflake_connection") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._execute_sql") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._get_metaflow_s3_client") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.current") + @patch("os.makedirs") + @patch("os.remove") + @patch("os.rmdir") + def test_publish_via_s3_stage_basic( + self, + mock_rmdir, + mock_remove, + mock_makedirs, + mock_current, + mock_s3_client, + mock_execute_sql, + mock_get_conn, + ): + """Test basic DataFrame publishing via S3 stage.""" + from ds_platform_utils.metaflow.pandas_via_s3_stage import ( + publish_pandas_via_s3_stage, + ) + + # Setup mocks + mock_current.is_production = False + mock_current.card = MagicMock() + + mock_conn = MagicMock() + mock_get_conn.return_value = mock_conn + + mock_s3 = MagicMock() + mock_s3_client.return_value.__enter__ = Mock(return_value=mock_s3) + mock_s3_client.return_value.__exit__ = Mock(return_value=False) + + # Create test DataFrame + df = pd.DataFrame( + { + "col1": [1, 2, 3], + "col2": ["a", "b", "c"], + } + ) + + table_schema = [ + ("col1", "INTEGER"), + ("col2", "VARCHAR(255)"), + ] + + # Execute + publish_pandas_via_s3_stage( + table_name="TEST_TABLE", + df=df, + table_schema=table_schema, + batch_size=10, + ) + + # Verify + # Connection was closed + mock_conn.close.assert_called_once() + + # S3 put_files was called + assert mock_s3.put_files.called + + # SQL was executed (table creation and COPY INTO) + assert mock_execute_sql.call_count >= 2 + + def test_publish_via_s3_stage_empty_dataframe(self): + """Test that publishing an empty DataFrame raises ValueError.""" + from ds_platform_utils.metaflow.pandas_via_s3_stage import ( + publish_pandas_via_s3_stage, + ) + + df = pd.DataFrame() + table_schema = [("col1", "INTEGER")] + + with pytest.raises(ValueError, match="DataFrame is empty"): + publish_pandas_via_s3_stage( + table_name="TEST_TABLE", + df=df, + table_schema=table_schema, + ) + + def test_publish_via_s3_stage_invalid_type(self): + """Test that publishing non-DataFrame raises TypeError.""" + from ds_platform_utils.metaflow.pandas_via_s3_stage import ( + publish_pandas_via_s3_stage, + ) + + table_schema = [("col1", "INTEGER")] + + with pytest.raises(TypeError, match="df must be a pandas DataFrame"): + publish_pandas_via_s3_stage( + table_name="TEST_TABLE", + df="not a dataframe", + table_schema=table_schema, + ) + + +class TestMakeBatchPredictionsFromSnowflakeViaS3Stage: + """Test make_batch_predictions_from_snowflake_via_s3_stage function.""" + + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.get_snowflake_connection") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._execute_sql") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._list_files_in_s3_folder") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._get_df_from_s3_files") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._get_metaflow_s3_client") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.current") + @patch("os.remove") + def test_batch_predictions_basic( + self, + mock_remove, + mock_current, + mock_s3_client, + mock_get_df_files, + mock_list_files, + mock_execute_sql, + mock_get_conn, + ): + """Test basic batch predictions pipeline.""" + from ds_platform_utils.metaflow.pandas_via_s3_stage import ( + make_batch_predictions_from_snowflake_via_s3_stage, + ) + + # Setup mocks + mock_current.is_production = False + mock_current.card = MagicMock() + + mock_conn = MagicMock() + mock_get_conn.return_value = mock_conn + + mock_s3 = MagicMock() + mock_s3_client.return_value.__enter__ = Mock(return_value=mock_s3) + mock_s3_client.return_value.__exit__ = Mock(return_value=False) + + # Mock S3 file listing + mock_list_files.return_value = [ + "s3://bucket/path/file1.parquet", + "s3://bucket/path/file2.parquet", + ] + + # Mock reading from S3 + input_df = pd.DataFrame({"input_col": [1, 2, 3]}) + mock_get_df_files.return_value = input_df + + # Define predictor function + def predictor(df: pd.DataFrame) -> pd.DataFrame: + return pd.DataFrame( + { + "prediction": df["input_col"] * 2, + } + ) + + output_schema = [("prediction", "INTEGER")] + + # Execute + make_batch_predictions_from_snowflake_via_s3_stage( + input_query="SELECT * FROM INPUT_TABLE", + output_table_name="OUTPUT_TABLE", + output_table_schema=output_schema, + model_predictor_function=predictor, + ) + + # Verify + # Connection was closed + mock_conn.close.assert_called_once() + + # Predictor was called for each file (2 times) + # S3 put_files was called for predictions + assert mock_s3.put_files.called + + # SQL was executed (COPY INTO for input, table creation, COPY INTO for output) + assert mock_execute_sql.call_count >= 3 + + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.get_snowflake_connection") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._execute_sql") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._list_files_in_s3_folder") + @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.current") + def test_batch_predictions_no_files_raises_error( + self, + mock_current, + mock_list_files, + mock_execute_sql, + mock_get_conn, + ): + """Test that no input files raises ValueError.""" + from ds_platform_utils.metaflow.pandas_via_s3_stage import ( + make_batch_predictions_from_snowflake_via_s3_stage, + ) + + # Setup mocks + mock_current.is_production = False + mock_current.card = MagicMock() + + mock_conn = MagicMock() + mock_get_conn.return_value = mock_conn + + # Mock empty file list + mock_list_files.return_value = [] + + def predictor(df: pd.DataFrame) -> pd.DataFrame: + return df + + output_schema = [("col1", "INTEGER")] + + # Execute and verify error + with pytest.raises(ValueError, match="No input files found"): + make_batch_predictions_from_snowflake_via_s3_stage( + input_query="SELECT * FROM INPUT_TABLE", + output_table_name="OUTPUT_TABLE", + output_table_schema=output_schema, + model_predictor_function=predictor, + ) From 2afeb6e3c4e8d64b9ae1f49d8bbcb4c172f4ee1d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:06:50 +0530 Subject: [PATCH 002/167] feat: implement S3 stage operations for Snowflake integration and add functional tests --- src/ds_platform_utils/metaflow/_consts.py | 10 + .../metaflow/pandas_via_s3_stage.py | 231 +++--------- .../{metaflow_s3/utils.py => metaflow/s3.py} | 34 +- .../metaflow/test_pandas_via_s3_stage.py | 352 +++++------------- 4 files changed, 178 insertions(+), 449 deletions(-) rename src/ds_platform_utils/{metaflow_s3/utils.py => metaflow/s3.py} (69%) diff --git a/src/ds_platform_utils/metaflow/_consts.py b/src/ds_platform_utils/metaflow/_consts.py index 181b0b4..ebe47d5 100644 --- a/src/ds_platform_utils/metaflow/_consts.py +++ b/src/ds_platform_utils/metaflow/_consts.py @@ -1,3 +1,13 @@ PROD_SCHEMA = "DATA_SCIENCE" NON_PROD_SCHEMA = "DATA_SCIENCE_STAGE" SNOWFLAKE_INTEGRATION = "snowflake-default" + + +# S3 Stage Configuration +# Dev environment +DEV_S3_BUCKET = "s3://dev-outerbounds-snowflake-stage" +DEV_SNOWFLAKE_STAGE = "DEV_OUTERBOUNDS_S3_STAGE" + +# Prod environment +PROD_S3_BUCKET = "s3://prod-outerbounds-snowflake-stage" +PROD_SNOWFLAKE_STAGE = "PROD_OUTERBOUNDS_S3_STAGE" diff --git a/src/ds_platform_utils/metaflow/pandas_via_s3_stage.py b/src/ds_platform_utils/metaflow/pandas_via_s3_stage.py index 08265ec..4e85ded 100644 --- a/src/ds_platform_utils/metaflow/pandas_via_s3_stage.py +++ b/src/ds_platform_utils/metaflow/pandas_via_s3_stage.py @@ -17,160 +17,46 @@ """ import json -import os from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple, Union import pandas as pd -from metaflow import S3, current +from metaflow import current from metaflow.cards import Markdown, Table from ds_platform_utils._snowflake.run_query import _execute_sql -from ds_platform_utils.metaflow._consts import NON_PROD_SCHEMA, PROD_SCHEMA -from ds_platform_utils.metaflow.get_snowflake_connection import get_snowflake_connection +from ds_platform_utils.metaflow._consts import ( + DEV_S3_BUCKET, + DEV_SNOWFLAKE_STAGE, + NON_PROD_SCHEMA, + PROD_S3_BUCKET, + PROD_SCHEMA, + PROD_SNOWFLAKE_STAGE, +) +from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query, get_snowflake_connection from ds_platform_utils.metaflow.pandas import TWarehouse +from ds_platform_utils.metaflow.s3 import ( + _get_df_from_s3_files, + _get_df_from_s3_folder, + _list_files_in_s3_folder, + _put_df_to_s3_folder, +) from ds_platform_utils.metaflow.write_audit_publish import ( add_comment_to_each_sql_statement, get_select_dev_query_tags, ) -# S3 Stage Configuration -# Dev environment -DEV_S3_BUCKET = "s3://dev-outerbounds-snowflake-stage" -DEV_SNOWFLAKE_STAGE = "DEV_OUTERBOUNDS_S3_STAGE" - -# Prod environment -PROD_S3_BUCKET = "s3://prod-outerbounds-snowflake-stage" -PROD_SNOWFLAKE_STAGE = "PROD_OUTERBOUNDS_S3_STAGE" - -# IAM Role for S3 access (same for both environments) -S3_IAM_ROLE = "arn:aws:iam::209479263910:role/outerbounds_iam_role" - - -def _get_metaflow_s3_client() -> S3: - """Get Metaflow S3 client with configured IAM role.""" - return S3(role=S3_IAM_ROLE) - - -def _get_s3_config(is_production: bool = False) -> Tuple[str, str]: - """Get S3 bucket and Snowflake stage name based on environment. - :param is_production: If True, use production S3 bucket and stage. - If False, use dev S3 bucket and stage. - :return: Tuple of (s3_bucket_path, snowflake_stage_name) - """ +def _get_s3_config(is_production: bool) -> Tuple[str, str]: + """Return the appropriate S3 bucket and Snowflake stage based on the environment.""" if is_production: - return PROD_S3_BUCKET, PROD_SNOWFLAKE_STAGE - return DEV_S3_BUCKET, DEV_SNOWFLAKE_STAGE - - -def _list_files_in_s3_folder(path: str) -> List[str]: - """List all files in an S3 folder. - - :param path: S3 URI path (must start with 's3://') - :return: List of file URLs - """ - if not path.startswith("s3://"): - raise ValueError("Invalid S3 URI. Must start with 's3://'.") - - with _get_metaflow_s3_client() as s3: - return [file_path.url for file_path in s3.list_paths([path])] - - -def _get_df_from_s3_files(paths: List[str]) -> pd.DataFrame: - """Read multiple parquet files from S3 and return a single DataFrame. - - :param paths: List of S3 URIs to parquet files - :return: Combined DataFrame - """ - if any(not path.startswith("s3://") for path in paths): - raise ValueError("Invalid S3 URI. All paths must start with 's3://'.") - - with _get_metaflow_s3_client() as s3: - df_paths = [obj.path for obj in s3.get_many(paths)] - return pd.read_parquet(df_paths) - - -def _get_df_from_s3_folder(path: str) -> pd.DataFrame: - """Read all parquet files from an S3 folder and return a single DataFrame. - - :param path: S3 URI folder path - :return: Combined DataFrame - """ - if not path.startswith("s3://"): - raise ValueError("Invalid S3 URI. Must start with 's3://'.") - - files = _list_files_in_s3_folder(path) - if not files: - # Return empty DataFrame if no files found - return pd.DataFrame() - return _get_df_from_s3_files(files) - - -def _put_df_to_s3_as_parquet_files( - df: pd.DataFrame, - s3_base_path: str, - batch_size: Optional[int] = None, - file_prefix: str = "data_part", -) -> int: - """Write DataFrame to S3 as parquet files in batches. - - This helper function handles the complete workflow of: - 1. Writing DataFrame to local parquet files in batches - 2. Uploading all files to S3 using put_files - 3. Cleaning up local temporary files - - :param df: DataFrame to write to S3 - :param s3_base_path: Base S3 path (without trailing slash). Files will be written as - {s3_base_path}/{file_prefix}_0.parquet, {file_prefix}_1.parquet, etc. - :param batch_size: Number of rows per parquet file. If None, writes entire DataFrame to single file. - :param file_prefix: Prefix for output files. Default "data_part" - :return: Number of parquet files created - """ - if not s3_base_path.startswith("s3://"): - raise ValueError("Invalid S3 URI. Must start with 's3://'.") - - # Create unique temporary directory - timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") - local_temp_dir = f"/tmp/s3_upload_{timestamp}" - os.makedirs(local_temp_dir, exist_ok=True) - - try: - key_paths = [] - - if batch_size is None: - # Write entire DataFrame to single file - batch_num = 0 - local_file_path = f"{local_temp_dir}/{file_prefix}_{batch_num}.parquet" - s3_file_path = f"{s3_base_path}/{file_prefix}_{batch_num}.parquet" + s3_bucket = PROD_S3_BUCKET + snowflake_stage = PROD_SNOWFLAKE_STAGE + else: + s3_bucket = DEV_S3_BUCKET + snowflake_stage = DEV_SNOWFLAKE_STAGE - df.to_parquet(local_file_path, index=False, engine="pyarrow") - key_paths.append([s3_file_path, local_file_path]) - else: - # Write DataFrame in batches - for i in range(0, len(df), batch_size): - batch_num = i // batch_size - local_file_path = f"{local_temp_dir}/{file_prefix}_{batch_num}.parquet" - s3_file_path = f"{s3_base_path}/{file_prefix}_{batch_num}.parquet" - - df.iloc[i : i + batch_size].to_parquet(local_file_path, index=False, engine="pyarrow") - key_paths.append([s3_file_path, local_file_path]) - - # Upload all files to S3 using put_files - with _get_metaflow_s3_client() as s3: - s3.put_files(key_paths=key_paths) - - num_files = len(key_paths) - - finally: - # Clean up local files - for _, local_path in key_paths: - if os.path.exists(local_path): - os.remove(local_path) - if os.path.exists(local_temp_dir): - os.rmdir(local_temp_dir) - - return num_files + return s3_bucket, snowflake_stage def _generate_snowflake_to_s3_copy_query( @@ -233,22 +119,18 @@ def _generate_s3_to_snowflake_copy_query( # Step 2: Create table if auto_create_table or overwrite if auto_create_table or overwrite: table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_schema]) - create_table_query = f""" -CREATE TABLE IF NOT EXISTS {database}.{schema}.{table_name} ( - {table_create_columns_str} -);""" + create_table_query = ( + f"""CREATE TABLE IF NOT EXISTS {database}.{schema}.{table_name} ( {table_create_columns_str} );""" + ) sql_statements.append(create_table_query) # Step 3: Generate COPY INTO command columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_schema]) - copy_query = f""" -COPY INTO {database}.{schema}.{table_name} -FROM ( - SELECT {columns_str} - FROM @{snowflake_stage}/{s3_folder_path}/ -) -FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = TRUE);""" + copy_query = f"""COPY INTO {database}.{schema}.{table_name} FROM ( + SELECT {columns_str} + FROM @{snowflake_stage}/{s3_folder_path}/ ) + FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = TRUE);""" sql_statements.append(copy_query) # Combine all statements @@ -291,27 +173,24 @@ def query_pandas_from_snowflake_via_s3_stage( substitute_map_into_string, ) - # Determine environment - is_production = current.is_production if hasattr(current, "is_production") else False - s3_bucket, snowflake_stage = _get_s3_config(is_production) - schema = PROD_SCHEMA if is_production else NON_PROD_SCHEMA - - # Process query - query = get_query_from_string_or_fpath(query) - - # Add query tags for cost tracking + # adding query tags comment in query for cost tracking in select.dev tags = get_select_dev_query_tags() query_comment_str = f"\n\n/* {json.dumps(tags)} */" + query = get_query_from_string_or_fpath(query) query = add_comment_to_each_sql_statement(query, query_comment_str) - # Handle schema substitution + schema = PROD_SCHEMA if current.is_production else NON_PROD_SCHEMA if "{{schema}}" in query or "{{ schema }}" in query: query = substitute_map_into_string(query, {"schema": schema}) - - # Handle additional context substitution if ctx: query = substitute_map_into_string(query, ctx) + # print query if DEBUG_QUERY env var is set + _debug_print_query(query) + + # Determine environment + s3_bucket, snowflake_stage = _get_s3_config(current.is_production if hasattr(current, "is_production") else False) + # Create timestamp-based temporary folder timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") temp_folder = f"temp/query_{timestamp}" @@ -326,8 +205,6 @@ def query_pandas_from_snowflake_via_s3_stage( ) # Add to Metaflow card - environment = "PROD" if is_production else "DEV" - current.card.append(Markdown(f"## Querying Snowflake via S3 Stage ({environment})")) if warehouse is not None: current.card.append(Markdown(f"### Using Warehouse: `{warehouse}`")) current.card.append(Markdown(f"### S3 Staging Path: `{s3_path}`")) @@ -339,9 +216,7 @@ def query_pandas_from_snowflake_via_s3_stage( if warehouse is not None: _execute_sql(conn, f"USE WAREHOUSE {warehouse};") - # Set query tag for cost tracking - tags_json = json.dumps(tags) - _execute_sql(conn, f"ALTER SESSION SET QUERY_TAG = '{tags_json}';") + _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") # Copy data to S3 _execute_sql(conn, copy_query) @@ -365,7 +240,7 @@ def publish_pandas_via_s3_stage( # noqa: PLR0913 (too many arguments) table_name: str, df: pd.DataFrame, table_schema: List[Tuple[str, str]], - batch_size: int = 100000, + chunk_size: int = 100000, warehouse: Optional[TWarehouse] = None, overwrite: bool = True, auto_create_table: bool = True, @@ -387,7 +262,7 @@ def publish_pandas_via_s3_stage( # noqa: PLR0913 (too many arguments) :param df: DataFrame to write to Snowflake :param table_schema: List of tuples defining column names and types. Example: [("col1", "VARCHAR(255)"), ("col2", "INTEGER")] - :param batch_size: Number of rows per parquet file. Default 100,000 + :param chunk_size: Number of rows per parquet file. Default 100,000 :param warehouse: The Snowflake warehouse to use. Defaults to shared warehouse based on environment. :param overwrite: If True, drop and recreate the table. Default True :param auto_create_table: If True, create the table if it doesn't exist. Default True @@ -422,8 +297,8 @@ def publish_pandas_via_s3_stage( # noqa: PLR0913 (too many arguments) # Create timestamp-based temporary folder timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") - temp_folder = f"temp/publish_{timestamp}" - s3_path = f"{s3_bucket}/{temp_folder}" + upload_folder = f"temp/publish_{timestamp}" + s3_path = f"{s3_bucket}/{upload_folder}" # Add to Metaflow card environment = "PROD" if is_production else "DEV" @@ -436,14 +311,13 @@ def publish_pandas_via_s3_stage( # noqa: PLR0913 (too many arguments) current.card.append(Table.from_dataframe(df.head())) # Upload DataFrame to S3 as parquet files - num_files = _put_df_to_s3_as_parquet_files( + _put_df_to_s3_folder( df=df, - s3_base_path=s3_path, - batch_size=batch_size, - file_prefix="data_part", + path=s3_path, + chunk_size=chunk_size, ) - current.card.append(Markdown(f"### Uploaded {num_files} parquet file(s) to S3")) + current.card.append(Markdown("### Uploaded parquet files to S3")) # Connect to Snowflake conn = get_snowflake_connection(use_utc) @@ -451,10 +325,7 @@ def publish_pandas_via_s3_stage( # noqa: PLR0913 (too many arguments) if warehouse is not None: _execute_sql(conn, f"USE WAREHOUSE {warehouse};") - # Set query tag for cost tracking - tags = get_select_dev_query_tags() - tags_json = json.dumps(tags) - _execute_sql(conn, f"ALTER SESSION SET QUERY_TAG = '{tags_json}';") + _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") # Generate and execute SQL to create table and load data from S3 sql_commands = _generate_s3_to_snowflake_copy_query( @@ -462,7 +333,7 @@ def publish_pandas_via_s3_stage( # noqa: PLR0913 (too many arguments) schema=schema, table_name=table_name, snowflake_stage=snowflake_stage, - s3_folder_path=temp_folder, + s3_folder_path=upload_folder, table_schema=table_schema, overwrite=overwrite, auto_create_table=auto_create_table, diff --git a/src/ds_platform_utils/metaflow_s3/utils.py b/src/ds_platform_utils/metaflow/s3.py similarity index 69% rename from src/ds_platform_utils/metaflow_s3/utils.py rename to src/ds_platform_utils/metaflow/s3.py index f1856ca..4969a83 100644 --- a/src/ds_platform_utils/metaflow_s3/utils.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -2,59 +2,59 @@ from metaflow import S3 -def get_metaflow_s3_client(): +def _get_metaflow_s3_client(): return S3(role="arn:aws:iam::209479263910:role/outerbounds_iam_role") -def list_files_in_s3_folder(path: str) -> list: +def _list_files_in_s3_folder(path: str) -> list: if not path.startswith("s3://"): raise ValueError("Invalid S3 URI. Must start with 's3://'.") - with get_metaflow_s3_client() as s3: + with _get_metaflow_s3_client() as s3: return [path.url for path in s3.list_paths([path])] -def get_df_from_s3_file(path: str) -> pd.DataFrame: +def _get_df_from_s3_file(path: str) -> pd.DataFrame: if not path.startswith("s3://"): raise ValueError("Invalid S3 URI. Must start with 's3://'.") - with get_metaflow_s3_client() as s3: + with _get_metaflow_s3_client() as s3: return pd.read_parquet(s3.get(path).path) -def get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: +def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: if any(not path.startswith("s3://") for path in paths): raise ValueError("Invalid S3 URI. All paths must start with 's3://'.") - with get_metaflow_s3_client() as s3: + with _get_metaflow_s3_client() as s3: df_paths = [obj.path for obj in s3.get_many(paths)] return pd.read_parquet(df_paths) -def get_df_from_s3_folder(path: str) -> pd.DataFrame: +def _get_df_from_s3_folder(path: str) -> pd.DataFrame: if not path.startswith("s3://"): raise ValueError("Invalid S3 URI. Must start with 's3://'.") - files = list_files_in_s3_folder(path) - return get_df_from_s3_files(files) + files = _list_files_in_s3_folder(path) + return _get_df_from_s3_files(files) -def put_df_to_s3_file(df: pd.DataFrame, path: str) -> None: +def _put_df_to_s3_file(df: pd.DataFrame, path: str) -> None: if not path.startswith("s3://"): raise ValueError("Invalid S3 URI. Must start with 's3://'.") - with get_metaflow_s3_client() as s3: + with _get_metaflow_s3_client() as s3: local_path = "/tmp/temp_parquet_file.parquet" df.to_parquet(local_path) s3.put_files(key_paths=[[path, local_path]]) -def put_df_to_s3_folder(df: pd.DataFrame, path: str, chunk_size=None) -> None: +def _put_df_to_s3_folder(df: pd.DataFrame, path: str, chunk_size=None) -> None: if not path.startswith("s3://"): raise ValueError("Invalid S3 URI. Must start with 's3://'.") if not path.endswith("/"): - path += "/" + path = path.removesuffix("/") target_chunk_size_mb = 50 target_chunk_size_bytes = target_chunk_size_mb * 1024 * 1024 @@ -68,13 +68,13 @@ def estimate_bytes_per_row(df_sample): chunk_size = int(target_chunk_size_bytes / bytes_per_row) chunk_size = max(1, chunk_size) - with get_metaflow_s3_client() as s3: - local_path_template = "/tmp/temp_parquet_file_part_{}.parquet" + with _get_metaflow_s3_client() as s3: + local_path_template = "/tmp/data_part_{}.parquet" key_paths = [] num_rows = df.shape[0] for i in range(0, num_rows, chunk_size): local_path = local_path_template.format(i // chunk_size) df.iloc[i : i + chunk_size].to_parquet(local_path) - s3_path = f"{path}part_{i // chunk_size}.parquet" + s3_path = f"{path}/data_part_{i // chunk_size}.parquet" key_paths.append([s3_path, local_path]) s3.put_files(key_paths=key_paths) diff --git a/tests/functional_tests/metaflow/test_pandas_via_s3_stage.py b/tests/functional_tests/metaflow/test_pandas_via_s3_stage.py index 778c1ca..c603bac 100644 --- a/tests/functional_tests/metaflow/test_pandas_via_s3_stage.py +++ b/tests/functional_tests/metaflow/test_pandas_via_s3_stage.py @@ -1,287 +1,135 @@ -"""Functional tests for Snowflake S3 stage operations. +"""A Metaflow flow.""" -These tests verify the S3-Snowflake bridge functionality for large-scale data operations. -""" +import subprocess +import sys -from unittest.mock import MagicMock, Mock, patch - -import pandas as pd import pytest +from metaflow import FlowSpec, project, step -from ds_platform_utils.metaflow.pandas_via_s3_stage import ( - DEV_S3_BUCKET, - DEV_SNOWFLAKE_STAGE, - PROD_S3_BUCKET, - PROD_SNOWFLAKE_STAGE, - _get_s3_config, -) - - -class TestS3Config: - """Test S3 configuration selection based on environment.""" - - def test_get_s3_config_dev(self): - """Test that dev configuration is returned when is_production=False.""" - bucket, stage = _get_s3_config(is_production=False) - assert bucket == DEV_S3_BUCKET - assert stage == DEV_SNOWFLAKE_STAGE - - def test_get_s3_config_prod(self): - """Test that prod configuration is returned when is_production=True.""" - bucket, stage = _get_s3_config(is_production=True) - assert bucket == PROD_S3_BUCKET - assert stage == PROD_SNOWFLAKE_STAGE - - -class TestQueryPandasFromSnowflakeViaS3Stage: - """Test query_pandas_from_snowflake_via_s3_stage function.""" - - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.get_snowflake_connection") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._execute_sql") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._get_df_from_s3_folder") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.current") - def test_query_via_s3_stage_basic(self, mock_current, mock_get_df, mock_execute_sql, mock_get_conn): - """Test basic query execution via S3 stage.""" - from ds_platform_utils.metaflow.pandas_via_s3_stage import ( - query_pandas_from_snowflake_via_s3_stage, - ) - - # Setup mocks - mock_current.is_production = False - mock_current.card = MagicMock() - - mock_conn = MagicMock() - mock_get_conn.return_value = mock_conn - - expected_df = pd.DataFrame({"col1": [1, 2, 3], "col2": ["a", "b", "c"]}) - mock_get_df.return_value = expected_df - - # Execute - query = "SELECT * FROM TEST_TABLE" - result_df = query_pandas_from_snowflake_via_s3_stage(query=query) - - # Verify - assert isinstance(result_df, pd.DataFrame) - assert len(result_df) == 3 - assert list(result_df.columns) == ["col1", "col2"] - - # Verify connection was closed - mock_conn.close.assert_called_once() - - # Verify COPY INTO was executed - assert mock_execute_sql.called - - -class TestPublishPandasViaS3Stage: - """Test publish_pandas_via_s3_stage function.""" +from ds_platform_utils.metaflow.pandas_via_s3_stage import publish_pandas_via_s3_stage - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.get_snowflake_connection") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._execute_sql") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._get_metaflow_s3_client") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.current") - @patch("os.makedirs") - @patch("os.remove") - @patch("os.rmdir") - def test_publish_via_s3_stage_basic( - self, - mock_rmdir, - mock_remove, - mock_makedirs, - mock_current, - mock_s3_client, - mock_execute_sql, - mock_get_conn, - ): - """Test basic DataFrame publishing via S3 stage.""" - from ds_platform_utils.metaflow.pandas_via_s3_stage import ( - publish_pandas_via_s3_stage, - ) - # Setup mocks - mock_current.is_production = False - mock_current.card = MagicMock() +@project(name="test_pandas_read_write_flow_via_s3_stage") +class TestPandasReadWriteFlowViaS3Stage(FlowSpec): + """A sample flow.""" - mock_conn = MagicMock() - mock_get_conn.return_value = mock_conn + @step + def start(self): + """Start the flow.""" + self.next(self.test_publish_pandas) - mock_s3 = MagicMock() - mock_s3_client.return_value.__enter__ = Mock(return_value=mock_s3) - mock_s3_client.return_value.__exit__ = Mock(return_value=False) + @step + def test_publish_pandas(self): + """Test the publish_pandas function.""" + import pandas as pd - # Create test DataFrame - df = pd.DataFrame( - { - "col1": [1, 2, 3], - "col2": ["a", "b", "c"], - } - ) + from ds_platform_utils.metaflow import publish_pandas_via_s3_stage - table_schema = [ - ("col1", "INTEGER"), - ("col2", "VARCHAR(255)"), - ] + # Create a sample DataFrame + data = { + "id": [1, 2, 3, 4, 5], + "name": ["Mario", "Luigi", "Peach", "Bowser", "Toad"], + "score": [90.5, 85.2, 88.7, 92.1, 78.9], + } + df = pd.DataFrame(data) - # Execute + # Publish the DataFrame to Snowflake publish_pandas_via_s3_stage( - table_name="TEST_TABLE", + table_name="pandas_test_table", df=df, - table_schema=table_schema, - batch_size=10, - ) - - # Verify - # Connection was closed - mock_conn.close.assert_called_once() - - # S3 put_files was called - assert mock_s3.put_files.called - - # SQL was executed (table creation and COPY INTO) - assert mock_execute_sql.call_count >= 2 - - def test_publish_via_s3_stage_empty_dataframe(self): - """Test that publishing an empty DataFrame raises ValueError.""" - from ds_platform_utils.metaflow.pandas_via_s3_stage import ( - publish_pandas_via_s3_stage, - ) - - df = pd.DataFrame() - table_schema = [("col1", "INTEGER")] - - with pytest.raises(ValueError, match="DataFrame is empty"): - publish_pandas_via_s3_stage( - table_name="TEST_TABLE", - df=df, - table_schema=table_schema, - ) - - def test_publish_via_s3_stage_invalid_type(self): - """Test that publishing non-DataFrame raises TypeError.""" - from ds_platform_utils.metaflow.pandas_via_s3_stage import ( - publish_pandas_via_s3_stage, + table_schema=[ + ("id", "INTEGER"), + ("name", "TEXT"), + ("score", "FLOAT"), + ], + auto_create_table=True, + overwrite=True, ) - table_schema = [("col1", "INTEGER")] - - with pytest.raises(TypeError, match="df must be a pandas DataFrame"): - publish_pandas_via_s3_stage( - table_name="TEST_TABLE", - df="not a dataframe", - table_schema=table_schema, - ) + self.next(self.test_publish_pandas_with_warehouse) + @step + def test_publish_pandas_with_warehouse(self): + """Test the publish pandas on having parameters: warehouse.""" + import pandas as pd -class TestMakeBatchPredictionsFromSnowflakeViaS3Stage: - """Test make_batch_predictions_from_snowflake_via_s3_stage function.""" + # Create a sample DataFrame + data = { + "id": [1, 2, 3, 4, 5], + "name": ["Mario", "Luigi", "Peach", "Bowser", "Toad"], + "score": [90.5, 85.2, 88.7, 92.1, 78.9], + } + df = pd.DataFrame(data) - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.get_snowflake_connection") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._execute_sql") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._list_files_in_s3_folder") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._get_df_from_s3_files") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._get_metaflow_s3_client") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.current") - @patch("os.remove") - def test_batch_predictions_basic( - self, - mock_remove, - mock_current, - mock_s3_client, - mock_get_df_files, - mock_list_files, - mock_execute_sql, - mock_get_conn, - ): - """Test basic batch predictions pipeline.""" - from ds_platform_utils.metaflow.pandas_via_s3_stage import ( - make_batch_predictions_from_snowflake_via_s3_stage, + # Publish the DataFrame to Snowflake with a specific warehouse + publish_pandas_via_s3_stage( + table_name="pandas_test_table", + df=df, + table_schema=[ + ("id", "INTEGER"), + ("name", "TEXT"), + ("score", "FLOAT"), + ], + auto_create_table=True, + overwrite=True, + warehouse="OUTERBOUNDS_DATA_SCIENCE_MED_WH", ) - # Setup mocks - mock_current.is_production = False - mock_current.card = MagicMock() + self.next(self.test_query_pandas) - mock_conn = MagicMock() - mock_get_conn.return_value = mock_conn + @step + def test_query_pandas(self): + """Test the query_pandas_from_snowflake function.""" + from ds_platform_utils.metaflow import query_pandas_from_snowflake_via_s3_stage - mock_s3 = MagicMock() - mock_s3_client.return_value.__enter__ = Mock(return_value=mock_s3) - mock_s3_client.return_value.__exit__ = Mock(return_value=False) + # Query to retrieve the data we just published + query = "SELECT * FROM PATTERN_DB.{{schema}}.PANDAS_TEST_TABLE;" - # Mock S3 file listing - mock_list_files.return_value = [ - "s3://bucket/path/file1.parquet", - "s3://bucket/path/file2.parquet", - ] + # Query the data back + result_df = query_pandas_from_snowflake_via_s3_stage(query) - # Mock reading from S3 - input_df = pd.DataFrame({"input_col": [1, 2, 3]}) - mock_get_df_files.return_value = input_df + # Quick validation + assert len(result_df) == 5, "Expected 5 rows in the result" + assert "id" in result_df.columns, "Expected 'id' column in result" + assert "name" in result_df.columns, "Expected 'name' column in result" + assert "score" in result_df.columns, "Expected 'score' column in result" - # Define predictor function - def predictor(df: pd.DataFrame) -> pd.DataFrame: - return pd.DataFrame( - { - "prediction": df["input_col"] * 2, - } - ) + self.next(self.end) - output_schema = [("prediction", "INTEGER")] + @step + def end(self): + """End the flow.""" + pass - # Execute - make_batch_predictions_from_snowflake_via_s3_stage( - input_query="SELECT * FROM INPUT_TABLE", - output_table_name="OUTPUT_TABLE", - output_table_schema=output_schema, - model_predictor_function=predictor, - ) - - # Verify - # Connection was closed - mock_conn.close.assert_called_once() - # Predictor was called for each file (2 times) - # S3 put_files was called for predictions - assert mock_s3.put_files.called +if __name__ == "__main__": + TestPandasReadWriteFlowViaS3Stage() - # SQL was executed (COPY INTO for input, table creation, COPY INTO for output) - assert mock_execute_sql.call_count >= 3 - - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.get_snowflake_connection") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._execute_sql") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage._list_files_in_s3_folder") - @patch("ds_platform_utils.metaflow.pandas_via_s3_stage.current") - def test_batch_predictions_no_files_raises_error( - self, - mock_current, - mock_list_files, - mock_execute_sql, - mock_get_conn, - ): - """Test that no input files raises ValueError.""" - from ds_platform_utils.metaflow.pandas_via_s3_stage import ( - make_batch_predictions_from_snowflake_via_s3_stage, - ) - # Setup mocks - mock_current.is_production = False - mock_current.card = MagicMock() +@pytest.mark.slow +def test_pandas_read_write_flow(): + """Test that the publish flow runs successfully.""" + cmd = [sys.executable, __file__, "--environment=local", "--with=card", "run"] - mock_conn = MagicMock() - mock_get_conn.return_value = mock_conn + print("\n=== Metaflow Output ===") + for line in execute_with_output(cmd): + print(line, end="") - # Mock empty file list - mock_list_files.return_value = [] - def predictor(df: pd.DataFrame) -> pd.DataFrame: - return df +def execute_with_output(cmd): + """Execute a command and yield output lines as they are produced.""" + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, # Merge stderr into stdout + universal_newlines=True, + bufsize=1, + ) - output_schema = [("col1", "INTEGER")] + for line in iter(process.stdout.readline, ""): + yield line - # Execute and verify error - with pytest.raises(ValueError, match="No input files found"): - make_batch_predictions_from_snowflake_via_s3_stage( - input_query="SELECT * FROM INPUT_TABLE", - output_table_name="OUTPUT_TABLE", - output_table_schema=output_schema, - model_predictor_function=predictor, - ) + process.stdout.close() + return_code = process.wait() + if return_code: + raise subprocess.CalledProcessError(return_code, cmd) From 90be5e7243f0acaa79e315fe84b295d267f1c60f Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:07:00 +0530 Subject: [PATCH 003/167] feat: enhance Snowflake to S3 operations with configuration and query generation --- src/ds_platform_utils/metaflow/pandas.py | 107 +++++++++++++++++++---- 1 file changed, 88 insertions(+), 19 deletions(-) diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index d89c0ec..6d5355f 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -1,7 +1,7 @@ import json from datetime import datetime from pathlib import Path -from typing import Any, Dict, Literal, Optional, Union +from typing import Any, Dict, Literal, Optional, Tuple, Union import pandas as pd import pyarrow @@ -12,8 +12,17 @@ from snowflake.connector.pandas_tools import write_pandas from ds_platform_utils._snowflake.run_query import _execute_sql -from ds_platform_utils.metaflow._consts import NON_PROD_SCHEMA, PROD_SCHEMA +from ds_platform_utils.metaflow._consts import ( + DEV_S3_BUCKET, + DEV_SNOWFLAKE_STAGE, + NON_PROD_SCHEMA, + PROD_S3_BUCKET, + PROD_SCHEMA, + PROD_SNOWFLAKE_STAGE, + S3_DATA_FOLDER, +) from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query, get_snowflake_connection +from ds_platform_utils.metaflow.s3 import _get_df_from_s3_folder from ds_platform_utils.metaflow.write_audit_publish import ( _make_snowflake_table_url, add_comment_to_each_sql_statement, @@ -36,6 +45,46 @@ ] +def _get_s3_config(is_production: bool) -> Tuple[str, str]: + """Return the appropriate S3 bucket and Snowflake stage based on the environment.""" + if is_production: + s3_bucket = PROD_S3_BUCKET + snowflake_stage = PROD_SNOWFLAKE_STAGE + else: + s3_bucket = DEV_S3_BUCKET + snowflake_stage = DEV_SNOWFLAKE_STAGE + + return s3_bucket, snowflake_stage + + +def _generate_snowflake_to_s3_copy_query( + query: str, + snowflake_stage_path: str, + file_name: str = "data.parquet", +) -> str: + """Generate SQL COPY INTO command to export Snowflake query results to S3. + + :param query: SQL query to execute + :param snowflake_stage: Snowflake stage name (e.g., 'DEV_OUTERBOUNDS_S3_STAGE') + :param s3_folder_path: Relative S3 folder path within the stage (e.g., 'temp/query_20260205_123456') + :param file_name: Output file name. Default 'data.parquet' + :return: COPY INTO SQL command + """ + if query.count(";") > 1: + raise ValueError("Multiple SQL statements detected. Please provide a single query statement.") + query = query.replace(";", "") # Remove trailing semicolon if present + copy_query = f""" + COPY INTO @{snowflake_stage_path}/ + FROM ( + {query} + ) + OVERWRITE = TRUE + FILE_FORMAT = (TYPE = 'parquet') + HEADER = TRUE; + """ + return copy_query + + def publish_pandas( # noqa: PLR0913 (too many arguments) table_name: str, df: pd.DataFrame, @@ -114,12 +163,13 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) if warehouse is not None: _execute_sql(conn, f"USE WAREHOUSE {warehouse};") - # set query tag for cost tracking in select.dev - # REASON: because write_pandas() doesn't allow modifying the SQL query to add SQL comments in it directly, - # so we set a session query tag instead. - tags = get_select_dev_query_tags() - query_tag_str = json.dumps(tags) - _execute_sql(conn, f"ALTER SESSION SET QUERY_TAG = '{query_tag_str}';") + # set query tag for cost tracking in select.dev + # REASON: because write_pandas() doesn't allow modifying the SQL query to add SQL comments in it directly, + # so we set a session query tag instead. + tags = get_select_dev_query_tags() + query_tag_str = json.dumps(tags) + _execute_sql(conn, f"ALTER SESSION SET QUERY_TAG = '{query_tag_str}';") + _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") # https://docs.snowflake.com/en/developer-guide/snowpark/reference/python/latest/snowpark/api/snowflake.snowpark.Session.write_pandas write_pandas( @@ -150,6 +200,7 @@ def query_pandas_from_snowflake( warehouse: Optional[TWarehouse] = None, ctx: Optional[Dict[str, Any]] = None, use_utc: bool = True, + use_s3_stage: bool = False, ) -> pd.DataFrame: """Returns a pandas dataframe from a Snowflake query. @@ -160,6 +211,7 @@ def query_pandas_from_snowflake( `OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_XS_WH` warehouse, when running in the Outerbounds **PROD** perimeter. :param ctx: Context dictionary to substitute into the query string. :param use_utc: Whether to set the Snowflake session to use UTC time zone. Default is True. + :param use_s3_stage: Whether to use the S3 stage method to query Snowflake, which is more efficient for large queries. :return: DataFrame containing the results of the query. **NOTE:** If the query contains `{schema}` placeholders, they will be replaced with the appropriate schema name. @@ -176,6 +228,8 @@ def query_pandas_from_snowflake( substitute_map_into_string, ) + schema = PROD_SCHEMA if current.is_production else NON_PROD_SCHEMA + # adding query tags comment in query for cost tracking in select.dev tags = get_select_dev_query_tags() query_comment_str = f"\n\n/* {json.dumps(tags)} */" @@ -183,7 +237,6 @@ def query_pandas_from_snowflake( query = add_comment_to_each_sql_statement(query, query_comment_str) if "{{schema}}" in query or "{{ schema }}" in query: - schema = PROD_SCHEMA if current.is_production else NON_PROD_SCHEMA query = substitute_map_into_string(query, {"schema": schema}) if ctx: @@ -200,17 +253,33 @@ def query_pandas_from_snowflake( conn: SnowflakeConnection = get_snowflake_connection(use_utc) if warehouse is not None: _execute_sql(conn, f"USE WAREHOUSE {warehouse};") - - cursor_result = _execute_sql(conn, query) - if cursor_result is None: - # No statements to execute, return empty DataFrame - df = pd.DataFrame() + _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") + + if use_s3_stage: + s3_bucket, snowflake_stage = _get_s3_config(current.is_production) + data_folder = "query_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) + s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{data_folder}" + sf_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{data_folder}" + + copy_query = _generate_snowflake_to_s3_copy_query( + query=query, + snowflake_stage_path=sf_stage_path, + ) + # Copy data to S3 + _execute_sql(conn, copy_query) + + df = _get_df_from_s3_folder(s3_path) else: - # force_return_table=True -- returns a Pyarrow Table always even if the result is empty - result: pyarrow.Table = cursor_result.fetch_arrow_all(force_return_table=True) - df = result.to_pandas() - df.columns = df.columns.str.lower() - + cursor_result = _execute_sql(conn, query) + if cursor_result is None: + # No statements to execute, return empty DataFrame + df = pd.DataFrame() + else: + # force_return_table=True -- returns a Pyarrow Table always even if the result is empty + result: pyarrow.Table = cursor_result.fetch_arrow_all(force_return_table=True) + df = result.to_pandas() + + df.columns = df.columns.str.lower() current.card.append(Markdown("### Query Result")) current.card.append(Table.from_dataframe(df.head())) From 4384700878ad916b8ee07bdc7af3e0bf590cc118 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:27:45 +0530 Subject: [PATCH 004/167] feat: add S3 to Snowflake data transfer functionality --- src/ds_platform_utils/metaflow/__init__.py | 4 - src/ds_platform_utils/metaflow/pandas.py | 95 +++++++++++++++++++++- src/ds_platform_utils/metaflow/s3.py | 10 ++- 3 files changed, 97 insertions(+), 12 deletions(-) diff --git a/src/ds_platform_utils/metaflow/__init__.py b/src/ds_platform_utils/metaflow/__init__.py index eb9065e..7ae8804 100644 --- a/src/ds_platform_utils/metaflow/__init__.py +++ b/src/ds_platform_utils/metaflow/__init__.py @@ -1,8 +1,6 @@ from .pandas import publish_pandas, query_pandas_from_snowflake from .pandas_via_s3_stage import ( make_batch_predictions_from_snowflake_via_s3_stage, - publish_pandas_via_s3_stage, - query_pandas_from_snowflake_via_s3_stage, ) from .restore_step_state import restore_step_state from .validate_config import make_pydantic_parser_fn @@ -13,8 +11,6 @@ "make_pydantic_parser_fn", "publish", "publish_pandas", - "publish_pandas_via_s3_stage", "query_pandas_from_snowflake", - "query_pandas_from_snowflake_via_s3_stage", "restore_step_state", ] diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 6d5355f..a26dfc0 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -1,7 +1,7 @@ import json from datetime import datetime from pathlib import Path -from typing import Any, Dict, Literal, Optional, Tuple, Union +from typing import Any, Dict, List, Literal, Optional, Tuple, Union import pandas as pd import pyarrow @@ -22,7 +22,7 @@ S3_DATA_FOLDER, ) from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query, get_snowflake_connection -from ds_platform_utils.metaflow.s3 import _get_df_from_s3_folder +from ds_platform_utils.metaflow.s3 import _get_df_from_s3_folder, _put_df_to_s3_folder from ds_platform_utils.metaflow.write_audit_publish import ( _make_snowflake_table_url, add_comment_to_each_sql_statement, @@ -65,8 +65,7 @@ def _generate_snowflake_to_s3_copy_query( """Generate SQL COPY INTO command to export Snowflake query results to S3. :param query: SQL query to execute - :param snowflake_stage: Snowflake stage name (e.g., 'DEV_OUTERBOUNDS_S3_STAGE') - :param s3_folder_path: Relative S3 folder path within the stage (e.g., 'temp/query_20260205_123456') + :param snowflake_stage_path: The path to the Snowflake stage where the data will be exported. This should include the stage name and any necessary subfolders (e.g., 'my_snowflake_stage/my_folder'). :param file_name: Output file name. Default 'data.parquet' :return: COPY INTO SQL command """ @@ -85,6 +84,58 @@ def _generate_snowflake_to_s3_copy_query( return copy_query +def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 + schema: str, + table_name: str, + snowflake_stage_path: str, + table_schema: List[Tuple[str, str]], + overwrite: bool = True, + auto_create_table: bool = True, + use_logical_type: bool = True, +) -> str: + """Generate SQL commands to load data from S3 to Snowflake table. + + This function generates a complete SQL script that includes: + 1. DROP TABLE IF EXISTS (if overwrite=True) + 2. CREATE TABLE IF NOT EXISTS (if auto_create_table=True or overwrite=True) + 3. COPY INTO command to load data from S3 + + :param schema: Snowflake schema name (e.g., 'DATA_SCIENCE' or 'DATA_SCIENCE_STAGE') + :param table_name: Target table name + :param snowflake_stage_path: The path to the Snowflake stage where the data will be exported. This should include the stage name and any necessary subfolders (e.g., 'my_snowflake_stage/my_folder'). + :param table_schema: List of tuples with column names and types + :param overwrite: If True, drop and recreate the table. Default True + :param auto_create_table: If True, create the table if it doesn't exist. Default True + :param use_logical_type: Whether to use Parquet logical types when reading the parquet files. Default True. + :return: Complete SQL script with table management and COPY INTO commands + """ + sql_statements = [] + + # Step 1: Drop table if overwrite is True + if overwrite: + sql_statements.append(f"DROP TABLE IF EXISTS PATTERN_DB.{schema}.{table_name};") + + # Step 2: Create table if auto_create_table or overwrite + if auto_create_table or overwrite: + table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_schema]) + create_table_query = ( + f"""CREATE TABLE IF NOT EXISTS PATTERN_DB.{schema}.{table_name} ( {table_create_columns_str} );""" + ) + sql_statements.append(create_table_query) + + # Step 3: Generate COPY INTO command + columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_schema]) + + copy_query = f"""COPY INTO PATTERN_DB.{schema}.{table_name} FROM ( + SELECT {columns_str} + FROM @{snowflake_stage_path} ) + FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = {use_logical_type});""" + sql_statements.append(copy_query) + + # Combine all statements + return "\n\n".join(sql_statements) + + def publish_pandas( # noqa: PLR0913 (too many arguments) table_name: str, df: pd.DataFrame, @@ -98,6 +149,8 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) overwrite: bool = False, use_logical_type: bool = True, # prevent date times with timezone from being written incorrectly use_utc: bool = True, + use_s3_stage: bool = False, + table_schema: Optional[List[Tuple[str, str]]] = None, ) -> None: """Store a pandas dataframe as a Snowflake table. @@ -138,6 +191,11 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) parquet files for the uploaded pandas dataframe. :param use_utc: Whether to set the Snowflake session to use UTC time zone. Default is True. + + :param use_s3_stage: Whether to use the S3 stage method to publish the DataFrame, which is more efficient for large DataFrames. + + :param table_schema: Optional list of tuples specifying the column names and types for the Snowflake table. + This is only used when `use_s3_stage` is True, and is required in that case. The list should be in the format: `[(col_name1, col_type1), (col_name2, col_type2), ...]`, where `col_type` is a valid Snowflake data type (e.g., 'STRING', 'NUMBER', 'TIMESTAMP_NTZ', etc.). """ if not isinstance(df, pd.DataFrame): raise TypeError("df must be a pandas DataFrame.") @@ -171,6 +229,35 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) _execute_sql(conn, f"ALTER SESSION SET QUERY_TAG = '{query_tag_str}';") _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") + if use_s3_stage: + if table_schema is None: + raise ValueError("table_schema is required when use_s3_stage is True.") + s3_bucket, snowflake_stage = _get_s3_config(current.is_production) + data_folder = "publish_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) + s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{data_folder}" + sf_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{data_folder}" + + # Write DataFrame to S3 as Parquet + # Upload DataFrame to S3 as parquet files + _put_df_to_s3_folder( + df=df, + path=s3_path, + chunk_size=chunk_size, + compression=compression, + ) + + # Generate and execute Snowflake SQL to load data from S3 to Snowflake + copy_query = _generate_s3_to_snowflake_copy_query( + schema=schema, + table_name=table_name, + snowflake_stage_path=sf_stage_path, + table_schema=table_schema, + overwrite=overwrite, + auto_create_table=auto_create_table, + use_logical_type=use_logical_type, + ) + _execute_sql(conn, copy_query) + # https://docs.snowflake.com/en/developer-guide/snowpark/reference/python/latest/snowpark/api/snowflake.snowpark.Session.write_pandas write_pandas( conn=conn, diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 4969a83..79c9187 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -44,12 +44,13 @@ def _put_df_to_s3_file(df: pd.DataFrame, path: str) -> None: raise ValueError("Invalid S3 URI. Must start with 's3://'.") with _get_metaflow_s3_client() as s3: - local_path = "/tmp/temp_parquet_file.parquet" + timestamp_str = pd.Timestamp("now").strftime("%Y%m%d_%H%M%S_%f") + local_path = f"/tmp/{timestamp_str}.parquet" df.to_parquet(local_path) s3.put_files(key_paths=[[path, local_path]]) -def _put_df_to_s3_folder(df: pd.DataFrame, path: str, chunk_size=None) -> None: +def _put_df_to_s3_folder(df: pd.DataFrame, path: str, chunk_size=None, compression="snappy") -> None: if not path.startswith("s3://"): raise ValueError("Invalid S3 URI. Must start with 's3://'.") @@ -69,12 +70,13 @@ def estimate_bytes_per_row(df_sample): chunk_size = max(1, chunk_size) with _get_metaflow_s3_client() as s3: - local_path_template = "/tmp/data_part_{}.parquet" + timestamp = pd.Timestamp("now").strftime("%Y%m%d_%H%M%S_%f") + local_path_template = f"/tmp/{timestamp}_data_part_{{}}.parquet" key_paths = [] num_rows = df.shape[0] for i in range(0, num_rows, chunk_size): local_path = local_path_template.format(i // chunk_size) - df.iloc[i : i + chunk_size].to_parquet(local_path) + df.iloc[i : i + chunk_size].to_parquet(local_path, index=False, compression=compression) s3_path = f"{path}/data_part_{i // chunk_size}.parquet" key_paths.append([s3_path, local_path]) s3.put_files(key_paths=key_paths) From 179c950daf4368787796967bada71a6aaba26e9b Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:28:29 +0530 Subject: [PATCH 005/167] feat: add S3 data folder constant for Snowflake stage operations --- src/ds_platform_utils/metaflow/_consts.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ds_platform_utils/metaflow/_consts.py b/src/ds_platform_utils/metaflow/_consts.py index ebe47d5..945d644 100644 --- a/src/ds_platform_utils/metaflow/_consts.py +++ b/src/ds_platform_utils/metaflow/_consts.py @@ -11,3 +11,5 @@ # Prod environment PROD_S3_BUCKET = "s3://prod-outerbounds-snowflake-stage" PROD_SNOWFLAKE_STAGE = "PROD_OUTERBOUNDS_S3_STAGE" + +S3_DATA_FOLDER = "s3_stage_data" From e1c4a0ef68dd63e90d313c86906c55137566442f Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 10:29:03 +0530 Subject: [PATCH 006/167] refactor: remove outdated functional tests for pandas via S3 stage --- .../metaflow/test_pandas_via_s3_stage.py | 135 ------------------ 1 file changed, 135 deletions(-) delete mode 100644 tests/functional_tests/metaflow/test_pandas_via_s3_stage.py diff --git a/tests/functional_tests/metaflow/test_pandas_via_s3_stage.py b/tests/functional_tests/metaflow/test_pandas_via_s3_stage.py deleted file mode 100644 index c603bac..0000000 --- a/tests/functional_tests/metaflow/test_pandas_via_s3_stage.py +++ /dev/null @@ -1,135 +0,0 @@ -"""A Metaflow flow.""" - -import subprocess -import sys - -import pytest -from metaflow import FlowSpec, project, step - -from ds_platform_utils.metaflow.pandas_via_s3_stage import publish_pandas_via_s3_stage - - -@project(name="test_pandas_read_write_flow_via_s3_stage") -class TestPandasReadWriteFlowViaS3Stage(FlowSpec): - """A sample flow.""" - - @step - def start(self): - """Start the flow.""" - self.next(self.test_publish_pandas) - - @step - def test_publish_pandas(self): - """Test the publish_pandas function.""" - import pandas as pd - - from ds_platform_utils.metaflow import publish_pandas_via_s3_stage - - # Create a sample DataFrame - data = { - "id": [1, 2, 3, 4, 5], - "name": ["Mario", "Luigi", "Peach", "Bowser", "Toad"], - "score": [90.5, 85.2, 88.7, 92.1, 78.9], - } - df = pd.DataFrame(data) - - # Publish the DataFrame to Snowflake - publish_pandas_via_s3_stage( - table_name="pandas_test_table", - df=df, - table_schema=[ - ("id", "INTEGER"), - ("name", "TEXT"), - ("score", "FLOAT"), - ], - auto_create_table=True, - overwrite=True, - ) - - self.next(self.test_publish_pandas_with_warehouse) - - @step - def test_publish_pandas_with_warehouse(self): - """Test the publish pandas on having parameters: warehouse.""" - import pandas as pd - - # Create a sample DataFrame - data = { - "id": [1, 2, 3, 4, 5], - "name": ["Mario", "Luigi", "Peach", "Bowser", "Toad"], - "score": [90.5, 85.2, 88.7, 92.1, 78.9], - } - df = pd.DataFrame(data) - - # Publish the DataFrame to Snowflake with a specific warehouse - publish_pandas_via_s3_stage( - table_name="pandas_test_table", - df=df, - table_schema=[ - ("id", "INTEGER"), - ("name", "TEXT"), - ("score", "FLOAT"), - ], - auto_create_table=True, - overwrite=True, - warehouse="OUTERBOUNDS_DATA_SCIENCE_MED_WH", - ) - - self.next(self.test_query_pandas) - - @step - def test_query_pandas(self): - """Test the query_pandas_from_snowflake function.""" - from ds_platform_utils.metaflow import query_pandas_from_snowflake_via_s3_stage - - # Query to retrieve the data we just published - query = "SELECT * FROM PATTERN_DB.{{schema}}.PANDAS_TEST_TABLE;" - - # Query the data back - result_df = query_pandas_from_snowflake_via_s3_stage(query) - - # Quick validation - assert len(result_df) == 5, "Expected 5 rows in the result" - assert "id" in result_df.columns, "Expected 'id' column in result" - assert "name" in result_df.columns, "Expected 'name' column in result" - assert "score" in result_df.columns, "Expected 'score' column in result" - - self.next(self.end) - - @step - def end(self): - """End the flow.""" - pass - - -if __name__ == "__main__": - TestPandasReadWriteFlowViaS3Stage() - - -@pytest.mark.slow -def test_pandas_read_write_flow(): - """Test that the publish flow runs successfully.""" - cmd = [sys.executable, __file__, "--environment=local", "--with=card", "run"] - - print("\n=== Metaflow Output ===") - for line in execute_with_output(cmd): - print(line, end="") - - -def execute_with_output(cmd): - """Execute a command and yield output lines as they are produced.""" - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, # Merge stderr into stdout - universal_newlines=True, - bufsize=1, - ) - - for line in iter(process.stdout.readline, ""): - yield line - - process.stdout.close() - return_code = process.wait() - if return_code: - raise subprocess.CalledProcessError(return_code, cmd) From 09c172e34a051950d4832cd9906e9d3b7b175ca0 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:50:48 +0530 Subject: [PATCH 007/167] feat: add batch inference function for Snowflake integration and infer schema from DataFrame --- .../metaflow/batch_inference.py | 112 ++++++++++++++++++ src/ds_platform_utils/metaflow/pandas.py | 28 +++++ 2 files changed, 140 insertions(+) create mode 100644 src/ds_platform_utils/metaflow/batch_inference.py diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py new file mode 100644 index 0000000..ad09ab1 --- /dev/null +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -0,0 +1,112 @@ +from pathlib import Path +from typing import Callable, List, Optional, Tuple, Union + +import pandas as pd +from metaflow import current +from metaflow.cards import Markdown, Table + +from ds_platform_utils._snowflake.run_query import _execute_sql +from ds_platform_utils._snowflake.write_audit_publish import get_query_from_string_or_fpath, substitute_map_into_string +from ds_platform_utils.metaflow._consts import ( + NON_PROD_SCHEMA, + PROD_SCHEMA, + S3_DATA_FOLDER, +) +from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query, get_snowflake_connection +from ds_platform_utils.metaflow.pandas import ( + _generate_s3_to_snowflake_copy_query, + _generate_snowflake_to_s3_copy_query, + _get_s3_config, +) +from ds_platform_utils.metaflow.s3 import _get_df_from_s3_file, _put_df_to_s3_file + + +def batch_inference( # noqa: PLR0913 (too many arguments) + input_query: Union[str, Path], + output_table_name: str, + output_table_schema: List[Tuple[str, str]], + model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], + use_utc: bool = True, + batch_size_in_mb: int = 100, + parallelism: int = 1, + warehouse: Optional[str] = None, + ctx: Optional[dict] = None, +): + is_production = current.is_production if hasattr(current, "is_production") else False + s3_bucket, snowflake_stage = _get_s3_config(is_production) + schema = PROD_SCHEMA if is_production else NON_PROD_SCHEMA + + timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") + upload_folder = f"publish_{timestamp}" + download_folder = f"query_{timestamp}" + input_s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{download_folder}" + input_snowflake_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{download_folder}" + output_s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{upload_folder}" + output_snowflake_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{upload_folder}" + + # Step 1: Build COPY INTO query to export data from Snowflake to S3 + + input_query = get_query_from_string_or_fpath(input_query) + input_query = substitute_map_into_string(input_query, {"schema": schema} | (ctx or {})) + + _debug_print_query(input_query) + + current.card.append(Markdown("### Batch Predictions From Snowflake via S3 Stage")) + current.card.append(Markdown(input_query)) + current.card.append(Markdown(f"#### Input S3 staging path: `{input_s3_path}`")) + conn = get_snowflake_connection(use_utc) + if warehouse is not None: + _execute_sql(conn, f"USE WAREHOUSE {warehouse};") + _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") + + copy_to_s3_query = _generate_snowflake_to_s3_copy_query( + query=input_query, + snowflake_stage_path=input_snowflake_stage_path, + ) + _execute_sql(conn, copy_to_s3_query) + conn.close() + + # Step 2: Get input files from S3 and apply model predictor function to generate output dataframe + + input_files = _list_files_in_s3_folder(input_s3_path) + + if not input_files: + raise ValueError(f"No input files found in S3 path: {input_s3_path}") + + current.card.append(Markdown("#### Input query results")) + current.card.append(Table.from_dataframe(_get_df_from_s3_file(input_files[0]))) + + # Step 3: Process each file through the model and write predictions to S3 + total_predictions = 0 + for file_idx, input_file in enumerate(input_files): + # Read single file + input_df = _get_df_from_s3_file(input_file) + + # Run predictions + predictions_df = model_predictor_function(input_df) + + # Write predictions to S3 + _put_df_to_s3_file( + df=predictions_df, + path=f"{output_s3_path}/data_part_{file_idx}.parquet", + ) + + total_predictions += len(predictions_df) + + # Step 4: Build COPY INTO query to load predictions from S3 back to Snowflake + + conn = get_snowflake_connection(use_utc) + if warehouse is not None: + _execute_sql(conn, f"USE WAREHOUSE {warehouse};") + _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") + + copy_from_s3_query = _generate_s3_to_snowflake_copy_query( + schema=schema, + table_name=output_table_name, + snowflake_stage_path=output_snowflake_stage_path, + overwrite=True, + auto_create_table=True, + table_schema=output_table_schema, + ) + _execute_sql(conn, copy_from_s3_query) + conn.close() diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index a26dfc0..bf8e07c 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -136,6 +136,34 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 return "\n\n".join(sql_statements) +def _infer_snowflake_schema_from_df(df: pd.DataFrame) -> List[Tuple[str, str]]: + """Infer Snowflake table schema from a pandas DataFrame. + + This function maps pandas data types to corresponding Snowflake data types. + It returns a list of tuples, where each tuple contains a column name and its inferred Snowflake data type. + + :param df: Input pandas DataFrame + :return: List of tuples with column names and inferred Snowflake data types + """ + dtype_mapping = { + "object": "TEXT", + "int64": "NUMBER", + "float64": "FLOAT", + "bool": "BOOLEAN", + "datetime64[ns]": "TIMESTAMP_NTZ", + "datetime64[ns, tz]": "TIMESTAMP_TZ", + # Add more mappings as needed + } + + schema = [] + for col_name, dtype in df.dtypes.items(): + dtype_str = str(dtype) + snowflake_type = dtype_mapping.get(dtype_str, "STRING") # Default to STRING if type is not mapped + schema.append((col_name, snowflake_type)) + + return schema + + def publish_pandas( # noqa: PLR0913 (too many arguments) table_name: str, df: pd.DataFrame, From ed961494caa0da46a8e5fedb975309dce477fac2 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:41:56 +0530 Subject: [PATCH 008/167] fix: add missing import for listing files in S3 for batch inference --- src/ds_platform_utils/metaflow/batch_inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index ad09ab1..f2ad16b 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -18,7 +18,7 @@ _generate_snowflake_to_s3_copy_query, _get_s3_config, ) -from ds_platform_utils.metaflow.s3 import _get_df_from_s3_file, _put_df_to_s3_file +from ds_platform_utils.metaflow.s3 import _get_df_from_s3_file, _list_files_in_s3_folder, _put_df_to_s3_file def batch_inference( # noqa: PLR0913 (too many arguments) From fa9eb51c839825a5050e112db44342555882053f Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:51:23 +0530 Subject: [PATCH 009/167] feat: add batch size parameter to Snowflake to S3 copy query and update table creation logic --- src/ds_platform_utils/metaflow/batch_inference.py | 1 + src/ds_platform_utils/metaflow/pandas.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index f2ad16b..4501b88 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -62,6 +62,7 @@ def batch_inference( # noqa: PLR0913 (too many arguments) copy_to_s3_query = _generate_snowflake_to_s3_copy_query( query=input_query, snowflake_stage_path=input_snowflake_stage_path, + batch_size_in_mb=batch_size_in_mb, ) _execute_sql(conn, copy_to_s3_query) conn.close() diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index bf8e07c..7e1ae94 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -61,6 +61,7 @@ def _generate_snowflake_to_s3_copy_query( query: str, snowflake_stage_path: str, file_name: str = "data.parquet", + batch_size_in_mb: int = 16, ) -> str: """Generate SQL COPY INTO command to export Snowflake query results to S3. @@ -79,6 +80,7 @@ def _generate_snowflake_to_s3_copy_query( ) OVERWRITE = TRUE FILE_FORMAT = (TYPE = 'parquet') + MAX_FILE_SIZE = {batch_size_in_mb * 1024 * 1024} HEADER = TRUE; """ return copy_query @@ -119,7 +121,7 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 if auto_create_table or overwrite: table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_schema]) create_table_query = ( - f"""CREATE TABLE IF NOT EXISTS PATTERN_DB.{schema}.{table_name} ( {table_create_columns_str} );""" + f"""CREATE OR REPLACE TABLE PATTERN_DB.{schema}.{table_name} ( {table_create_columns_str} );""" ) sql_statements.append(create_table_query) From 3366eadc9a6a7f825fa704aac7771e1378f7a2fe Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:07:31 +0530 Subject: [PATCH 010/167] feat: enhance batch inference with multiprocessing for file processing --- .../metaflow/batch_inference.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 4501b88..033b299 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -1,3 +1,4 @@ +from multiprocessing import Pool from pathlib import Path from typing import Callable, List, Optional, Tuple, Union @@ -78,21 +79,25 @@ def batch_inference( # noqa: PLR0913 (too many arguments) current.card.append(Table.from_dataframe(_get_df_from_s3_file(input_files[0]))) # Step 3: Process each file through the model and write predictions to S3 + enumerated_input_files = list(enumerate(input_files)) total_predictions = 0 - for file_idx, input_file in enumerate(input_files): - # Read single file - input_df = _get_df_from_s3_file(input_file) - # Run predictions + def process_file(args): + file_idx, input_file = args + print(f"Processing file {file_idx + 1}/{len(input_files)}") + input_df = _get_df_from_s3_file(input_file) predictions_df = model_predictor_function(input_df) - - # Write predictions to S3 _put_df_to_s3_file( df=predictions_df, path=f"{output_s3_path}/data_part_{file_idx}.parquet", ) + return len(predictions_df) + + with Pool(processes=parallelism) as pool: + prediction_counts = pool.map(process_file, enumerated_input_files) + total_predictions = sum(prediction_counts) - total_predictions += len(predictions_df) + print(f"Total predictions generated: {total_predictions}") # Step 4: Build COPY INTO query to load predictions from S3 back to Snowflake From 535cc7167f5a11af3f96cb6b4ff0dabea15cd66c Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:19:20 +0530 Subject: [PATCH 011/167] feat: refactor file processing in batch inference to use a dedicated function for improved readability and maintainability --- .../metaflow/batch_inference.py | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 033b299..df73f62 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -22,6 +22,24 @@ from ds_platform_utils.metaflow.s3 import _get_df_from_s3_file, _list_files_in_s3_folder, _put_df_to_s3_file +def _process_inference_file( + file_idx: int, + input_file: str, + output_s3_path: str, + model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], + input_files: List[str], +): + """Process a single file through the model predictor and write results to S3.""" + print(f"Processing file {file_idx + 1}/{len(input_files)}") + input_df = _get_df_from_s3_file(input_file) + predictions_df = model_predictor_function(input_df) + _put_df_to_s3_file( + df=predictions_df, + path=f"{output_s3_path}/data_part_{file_idx}.parquet", + ) + return len(predictions_df) + + def batch_inference( # noqa: PLR0913 (too many arguments) input_query: Union[str, Path], output_table_name: str, @@ -79,22 +97,16 @@ def batch_inference( # noqa: PLR0913 (too many arguments) current.card.append(Table.from_dataframe(_get_df_from_s3_file(input_files[0]))) # Step 3: Process each file through the model and write predictions to S3 - enumerated_input_files = list(enumerate(input_files)) total_predictions = 0 - def process_file(args): - file_idx, input_file = args - print(f"Processing file {file_idx + 1}/{len(input_files)}") - input_df = _get_df_from_s3_file(input_file) - predictions_df = model_predictor_function(input_df) - _put_df_to_s3_file( - df=predictions_df, - path=f"{output_s3_path}/data_part_{file_idx}.parquet", - ) - return len(predictions_df) - with Pool(processes=parallelism) as pool: - prediction_counts = pool.map(process_file, enumerated_input_files) + prediction_counts = pool.starmap( + _process_inference_file, + [ + (file_idx, input_file, output_s3_path, model_predictor_function, input_files) + for file_idx, input_file in enumerate(input_files) + ], + ) total_predictions = sum(prediction_counts) print(f"Total predictions generated: {total_predictions}") From 58b52f4103b074c84fed4f054fed75812ef5c8f8 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:25:51 +0530 Subject: [PATCH 012/167] feat: refactor file processing in batch inference to inline function for improved clarity and performance --- .../metaflow/batch_inference.py | 38 +++++++------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index df73f62..033b299 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -22,24 +22,6 @@ from ds_platform_utils.metaflow.s3 import _get_df_from_s3_file, _list_files_in_s3_folder, _put_df_to_s3_file -def _process_inference_file( - file_idx: int, - input_file: str, - output_s3_path: str, - model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], - input_files: List[str], -): - """Process a single file through the model predictor and write results to S3.""" - print(f"Processing file {file_idx + 1}/{len(input_files)}") - input_df = _get_df_from_s3_file(input_file) - predictions_df = model_predictor_function(input_df) - _put_df_to_s3_file( - df=predictions_df, - path=f"{output_s3_path}/data_part_{file_idx}.parquet", - ) - return len(predictions_df) - - def batch_inference( # noqa: PLR0913 (too many arguments) input_query: Union[str, Path], output_table_name: str, @@ -97,16 +79,22 @@ def batch_inference( # noqa: PLR0913 (too many arguments) current.card.append(Table.from_dataframe(_get_df_from_s3_file(input_files[0]))) # Step 3: Process each file through the model and write predictions to S3 + enumerated_input_files = list(enumerate(input_files)) total_predictions = 0 - with Pool(processes=parallelism) as pool: - prediction_counts = pool.starmap( - _process_inference_file, - [ - (file_idx, input_file, output_s3_path, model_predictor_function, input_files) - for file_idx, input_file in enumerate(input_files) - ], + def process_file(args): + file_idx, input_file = args + print(f"Processing file {file_idx + 1}/{len(input_files)}") + input_df = _get_df_from_s3_file(input_file) + predictions_df = model_predictor_function(input_df) + _put_df_to_s3_file( + df=predictions_df, + path=f"{output_s3_path}/data_part_{file_idx}.parquet", ) + return len(predictions_df) + + with Pool(processes=parallelism) as pool: + prediction_counts = pool.map(process_file, enumerated_input_files) total_predictions = sum(prediction_counts) print(f"Total predictions generated: {total_predictions}") From bac2b1dd3f860aa3cd8d8459b53aa32ddb0c5b20 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:33:11 +0530 Subject: [PATCH 013/167] feat: add function to download all files from S3 folder with URI validation --- src/ds_platform_utils/metaflow/s3.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 79c9187..ad40d64 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -14,6 +14,14 @@ def _list_files_in_s3_folder(path: str) -> list: return [path.url for path in s3.list_paths([path])] +def _download_all_files_in_s3_folder(path: str) -> list: + if not path.startswith("s3://"): + raise ValueError("Invalid S3 URI. Must start with 's3://'.") + + with _get_metaflow_s3_client() as s3: + return [obj.path for obj in s3.get_many(_list_files_in_s3_folder(path))] + + def _get_df_from_s3_file(path: str) -> pd.DataFrame: if not path.startswith("s3://"): raise ValueError("Invalid S3 URI. Must start with 's3://'.") From ea0544c156a5ba2b747852602fa765130a10b234 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:35:09 +0530 Subject: [PATCH 014/167] feat: replace file listing with direct download of all files from S3 in batch inference --- .../metaflow/batch_inference.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 033b299..1504e68 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -1,4 +1,3 @@ -from multiprocessing import Pool from pathlib import Path from typing import Callable, List, Optional, Tuple, Union @@ -19,7 +18,7 @@ _generate_snowflake_to_s3_copy_query, _get_s3_config, ) -from ds_platform_utils.metaflow.s3 import _get_df_from_s3_file, _list_files_in_s3_folder, _put_df_to_s3_file +from ds_platform_utils.metaflow.s3 import _download_all_files_in_s3_folder, _get_df_from_s3_file, _put_df_to_s3_file def batch_inference( # noqa: PLR0913 (too many arguments) @@ -70,7 +69,7 @@ def batch_inference( # noqa: PLR0913 (too many arguments) # Step 2: Get input files from S3 and apply model predictor function to generate output dataframe - input_files = _list_files_in_s3_folder(input_s3_path) + input_files = _download_all_files_in_s3_folder(input_s3_path) if not input_files: raise ValueError(f"No input files found in S3 path: {input_s3_path}") @@ -82,20 +81,15 @@ def batch_inference( # noqa: PLR0913 (too many arguments) enumerated_input_files = list(enumerate(input_files)) total_predictions = 0 - def process_file(args): - file_idx, input_file = args + for file_idx, input_file in enumerated_input_files: print(f"Processing file {file_idx + 1}/{len(input_files)}") - input_df = _get_df_from_s3_file(input_file) + input_df = pd.read_parquet(input_file) predictions_df = model_predictor_function(input_df) _put_df_to_s3_file( df=predictions_df, path=f"{output_s3_path}/data_part_{file_idx}.parquet", ) - return len(predictions_df) - - with Pool(processes=parallelism) as pool: - prediction_counts = pool.map(process_file, enumerated_input_files) - total_predictions = sum(prediction_counts) + total_predictions += len(predictions_df) print(f"Total predictions generated: {total_predictions}") From bb6591122e2cf6b72f4c9fb9f5c4a10a1e9ea194 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:37:09 +0530 Subject: [PATCH 015/167] feat: update input file processing to read parquet format instead of using S3 file retrieval function --- src/ds_platform_utils/metaflow/batch_inference.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 1504e68..a1bd612 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -18,7 +18,7 @@ _generate_snowflake_to_s3_copy_query, _get_s3_config, ) -from ds_platform_utils.metaflow.s3 import _download_all_files_in_s3_folder, _get_df_from_s3_file, _put_df_to_s3_file +from ds_platform_utils.metaflow.s3 import _download_all_files_in_s3_folder, _put_df_to_s3_file def batch_inference( # noqa: PLR0913 (too many arguments) @@ -75,7 +75,7 @@ def batch_inference( # noqa: PLR0913 (too many arguments) raise ValueError(f"No input files found in S3 path: {input_s3_path}") current.card.append(Markdown("#### Input query results")) - current.card.append(Table.from_dataframe(_get_df_from_s3_file(input_files[0]))) + current.card.append(Table.from_dataframe(pd.read_parquet(input_files[0]))) # Step 3: Process each file through the model and write predictions to S3 enumerated_input_files = list(enumerate(input_files)) From 43bfb41dfbea49efc25b13f50f64d5c36136a384 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:52:39 +0530 Subject: [PATCH 016/167] feat: refactor _download_all_files_in_s3_folder to use a direct S3 client assignment for improved clarity --- src/ds_platform_utils/metaflow/s3.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index ad40d64..7977561 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -18,8 +18,8 @@ def _download_all_files_in_s3_folder(path: str) -> list: if not path.startswith("s3://"): raise ValueError("Invalid S3 URI. Must start with 's3://'.") - with _get_metaflow_s3_client() as s3: - return [obj.path for obj in s3.get_many(_list_files_in_s3_folder(path))] + s3 = _get_metaflow_s3_client() + return [obj.path for obj in s3.get_many(_list_files_in_s3_folder(path))] def _get_df_from_s3_file(path: str) -> pd.DataFrame: From 172278ac4218801d9b2f03acf39138cf5fcf1751 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:58:43 +0530 Subject: [PATCH 017/167] feat: optimize batch inference file processing using multiprocessing for improved performance --- src/ds_platform_utils/metaflow/batch_inference.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index a1bd612..835407f 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -1,3 +1,4 @@ +from multiprocessing import Pool from pathlib import Path from typing import Callable, List, Optional, Tuple, Union @@ -78,10 +79,9 @@ def batch_inference( # noqa: PLR0913 (too many arguments) current.card.append(Table.from_dataframe(pd.read_parquet(input_files[0]))) # Step 3: Process each file through the model and write predictions to S3 - enumerated_input_files = list(enumerate(input_files)) - total_predictions = 0 - for file_idx, input_file in enumerated_input_files: + def process_file(args): + file_idx, input_file = args print(f"Processing file {file_idx + 1}/{len(input_files)}") input_df = pd.read_parquet(input_file) predictions_df = model_predictor_function(input_df) @@ -89,8 +89,13 @@ def batch_inference( # noqa: PLR0913 (too many arguments) df=predictions_df, path=f"{output_s3_path}/data_part_{file_idx}.parquet", ) - total_predictions += len(predictions_df) + return len(predictions_df) + + enumerated_input_files = list(enumerate(input_files)) + with Pool(processes=parallelism) as pool: + prediction_counts = pool.map(process_file, enumerated_input_files) + total_predictions = sum(prediction_counts) print(f"Total predictions generated: {total_predictions}") # Step 4: Build COPY INTO query to load predictions from S3 back to Snowflake From 82f30af4b1d4dedbb0cd28ecd81abfe0c21e4a2e Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:16:03 +0530 Subject: [PATCH 018/167] feat: add picklability check for process_file function before multiprocessing --- src/ds_platform_utils/metaflow/batch_inference.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 835407f..ca7c781 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -91,6 +91,12 @@ def process_file(args): ) return len(predictions_df) + import pickle + + print( + pickle.dumps(process_file)[:100] + ) # just to check if the function is picklable before we try to use it in multiprocessing. If this line raises an error, then the process_file function is not picklable and we won't be able to use it in multiprocessing.Pool + enumerated_input_files = list(enumerate(input_files)) with Pool(processes=parallelism) as pool: prediction_counts = pool.map(process_file, enumerated_input_files) From 4b8712db5a3862d5636bb7c202b33189e574c4c9 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:20:36 +0530 Subject: [PATCH 019/167] feat: refactor process_file function for improved picklability and integrate with multiprocessing --- .../metaflow/batch_inference.py | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index ca7c781..1c8bfa1 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -22,6 +22,18 @@ from ds_platform_utils.metaflow.s3 import _download_all_files_in_s3_folder, _put_df_to_s3_file +def process_file(args, model_predictor_function, output_s3_path, len_input_files): + file_idx, input_file = args + print(f"Processing file {file_idx + 1}/{len_input_files}") + input_df = pd.read_parquet(input_file) + predictions_df = model_predictor_function(input_df) + _put_df_to_s3_file( + df=predictions_df, + path=f"{output_s3_path}/data_part_{file_idx}.parquet", + ) + return len(predictions_df) + + def batch_inference( # noqa: PLR0913 (too many arguments) input_query: Union[str, Path], output_table_name: str, @@ -80,26 +92,26 @@ def batch_inference( # noqa: PLR0913 (too many arguments) # Step 3: Process each file through the model and write predictions to S3 - def process_file(args): - file_idx, input_file = args - print(f"Processing file {file_idx + 1}/{len(input_files)}") - input_df = pd.read_parquet(input_file) - predictions_df = model_predictor_function(input_df) - _put_df_to_s3_file( - df=predictions_df, - path=f"{output_s3_path}/data_part_{file_idx}.parquet", - ) - return len(predictions_df) + from itertools import partial + + process_file_partial = partial( + process_file, + model_predictor_function=model_predictor_function, + output_s3_path=output_s3_path, + len_input_files=len(input_files), + ) import pickle + print(pickle.dumps(process_file)[:100]) # just to check if the function is pic + print( - pickle.dumps(process_file)[:100] + pickle.dumps(process_file_partial)[:100] ) # just to check if the function is picklable before we try to use it in multiprocessing. If this line raises an error, then the process_file function is not picklable and we won't be able to use it in multiprocessing.Pool enumerated_input_files = list(enumerate(input_files)) with Pool(processes=parallelism) as pool: - prediction_counts = pool.map(process_file, enumerated_input_files) + prediction_counts = pool.map(process_file_partial, enumerated_input_files) total_predictions = sum(prediction_counts) print(f"Total predictions generated: {total_predictions}") From b7624cc7c6945d78a650875529e26c402a880af3 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:21:19 +0530 Subject: [PATCH 020/167] feat: replace itertools.partial with functools.partial for improved performance --- src/ds_platform_utils/metaflow/batch_inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 1c8bfa1..16e0514 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -92,7 +92,7 @@ def batch_inference( # noqa: PLR0913 (too many arguments) # Step 3: Process each file through the model and write predictions to S3 - from itertools import partial + from functools import partial process_file_partial = partial( process_file, From ccc1e29849d2f160bcd407a4e83909a6236f33ce Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:32:14 +0530 Subject: [PATCH 021/167] feat: replace multiprocessing.Pool with metaflow.parallel_map for improved parallel processing --- src/ds_platform_utils/metaflow/batch_inference.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 16e0514..687f0fb 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -1,4 +1,3 @@ -from multiprocessing import Pool from pathlib import Path from typing import Callable, List, Optional, Tuple, Union @@ -110,8 +109,9 @@ def batch_inference( # noqa: PLR0913 (too many arguments) ) # just to check if the function is picklable before we try to use it in multiprocessing. If this line raises an error, then the process_file function is not picklable and we won't be able to use it in multiprocessing.Pool enumerated_input_files = list(enumerate(input_files)) - with Pool(processes=parallelism) as pool: - prediction_counts = pool.map(process_file_partial, enumerated_input_files) + from metaflow import parallel_map + + prediction_counts = parallel_map(process_file_partial, enumerated_input_files, max_parallel=parallelism) total_predictions = sum(prediction_counts) print(f"Total predictions generated: {total_predictions}") From c7f9b5bd3678675e295d7189b9a38f9fc3013464 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:41:08 +0530 Subject: [PATCH 022/167] feat: refactor process_file function for improved integration with parallel_map --- .../metaflow/batch_inference.py | 40 +++++-------------- 1 file changed, 11 insertions(+), 29 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 687f0fb..a0c7ea9 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -21,18 +21,6 @@ from ds_platform_utils.metaflow.s3 import _download_all_files_in_s3_folder, _put_df_to_s3_file -def process_file(args, model_predictor_function, output_s3_path, len_input_files): - file_idx, input_file = args - print(f"Processing file {file_idx + 1}/{len_input_files}") - input_df = pd.read_parquet(input_file) - predictions_df = model_predictor_function(input_df) - _put_df_to_s3_file( - df=predictions_df, - path=f"{output_s3_path}/data_part_{file_idx}.parquet", - ) - return len(predictions_df) - - def batch_inference( # noqa: PLR0913 (too many arguments) input_query: Union[str, Path], output_table_name: str, @@ -91,27 +79,21 @@ def batch_inference( # noqa: PLR0913 (too many arguments) # Step 3: Process each file through the model and write predictions to S3 - from functools import partial - - process_file_partial = partial( - process_file, - model_predictor_function=model_predictor_function, - output_s3_path=output_s3_path, - len_input_files=len(input_files), - ) - - import pickle - - print(pickle.dumps(process_file)[:100]) # just to check if the function is pic - - print( - pickle.dumps(process_file_partial)[:100] - ) # just to check if the function is picklable before we try to use it in multiprocessing. If this line raises an error, then the process_file function is not picklable and we won't be able to use it in multiprocessing.Pool + def process_file(args): + file_idx, input_file = args + print(f"Processing file {file_idx + 1}/{len(input_files)}") + input_df = pd.read_parquet(input_file) + predictions_df = model_predictor_function(input_df) + _put_df_to_s3_file( + df=predictions_df, + path=f"{output_s3_path}/data_part_{file_idx}.parquet", + ) + return len(predictions_df) enumerated_input_files = list(enumerate(input_files)) from metaflow import parallel_map - prediction_counts = parallel_map(process_file_partial, enumerated_input_files, max_parallel=parallelism) + prediction_counts = parallel_map(process_file, enumerated_input_files, max_parallel=parallelism) total_predictions = sum(prediction_counts) print(f"Total predictions generated: {total_predictions}") From 70984bdeb40eff3c40030c74d661a3598b5a9fa2 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:48:49 +0530 Subject: [PATCH 023/167] feat: replace parallel_map with concurrent.futures.ProcessPoolExecutor for improved parallel processing --- src/ds_platform_utils/metaflow/batch_inference.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index a0c7ea9..7750396 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -91,10 +91,12 @@ def process_file(args): return len(predictions_df) enumerated_input_files = list(enumerate(input_files)) - from metaflow import parallel_map + import concurrent.futures - prediction_counts = parallel_map(process_file, enumerated_input_files, max_parallel=parallelism) + with concurrent.futures.ProcessPoolExecutor(max_workers=parallelism) as executor: + prediction_counts = list(executor.map(process_file, enumerated_input_files)) + print("Predictions generated per file:") total_predictions = sum(prediction_counts) print(f"Total predictions generated: {total_predictions}") From 7fe6bf4d0f109a3c52018f14439cf340677a46c4 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 9 Feb 2026 19:50:13 +0530 Subject: [PATCH 024/167] feat: switch from ProcessPoolExecutor to ThreadPoolExecutor for improved concurrency in batch inference --- src/ds_platform_utils/metaflow/batch_inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 7750396..1cd0791 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -93,7 +93,7 @@ def process_file(args): enumerated_input_files = list(enumerate(input_files)) import concurrent.futures - with concurrent.futures.ProcessPoolExecutor(max_workers=parallelism) as executor: + with concurrent.futures.ThreadPoolExecutor(max_workers=parallelism) as executor: prediction_counts = list(executor.map(process_file, enumerated_input_files)) print("Predictions generated per file:") From 4bab774b096fbe86ad55d78d8fb00287347eb31a Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 00:38:32 +0530 Subject: [PATCH 025/167] feat: enhance batch inference and S3 integration with schema inference and temporary file handling --- .../metaflow/batch_inference.py | 68 ++++++++------ src/ds_platform_utils/metaflow/pandas.py | 92 +++++++++---------- 2 files changed, 81 insertions(+), 79 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 1cd0791..844efe3 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -1,3 +1,4 @@ +import tempfile from pathlib import Path from typing import Callable, List, Optional, Tuple, Union @@ -17,17 +18,18 @@ _generate_s3_to_snowflake_copy_query, _generate_snowflake_to_s3_copy_query, _get_s3_config, + _infer_table_schema, ) -from ds_platform_utils.metaflow.s3 import _download_all_files_in_s3_folder, _put_df_to_s3_file +from ds_platform_utils.metaflow.s3 import _get_metaflow_s3_client, _list_files_in_s3_folder def batch_inference( # noqa: PLR0913 (too many arguments) input_query: Union[str, Path], output_table_name: str, - output_table_schema: List[Tuple[str, str]], model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], + output_table_schema: Optional[List[Tuple[str, str]]] = None, use_utc: bool = True, - batch_size_in_mb: int = 100, + batch_size_in_mb: int = 16, parallelism: int = 1, warehouse: Optional[str] = None, ctx: Optional[dict] = None, @@ -67,46 +69,56 @@ def batch_inference( # noqa: PLR0913 (too many arguments) _execute_sql(conn, copy_to_s3_query) conn.close() - # Step 2: Get input files from S3 and apply model predictor function to generate output dataframe + s3_files = _list_files_in_s3_folder(input_s3_path) + s3 = _get_metaflow_s3_client() + local_input_files = [obj.path for obj in s3.get_many(s3_files)] - input_files = _download_all_files_in_s3_folder(input_s3_path) - - if not input_files: - raise ValueError(f"No input files found in S3 path: {input_s3_path}") + temp_folder = tempfile.TemporaryDirectory() + local_output_path = temp_folder.name + s3_local_mapping = [] current.card.append(Markdown("#### Input query results")) - current.card.append(Table.from_dataframe(pd.read_parquet(input_files[0]))) + current.card.append(Table.from_dataframe(pd.read_parquet(local_input_files[0]).head(5))) + + def process_file(batch_id, input_files_batch): + print(f"Processing batch {batch_id}") + df = pd.read_parquet(input_files_batch) + predictions_df = model_predictor_function(df) + local_output_file = f"{local_output_path}/predictions_batch_{batch_id}.parquet" + s3_output_file = f"{output_s3_path}/predictions_batch_{batch_id}.parquet" + predictions_df.to_parquet(local_output_file, index=False) + return s3_output_file, local_output_file - # Step 3: Process each file through the model and write predictions to S3 + # enumerated_input_files = list(enumerate(local_input_files)) - def process_file(args): - file_idx, input_file = args - print(f"Processing file {file_idx + 1}/{len(input_files)}") - input_df = pd.read_parquet(input_file) - predictions_df = model_predictor_function(input_df) - _put_df_to_s3_file( - df=predictions_df, - path=f"{output_s3_path}/data_part_{file_idx}.parquet", - ) - return len(predictions_df) + from concurrent.futures import ThreadPoolExecutor - enumerated_input_files = list(enumerate(input_files)) - import concurrent.futures + print("Starting batch inference...") + print(f"Total files to process: {len(local_input_files)}") + with ThreadPoolExecutor(max_workers=parallelism) as executor: + futures = [] + for i in range(0, len(local_input_files)): + batch_id = i + futures.append(executor.submit(process_file, batch_id, local_input_files[i])) - with concurrent.futures.ThreadPoolExecutor(max_workers=parallelism) as executor: - prediction_counts = list(executor.map(process_file, enumerated_input_files)) + for future in futures: + s3_local_mapping.append(future.result()) + print("Batch inference completed. Uploading results to S3...") - print("Predictions generated per file:") - total_predictions = sum(prediction_counts) - print(f"Total predictions generated: {total_predictions}") + s3.put_files(key_paths=s3_local_mapping) - # Step 4: Build COPY INTO query to load predictions from S3 back to Snowflake + s3.close() + temp_folder.cleanup() conn = get_snowflake_connection(use_utc) if warehouse is not None: _execute_sql(conn, f"USE WAREHOUSE {warehouse};") _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") + if output_table_schema is None: + # Infer schema from the first predictions file + output_table_schema = _infer_table_schema(conn, output_snowflake_stage_path, True) + copy_from_s3_query = _generate_s3_to_snowflake_copy_query( schema=schema, table_name=output_table_name, diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 7e1ae94..51eba65 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -90,7 +90,7 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 schema: str, table_name: str, snowflake_stage_path: str, - table_schema: List[Tuple[str, str]], + table_schema: Optional[List[Tuple[str, str]]] = None, overwrite: bool = True, auto_create_table: bool = True, use_logical_type: bool = True, @@ -138,32 +138,29 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 return "\n\n".join(sql_statements) -def _infer_snowflake_schema_from_df(df: pd.DataFrame) -> List[Tuple[str, str]]: - """Infer Snowflake table schema from a pandas DataFrame. - - This function maps pandas data types to corresponding Snowflake data types. - It returns a list of tuples, where each tuple contains a column name and its inferred Snowflake data type. +def _infer_table_schema(conn, snowflake_stage_path: str, use_logical_type: bool) -> List[Tuple[str, str]]: + """Infer Snowflake table schema from Parquet files in a Snowflake stage. - :param df: Input pandas DataFrame + :param snowflake_stage_path: The path to the Snowflake stage where the Parquet files are located. This should include the stage name and any necessary subfolders (e.g., 'my_snowflake_stage/my_folder'). :return: List of tuples with column names and inferred Snowflake data types """ - dtype_mapping = { - "object": "TEXT", - "int64": "NUMBER", - "float64": "FLOAT", - "bool": "BOOLEAN", - "datetime64[ns]": "TIMESTAMP_NTZ", - "datetime64[ns, tz]": "TIMESTAMP_TZ", - # Add more mappings as needed - } - - schema = [] - for col_name, dtype in df.dtypes.items(): - dtype_str = str(dtype) - snowflake_type = dtype_mapping.get(dtype_str, "STRING") # Default to STRING if type is not mapped - schema.append((col_name, snowflake_type)) - - return schema + _execute_sql( + conn, + f"CREATE OR REPLACE TEMP FILE FORMAT PQT_FILE_FORMATTYPE = PARQUETUSE_LOGICAL_TYPE = {use_logical_type};", + ) + infer_schema_query = f""" + SELECT column_name, data_type + FROM TABLE( + INFER_SCHEMA( + LOCATION => '@{snowflake_stage_path}', + FILE_FORMAT => 'PQT_FILE_FORMAT' + )); + """ + cursor = _execute_sql(conn, infer_schema_query) + if cursor is None: + raise ValueError("Failed to infer schema: No cursor returned from Snowflake.") + result = cursor.fetch_pandas_all() + return list(zip(result["COLUMN_NAME"], result["TYPE"])) def publish_pandas( # noqa: PLR0913 (too many arguments) @@ -246,22 +243,11 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) current.card.append(Table.from_dataframe(df.head())) conn: SnowflakeConnection = get_snowflake_connection(use_utc) - - # set warehouse if warehouse is not None: _execute_sql(conn, f"USE WAREHOUSE {warehouse};") - - # set query tag for cost tracking in select.dev - # REASON: because write_pandas() doesn't allow modifying the SQL query to add SQL comments in it directly, - # so we set a session query tag instead. - tags = get_select_dev_query_tags() - query_tag_str = json.dumps(tags) - _execute_sql(conn, f"ALTER SESSION SET QUERY_TAG = '{query_tag_str}';") _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") if use_s3_stage: - if table_schema is None: - raise ValueError("table_schema is required when use_s3_stage is True.") s3_bucket, snowflake_stage = _get_s3_config(current.is_production) data_folder = "publish_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{data_folder}" @@ -276,6 +262,9 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) compression=compression, ) + if table_schema is None: + # Infer table schema from the Parquet files in the Snowflake stage + table_schema = _infer_table_schema(conn, sf_stage_path, use_logical_type) # Generate and execute Snowflake SQL to load data from S3 to Snowflake copy_query = _generate_s3_to_snowflake_copy_query( schema=schema, @@ -288,21 +277,22 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) ) _execute_sql(conn, copy_query) - # https://docs.snowflake.com/en/developer-guide/snowpark/reference/python/latest/snowpark/api/snowflake.snowpark.Session.write_pandas - write_pandas( - conn=conn, - df=df, - table_name=table_name, - schema=schema, - chunk_size=chunk_size, - compression=compression, - parallel=parallel, - quote_identifiers=quote_identifiers, - auto_create_table=auto_create_table, - overwrite=overwrite, - use_logical_type=use_logical_type, - ) - + else: + # https://docs.snowflake.com/en/developer-guide/snowpark/reference/python/latest/snowpark/api/snowflake.snowpark.Session.write_pandas + write_pandas( + conn=conn, + df=df, + table_name=table_name, + schema=schema, + chunk_size=chunk_size, + compression=compression, + parallel=parallel, + quote_identifiers=quote_identifiers, + auto_create_table=auto_create_table, + overwrite=overwrite, + use_logical_type=use_logical_type, + ) + conn.close() # Add a link to the table in Snowflake to the card table_url = _make_snowflake_table_url( database="PATTERN_DB", @@ -395,7 +385,7 @@ def query_pandas_from_snowflake( # force_return_table=True -- returns a Pyarrow Table always even if the result is empty result: pyarrow.Table = cursor_result.fetch_arrow_all(force_return_table=True) df = result.to_pandas() - + conn.close() df.columns = df.columns.str.lower() current.card.append(Markdown("### Query Result")) current.card.append(Table.from_dataframe(df.head())) From 139fc898adcf4e00087cf0f133eb329ebcb2dacc Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:39:56 +0530 Subject: [PATCH 026/167] feat: remove redundant import of ThreadPoolExecutor in batch_inference function --- src/ds_platform_utils/metaflow/batch_inference.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 844efe3..a2c7723 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -1,4 +1,5 @@ import tempfile +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Callable, List, Optional, Tuple, Union @@ -23,7 +24,7 @@ from ds_platform_utils.metaflow.s3 import _get_metaflow_s3_client, _list_files_in_s3_folder -def batch_inference( # noqa: PLR0913 (too many arguments) +def batch_inference( # noqa: PLR0913, PLR0915 input_query: Union[str, Path], output_table_name: str, model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], @@ -91,8 +92,6 @@ def process_file(batch_id, input_files_batch): # enumerated_input_files = list(enumerate(local_input_files)) - from concurrent.futures import ThreadPoolExecutor - print("Starting batch inference...") print(f"Total files to process: {len(local_input_files)}") with ThreadPoolExecutor(max_workers=parallelism) as executor: From cfa7831b17ea2cee2810d1210ab9995a7254d752 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:45:36 +0530 Subject: [PATCH 027/167] feat: streamline S3 file handling with temporary file management and remove unused functions --- .../metaflow/batch_inference.py | 35 ++++------------- src/ds_platform_utils/metaflow/s3.py | 39 ++++++++----------- 2 files changed, 24 insertions(+), 50 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index a2c7723..8ff7d28 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -1,4 +1,3 @@ -import tempfile from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Callable, List, Optional, Tuple, Union @@ -9,6 +8,7 @@ from ds_platform_utils._snowflake.run_query import _execute_sql from ds_platform_utils._snowflake.write_audit_publish import get_query_from_string_or_fpath, substitute_map_into_string +from ds_platform_utils.metaflow import s3 from ds_platform_utils.metaflow._consts import ( NON_PROD_SCHEMA, PROD_SCHEMA, @@ -21,7 +21,6 @@ _get_s3_config, _infer_table_schema, ) -from ds_platform_utils.metaflow.s3 import _get_metaflow_s3_client, _list_files_in_s3_folder def batch_inference( # noqa: PLR0913, PLR0915 @@ -70,45 +69,27 @@ def batch_inference( # noqa: PLR0913, PLR0915 _execute_sql(conn, copy_to_s3_query) conn.close() - s3_files = _list_files_in_s3_folder(input_s3_path) - s3 = _get_metaflow_s3_client() - local_input_files = [obj.path for obj in s3.get_many(s3_files)] - - temp_folder = tempfile.TemporaryDirectory() - local_output_path = temp_folder.name - s3_local_mapping = [] - + input_s3_files = s3._list_files_in_s3_folder(input_s3_path) current.card.append(Markdown("#### Input query results")) - current.card.append(Table.from_dataframe(pd.read_parquet(local_input_files[0]).head(5))) + current.card.append(Table.from_dataframe(pd.read_parquet(s3._get_df_from_s3_file(input_s3_files[0])).head(5))) def process_file(batch_id, input_files_batch): print(f"Processing batch {batch_id}") - df = pd.read_parquet(input_files_batch) + df = pd.read_parquet(s3._get_df_from_s3_files(input_files_batch)) predictions_df = model_predictor_function(df) - local_output_file = f"{local_output_path}/predictions_batch_{batch_id}.parquet" s3_output_file = f"{output_s3_path}/predictions_batch_{batch_id}.parquet" - predictions_df.to_parquet(local_output_file, index=False) - return s3_output_file, local_output_file - - # enumerated_input_files = list(enumerate(local_input_files)) + s3._put_df_to_s3_file(predictions_df, s3_output_file) print("Starting batch inference...") - print(f"Total files to process: {len(local_input_files)}") + print(f"Total files to process: {len(input_s3_files)}") with ThreadPoolExecutor(max_workers=parallelism) as executor: futures = [] - for i in range(0, len(local_input_files)): + for i in range(0, len(input_s3_files)): batch_id = i - futures.append(executor.submit(process_file, batch_id, local_input_files[i])) + futures.append(executor.submit(process_file, batch_id, input_s3_files[i])) - for future in futures: - s3_local_mapping.append(future.result()) print("Batch inference completed. Uploading results to S3...") - s3.put_files(key_paths=s3_local_mapping) - - s3.close() - temp_folder.cleanup() - conn = get_snowflake_connection(use_utc) if warehouse is not None: _execute_sql(conn, f"USE WAREHOUSE {warehouse};") diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 7977561..c286995 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -1,3 +1,5 @@ +import tempfile + import pandas as pd from metaflow import S3 @@ -14,14 +16,6 @@ def _list_files_in_s3_folder(path: str) -> list: return [path.url for path in s3.list_paths([path])] -def _download_all_files_in_s3_folder(path: str) -> list: - if not path.startswith("s3://"): - raise ValueError("Invalid S3 URI. Must start with 's3://'.") - - s3 = _get_metaflow_s3_client() - return [obj.path for obj in s3.get_many(_list_files_in_s3_folder(path))] - - def _get_df_from_s3_file(path: str) -> pd.DataFrame: if not path.startswith("s3://"): raise ValueError("Invalid S3 URI. Must start with 's3://'.") @@ -52,10 +46,9 @@ def _put_df_to_s3_file(df: pd.DataFrame, path: str) -> None: raise ValueError("Invalid S3 URI. Must start with 's3://'.") with _get_metaflow_s3_client() as s3: - timestamp_str = pd.Timestamp("now").strftime("%Y%m%d_%H%M%S_%f") - local_path = f"/tmp/{timestamp_str}.parquet" - df.to_parquet(local_path) - s3.put_files(key_paths=[[path, local_path]]) + with tempfile.NamedTemporaryFile(suffix=".parquet") as tmp_file: + df.to_parquet(tmp_file.name) + s3.put_files(key_paths=[[path, tmp_file.name]]) def _put_df_to_s3_folder(df: pd.DataFrame, path: str, chunk_size=None, compression="snappy") -> None: @@ -77,14 +70,14 @@ def estimate_bytes_per_row(df_sample): chunk_size = int(target_chunk_size_bytes / bytes_per_row) chunk_size = max(1, chunk_size) - with _get_metaflow_s3_client() as s3: - timestamp = pd.Timestamp("now").strftime("%Y%m%d_%H%M%S_%f") - local_path_template = f"/tmp/{timestamp}_data_part_{{}}.parquet" - key_paths = [] - num_rows = df.shape[0] - for i in range(0, num_rows, chunk_size): - local_path = local_path_template.format(i // chunk_size) - df.iloc[i : i + chunk_size].to_parquet(local_path, index=False, compression=compression) - s3_path = f"{path}/data_part_{i // chunk_size}.parquet" - key_paths.append([s3_path, local_path]) - s3.put_files(key_paths=key_paths) + with tempfile.TemporaryDirectory() as temp_dir: + with _get_metaflow_s3_client() as s3: + template_path = f"{temp_dir}/data_part_{{}}.parquet" + key_paths = [] + num_rows = df.shape[0] + for i in range(0, num_rows, chunk_size): + local_path = template_path.format(i // chunk_size) + df.iloc[i : i + chunk_size].to_parquet(local_path, index=False, compression=compression) + s3_path = f"{path}/data_part_{i // chunk_size}.parquet" + key_paths.append([s3_path, local_path]) + s3.put_files(key_paths=key_paths) From 7740a61f21db4a48628bc96b43acbfb9100b233a Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:48:25 +0530 Subject: [PATCH 028/167] feat: add logging for data export process in batch inference --- src/ds_platform_utils/metaflow/batch_inference.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 8ff7d28..f54c909 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -66,8 +66,10 @@ def batch_inference( # noqa: PLR0913, PLR0915 snowflake_stage_path=input_snowflake_stage_path, batch_size_in_mb=batch_size_in_mb, ) + print("Exporting data from Snowflake to S3...") _execute_sql(conn, copy_to_s3_query) conn.close() + print("Data export completed. Starting batch inference...") input_s3_files = s3._list_files_in_s3_folder(input_s3_path) current.card.append(Markdown("#### Input query results")) From bfd8dcba7f02ccc65605c3cfd109fc8d6c15913d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:53:41 +0530 Subject: [PATCH 029/167] feat: add timing logs for data export and batch inference processes --- .../metaflow/batch_inference.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index f54c909..099a261 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -1,3 +1,4 @@ +import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Callable, List, Optional, Tuple, Union @@ -66,10 +67,12 @@ def batch_inference( # noqa: PLR0913, PLR0915 snowflake_stage_path=input_snowflake_stage_path, batch_size_in_mb=batch_size_in_mb, ) + t0 = time.time() print("Exporting data from Snowflake to S3...") _execute_sql(conn, copy_to_s3_query) conn.close() - print("Data export completed. Starting batch inference...") + t1 = time.time() + print(f"Data export completed in {t1 - t0:.2f} seconds. Starting batch inference...") input_s3_files = s3._list_files_in_s3_folder(input_s3_path) current.card.append(Markdown("#### Input query results")) @@ -77,10 +80,18 @@ def batch_inference( # noqa: PLR0913, PLR0915 def process_file(batch_id, input_files_batch): print(f"Processing batch {batch_id}") + print(f"Reading input files for batch {batch_id} from S3...") + t1 = time.time() df = pd.read_parquet(s3._get_df_from_s3_files(input_files_batch)) + t2 = time.time() + print(f"Read {len(input_files_batch)} files with {len(df)} rows in {t2 - t1:.2f} seconds.") predictions_df = model_predictor_function(df) + t3 = time.time() + print(f"Generated predictions for batch {batch_id} in {t3 - t2:.2f} seconds.") s3_output_file = f"{output_s3_path}/predictions_batch_{batch_id}.parquet" s3._put_df_to_s3_file(predictions_df, s3_output_file) + t4 = time.time() + print(f"Uploaded predictions for batch {batch_id} to S3 in {t4 - t3:.2f} seconds.") print("Starting batch inference...") print(f"Total files to process: {len(input_s3_files)}") @@ -109,5 +120,11 @@ def process_file(batch_id, input_files_batch): auto_create_table=True, table_schema=output_table_schema, ) + t0 = time.time() + print("Copying predictions from S3 to Snowflake...") _execute_sql(conn, copy_from_s3_query) + t1 = time.time() + print(f"Data import completed in {t1 - t0:.2f} seconds}.") + + conn.close() From e66fcda63b4ce98388077121338eda9d473d6bdd Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:56:39 +0530 Subject: [PATCH 030/167] feat: update batch file processing to handle single S3 file input and improve logging --- src/ds_platform_utils/metaflow/batch_inference.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 099a261..22eb229 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -78,13 +78,13 @@ def batch_inference( # noqa: PLR0913, PLR0915 current.card.append(Markdown("#### Input query results")) current.card.append(Table.from_dataframe(pd.read_parquet(s3._get_df_from_s3_file(input_s3_files[0])).head(5))) - def process_file(batch_id, input_files_batch): + def process_file(batch_id, input_s3_file): print(f"Processing batch {batch_id}") print(f"Reading input files for batch {batch_id} from S3...") t1 = time.time() - df = pd.read_parquet(s3._get_df_from_s3_files(input_files_batch)) + df = pd.read_parquet(s3._get_df_from_s3_file(input_s3_file)) t2 = time.time() - print(f"Read {len(input_files_batch)} files with {len(df)} rows in {t2 - t1:.2f} seconds.") + print(f"Read {len(input_s3_file)} files with {len(df)} rows in {t2 - t1:.2f} seconds.") predictions_df = model_predictor_function(df) t3 = time.time() print(f"Generated predictions for batch {batch_id} in {t3 - t2:.2f} seconds.") @@ -124,7 +124,6 @@ def process_file(batch_id, input_files_batch): print("Copying predictions from S3 to Snowflake...") _execute_sql(conn, copy_from_s3_query) t1 = time.time() - print(f"Data import completed in {t1 - t0:.2f} seconds}.") - - + print(f"Data import completed in {t1 - t0:.2f} seconds.") + conn.close() From ea250c64c017f8121172ce7e85e23436e65a4e38 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:58:15 +0530 Subject: [PATCH 031/167] feat: fix data retrieval from S3 by removing unnecessary parquet read step --- src/ds_platform_utils/metaflow/batch_inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 22eb229..a21e1d6 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -76,7 +76,7 @@ def batch_inference( # noqa: PLR0913, PLR0915 input_s3_files = s3._list_files_in_s3_folder(input_s3_path) current.card.append(Markdown("#### Input query results")) - current.card.append(Table.from_dataframe(pd.read_parquet(s3._get_df_from_s3_file(input_s3_files[0])).head(5))) + current.card.append(Table.from_dataframe(s3._get_df_from_s3_file(input_s3_files[0]).head(5))) def process_file(batch_id, input_s3_file): print(f"Processing batch {batch_id}") From b23c84567ffffdd46e0845c39b3b85205eec8ea2 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:19:00 +0530 Subject: [PATCH 032/167] feat: optimize batch inference by removing unnecessary parquet read step and ensuring all futures complete --- src/ds_platform_utils/metaflow/batch_inference.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index a21e1d6..1cedbc4 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -82,9 +82,9 @@ def process_file(batch_id, input_s3_file): print(f"Processing batch {batch_id}") print(f"Reading input files for batch {batch_id} from S3...") t1 = time.time() - df = pd.read_parquet(s3._get_df_from_s3_file(input_s3_file)) + df = s3._get_df_from_s3_file(input_s3_file) t2 = time.time() - print(f"Read {len(input_s3_file)} files with {len(df)} rows in {t2 - t1:.2f} seconds.") + print(f"Read file with {len(df)} rows in {t2 - t1:.2f} seconds.") predictions_df = model_predictor_function(df) t3 = time.time() print(f"Generated predictions for batch {batch_id} in {t3 - t2:.2f} seconds.") @@ -100,6 +100,9 @@ def process_file(batch_id, input_s3_file): for i in range(0, len(input_s3_files)): batch_id = i futures.append(executor.submit(process_file, batch_id, input_s3_files[i])) + # Wait for all futures to complete + for future in futures: + future.result() print("Batch inference completed. Uploading results to S3...") From 789b5b6f65a459ca5c46c9e0867677039795d5fe Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:42:18 +0530 Subject: [PATCH 033/167] feat: update batch size handling and improve file processing in batch inference --- .../metaflow/batch_inference.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 1cedbc4..3f78f52 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -23,6 +23,8 @@ _infer_table_schema, ) +default_file_size_in_mb = 16 + def batch_inference( # noqa: PLR0913, PLR0915 input_query: Union[str, Path], @@ -30,7 +32,7 @@ def batch_inference( # noqa: PLR0913, PLR0915 model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], output_table_schema: Optional[List[Tuple[str, str]]] = None, use_utc: bool = True, - batch_size_in_mb: int = 16, + batch_size_in_mb: int = 128, parallelism: int = 1, warehouse: Optional[str] = None, ctx: Optional[dict] = None, @@ -65,7 +67,7 @@ def batch_inference( # noqa: PLR0913, PLR0915 copy_to_s3_query = _generate_snowflake_to_s3_copy_query( query=input_query, snowflake_stage_path=input_snowflake_stage_path, - batch_size_in_mb=batch_size_in_mb, + batch_size_in_mb=default_file_size_in_mb, ) t0 = time.time() print("Exporting data from Snowflake to S3...") @@ -74,15 +76,17 @@ def batch_inference( # noqa: PLR0913, PLR0915 t1 = time.time() print(f"Data export completed in {t1 - t0:.2f} seconds. Starting batch inference...") + batch_size = max(1, batch_size_in_mb // default_file_size_in_mb) + input_s3_files = s3._list_files_in_s3_folder(input_s3_path) current.card.append(Markdown("#### Input query results")) current.card.append(Table.from_dataframe(s3._get_df_from_s3_file(input_s3_files[0]).head(5))) - def process_file(batch_id, input_s3_file): + def process_file(batch_id, input_s3_files): print(f"Processing batch {batch_id}") print(f"Reading input files for batch {batch_id} from S3...") t1 = time.time() - df = s3._get_df_from_s3_file(input_s3_file) + df = s3._get_df_from_s3_files(input_s3_files) t2 = time.time() print(f"Read file with {len(df)} rows in {t2 - t1:.2f} seconds.") predictions_df = model_predictor_function(df) @@ -97,9 +101,10 @@ def process_file(batch_id, input_s3_file): print(f"Total files to process: {len(input_s3_files)}") with ThreadPoolExecutor(max_workers=parallelism) as executor: futures = [] - for i in range(0, len(input_s3_files)): - batch_id = i - futures.append(executor.submit(process_file, batch_id, input_s3_files[i])) + for i in range(0, len(input_s3_files), batch_size): + batch_id = i // batch_size + batch_files = input_s3_files[i : i + batch_size] + futures.append(executor.submit(process_file, batch_id, batch_files)) # Wait for all futures to complete for future in futures: future.result() From 4172057cda7182a3a20ef5d2f25ceff89b93d7bd Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:45:54 +0530 Subject: [PATCH 034/167] feat: add polars dependency for enhanced data processing capabilities --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 4ba58fd..cc581f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "pandas", "jinja2", "sqlparse>=0.5.3", + "polars>=1.36.1", ] [dependency-groups] From ad87d29ecfb292812f470da23d9be6f7c4895f1f Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:46:38 +0530 Subject: [PATCH 035/167] feat: switch from pandas to polars for improved performance in S3 file retrieval --- src/ds_platform_utils/metaflow/s3.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index c286995..047ce7c 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -1,6 +1,7 @@ import tempfile import pandas as pd +import polars as pl from metaflow import S3 @@ -30,7 +31,7 @@ def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: with _get_metaflow_s3_client() as s3: df_paths = [obj.path for obj in s3.get_many(paths)] - return pd.read_parquet(df_paths) + return pl.read_parquet(df_paths).to_pandas() def _get_df_from_s3_folder(path: str) -> pd.DataFrame: From a5662b77f05c651cb19a0800dcf3b48eaf63a529 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:42:32 +0530 Subject: [PATCH 036/167] feat: remove pandas_via_s3_stage module to streamline data processing with polars --- .../metaflow/pandas_via_s3_stage.py | 534 ------------------ 1 file changed, 534 deletions(-) delete mode 100644 src/ds_platform_utils/metaflow/pandas_via_s3_stage.py diff --git a/src/ds_platform_utils/metaflow/pandas_via_s3_stage.py b/src/ds_platform_utils/metaflow/pandas_via_s3_stage.py deleted file mode 100644 index 4e85ded..0000000 --- a/src/ds_platform_utils/metaflow/pandas_via_s3_stage.py +++ /dev/null @@ -1,534 +0,0 @@ -"""Pandas operations for Snowflake via S3 stage - optimized for large-scale data transfers. - -This module provides efficient data transfer between Snowflake and Pandas DataFrames using S3 as -an intermediate staging area. This approach is significantly faster for large datasets compared -to direct database connections. - -Use these functions when: -- Querying large result sets (>10M rows) from Snowflake -- Writing large DataFrames (>10M rows) to Snowflake -- Processing batch predictions with large datasets - -The functions automatically handle: -- Dev/prod environment switching via current.is_production -- Temporary S3 folder creation with timestamps -- Parquet file chunking for optimal performance -- Metaflow card integration for visibility -""" - -import json -from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple, Union - -import pandas as pd -from metaflow import current -from metaflow.cards import Markdown, Table - -from ds_platform_utils._snowflake.run_query import _execute_sql -from ds_platform_utils.metaflow._consts import ( - DEV_S3_BUCKET, - DEV_SNOWFLAKE_STAGE, - NON_PROD_SCHEMA, - PROD_S3_BUCKET, - PROD_SCHEMA, - PROD_SNOWFLAKE_STAGE, -) -from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query, get_snowflake_connection -from ds_platform_utils.metaflow.pandas import TWarehouse -from ds_platform_utils.metaflow.s3 import ( - _get_df_from_s3_files, - _get_df_from_s3_folder, - _list_files_in_s3_folder, - _put_df_to_s3_folder, -) -from ds_platform_utils.metaflow.write_audit_publish import ( - add_comment_to_each_sql_statement, - get_select_dev_query_tags, -) - - -def _get_s3_config(is_production: bool) -> Tuple[str, str]: - """Return the appropriate S3 bucket and Snowflake stage based on the environment.""" - if is_production: - s3_bucket = PROD_S3_BUCKET - snowflake_stage = PROD_SNOWFLAKE_STAGE - else: - s3_bucket = DEV_S3_BUCKET - snowflake_stage = DEV_SNOWFLAKE_STAGE - - return s3_bucket, snowflake_stage - - -def _generate_snowflake_to_s3_copy_query( - query: str, - snowflake_stage: str, - s3_folder_path: str, - file_name: str = "data.parquet", -) -> str: - """Generate SQL COPY INTO command to export Snowflake query results to S3. - - :param query: SQL query to execute - :param snowflake_stage: Snowflake stage name (e.g., 'DEV_OUTERBOUNDS_S3_STAGE') - :param s3_folder_path: Relative S3 folder path within the stage (e.g., 'temp/query_20260205_123456') - :param file_name: Output file name. Default 'data.parquet' - :return: COPY INTO SQL command - """ - copy_query = f""" - COPY INTO @{snowflake_stage}/{s3_folder_path}/{file_name} - FROM ({query}) - OVERWRITE = TRUE - FILE_FORMAT = (TYPE = 'parquet') - HEADER = TRUE; - """ - return copy_query - - -def _generate_s3_to_snowflake_copy_query( - database: str, - schema: str, - table_name: str, - snowflake_stage: str, - s3_folder_path: str, - table_schema: List[Tuple[str, str]], - overwrite: bool = True, - auto_create_table: bool = True, -) -> str: - """Generate SQL commands to load data from S3 to Snowflake table. - - This function generates a complete SQL script that includes: - 1. DROP TABLE IF EXISTS (if overwrite=True) - 2. CREATE TABLE IF NOT EXISTS (if auto_create_table=True or overwrite=True) - 3. COPY INTO command to load data from S3 - - :param database: Snowflake database name (e.g., 'PATTERN_DB') - :param schema: Snowflake schema name (e.g., 'DATA_SCIENCE' or 'DATA_SCIENCE_STAGE') - :param table_name: Target table name - :param snowflake_stage: Snowflake stage name (e.g., 'DEV_OUTERBOUNDS_S3_STAGE') - :param s3_folder_path: Relative S3 folder path within the stage - :param table_schema: List of tuples with column names and types - :param overwrite: If True, drop and recreate the table. Default True - :param auto_create_table: If True, create the table if it doesn't exist. Default True - :return: Complete SQL script with table management and COPY INTO commands - """ - sql_statements = [] - - # Step 1: Drop table if overwrite is True - if overwrite: - sql_statements.append(f"DROP TABLE IF EXISTS {database}.{schema}.{table_name};") - - # Step 2: Create table if auto_create_table or overwrite - if auto_create_table or overwrite: - table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_schema]) - create_table_query = ( - f"""CREATE TABLE IF NOT EXISTS {database}.{schema}.{table_name} ( {table_create_columns_str} );""" - ) - sql_statements.append(create_table_query) - - # Step 3: Generate COPY INTO command - columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_schema]) - - copy_query = f"""COPY INTO {database}.{schema}.{table_name} FROM ( - SELECT {columns_str} - FROM @{snowflake_stage}/{s3_folder_path}/ ) - FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = TRUE);""" - sql_statements.append(copy_query) - - # Combine all statements - return "\n\n".join(sql_statements) - - -def query_pandas_from_snowflake_via_s3_stage( - query: Union[str, Path], - warehouse: Optional[TWarehouse] = None, - ctx: Optional[Dict[str, Any]] = None, - use_utc: bool = True, -) -> pd.DataFrame: - """Query Snowflake and return large result sets efficiently via S3 stage. - - This function is optimized for large query results (>10M rows). It uses Snowflake's - COPY INTO command to export query results to S3, then reads the parquet files from S3. - This is significantly faster than using cursor.fetch_pandas_all() for large datasets. - - The function automatically: - - Creates a timestamp-based temporary folder in S3 - - Exports query results to parquet files in S3 - - Reads and combines all parquet files into a single DataFrame - - Uses the appropriate S3 bucket/stage based on current.is_production - - :param query: SQL query string or path to a .sql file - :param warehouse: The Snowflake warehouse to use. Defaults to shared warehouse based on environment. - :param ctx: Context dictionary to substitute into the query string - :param use_utc: Whether to set the Snowflake session to use UTC time zone. Default is True. - :return: DataFrame containing the results of the query - - Example: - >>> df = query_pandas_from_snowflake_via_s3_stage( - ... query="SELECT * FROM LARGE_TABLE LIMIT 100000000", - ... warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH" - ... ) - - """ - from ds_platform_utils._snowflake.write_audit_publish import ( - get_query_from_string_or_fpath, - substitute_map_into_string, - ) - - # adding query tags comment in query for cost tracking in select.dev - tags = get_select_dev_query_tags() - query_comment_str = f"\n\n/* {json.dumps(tags)} */" - query = get_query_from_string_or_fpath(query) - query = add_comment_to_each_sql_statement(query, query_comment_str) - - schema = PROD_SCHEMA if current.is_production else NON_PROD_SCHEMA - if "{{schema}}" in query or "{{ schema }}" in query: - query = substitute_map_into_string(query, {"schema": schema}) - if ctx: - query = substitute_map_into_string(query, ctx) - - # print query if DEBUG_QUERY env var is set - _debug_print_query(query) - - # Determine environment - s3_bucket, snowflake_stage = _get_s3_config(current.is_production if hasattr(current, "is_production") else False) - - # Create timestamp-based temporary folder - timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") - temp_folder = f"temp/query_{timestamp}" - s3_path = f"{s3_bucket}/{temp_folder}/" - - # Build COPY INTO query to export results to S3 - copy_query = _generate_snowflake_to_s3_copy_query( - query=query, - snowflake_stage=snowflake_stage, - s3_folder_path=temp_folder, - file_name="data.parquet", - ) - - # Add to Metaflow card - if warehouse is not None: - current.card.append(Markdown(f"### Using Warehouse: `{warehouse}`")) - current.card.append(Markdown(f"### S3 Staging Path: `{s3_path}`")) - current.card.append(Markdown(f"### Query:\n```sql\n{query}\n```")) - - # Execute query - conn = get_snowflake_connection(use_utc) - - if warehouse is not None: - _execute_sql(conn, f"USE WAREHOUSE {warehouse};") - - _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") - - # Copy data to S3 - _execute_sql(conn, copy_query) - conn.close() - - # Read data from S3 - df = _get_df_from_s3_folder(s3_path) - - # Lowercase column names for consistency - df.columns = df.columns.str.lower() - - # Add preview to card - current.card.append(Markdown("### Query Result Preview")) - current.card.append(Table.from_dataframe(df.head(10))) - current.card.append(Markdown(f"**Total rows:** {len(df):,}")) - - return df - - -def publish_pandas_via_s3_stage( # noqa: PLR0913 (too many arguments) - table_name: str, - df: pd.DataFrame, - table_schema: List[Tuple[str, str]], - chunk_size: int = 100000, - warehouse: Optional[TWarehouse] = None, - overwrite: bool = True, - auto_create_table: bool = True, - use_utc: bool = True, -) -> None: - """Write large DataFrame to Snowflake table efficiently via S3 stage. - - This function is optimized for large DataFrames (>10M rows). It uploads the DataFrame - as parquet files to S3, then uses Snowflake's COPY INTO command to load the data. - This is significantly faster than using write_pandas() for large datasets. - - The function automatically: - - Chunks the DataFrame into batches and writes to S3 as parquet files - - Creates or overwrites the target table based on parameters - - Loads all parquet files from S3 into Snowflake - - Uses the appropriate S3 bucket/stage based on current.is_production - - :param table_name: Name of the Snowflake table to create/update - :param df: DataFrame to write to Snowflake - :param table_schema: List of tuples defining column names and types. - Example: [("col1", "VARCHAR(255)"), ("col2", "INTEGER")] - :param chunk_size: Number of rows per parquet file. Default 100,000 - :param warehouse: The Snowflake warehouse to use. Defaults to shared warehouse based on environment. - :param overwrite: If True, drop and recreate the table. Default True - :param auto_create_table: If True, create the table if it doesn't exist. Default True - :param use_utc: Whether to set the Snowflake session to use UTC time zone. Default is True - - Example: - >>> schema = [ - ... ("asin", "VARCHAR(255)"), - ... ("date", "DATE"), - ... ("forecast", "FLOAT") - ... ] - >>> publish_pandas_via_s3_stage( - ... table_name="FORECAST_RESULTS", - ... df=large_df, - ... table_schema=schema, - ... warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" - ... ) - - """ - if not isinstance(df, pd.DataFrame): - raise TypeError("df must be a pandas DataFrame.") - - if df.empty: - raise ValueError("DataFrame is empty.") - - # Determine environment - is_production = current.is_production if hasattr(current, "is_production") else False - s3_bucket, snowflake_stage = _get_s3_config(is_production) - schema = PROD_SCHEMA if is_production else NON_PROD_SCHEMA - - table_name = table_name.upper() - - # Create timestamp-based temporary folder - timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") - upload_folder = f"temp/publish_{timestamp}" - s3_path = f"{s3_bucket}/{upload_folder}" - - # Add to Metaflow card - environment = "PROD" if is_production else "DEV" - current.card.append(Markdown(f"## Publishing DataFrame to Snowflake via S3 Stage ({environment})")) - if warehouse is not None: - current.card.append(Markdown(f"### Using Warehouse: `{warehouse}`")) - current.card.append(Markdown(f"### Target Table: `{schema}.{table_name}`")) - current.card.append(Markdown(f"### S3 Staging Path: `{s3_path}`")) - current.card.append(Markdown(f"### Rows: {len(df):,} | Columns: {len(df.columns)}")) - current.card.append(Table.from_dataframe(df.head())) - - # Upload DataFrame to S3 as parquet files - _put_df_to_s3_folder( - df=df, - path=s3_path, - chunk_size=chunk_size, - ) - - current.card.append(Markdown("### Uploaded parquet files to S3")) - - # Connect to Snowflake - conn = get_snowflake_connection(use_utc) - - if warehouse is not None: - _execute_sql(conn, f"USE WAREHOUSE {warehouse};") - - _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") - - # Generate and execute SQL to create table and load data from S3 - sql_commands = _generate_s3_to_snowflake_copy_query( - database="PATTERN_DB", - schema=schema, - table_name=table_name, - snowflake_stage=snowflake_stage, - s3_folder_path=upload_folder, - table_schema=table_schema, - overwrite=overwrite, - auto_create_table=auto_create_table, - ) - - current.card.append(Markdown("### Loading data from S3 to Snowflake...")) - - # Execute all SQL commands - _execute_sql(conn, sql_commands) - conn.close() - - # Add success message to card - from ds_platform_utils.metaflow.write_audit_publish import _make_snowflake_table_url - - table_url = _make_snowflake_table_url( - database="PATTERN_DB", - schema=schema, - table=table_name, - ) - current.card.append(Markdown(f"### ✅ Successfully published {len(df):,} rows")) - current.card.append(Markdown(f"[View table in Snowflake]({table_url})")) - - -def make_batch_predictions_from_snowflake_via_s3_stage( # noqa: PLR0913 (too many arguments) - input_query: Union[str, Path], - output_table_name: str, - output_table_schema: List[Tuple[str, str]], - model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], - warehouse: Optional[TWarehouse] = None, - ctx: Optional[Dict[str, Any]] = None, - use_utc: bool = True, -) -> None: - """Process large datasets through a model/function using S3 for efficient batch processing. - - This function implements an end-to-end pipeline for batch predictions: - 1. Query data from Snowflake → Export to S3 - 2. Read data from S3 file by file - 3. Process each file through the model_predictor_function - 4. Write predictions to S3 - 5. Load all predictions from S3 to Snowflake table - - This approach is memory-efficient for very large datasets as it processes data file by file - rather than loading everything into memory at once. - - :param input_query: SQL query to fetch input data from Snowflake - :param output_table_name: Name of the Snowflake table to write predictions to - :param output_table_schema: Schema for the output table. - Example: [("col1", "VARCHAR(255)"), ("col2", "FLOAT")] - :param model_predictor_function: Function that takes a DataFrame and returns a DataFrame of predictions. - Signature: fn(df: pd.DataFrame) -> pd.DataFrame - :param warehouse: The Snowflake warehouse to use. Defaults to shared warehouse based on environment. - :param ctx: Context dictionary to substitute into the input query - :param use_utc: Whether to set the Snowflake session to use UTC time zone. Default is True - - Example: - >>> def predict_fn(input_df: pd.DataFrame) -> pd.DataFrame: - ... # Your model prediction logic here - ... predictions = model.predict(input_df) - ... return pd.DataFrame({"asin": input_df["asin"], "forecast": predictions}) - ... - >>> output_schema = [("asin", "VARCHAR(255)"), ("forecast", "FLOAT")] - >>> make_batch_predictions_from_snowflake_via_s3_stage( - ... input_query="SELECT * FROM INPUT_TABLE", - ... output_table_name="PREDICTIONS", - ... output_table_schema=output_schema, - ... model_predictor_function=predict_fn - ... ) - - """ - from ds_platform_utils._snowflake.write_audit_publish import ( - get_query_from_string_or_fpath, - substitute_map_into_string, - ) - - # Determine environment - is_production = current.is_production if hasattr(current, "is_production") else False - s3_bucket, snowflake_stage = _get_s3_config(is_production) - schema = PROD_SCHEMA if is_production else NON_PROD_SCHEMA - - output_table_name = output_table_name.upper() - - # Process input query - query = get_query_from_string_or_fpath(input_query) - - # Handle schema substitution - if "{{schema}}" in query or "{{ schema }}" in query: - query = substitute_map_into_string(query, {"schema": schema}) - - # Handle additional context substitution - if ctx: - query = substitute_map_into_string(query, ctx) - - # Create timestamps for input and output folders - input_timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") - output_timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") - input_temp_folder = f"temp/batch_input_{input_timestamp}" - output_temp_folder = f"temp/batch_output_{output_timestamp}" - input_s3_path = f"{s3_bucket}/{input_temp_folder}/" - output_s3_path = f"{s3_bucket}/{output_temp_folder}/" - - # Add to Metaflow card - environment = "PROD" if is_production else "DEV" - current.card.append(Markdown(f"## Batch Predictions Pipeline via S3 Stage ({environment})")) - if warehouse is not None: - current.card.append(Markdown(f"### Using Warehouse: `{warehouse}`")) - current.card.append(Markdown(f"### Output Table: `{schema}.{output_table_name}`")) - current.card.append(Markdown(f"### Input Query:\n```sql\n{query}\n```")) - - # Step 1: Export input data from Snowflake to S3 - current.card.append(Markdown("### Step 1: Exporting input data from Snowflake to S3...")) - - # Add query tags for cost tracking - tags = get_select_dev_query_tags() - query_comment_str = f"\n\n/* {json.dumps(tags)} */" - query_with_tags = add_comment_to_each_sql_statement(query, query_comment_str) - - # Build COPY INTO query to export data from Snowflake to S3 - copy_to_s3_query = _generate_snowflake_to_s3_copy_query( - query=query_with_tags, - snowflake_stage=snowflake_stage, - s3_folder_path=input_temp_folder, - file_name="data.parquet", - ) - - conn = get_snowflake_connection(use_utc) - - if warehouse is not None: - _execute_sql(conn, f"USE WAREHOUSE {warehouse};") - - # Set query tag for cost tracking - tags_json = json.dumps(tags) - _execute_sql(conn, f"ALTER SESSION SET QUERY_TAG = '{tags_json}';") - - _execute_sql(conn, copy_to_s3_query) - - # Step 2: Get list of input files from S3 - input_files = _list_files_in_s3_folder(input_s3_path) - - if not input_files: - raise ValueError(f"No input files found in S3 path: {input_s3_path}") - - current.card.append(Markdown(f"### Step 2: Processing {len(input_files)} file(s)...")) - - # Step 3: Process each file through the model and write predictions to S3 - total_predictions = 0 - for file_idx, input_file in enumerate(input_files): - current.card.append(Markdown(f"#### Processing file {file_idx + 1}/{len(input_files)}...")) - - # Read single file - input_df = _get_df_from_s3_files([input_file]) - - # Run predictions - predictions_df = model_predictor_function(input_df) - - # Write predictions to S3 - _put_df_to_s3_as_parquet_files( - df=predictions_df, - s3_base_path=output_s3_path.rstrip("/"), - batch_size=None, # Write each prediction result as single file - file_prefix=f"predictions_part_{file_idx}", - ) - - total_predictions += len(predictions_df) - current.card.append( - Markdown(f" - Processed {len(input_df):,} rows → Generated {len(predictions_df):,} predictions") - ) - - current.card.append(Markdown(f"### Step 3: Total predictions generated: {total_predictions:,}")) - - # Step 4: Create output table and load predictions from S3 to Snowflake - current.card.append(Markdown("### Step 4: Creating table and loading predictions from S3 to Snowflake...")) - - # Generate and execute SQL to create table and load data from S3 - sql_commands = _generate_s3_to_snowflake_copy_query( - database="PATTERN_DB", - schema=schema, - table_name=output_table_name, - snowflake_stage=snowflake_stage, - s3_folder_path=output_temp_folder, - table_schema=output_table_schema, - overwrite=False, # Don't overwrite for batch predictions - auto_create_table=True, # Create table if it doesn't exist - ) - - _execute_sql(conn, sql_commands) - conn.close() - - # Add success message to card - from ds_platform_utils.metaflow.write_audit_publish import _make_snowflake_table_url - - table_url = _make_snowflake_table_url( - database="PATTERN_DB", - schema=schema, - table=output_table_name, - ) - current.card.append(Markdown("### ✅ Successfully completed batch predictions")) - current.card.append(Markdown(f"**Total predictions:** {total_predictions:,}")) - current.card.append(Markdown(f"[View results in Snowflake]({table_url})")) From c7d3e71449314ee05c08828d34b79e3eaf7f52a5 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:43:55 +0530 Subject: [PATCH 037/167] feat: remove polars dependency and revert to pandas for S3 file retrieval --- src/ds_platform_utils/metaflow/s3.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 047ce7c..c286995 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -1,7 +1,6 @@ import tempfile import pandas as pd -import polars as pl from metaflow import S3 @@ -31,7 +30,7 @@ def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: with _get_metaflow_s3_client() as s3: df_paths = [obj.path for obj in s3.get_many(paths)] - return pl.read_parquet(df_paths).to_pandas() + return pd.read_parquet(df_paths) def _get_df_from_s3_folder(path: str) -> pd.DataFrame: From 65a70bbcb663430d1360d4f9aa2515bfebfdb1e0 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:44:12 +0530 Subject: [PATCH 038/167] feat: remove polars dependency from project requirements --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cc581f4..4ba58fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,6 @@ dependencies = [ "pandas", "jinja2", "sqlparse>=0.5.3", - "polars>=1.36.1", ] [dependency-groups] From 9ddef7f7dd1b57dd3356a0c33ef0a74589f384c0 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:44:55 +0530 Subject: [PATCH 039/167] feat: remove make_batch_predictions_from_snowflake_via_s3_stage import to streamline code --- src/ds_platform_utils/metaflow/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ds_platform_utils/metaflow/__init__.py b/src/ds_platform_utils/metaflow/__init__.py index 7ae8804..c22e587 100644 --- a/src/ds_platform_utils/metaflow/__init__.py +++ b/src/ds_platform_utils/metaflow/__init__.py @@ -1,13 +1,9 @@ from .pandas import publish_pandas, query_pandas_from_snowflake -from .pandas_via_s3_stage import ( - make_batch_predictions_from_snowflake_via_s3_stage, -) from .restore_step_state import restore_step_state from .validate_config import make_pydantic_parser_fn from .write_audit_publish import publish __all__ = [ - "make_batch_predictions_from_snowflake_via_s3_stage", "make_pydantic_parser_fn", "publish", "publish_pandas", From 368dcc25f5f5f1ebcc55a06226eb15b5eecf19f3 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:03:33 +0530 Subject: [PATCH 040/167] feat: add Metaflow flow for publishing and querying pandas DataFrames via S3 --- .../metaflow/test__pandas_s3.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/functional_tests/metaflow/test__pandas_s3.py diff --git a/tests/functional_tests/metaflow/test__pandas_s3.py b/tests/functional_tests/metaflow/test__pandas_s3.py new file mode 100644 index 0000000..7b85d41 --- /dev/null +++ b/tests/functional_tests/metaflow/test__pandas_s3.py @@ -0,0 +1,131 @@ +"""A Metaflow flow.""" + +import subprocess +import sys + +import pytest +from metaflow import FlowSpec, project, step + + +@project(name="test_pandas_read_write_flow_via_s3") +class TestPandasReadWriteFlowViaS3(FlowSpec): + """A sample flow.""" + + @step + def start(self): + """Start the flow.""" + self.next(self.test_publish_pandas) + + @step + def test_publish_pandas_with_schema(self): + """Test the publish_pandas function.""" + import pandas as pd + + from ds_platform_utils.metaflow import publish_pandas + + # Create a sample DataFrame + data = { + "id": [1, 2, 3, 4, 5], + "name": ["Mario", "Luigi", "Peach", "Bowser", "Toad"], + "score": [90.5, 85.2, 88.7, 92.1, 78.9], + } + df = pd.DataFrame(data) + + # Publish the DataFrame to Snowflake + publish_pandas( + table_name="pandas_test_table", + df=df, + auto_create_table=True, + overwrite=True, + use_s3_stage=True, + table_schema=[ + ("id", "INTEGER"), + ("name", "STRING"), + ("score", "FLOAT"), + ], + ) + + self.next(self.test_publish_pandas_with_warehouse) + + @step + def test_publish_pandas_without_schema(self): + """Test the publish pandas on having parameters: warehouse.""" + import pandas as pd + + from ds_platform_utils.metaflow import publish_pandas + + # Create a sample DataFrame + data = { + "id": [1, 2, 3, 4, 5], + "name": ["Mario", "Luigi", "Peach", "Bowser", "Toad"], + "score": [90.5, 85.2, 88.7, 92.1, 78.9], + } + df = pd.DataFrame(data) + + # Publish the DataFrame to Snowflake with a specific warehouse + publish_pandas( + table_name="pandas_test_table", + df=df, + auto_create_table=True, + overwrite=True, + use_s3_stage=True, + ) + + self.next(self.test_query_pandas) + + @step + def test_query_pandas(self): + """Test the query_pandas_from_snowflake function.""" + from ds_platform_utils.metaflow import query_pandas_from_snowflake + + # Query to retrieve the data we just published + query = "SELECT * FROM PATTERN_DB.{{schema}}.PANDAS_TEST_TABLE;" + + # Query the data back + result_df = query_pandas_from_snowflake(query, use_s3_stage=True) + + # Quick validation + assert len(result_df) == 5, "Expected 5 rows in the result" + assert "id" in result_df.columns, "Expected 'id' column in result" + assert "name" in result_df.columns, "Expected 'name' column in result" + assert "score" in result_df.columns, "Expected 'score' column in result" + + self.next(self.end) + + @step + def end(self): + """End the flow.""" + pass + + +if __name__ == "__main__": + TestPandasReadWriteFlowViaS3() + + +@pytest.mark.slow +def test_pandas_read_write_flow_via_s3(): + """Test that the publish flow runs successfully.""" + cmd = [sys.executable, __file__, "--environment=local", "--with=card", "run"] + + print("\n=== Metaflow Output ===") + for line in execute_with_output(cmd): + print(line, end="") + + +def execute_with_output(cmd): + """Execute a command and yield output lines as they are produced.""" + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, # Merge stderr into stdout + universal_newlines=True, + bufsize=1, + ) + + for line in iter(process.stdout.readline, ""): + yield line + + process.stdout.close() + return_code = process.wait() + if return_code: + raise subprocess.CalledProcessError(return_code, cmd) From 17997abc550066956326c98975eadc0a9d2a4740 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:05:05 +0530 Subject: [PATCH 041/167] Refactor code structure for improved readability and maintainability --- pyproject.toml | 2 +- uv.lock | 2259 ++++++++++++++++++++++++------------------------ 2 files changed, 1131 insertions(+), 1130 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4ba58fd..98ff0dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ds-platform-utils" -version = "0.3.0" +version = "0.4.0" description = "Utility library for Pattern Data Science." readme = "README.md" authors = [ diff --git a/uv.lock b/uv.lock index c2d957a..45ba74f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,4 +1,5 @@ version = 1 +revision = 3 requires-python = ">=3.9" resolution-markers = [ "python_full_version >= '3.12'", @@ -11,27 +12,27 @@ resolution-markers = [ 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 } +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 }, + { 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 = "appnope" version = "0.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170 } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321 }, + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, ] [[package]] name = "asn1crypto" version = "1.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080 } +sdist = { url = "https://files.pythonhosted.org/packages/de/cf/d547feed25b5244fcb9392e288ff9fdc3280b10260362fc45d37a798a6ee/asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c", size = 121080, upload-time = "2022-03-15T14:46:52.889Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045 }, + { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, ] [[package]] @@ -41,18 +42,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439 } +sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612 }, + { url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" }, ] [[package]] name = "asttokens" version = "3.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978, upload-time = "2024-11-30T04:30:14.439Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918, upload-time = "2024-11-30T04:30:10.946Z" }, ] [[package]] @@ -64,9 +65,9 @@ dependencies = [ { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/b1/22df131f6af59547f1c02186aca4a94d98d6c7b86afa984039bc3c827bf9/boto3-1.40.20.tar.gz", hash = "sha256:01fc76cce8b4e80de0e8151a8a8007570432a94f451a1018c74acb48fdbdf237", size = 111569 } +sdist = { url = "https://files.pythonhosted.org/packages/14/b1/22df131f6af59547f1c02186aca4a94d98d6c7b86afa984039bc3c827bf9/boto3-1.40.20.tar.gz", hash = "sha256:01fc76cce8b4e80de0e8151a8a8007570432a94f451a1018c74acb48fdbdf237", size = 111569, upload-time = "2025-08-28T20:42:36.789Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/3c/27fd25b687cbcf5be0bf2941606d83b21e3c4382ad6413666e5dafd7e0d6/boto3-1.40.20-py3-none-any.whl", hash = "sha256:5574750a65500a116dd3d838191b9a53bf5abb0adef34ed7b3151fe4dcf040ed", size = 139323 }, + { url = "https://files.pythonhosted.org/packages/f2/3c/27fd25b687cbcf5be0bf2941606d83b21e3c4382ad6413666e5dafd7e0d6/boto3-1.40.20-py3-none-any.whl", hash = "sha256:5574750a65500a116dd3d838191b9a53bf5abb0adef34ed7b3151fe4dcf040ed", size = 139323, upload-time = "2025-08-28T20:42:34.506Z" }, ] [[package]] @@ -79,27 +80,27 @@ dependencies = [ { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/61/f17daf2ffd324c9904342958cb2742efa828d99ceb06e223a59eec2a237f/botocore-1.40.20.tar.gz", hash = "sha256:440062473cc2172cb61533042643455ee32e7f163381335f6575988ad52461dc", size = 14322123 } +sdist = { url = "https://files.pythonhosted.org/packages/bd/61/f17daf2ffd324c9904342958cb2742efa828d99ceb06e223a59eec2a237f/botocore-1.40.20.tar.gz", hash = "sha256:440062473cc2172cb61533042643455ee32e7f163381335f6575988ad52461dc", size = 14322123, upload-time = "2025-08-28T20:42:26.132Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/cc/7d35e10d6aa670dd0f412fda909a6528b7dff9503be2e49599e9da03ae68/botocore-1.40.20-py3-none-any.whl", hash = "sha256:c584b439e2f1a2ada5e6bc0cc1502143ae2b2299d41ce2ae30053b59d5d17821", size = 13993096 }, + { url = "https://files.pythonhosted.org/packages/7d/cc/7d35e10d6aa670dd0f412fda909a6528b7dff9503be2e49599e9da03ae68/botocore-1.40.20-py3-none-any.whl", hash = "sha256:c584b439e2f1a2ada5e6bc0cc1502143ae2b2299d41ce2ae30053b59d5d17821", size = 13993096, upload-time = "2025-08-28T20:42:20.35Z" }, ] [[package]] name = "cachetools" version = "5.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 }, + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, ] [[package]] name = "certifi" version = "2025.8.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386 } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216 }, + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] [[package]] @@ -109,263 +110,263 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 }, - { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 }, - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 }, - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 }, - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 }, - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 }, - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 }, - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 }, - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 }, - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 }, - { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 }, - { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 }, - { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 }, - { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 }, - { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 }, - { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 }, - { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 }, - { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 }, - { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 }, - { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 }, - { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 }, - { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 }, - { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 }, - { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 }, - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 }, - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 }, - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 }, - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 }, - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 }, - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 }, - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 }, - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 }, - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 }, - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 }, - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 }, - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, - { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220 }, - { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605 }, - { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 }, - { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 }, - { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 }, - { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 }, - { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 }, - { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 }, - { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 }, - { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 }, - { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820 }, - { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 }, +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" }, + { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, + { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, + { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220, upload-time = "2024-09-04T20:45:01.577Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605, upload-time = "2024-09-04T20:45:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910, upload-time = "2024-09-04T20:45:05.315Z" }, + { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200, upload-time = "2024-09-04T20:45:06.903Z" }, + { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565, upload-time = "2024-09-04T20:45:08.975Z" }, + { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635, upload-time = "2024-09-04T20:45:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218, upload-time = "2024-09-04T20:45:12.366Z" }, + { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486, upload-time = "2024-09-04T20:45:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911, upload-time = "2024-09-04T20:45:15.696Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632, upload-time = "2024-09-04T20:45:17.284Z" }, + { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820, upload-time = "2024-09-04T20:45:18.762Z" }, + { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290, upload-time = "2024-09-04T20:45:20.226Z" }, ] [[package]] name = "cfgv" version = "3.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695 }, - { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153 }, - { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428 }, - { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627 }, - { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388 }, - { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077 }, - { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631 }, - { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210 }, - { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739 }, - { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825 }, - { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452 }, - { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483 }, - { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520 }, - { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876 }, - { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083 }, - { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295 }, - { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379 }, - { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018 }, - { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430 }, - { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600 }, - { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616 }, - { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108 }, - { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655 }, - { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223 }, - { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366 }, - { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104 }, - { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830 }, - { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854 }, - { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670 }, - { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501 }, - { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173 }, - { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822 }, - { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543 }, - { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326 }, - { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008 }, - { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196 }, - { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819 }, - { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350 }, - { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644 }, - { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468 }, - { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187 }, - { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699 }, - { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580 }, - { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366 }, - { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342 }, - { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995 }, - { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640 }, - { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636 }, - { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939 }, - { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580 }, - { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870 }, - { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797 }, - { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224 }, - { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086 }, - { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400 }, - { url = "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", size = 207520 }, - { url = "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", size = 147307 }, - { url = "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", size = 160448 }, - { url = "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", size = 157758 }, - { url = "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", size = 152487 }, - { url = "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", size = 150054 }, - { url = "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", size = 161703 }, - { url = "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", size = 159096 }, - { url = "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", size = 153852 }, - { url = "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", size = 99840 }, - { url = "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", size = 107438 }, - { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175 }, +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ca/9a0983dd5c8e9733565cf3db4df2b0a2e9a82659fd8aa2a868ac6e4a991f/charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", size = 207520, upload-time = "2025-08-09T07:57:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/99271dc37243a4f925b09090493fb96c9333d7992c6187f5cfe5312008d2/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", size = 147307, upload-time = "2025-08-09T07:57:12.4Z" }, + { url = "https://files.pythonhosted.org/packages/e4/69/132eab043356bba06eb333cc2cc60c6340857d0a2e4ca6dc2b51312886b3/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", size = 160448, upload-time = "2025-08-09T07:57:13.712Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/914d294daa4809c57667b77470533e65def9c0be1ef8b4c1183a99170e9d/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", size = 157758, upload-time = "2025-08-09T07:57:14.979Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a8/6f5bcf1bcf63cb45625f7c5cadca026121ff8a6c8a3256d8d8cd59302663/charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", size = 152487, upload-time = "2025-08-09T07:57:16.332Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/d3d0e9592f4e504f9dea08b8db270821c909558c353dc3b457ed2509f2fb/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", size = 150054, upload-time = "2025-08-09T07:57:17.576Z" }, + { url = "https://files.pythonhosted.org/packages/20/30/5f64fe3981677fe63fa987b80e6c01042eb5ff653ff7cec1b7bd9268e54e/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", size = 161703, upload-time = "2025-08-09T07:57:20.012Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ef/dd08b2cac9284fd59e70f7d97382c33a3d0a926e45b15fc21b3308324ffd/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", size = 159096, upload-time = "2025-08-09T07:57:21.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/dcef87cfc2b3f002a6478f38906f9040302c68aebe21468090e39cde1445/charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", size = 153852, upload-time = "2025-08-09T07:57:22.608Z" }, + { url = "https://files.pythonhosted.org/packages/63/86/9cbd533bd37883d467fcd1bd491b3547a3532d0fbb46de2b99feeebf185e/charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", size = 99840, upload-time = "2025-08-09T07:57:23.883Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d6/7e805c8e5c46ff9729c49950acc4ee0aeb55efb8b3a56687658ad10c3216/charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", size = 107438, upload-time = "2025-08-09T07:57:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] name = "comm" version = "0.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319 } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294 }, + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, ] [[package]] name = "coverage" version = "7.10.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/83/153f54356c7c200013a752ce1ed5448573dca546ce125801afca9e1ac1a4/coverage-7.10.5.tar.gz", hash = "sha256:f2e57716a78bc3ae80b2207be0709a3b2b63b9f2dcf9740ee6ac03588a2015b6", size = 821662 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/70/e77b0061a6c7157bfce645c6b9a715a08d4c86b3360a7b3252818080b817/coverage-7.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c6a5c3414bfc7451b879141ce772c546985163cf553f08e0f135f0699a911801", size = 216774 }, - { url = "https://files.pythonhosted.org/packages/91/08/2a79de5ecf37ee40f2d898012306f11c161548753391cec763f92647837b/coverage-7.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bc8e4d99ce82f1710cc3c125adc30fd1487d3cf6c2cd4994d78d68a47b16989a", size = 217175 }, - { url = "https://files.pythonhosted.org/packages/64/57/0171d69a699690149a6ba6a4eb702814448c8d617cf62dbafa7ce6bfdf63/coverage-7.10.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:02252dc1216e512a9311f596b3169fad54abcb13827a8d76d5630c798a50a754", size = 243931 }, - { url = "https://files.pythonhosted.org/packages/15/06/3a67662c55656702bd398a727a7f35df598eb11104fcb34f1ecbb070291a/coverage-7.10.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:73269df37883e02d460bee0cc16be90509faea1e3bd105d77360b512d5bb9c33", size = 245740 }, - { url = "https://files.pythonhosted.org/packages/00/f4/f8763aabf4dc30ef0d0012522d312f0b7f9fede6246a1f27dbcc4a1e523c/coverage-7.10.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8a81b0614642f91c9effd53eec284f965577591f51f547a1cbeb32035b4c2f", size = 247600 }, - { url = "https://files.pythonhosted.org/packages/9c/31/6632219a9065e1b83f77eda116fed4c76fb64908a6a9feae41816dab8237/coverage-7.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6a29f8e0adb7f8c2b95fa2d4566a1d6e6722e0a637634c6563cb1ab844427dd9", size = 245640 }, - { url = "https://files.pythonhosted.org/packages/6e/e2/3dba9b86037b81649b11d192bb1df11dde9a81013e434af3520222707bc8/coverage-7.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fcf6ab569436b4a647d4e91accba12509ad9f2554bc93d3aee23cc596e7f99c3", size = 243659 }, - { url = "https://files.pythonhosted.org/packages/02/b9/57170bd9f3e333837fc24ecc88bc70fbc2eb7ccfd0876854b0c0407078c3/coverage-7.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:90dc3d6fb222b194a5de60af8d190bedeeddcbc7add317e4a3cd333ee6b7c879", size = 244537 }, - { url = "https://files.pythonhosted.org/packages/b3/1c/93ac36ef1e8b06b8d5777393a3a40cb356f9f3dab980be40a6941e443588/coverage-7.10.5-cp310-cp310-win32.whl", hash = "sha256:414a568cd545f9dc75f0686a0049393de8098414b58ea071e03395505b73d7a8", size = 219285 }, - { url = "https://files.pythonhosted.org/packages/30/95/23252277e6e5fe649d6cd3ed3f35d2307e5166de4e75e66aa7f432abc46d/coverage-7.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:e551f9d03347196271935fd3c0c165f0e8c049220280c1120de0084d65e9c7ff", size = 220185 }, - { url = "https://files.pythonhosted.org/packages/cb/f2/336d34d2fc1291ca7c18eeb46f64985e6cef5a1a7ef6d9c23720c6527289/coverage-7.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c177e6ffe2ebc7c410785307758ee21258aa8e8092b44d09a2da767834f075f2", size = 216890 }, - { url = "https://files.pythonhosted.org/packages/39/ea/92448b07cc1cf2b429d0ce635f59cf0c626a5d8de21358f11e92174ff2a6/coverage-7.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:14d6071c51ad0f703d6440827eaa46386169b5fdced42631d5a5ac419616046f", size = 217287 }, - { url = "https://files.pythonhosted.org/packages/96/ba/ad5b36537c5179c808d0ecdf6e4aa7630b311b3c12747ad624dcd43a9b6b/coverage-7.10.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:61f78c7c3bc272a410c5ae3fde7792b4ffb4acc03d35a7df73ca8978826bb7ab", size = 247683 }, - { url = "https://files.pythonhosted.org/packages/28/e5/fe3bbc8d097029d284b5fb305b38bb3404895da48495f05bff025df62770/coverage-7.10.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f39071caa126f69d63f99b324fb08c7b1da2ec28cbb1fe7b5b1799926492f65c", size = 249614 }, - { url = "https://files.pythonhosted.org/packages/69/9c/a1c89a8c8712799efccb32cd0a1ee88e452f0c13a006b65bb2271f1ac767/coverage-7.10.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343a023193f04d46edc46b2616cdbee68c94dd10208ecd3adc56fcc54ef2baa1", size = 251719 }, - { url = "https://files.pythonhosted.org/packages/e9/be/5576b5625865aa95b5633315f8f4142b003a70c3d96e76f04487c3b5cc95/coverage-7.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:585ffe93ae5894d1ebdee69fc0b0d4b7c75d8007983692fb300ac98eed146f78", size = 249411 }, - { url = "https://files.pythonhosted.org/packages/94/0a/e39a113d4209da0dbbc9385608cdb1b0726a4d25f78672dc51c97cfea80f/coverage-7.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0ef4e66f006ed181df29b59921bd8fc7ed7cd6a9289295cd8b2824b49b570df", size = 247466 }, - { url = "https://files.pythonhosted.org/packages/40/cb/aebb2d8c9e3533ee340bea19b71c5b76605a0268aa49808e26fe96ec0a07/coverage-7.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb7b0bbf7cc1d0453b843eca7b5fa017874735bef9bfdfa4121373d2cc885ed6", size = 248104 }, - { url = "https://files.pythonhosted.org/packages/08/e6/26570d6ccce8ff5de912cbfd268e7f475f00597cb58da9991fa919c5e539/coverage-7.10.5-cp311-cp311-win32.whl", hash = "sha256:1d043a8a06987cc0c98516e57c4d3fc2c1591364831e9deb59c9e1b4937e8caf", size = 219327 }, - { url = "https://files.pythonhosted.org/packages/79/79/5f48525e366e518b36e66167e3b6e5db6fd54f63982500c6a5abb9d3dfbd/coverage-7.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:fefafcca09c3ac56372ef64a40f5fe17c5592fab906e0fdffd09543f3012ba50", size = 220213 }, - { url = "https://files.pythonhosted.org/packages/40/3c/9058128b7b0bf333130c320b1eb1ae485623014a21ee196d68f7737f8610/coverage-7.10.5-cp311-cp311-win_arm64.whl", hash = "sha256:7e78b767da8b5fc5b2faa69bb001edafcd6f3995b42a331c53ef9572c55ceb82", size = 218893 }, - { url = "https://files.pythonhosted.org/packages/27/8e/40d75c7128f871ea0fd829d3e7e4a14460cad7c3826e3b472e6471ad05bd/coverage-7.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c2d05c7e73c60a4cecc7d9b60dbfd603b4ebc0adafaef371445b47d0f805c8a9", size = 217077 }, - { url = "https://files.pythonhosted.org/packages/18/a8/f333f4cf3fb5477a7f727b4d603a2eb5c3c5611c7fe01329c2e13b23b678/coverage-7.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:32ddaa3b2c509778ed5373b177eb2bf5662405493baeff52278a0b4f9415188b", size = 217310 }, - { url = "https://files.pythonhosted.org/packages/ec/2c/fbecd8381e0a07d1547922be819b4543a901402f63930313a519b937c668/coverage-7.10.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd382410039fe062097aa0292ab6335a3f1e7af7bba2ef8d27dcda484918f20c", size = 248802 }, - { url = "https://files.pythonhosted.org/packages/3f/bc/1011da599b414fb6c9c0f34086736126f9ff71f841755786a6b87601b088/coverage-7.10.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7fa22800f3908df31cea6fb230f20ac49e343515d968cc3a42b30d5c3ebf9b5a", size = 251550 }, - { url = "https://files.pythonhosted.org/packages/4c/6f/b5c03c0c721c067d21bc697accc3642f3cef9f087dac429c918c37a37437/coverage-7.10.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f366a57ac81f5e12797136552f5b7502fa053c861a009b91b80ed51f2ce651c6", size = 252684 }, - { url = "https://files.pythonhosted.org/packages/f9/50/d474bc300ebcb6a38a1047d5c465a227605d6473e49b4e0d793102312bc5/coverage-7.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1dc8f1980a272ad4a6c84cba7981792344dad33bf5869361576b7aef42733a", size = 250602 }, - { url = "https://files.pythonhosted.org/packages/4a/2d/548c8e04249cbba3aba6bd799efdd11eee3941b70253733f5d355d689559/coverage-7.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2285c04ee8676f7938b02b4936d9b9b672064daab3187c20f73a55f3d70e6b4a", size = 248724 }, - { url = "https://files.pythonhosted.org/packages/e2/96/a7c3c0562266ac39dcad271d0eec8fc20ab576e3e2f64130a845ad2a557b/coverage-7.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2492e4dd9daab63f5f56286f8a04c51323d237631eb98505d87e4c4ff19ec34", size = 250158 }, - { url = "https://files.pythonhosted.org/packages/f3/75/74d4be58c70c42ef0b352d597b022baf12dbe2b43e7cb1525f56a0fb1d4b/coverage-7.10.5-cp312-cp312-win32.whl", hash = "sha256:38a9109c4ee8135d5df5505384fc2f20287a47ccbe0b3f04c53c9a1989c2bbaf", size = 219493 }, - { url = "https://files.pythonhosted.org/packages/4f/08/364e6012d1d4d09d1e27437382967efed971d7613f94bca9add25f0c1f2b/coverage-7.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:6b87f1ad60b30bc3c43c66afa7db6b22a3109902e28c5094957626a0143a001f", size = 220302 }, - { url = "https://files.pythonhosted.org/packages/db/d5/7c8a365e1f7355c58af4fe5faf3f90cc8e587590f5854808d17ccb4e7077/coverage-7.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:672a6c1da5aea6c629819a0e1461e89d244f78d7b60c424ecf4f1f2556c041d8", size = 218936 }, - { url = "https://files.pythonhosted.org/packages/9f/08/4166ecfb60ba011444f38a5a6107814b80c34c717bc7a23be0d22e92ca09/coverage-7.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef3b83594d933020f54cf65ea1f4405d1f4e41a009c46df629dd964fcb6e907c", size = 217106 }, - { url = "https://files.pythonhosted.org/packages/25/d7/b71022408adbf040a680b8c64bf6ead3be37b553e5844f7465643979f7ca/coverage-7.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b96bfdf7c0ea9faebce088a3ecb2382819da4fbc05c7b80040dbc428df6af44", size = 217353 }, - { url = "https://files.pythonhosted.org/packages/74/68/21e0d254dbf8972bb8dd95e3fe7038f4be037ff04ba47d6d1b12b37510ba/coverage-7.10.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:63df1fdaffa42d914d5c4d293e838937638bf75c794cf20bee12978fc8c4e3bc", size = 248350 }, - { url = "https://files.pythonhosted.org/packages/90/65/28752c3a896566ec93e0219fc4f47ff71bd2b745f51554c93e8dcb659796/coverage-7.10.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8002dc6a049aac0e81ecec97abfb08c01ef0c1fbf962d0c98da3950ace89b869", size = 250955 }, - { url = "https://files.pythonhosted.org/packages/a5/eb/ca6b7967f57f6fef31da8749ea20417790bb6723593c8cd98a987be20423/coverage-7.10.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63d4bb2966d6f5f705a6b0c6784c8969c468dbc4bcf9d9ded8bff1c7e092451f", size = 252230 }, - { url = "https://files.pythonhosted.org/packages/bc/29/17a411b2a2a18f8b8c952aa01c00f9284a1fbc677c68a0003b772ea89104/coverage-7.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1f672efc0731a6846b157389b6e6d5d5e9e59d1d1a23a5c66a99fd58339914d5", size = 250387 }, - { url = "https://files.pythonhosted.org/packages/c7/89/97a9e271188c2fbb3db82235c33980bcbc733da7da6065afbaa1d685a169/coverage-7.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3f39cef43d08049e8afc1fde4a5da8510fc6be843f8dea350ee46e2a26b2f54c", size = 248280 }, - { url = "https://files.pythonhosted.org/packages/d1/c6/0ad7d0137257553eb4706b4ad6180bec0a1b6a648b092c5bbda48d0e5b2c/coverage-7.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2968647e3ed5a6c019a419264386b013979ff1fb67dd11f5c9886c43d6a31fc2", size = 249894 }, - { url = "https://files.pythonhosted.org/packages/84/56/fb3aba936addb4c9e5ea14f5979393f1c2466b4c89d10591fd05f2d6b2aa/coverage-7.10.5-cp313-cp313-win32.whl", hash = "sha256:0d511dda38595b2b6934c2b730a1fd57a3635c6aa2a04cb74714cdfdd53846f4", size = 219536 }, - { url = "https://files.pythonhosted.org/packages/fc/54/baacb8f2f74431e3b175a9a2881feaa8feb6e2f187a0e7e3046f3c7742b2/coverage-7.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:9a86281794a393513cf117177fd39c796b3f8e3759bb2764259a2abba5cce54b", size = 220330 }, - { url = "https://files.pythonhosted.org/packages/64/8a/82a3788f8e31dee51d350835b23d480548ea8621f3effd7c3ba3f7e5c006/coverage-7.10.5-cp313-cp313-win_arm64.whl", hash = "sha256:cebd8e906eb98bb09c10d1feed16096700b1198d482267f8bf0474e63a7b8d84", size = 218961 }, - { url = "https://files.pythonhosted.org/packages/d8/a1/590154e6eae07beee3b111cc1f907c30da6fc8ce0a83ef756c72f3c7c748/coverage-7.10.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0520dff502da5e09d0d20781df74d8189ab334a1e40d5bafe2efaa4158e2d9e7", size = 217819 }, - { url = "https://files.pythonhosted.org/packages/0d/ff/436ffa3cfc7741f0973c5c89405307fe39b78dcf201565b934e6616fc4ad/coverage-7.10.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d9cd64aca68f503ed3f1f18c7c9174cbb797baba02ca8ab5112f9d1c0328cd4b", size = 218040 }, - { url = "https://files.pythonhosted.org/packages/a0/ca/5787fb3d7820e66273913affe8209c534ca11241eb34ee8c4fd2aaa9dd87/coverage-7.10.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0913dd1613a33b13c4f84aa6e3f4198c1a21ee28ccb4f674985c1f22109f0aae", size = 259374 }, - { url = "https://files.pythonhosted.org/packages/b5/89/21af956843896adc2e64fc075eae3c1cadb97ee0a6960733e65e696f32dd/coverage-7.10.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b7181c0feeb06ed8a02da02792f42f829a7b29990fef52eff257fef0885d760", size = 261551 }, - { url = "https://files.pythonhosted.org/packages/e1/96/390a69244ab837e0ac137989277879a084c786cf036c3c4a3b9637d43a89/coverage-7.10.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d42b7396b605f774d4372dd9c49bed71cbabce4ae1ccd074d155709dd8f235", size = 263776 }, - { url = "https://files.pythonhosted.org/packages/00/32/cfd6ae1da0a521723349f3129b2455832fc27d3f8882c07e5b6fefdd0da2/coverage-7.10.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b4fdc777e05c4940b297bf47bf7eedd56a39a61dc23ba798e4b830d585486ca5", size = 261326 }, - { url = "https://files.pythonhosted.org/packages/4c/c4/bf8d459fb4ce2201e9243ce6c015936ad283a668774430a3755f467b39d1/coverage-7.10.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:42144e8e346de44a6f1dbd0a56575dd8ab8dfa7e9007da02ea5b1c30ab33a7db", size = 259090 }, - { url = "https://files.pythonhosted.org/packages/f4/5d/a234f7409896468e5539d42234016045e4015e857488b0b5b5f3f3fa5f2b/coverage-7.10.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:66c644cbd7aed8fe266d5917e2c9f65458a51cfe5eeff9c05f15b335f697066e", size = 260217 }, - { url = "https://files.pythonhosted.org/packages/f3/ad/87560f036099f46c2ddd235be6476dd5c1d6be6bb57569a9348d43eeecea/coverage-7.10.5-cp313-cp313t-win32.whl", hash = "sha256:2d1b73023854068c44b0c554578a4e1ef1b050ed07cf8b431549e624a29a66ee", size = 220194 }, - { url = "https://files.pythonhosted.org/packages/36/a8/04a482594fdd83dc677d4a6c7e2d62135fff5a1573059806b8383fad9071/coverage-7.10.5-cp313-cp313t-win_amd64.whl", hash = "sha256:54a1532c8a642d8cc0bd5a9a51f5a9dcc440294fd06e9dda55e743c5ec1a8f14", size = 221258 }, - { url = "https://files.pythonhosted.org/packages/eb/ad/7da28594ab66fe2bc720f1bc9b131e62e9b4c6e39f044d9a48d18429cc21/coverage-7.10.5-cp313-cp313t-win_arm64.whl", hash = "sha256:74d5b63fe3f5f5d372253a4ef92492c11a4305f3550631beaa432fc9df16fcff", size = 219521 }, - { url = "https://files.pythonhosted.org/packages/d3/7f/c8b6e4e664b8a95254c35a6c8dd0bf4db201ec681c169aae2f1256e05c85/coverage-7.10.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:68c5e0bc5f44f68053369fa0d94459c84548a77660a5f2561c5e5f1e3bed7031", size = 217090 }, - { url = "https://files.pythonhosted.org/packages/44/74/3ee14ede30a6e10a94a104d1d0522d5fb909a7c7cac2643d2a79891ff3b9/coverage-7.10.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cf33134ffae93865e32e1e37df043bef15a5e857d8caebc0099d225c579b0fa3", size = 217365 }, - { url = "https://files.pythonhosted.org/packages/41/5f/06ac21bf87dfb7620d1f870dfa3c2cae1186ccbcdc50b8b36e27a0d52f50/coverage-7.10.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad8fa9d5193bafcf668231294241302b5e683a0518bf1e33a9a0dfb142ec3031", size = 248413 }, - { url = "https://files.pythonhosted.org/packages/21/bc/cc5bed6e985d3a14228539631573f3863be6a2587381e8bc5fdf786377a1/coverage-7.10.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:146fa1531973d38ab4b689bc764592fe6c2f913e7e80a39e7eeafd11f0ef6db2", size = 250943 }, - { url = "https://files.pythonhosted.org/packages/8d/43/6a9fc323c2c75cd80b18d58db4a25dc8487f86dd9070f9592e43e3967363/coverage-7.10.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6013a37b8a4854c478d3219ee8bc2392dea51602dd0803a12d6f6182a0061762", size = 252301 }, - { url = "https://files.pythonhosted.org/packages/69/7c/3e791b8845f4cd515275743e3775adb86273576596dc9f02dca37357b4f2/coverage-7.10.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eb90fe20db9c3d930fa2ad7a308207ab5b86bf6a76f54ab6a40be4012d88fcae", size = 250302 }, - { url = "https://files.pythonhosted.org/packages/5c/bc/5099c1e1cb0c9ac6491b281babea6ebbf999d949bf4aa8cdf4f2b53505e8/coverage-7.10.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:384b34482272e960c438703cafe63316dfbea124ac62006a455c8410bf2a2262", size = 248237 }, - { url = "https://files.pythonhosted.org/packages/7e/51/d346eb750a0b2f1e77f391498b753ea906fde69cc11e4b38dca28c10c88c/coverage-7.10.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:467dc74bd0a1a7de2bedf8deaf6811f43602cb532bd34d81ffd6038d6d8abe99", size = 249726 }, - { url = "https://files.pythonhosted.org/packages/a3/85/eebcaa0edafe427e93286b94f56ea7e1280f2c49da0a776a6f37e04481f9/coverage-7.10.5-cp314-cp314-win32.whl", hash = "sha256:556d23d4e6393ca898b2e63a5bca91e9ac2d5fb13299ec286cd69a09a7187fde", size = 219825 }, - { url = "https://files.pythonhosted.org/packages/3c/f7/6d43e037820742603f1e855feb23463979bf40bd27d0cde1f761dcc66a3e/coverage-7.10.5-cp314-cp314-win_amd64.whl", hash = "sha256:f4446a9547681533c8fa3e3c6cf62121eeee616e6a92bd9201c6edd91beffe13", size = 220618 }, - { url = "https://files.pythonhosted.org/packages/4a/b0/ed9432e41424c51509d1da603b0393404b828906236fb87e2c8482a93468/coverage-7.10.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e78bd9cf65da4c303bf663de0d73bf69f81e878bf72a94e9af67137c69b9fe9", size = 219199 }, - { url = "https://files.pythonhosted.org/packages/2f/54/5a7ecfa77910f22b659c820f67c16fc1e149ed132ad7117f0364679a8fa9/coverage-7.10.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5661bf987d91ec756a47c7e5df4fbcb949f39e32f9334ccd3f43233bbb65e508", size = 217833 }, - { url = "https://files.pythonhosted.org/packages/4e/0e/25672d917cc57857d40edf38f0b867fb9627115294e4f92c8fcbbc18598d/coverage-7.10.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a46473129244db42a720439a26984f8c6f834762fc4573616c1f37f13994b357", size = 218048 }, - { url = "https://files.pythonhosted.org/packages/cb/7c/0b2b4f1c6f71885d4d4b2b8608dcfc79057adb7da4143eb17d6260389e42/coverage-7.10.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1f64b8d3415d60f24b058b58d859e9512624bdfa57a2d1f8aff93c1ec45c429b", size = 259549 }, - { url = "https://files.pythonhosted.org/packages/94/73/abb8dab1609abec7308d83c6aec547944070526578ee6c833d2da9a0ad42/coverage-7.10.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:44d43de99a9d90b20e0163f9770542357f58860a26e24dc1d924643bd6aa7cb4", size = 261715 }, - { url = "https://files.pythonhosted.org/packages/0b/d1/abf31de21ec92731445606b8d5e6fa5144653c2788758fcf1f47adb7159a/coverage-7.10.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a931a87e5ddb6b6404e65443b742cb1c14959622777f2a4efd81fba84f5d91ba", size = 263969 }, - { url = "https://files.pythonhosted.org/packages/9c/b3/ef274927f4ebede96056173b620db649cc9cb746c61ffc467946b9d0bc67/coverage-7.10.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9559b906a100029274448f4c8b8b0a127daa4dade5661dfd821b8c188058842", size = 261408 }, - { url = "https://files.pythonhosted.org/packages/20/fc/83ca2812be616d69b4cdd4e0c62a7bc526d56875e68fd0f79d47c7923584/coverage-7.10.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b08801e25e3b4526ef9ced1aa29344131a8f5213c60c03c18fe4c6170ffa2874", size = 259168 }, - { url = "https://files.pythonhosted.org/packages/fc/4f/e0779e5716f72d5c9962e709d09815d02b3b54724e38567308304c3fc9df/coverage-7.10.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed9749bb8eda35f8b636fb7632f1c62f735a236a5d4edadd8bbcc5ea0542e732", size = 260317 }, - { url = "https://files.pythonhosted.org/packages/2b/fe/4247e732f2234bb5eb9984a0888a70980d681f03cbf433ba7b48f08ca5d5/coverage-7.10.5-cp314-cp314t-win32.whl", hash = "sha256:609b60d123fc2cc63ccee6d17e4676699075db72d14ac3c107cc4976d516f2df", size = 220600 }, - { url = "https://files.pythonhosted.org/packages/a7/a0/f294cff6d1034b87839987e5b6ac7385bec599c44d08e0857ac7f164ad0c/coverage-7.10.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0666cf3d2c1626b5a3463fd5b05f5e21f99e6aec40a3192eee4d07a15970b07f", size = 221714 }, - { url = "https://files.pythonhosted.org/packages/23/18/fa1afdc60b5528d17416df440bcbd8fd12da12bfea9da5b6ae0f7a37d0f7/coverage-7.10.5-cp314-cp314t-win_arm64.whl", hash = "sha256:bc85eb2d35e760120540afddd3044a5bf69118a91a296a8b3940dfc4fdcfe1e2", size = 219735 }, - { url = "https://files.pythonhosted.org/packages/3b/21/05248e8bc74683488cb7477e6b6b878decadd15af0ec96f56381d3d7ff2d/coverage-7.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:62835c1b00c4a4ace24c1a88561a5a59b612fbb83a525d1c70ff5720c97c0610", size = 216763 }, - { url = "https://files.pythonhosted.org/packages/a9/7f/161a0ad40cb1c7e19dc1aae106d3430cc88dac3d651796d6cf3f3730c800/coverage-7.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5255b3bbcc1d32a4069d6403820ac8e6dbcc1d68cb28a60a1ebf17e47028e898", size = 217154 }, - { url = "https://files.pythonhosted.org/packages/de/31/41929ee53af829ea5a88e71d335ea09d0bb587a3da1c5e58e59b48473ed8/coverage-7.10.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3876385722e335d6e991c430302c24251ef9c2a9701b2b390f5473199b1b8ebf", size = 243588 }, - { url = "https://files.pythonhosted.org/packages/6e/4e/2649344e33eeb3567041e8255a1942173cae81817fe06b60f3fafaafe111/coverage-7.10.5-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8048ce4b149c93447a55d279078c8ae98b08a6951a3c4d2d7e87f4efc7bfe100", size = 245412 }, - { url = "https://files.pythonhosted.org/packages/ac/b1/b21e1e69986ad89b051dd42c3ef06d9326e03ac3c0c844fc33385d1d9e35/coverage-7.10.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4028e7558e268dd8bcf4d9484aad393cafa654c24b4885f6f9474bf53183a82a", size = 247182 }, - { url = "https://files.pythonhosted.org/packages/4c/b5/80837be411ae092e03fcc2a7877bd9a659c531eff50453e463057a9eee44/coverage-7.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03f47dc870eec0367fcdd603ca6a01517d2504e83dc18dbfafae37faec66129a", size = 245066 }, - { url = "https://files.pythonhosted.org/packages/c0/ed/fcb0838ddf149d68d09f89af57397b0dd9d26b100cc729daf1b0caf0b2d3/coverage-7.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2d488d7d42b6ded7ea0704884f89dcabd2619505457de8fc9a6011c62106f6e5", size = 243138 }, - { url = "https://files.pythonhosted.org/packages/75/0f/505c6af24a9ae5d8919d209b9c31b7092815f468fa43bec3b1118232c62a/coverage-7.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b3dcf2ead47fa8be14224ee817dfc1df98043af568fe120a22f81c0eb3c34ad2", size = 244095 }, - { url = "https://files.pythonhosted.org/packages/e4/7e/c82a8bede46217c1d944bd19b65e7106633b998640f00ab49c5f747a5844/coverage-7.10.5-cp39-cp39-win32.whl", hash = "sha256:02650a11324b80057b8c9c29487020073d5e98a498f1857f37e3f9b6ea1b2426", size = 219289 }, - { url = "https://files.pythonhosted.org/packages/9a/ac/46645ef6be543f2e7de08cc2601a0b67e130c816be3b749ab741be689fb9/coverage-7.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:b45264dd450a10f9e03237b41a9a24e85cbb1e278e5a32adb1a303f58f0017f3", size = 220199 }, - { url = "https://files.pythonhosted.org/packages/08/b6/fff6609354deba9aeec466e4bcaeb9d1ed3e5d60b14b57df2a36fb2273f2/coverage-7.10.5-py3-none-any.whl", hash = "sha256:0be24d35e4db1d23d0db5c0f6a74a962e2ec83c426b5cac09f4234aadef38e4a", size = 208736 }, +sdist = { url = "https://files.pythonhosted.org/packages/61/83/153f54356c7c200013a752ce1ed5448573dca546ce125801afca9e1ac1a4/coverage-7.10.5.tar.gz", hash = "sha256:f2e57716a78bc3ae80b2207be0709a3b2b63b9f2dcf9740ee6ac03588a2015b6", size = 821662, upload-time = "2025-08-23T14:42:44.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/70/e77b0061a6c7157bfce645c6b9a715a08d4c86b3360a7b3252818080b817/coverage-7.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c6a5c3414bfc7451b879141ce772c546985163cf553f08e0f135f0699a911801", size = 216774, upload-time = "2025-08-23T14:40:26.301Z" }, + { url = "https://files.pythonhosted.org/packages/91/08/2a79de5ecf37ee40f2d898012306f11c161548753391cec763f92647837b/coverage-7.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bc8e4d99ce82f1710cc3c125adc30fd1487d3cf6c2cd4994d78d68a47b16989a", size = 217175, upload-time = "2025-08-23T14:40:29.142Z" }, + { url = "https://files.pythonhosted.org/packages/64/57/0171d69a699690149a6ba6a4eb702814448c8d617cf62dbafa7ce6bfdf63/coverage-7.10.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:02252dc1216e512a9311f596b3169fad54abcb13827a8d76d5630c798a50a754", size = 243931, upload-time = "2025-08-23T14:40:30.735Z" }, + { url = "https://files.pythonhosted.org/packages/15/06/3a67662c55656702bd398a727a7f35df598eb11104fcb34f1ecbb070291a/coverage-7.10.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:73269df37883e02d460bee0cc16be90509faea1e3bd105d77360b512d5bb9c33", size = 245740, upload-time = "2025-08-23T14:40:32.302Z" }, + { url = "https://files.pythonhosted.org/packages/00/f4/f8763aabf4dc30ef0d0012522d312f0b7f9fede6246a1f27dbcc4a1e523c/coverage-7.10.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f8a81b0614642f91c9effd53eec284f965577591f51f547a1cbeb32035b4c2f", size = 247600, upload-time = "2025-08-23T14:40:33.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/31/6632219a9065e1b83f77eda116fed4c76fb64908a6a9feae41816dab8237/coverage-7.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6a29f8e0adb7f8c2b95fa2d4566a1d6e6722e0a637634c6563cb1ab844427dd9", size = 245640, upload-time = "2025-08-23T14:40:35.248Z" }, + { url = "https://files.pythonhosted.org/packages/6e/e2/3dba9b86037b81649b11d192bb1df11dde9a81013e434af3520222707bc8/coverage-7.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fcf6ab569436b4a647d4e91accba12509ad9f2554bc93d3aee23cc596e7f99c3", size = 243659, upload-time = "2025-08-23T14:40:36.815Z" }, + { url = "https://files.pythonhosted.org/packages/02/b9/57170bd9f3e333837fc24ecc88bc70fbc2eb7ccfd0876854b0c0407078c3/coverage-7.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:90dc3d6fb222b194a5de60af8d190bedeeddcbc7add317e4a3cd333ee6b7c879", size = 244537, upload-time = "2025-08-23T14:40:38.737Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1c/93ac36ef1e8b06b8d5777393a3a40cb356f9f3dab980be40a6941e443588/coverage-7.10.5-cp310-cp310-win32.whl", hash = "sha256:414a568cd545f9dc75f0686a0049393de8098414b58ea071e03395505b73d7a8", size = 219285, upload-time = "2025-08-23T14:40:40.342Z" }, + { url = "https://files.pythonhosted.org/packages/30/95/23252277e6e5fe649d6cd3ed3f35d2307e5166de4e75e66aa7f432abc46d/coverage-7.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:e551f9d03347196271935fd3c0c165f0e8c049220280c1120de0084d65e9c7ff", size = 220185, upload-time = "2025-08-23T14:40:42.026Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f2/336d34d2fc1291ca7c18eeb46f64985e6cef5a1a7ef6d9c23720c6527289/coverage-7.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c177e6ffe2ebc7c410785307758ee21258aa8e8092b44d09a2da767834f075f2", size = 216890, upload-time = "2025-08-23T14:40:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/92448b07cc1cf2b429d0ce635f59cf0c626a5d8de21358f11e92174ff2a6/coverage-7.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:14d6071c51ad0f703d6440827eaa46386169b5fdced42631d5a5ac419616046f", size = 217287, upload-time = "2025-08-23T14:40:45.214Z" }, + { url = "https://files.pythonhosted.org/packages/96/ba/ad5b36537c5179c808d0ecdf6e4aa7630b311b3c12747ad624dcd43a9b6b/coverage-7.10.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:61f78c7c3bc272a410c5ae3fde7792b4ffb4acc03d35a7df73ca8978826bb7ab", size = 247683, upload-time = "2025-08-23T14:40:46.791Z" }, + { url = "https://files.pythonhosted.org/packages/28/e5/fe3bbc8d097029d284b5fb305b38bb3404895da48495f05bff025df62770/coverage-7.10.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f39071caa126f69d63f99b324fb08c7b1da2ec28cbb1fe7b5b1799926492f65c", size = 249614, upload-time = "2025-08-23T14:40:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/69/9c/a1c89a8c8712799efccb32cd0a1ee88e452f0c13a006b65bb2271f1ac767/coverage-7.10.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343a023193f04d46edc46b2616cdbee68c94dd10208ecd3adc56fcc54ef2baa1", size = 251719, upload-time = "2025-08-23T14:40:49.349Z" }, + { url = "https://files.pythonhosted.org/packages/e9/be/5576b5625865aa95b5633315f8f4142b003a70c3d96e76f04487c3b5cc95/coverage-7.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:585ffe93ae5894d1ebdee69fc0b0d4b7c75d8007983692fb300ac98eed146f78", size = 249411, upload-time = "2025-08-23T14:40:50.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/e39a113d4209da0dbbc9385608cdb1b0726a4d25f78672dc51c97cfea80f/coverage-7.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0ef4e66f006ed181df29b59921bd8fc7ed7cd6a9289295cd8b2824b49b570df", size = 247466, upload-time = "2025-08-23T14:40:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/40/cb/aebb2d8c9e3533ee340bea19b71c5b76605a0268aa49808e26fe96ec0a07/coverage-7.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eb7b0bbf7cc1d0453b843eca7b5fa017874735bef9bfdfa4121373d2cc885ed6", size = 248104, upload-time = "2025-08-23T14:40:54.064Z" }, + { url = "https://files.pythonhosted.org/packages/08/e6/26570d6ccce8ff5de912cbfd268e7f475f00597cb58da9991fa919c5e539/coverage-7.10.5-cp311-cp311-win32.whl", hash = "sha256:1d043a8a06987cc0c98516e57c4d3fc2c1591364831e9deb59c9e1b4937e8caf", size = 219327, upload-time = "2025-08-23T14:40:55.424Z" }, + { url = "https://files.pythonhosted.org/packages/79/79/5f48525e366e518b36e66167e3b6e5db6fd54f63982500c6a5abb9d3dfbd/coverage-7.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:fefafcca09c3ac56372ef64a40f5fe17c5592fab906e0fdffd09543f3012ba50", size = 220213, upload-time = "2025-08-23T14:40:56.724Z" }, + { url = "https://files.pythonhosted.org/packages/40/3c/9058128b7b0bf333130c320b1eb1ae485623014a21ee196d68f7737f8610/coverage-7.10.5-cp311-cp311-win_arm64.whl", hash = "sha256:7e78b767da8b5fc5b2faa69bb001edafcd6f3995b42a331c53ef9572c55ceb82", size = 218893, upload-time = "2025-08-23T14:40:58.011Z" }, + { url = "https://files.pythonhosted.org/packages/27/8e/40d75c7128f871ea0fd829d3e7e4a14460cad7c3826e3b472e6471ad05bd/coverage-7.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c2d05c7e73c60a4cecc7d9b60dbfd603b4ebc0adafaef371445b47d0f805c8a9", size = 217077, upload-time = "2025-08-23T14:40:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/18/a8/f333f4cf3fb5477a7f727b4d603a2eb5c3c5611c7fe01329c2e13b23b678/coverage-7.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:32ddaa3b2c509778ed5373b177eb2bf5662405493baeff52278a0b4f9415188b", size = 217310, upload-time = "2025-08-23T14:41:00.628Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2c/fbecd8381e0a07d1547922be819b4543a901402f63930313a519b937c668/coverage-7.10.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd382410039fe062097aa0292ab6335a3f1e7af7bba2ef8d27dcda484918f20c", size = 248802, upload-time = "2025-08-23T14:41:02.012Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bc/1011da599b414fb6c9c0f34086736126f9ff71f841755786a6b87601b088/coverage-7.10.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7fa22800f3908df31cea6fb230f20ac49e343515d968cc3a42b30d5c3ebf9b5a", size = 251550, upload-time = "2025-08-23T14:41:03.438Z" }, + { url = "https://files.pythonhosted.org/packages/4c/6f/b5c03c0c721c067d21bc697accc3642f3cef9f087dac429c918c37a37437/coverage-7.10.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f366a57ac81f5e12797136552f5b7502fa053c861a009b91b80ed51f2ce651c6", size = 252684, upload-time = "2025-08-23T14:41:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/f9/50/d474bc300ebcb6a38a1047d5c465a227605d6473e49b4e0d793102312bc5/coverage-7.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1dc8f1980a272ad4a6c84cba7981792344dad33bf5869361576b7aef42733a", size = 250602, upload-time = "2025-08-23T14:41:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2d/548c8e04249cbba3aba6bd799efdd11eee3941b70253733f5d355d689559/coverage-7.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2285c04ee8676f7938b02b4936d9b9b672064daab3187c20f73a55f3d70e6b4a", size = 248724, upload-time = "2025-08-23T14:41:08.429Z" }, + { url = "https://files.pythonhosted.org/packages/e2/96/a7c3c0562266ac39dcad271d0eec8fc20ab576e3e2f64130a845ad2a557b/coverage-7.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2492e4dd9daab63f5f56286f8a04c51323d237631eb98505d87e4c4ff19ec34", size = 250158, upload-time = "2025-08-23T14:41:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/74d4be58c70c42ef0b352d597b022baf12dbe2b43e7cb1525f56a0fb1d4b/coverage-7.10.5-cp312-cp312-win32.whl", hash = "sha256:38a9109c4ee8135d5df5505384fc2f20287a47ccbe0b3f04c53c9a1989c2bbaf", size = 219493, upload-time = "2025-08-23T14:41:11.095Z" }, + { url = "https://files.pythonhosted.org/packages/4f/08/364e6012d1d4d09d1e27437382967efed971d7613f94bca9add25f0c1f2b/coverage-7.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:6b87f1ad60b30bc3c43c66afa7db6b22a3109902e28c5094957626a0143a001f", size = 220302, upload-time = "2025-08-23T14:41:12.449Z" }, + { url = "https://files.pythonhosted.org/packages/db/d5/7c8a365e1f7355c58af4fe5faf3f90cc8e587590f5854808d17ccb4e7077/coverage-7.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:672a6c1da5aea6c629819a0e1461e89d244f78d7b60c424ecf4f1f2556c041d8", size = 218936, upload-time = "2025-08-23T14:41:13.872Z" }, + { url = "https://files.pythonhosted.org/packages/9f/08/4166ecfb60ba011444f38a5a6107814b80c34c717bc7a23be0d22e92ca09/coverage-7.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef3b83594d933020f54cf65ea1f4405d1f4e41a009c46df629dd964fcb6e907c", size = 217106, upload-time = "2025-08-23T14:41:15.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/d7/b71022408adbf040a680b8c64bf6ead3be37b553e5844f7465643979f7ca/coverage-7.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b96bfdf7c0ea9faebce088a3ecb2382819da4fbc05c7b80040dbc428df6af44", size = 217353, upload-time = "2025-08-23T14:41:16.656Z" }, + { url = "https://files.pythonhosted.org/packages/74/68/21e0d254dbf8972bb8dd95e3fe7038f4be037ff04ba47d6d1b12b37510ba/coverage-7.10.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:63df1fdaffa42d914d5c4d293e838937638bf75c794cf20bee12978fc8c4e3bc", size = 248350, upload-time = "2025-08-23T14:41:18.128Z" }, + { url = "https://files.pythonhosted.org/packages/90/65/28752c3a896566ec93e0219fc4f47ff71bd2b745f51554c93e8dcb659796/coverage-7.10.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8002dc6a049aac0e81ecec97abfb08c01ef0c1fbf962d0c98da3950ace89b869", size = 250955, upload-time = "2025-08-23T14:41:19.577Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/ca6b7967f57f6fef31da8749ea20417790bb6723593c8cd98a987be20423/coverage-7.10.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63d4bb2966d6f5f705a6b0c6784c8969c468dbc4bcf9d9ded8bff1c7e092451f", size = 252230, upload-time = "2025-08-23T14:41:20.959Z" }, + { url = "https://files.pythonhosted.org/packages/bc/29/17a411b2a2a18f8b8c952aa01c00f9284a1fbc677c68a0003b772ea89104/coverage-7.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1f672efc0731a6846b157389b6e6d5d5e9e59d1d1a23a5c66a99fd58339914d5", size = 250387, upload-time = "2025-08-23T14:41:22.644Z" }, + { url = "https://files.pythonhosted.org/packages/c7/89/97a9e271188c2fbb3db82235c33980bcbc733da7da6065afbaa1d685a169/coverage-7.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:3f39cef43d08049e8afc1fde4a5da8510fc6be843f8dea350ee46e2a26b2f54c", size = 248280, upload-time = "2025-08-23T14:41:24.061Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c6/0ad7d0137257553eb4706b4ad6180bec0a1b6a648b092c5bbda48d0e5b2c/coverage-7.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2968647e3ed5a6c019a419264386b013979ff1fb67dd11f5c9886c43d6a31fc2", size = 249894, upload-time = "2025-08-23T14:41:26.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/56/fb3aba936addb4c9e5ea14f5979393f1c2466b4c89d10591fd05f2d6b2aa/coverage-7.10.5-cp313-cp313-win32.whl", hash = "sha256:0d511dda38595b2b6934c2b730a1fd57a3635c6aa2a04cb74714cdfdd53846f4", size = 219536, upload-time = "2025-08-23T14:41:27.694Z" }, + { url = "https://files.pythonhosted.org/packages/fc/54/baacb8f2f74431e3b175a9a2881feaa8feb6e2f187a0e7e3046f3c7742b2/coverage-7.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:9a86281794a393513cf117177fd39c796b3f8e3759bb2764259a2abba5cce54b", size = 220330, upload-time = "2025-08-23T14:41:29.081Z" }, + { url = "https://files.pythonhosted.org/packages/64/8a/82a3788f8e31dee51d350835b23d480548ea8621f3effd7c3ba3f7e5c006/coverage-7.10.5-cp313-cp313-win_arm64.whl", hash = "sha256:cebd8e906eb98bb09c10d1feed16096700b1198d482267f8bf0474e63a7b8d84", size = 218961, upload-time = "2025-08-23T14:41:30.511Z" }, + { url = "https://files.pythonhosted.org/packages/d8/a1/590154e6eae07beee3b111cc1f907c30da6fc8ce0a83ef756c72f3c7c748/coverage-7.10.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0520dff502da5e09d0d20781df74d8189ab334a1e40d5bafe2efaa4158e2d9e7", size = 217819, upload-time = "2025-08-23T14:41:31.962Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ff/436ffa3cfc7741f0973c5c89405307fe39b78dcf201565b934e6616fc4ad/coverage-7.10.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d9cd64aca68f503ed3f1f18c7c9174cbb797baba02ca8ab5112f9d1c0328cd4b", size = 218040, upload-time = "2025-08-23T14:41:33.472Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ca/5787fb3d7820e66273913affe8209c534ca11241eb34ee8c4fd2aaa9dd87/coverage-7.10.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0913dd1613a33b13c4f84aa6e3f4198c1a21ee28ccb4f674985c1f22109f0aae", size = 259374, upload-time = "2025-08-23T14:41:34.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/89/21af956843896adc2e64fc075eae3c1cadb97ee0a6960733e65e696f32dd/coverage-7.10.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1b7181c0feeb06ed8a02da02792f42f829a7b29990fef52eff257fef0885d760", size = 261551, upload-time = "2025-08-23T14:41:36.333Z" }, + { url = "https://files.pythonhosted.org/packages/e1/96/390a69244ab837e0ac137989277879a084c786cf036c3c4a3b9637d43a89/coverage-7.10.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36d42b7396b605f774d4372dd9c49bed71cbabce4ae1ccd074d155709dd8f235", size = 263776, upload-time = "2025-08-23T14:41:38.25Z" }, + { url = "https://files.pythonhosted.org/packages/00/32/cfd6ae1da0a521723349f3129b2455832fc27d3f8882c07e5b6fefdd0da2/coverage-7.10.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b4fdc777e05c4940b297bf47bf7eedd56a39a61dc23ba798e4b830d585486ca5", size = 261326, upload-time = "2025-08-23T14:41:40.343Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c4/bf8d459fb4ce2201e9243ce6c015936ad283a668774430a3755f467b39d1/coverage-7.10.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:42144e8e346de44a6f1dbd0a56575dd8ab8dfa7e9007da02ea5b1c30ab33a7db", size = 259090, upload-time = "2025-08-23T14:41:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5d/a234f7409896468e5539d42234016045e4015e857488b0b5b5f3f3fa5f2b/coverage-7.10.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:66c644cbd7aed8fe266d5917e2c9f65458a51cfe5eeff9c05f15b335f697066e", size = 260217, upload-time = "2025-08-23T14:41:43.591Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/87560f036099f46c2ddd235be6476dd5c1d6be6bb57569a9348d43eeecea/coverage-7.10.5-cp313-cp313t-win32.whl", hash = "sha256:2d1b73023854068c44b0c554578a4e1ef1b050ed07cf8b431549e624a29a66ee", size = 220194, upload-time = "2025-08-23T14:41:45.051Z" }, + { url = "https://files.pythonhosted.org/packages/36/a8/04a482594fdd83dc677d4a6c7e2d62135fff5a1573059806b8383fad9071/coverage-7.10.5-cp313-cp313t-win_amd64.whl", hash = "sha256:54a1532c8a642d8cc0bd5a9a51f5a9dcc440294fd06e9dda55e743c5ec1a8f14", size = 221258, upload-time = "2025-08-23T14:41:46.44Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ad/7da28594ab66fe2bc720f1bc9b131e62e9b4c6e39f044d9a48d18429cc21/coverage-7.10.5-cp313-cp313t-win_arm64.whl", hash = "sha256:74d5b63fe3f5f5d372253a4ef92492c11a4305f3550631beaa432fc9df16fcff", size = 219521, upload-time = "2025-08-23T14:41:47.882Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7f/c8b6e4e664b8a95254c35a6c8dd0bf4db201ec681c169aae2f1256e05c85/coverage-7.10.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:68c5e0bc5f44f68053369fa0d94459c84548a77660a5f2561c5e5f1e3bed7031", size = 217090, upload-time = "2025-08-23T14:41:49.327Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/3ee14ede30a6e10a94a104d1d0522d5fb909a7c7cac2643d2a79891ff3b9/coverage-7.10.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cf33134ffae93865e32e1e37df043bef15a5e857d8caebc0099d225c579b0fa3", size = 217365, upload-time = "2025-08-23T14:41:50.796Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/06ac21bf87dfb7620d1f870dfa3c2cae1186ccbcdc50b8b36e27a0d52f50/coverage-7.10.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad8fa9d5193bafcf668231294241302b5e683a0518bf1e33a9a0dfb142ec3031", size = 248413, upload-time = "2025-08-23T14:41:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/21/bc/cc5bed6e985d3a14228539631573f3863be6a2587381e8bc5fdf786377a1/coverage-7.10.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:146fa1531973d38ab4b689bc764592fe6c2f913e7e80a39e7eeafd11f0ef6db2", size = 250943, upload-time = "2025-08-23T14:41:53.922Z" }, + { url = "https://files.pythonhosted.org/packages/8d/43/6a9fc323c2c75cd80b18d58db4a25dc8487f86dd9070f9592e43e3967363/coverage-7.10.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6013a37b8a4854c478d3219ee8bc2392dea51602dd0803a12d6f6182a0061762", size = 252301, upload-time = "2025-08-23T14:41:56.528Z" }, + { url = "https://files.pythonhosted.org/packages/69/7c/3e791b8845f4cd515275743e3775adb86273576596dc9f02dca37357b4f2/coverage-7.10.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eb90fe20db9c3d930fa2ad7a308207ab5b86bf6a76f54ab6a40be4012d88fcae", size = 250302, upload-time = "2025-08-23T14:41:58.171Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bc/5099c1e1cb0c9ac6491b281babea6ebbf999d949bf4aa8cdf4f2b53505e8/coverage-7.10.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:384b34482272e960c438703cafe63316dfbea124ac62006a455c8410bf2a2262", size = 248237, upload-time = "2025-08-23T14:41:59.703Z" }, + { url = "https://files.pythonhosted.org/packages/7e/51/d346eb750a0b2f1e77f391498b753ea906fde69cc11e4b38dca28c10c88c/coverage-7.10.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:467dc74bd0a1a7de2bedf8deaf6811f43602cb532bd34d81ffd6038d6d8abe99", size = 249726, upload-time = "2025-08-23T14:42:01.343Z" }, + { url = "https://files.pythonhosted.org/packages/a3/85/eebcaa0edafe427e93286b94f56ea7e1280f2c49da0a776a6f37e04481f9/coverage-7.10.5-cp314-cp314-win32.whl", hash = "sha256:556d23d4e6393ca898b2e63a5bca91e9ac2d5fb13299ec286cd69a09a7187fde", size = 219825, upload-time = "2025-08-23T14:42:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f7/6d43e037820742603f1e855feb23463979bf40bd27d0cde1f761dcc66a3e/coverage-7.10.5-cp314-cp314-win_amd64.whl", hash = "sha256:f4446a9547681533c8fa3e3c6cf62121eeee616e6a92bd9201c6edd91beffe13", size = 220618, upload-time = "2025-08-23T14:42:05.037Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b0/ed9432e41424c51509d1da603b0393404b828906236fb87e2c8482a93468/coverage-7.10.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e78bd9cf65da4c303bf663de0d73bf69f81e878bf72a94e9af67137c69b9fe9", size = 219199, upload-time = "2025-08-23T14:42:06.662Z" }, + { url = "https://files.pythonhosted.org/packages/2f/54/5a7ecfa77910f22b659c820f67c16fc1e149ed132ad7117f0364679a8fa9/coverage-7.10.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:5661bf987d91ec756a47c7e5df4fbcb949f39e32f9334ccd3f43233bbb65e508", size = 217833, upload-time = "2025-08-23T14:42:08.262Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0e/25672d917cc57857d40edf38f0b867fb9627115294e4f92c8fcbbc18598d/coverage-7.10.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a46473129244db42a720439a26984f8c6f834762fc4573616c1f37f13994b357", size = 218048, upload-time = "2025-08-23T14:42:10.247Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7c/0b2b4f1c6f71885d4d4b2b8608dcfc79057adb7da4143eb17d6260389e42/coverage-7.10.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1f64b8d3415d60f24b058b58d859e9512624bdfa57a2d1f8aff93c1ec45c429b", size = 259549, upload-time = "2025-08-23T14:42:11.811Z" }, + { url = "https://files.pythonhosted.org/packages/94/73/abb8dab1609abec7308d83c6aec547944070526578ee6c833d2da9a0ad42/coverage-7.10.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:44d43de99a9d90b20e0163f9770542357f58860a26e24dc1d924643bd6aa7cb4", size = 261715, upload-time = "2025-08-23T14:42:13.505Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d1/abf31de21ec92731445606b8d5e6fa5144653c2788758fcf1f47adb7159a/coverage-7.10.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a931a87e5ddb6b6404e65443b742cb1c14959622777f2a4efd81fba84f5d91ba", size = 263969, upload-time = "2025-08-23T14:42:15.422Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b3/ef274927f4ebede96056173b620db649cc9cb746c61ffc467946b9d0bc67/coverage-7.10.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9559b906a100029274448f4c8b8b0a127daa4dade5661dfd821b8c188058842", size = 261408, upload-time = "2025-08-23T14:42:16.971Z" }, + { url = "https://files.pythonhosted.org/packages/20/fc/83ca2812be616d69b4cdd4e0c62a7bc526d56875e68fd0f79d47c7923584/coverage-7.10.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b08801e25e3b4526ef9ced1aa29344131a8f5213c60c03c18fe4c6170ffa2874", size = 259168, upload-time = "2025-08-23T14:42:18.512Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/e0779e5716f72d5c9962e709d09815d02b3b54724e38567308304c3fc9df/coverage-7.10.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ed9749bb8eda35f8b636fb7632f1c62f735a236a5d4edadd8bbcc5ea0542e732", size = 260317, upload-time = "2025-08-23T14:42:20.005Z" }, + { url = "https://files.pythonhosted.org/packages/2b/fe/4247e732f2234bb5eb9984a0888a70980d681f03cbf433ba7b48f08ca5d5/coverage-7.10.5-cp314-cp314t-win32.whl", hash = "sha256:609b60d123fc2cc63ccee6d17e4676699075db72d14ac3c107cc4976d516f2df", size = 220600, upload-time = "2025-08-23T14:42:22.027Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a0/f294cff6d1034b87839987e5b6ac7385bec599c44d08e0857ac7f164ad0c/coverage-7.10.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0666cf3d2c1626b5a3463fd5b05f5e21f99e6aec40a3192eee4d07a15970b07f", size = 221714, upload-time = "2025-08-23T14:42:23.616Z" }, + { url = "https://files.pythonhosted.org/packages/23/18/fa1afdc60b5528d17416df440bcbd8fd12da12bfea9da5b6ae0f7a37d0f7/coverage-7.10.5-cp314-cp314t-win_arm64.whl", hash = "sha256:bc85eb2d35e760120540afddd3044a5bf69118a91a296a8b3940dfc4fdcfe1e2", size = 219735, upload-time = "2025-08-23T14:42:25.156Z" }, + { url = "https://files.pythonhosted.org/packages/3b/21/05248e8bc74683488cb7477e6b6b878decadd15af0ec96f56381d3d7ff2d/coverage-7.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:62835c1b00c4a4ace24c1a88561a5a59b612fbb83a525d1c70ff5720c97c0610", size = 216763, upload-time = "2025-08-23T14:42:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7f/161a0ad40cb1c7e19dc1aae106d3430cc88dac3d651796d6cf3f3730c800/coverage-7.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5255b3bbcc1d32a4069d6403820ac8e6dbcc1d68cb28a60a1ebf17e47028e898", size = 217154, upload-time = "2025-08-23T14:42:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/de/31/41929ee53af829ea5a88e71d335ea09d0bb587a3da1c5e58e59b48473ed8/coverage-7.10.5-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3876385722e335d6e991c430302c24251ef9c2a9701b2b390f5473199b1b8ebf", size = 243588, upload-time = "2025-08-23T14:42:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/2649344e33eeb3567041e8255a1942173cae81817fe06b60f3fafaafe111/coverage-7.10.5-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8048ce4b149c93447a55d279078c8ae98b08a6951a3c4d2d7e87f4efc7bfe100", size = 245412, upload-time = "2025-08-23T14:42:31.296Z" }, + { url = "https://files.pythonhosted.org/packages/ac/b1/b21e1e69986ad89b051dd42c3ef06d9326e03ac3c0c844fc33385d1d9e35/coverage-7.10.5-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4028e7558e268dd8bcf4d9484aad393cafa654c24b4885f6f9474bf53183a82a", size = 247182, upload-time = "2025-08-23T14:42:33.155Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b5/80837be411ae092e03fcc2a7877bd9a659c531eff50453e463057a9eee44/coverage-7.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03f47dc870eec0367fcdd603ca6a01517d2504e83dc18dbfafae37faec66129a", size = 245066, upload-time = "2025-08-23T14:42:34.754Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ed/fcb0838ddf149d68d09f89af57397b0dd9d26b100cc729daf1b0caf0b2d3/coverage-7.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2d488d7d42b6ded7ea0704884f89dcabd2619505457de8fc9a6011c62106f6e5", size = 243138, upload-time = "2025-08-23T14:42:36.311Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/505c6af24a9ae5d8919d209b9c31b7092815f468fa43bec3b1118232c62a/coverage-7.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b3dcf2ead47fa8be14224ee817dfc1df98043af568fe120a22f81c0eb3c34ad2", size = 244095, upload-time = "2025-08-23T14:42:38.227Z" }, + { url = "https://files.pythonhosted.org/packages/e4/7e/c82a8bede46217c1d944bd19b65e7106633b998640f00ab49c5f747a5844/coverage-7.10.5-cp39-cp39-win32.whl", hash = "sha256:02650a11324b80057b8c9c29487020073d5e98a498f1857f37e3f9b6ea1b2426", size = 219289, upload-time = "2025-08-23T14:42:39.827Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ac/46645ef6be543f2e7de08cc2601a0b67e130c816be3b749ab741be689fb9/coverage-7.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:b45264dd450a10f9e03237b41a9a24e85cbb1e278e5a32adb1a303f58f0017f3", size = 220199, upload-time = "2025-08-23T14:42:41.363Z" }, + { url = "https://files.pythonhosted.org/packages/08/b6/fff6609354deba9aeec466e4bcaeb9d1ed3e5d60b14b57df2a36fb2273f2/coverage-7.10.5-py3-none-any.whl", hash = "sha256:0be24d35e4db1d23d0db5c0f6a74a962e2ec83c426b5cac09f4234aadef38e4a", size = 208736, upload-time = "2025-08-23T14:42:43.145Z" }, ] [package.optional-dependencies] @@ -380,105 +381,105 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/0d/d13399c94234ee8f3df384819dc67e0c5ce215fb751d567a55a1f4b028c7/cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719", size = 744949 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/29/2793d178d0eda1ca4a09a7c4e09a5185e75738cc6d526433e8663b460ea6/cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74", size = 7042702 }, - { url = "https://files.pythonhosted.org/packages/b3/b6/cabd07410f222f32c8d55486c464f432808abaa1f12af9afcbe8f2f19030/cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f", size = 4206483 }, - { url = "https://files.pythonhosted.org/packages/8b/9e/f9c7d36a38b1cfeb1cc74849aabe9bf817990f7603ff6eb485e0d70e0b27/cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf", size = 4429679 }, - { url = "https://files.pythonhosted.org/packages/9c/2a/4434c17eb32ef30b254b9e8b9830cee4e516f08b47fdd291c5b1255b8101/cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5", size = 4210553 }, - { url = "https://files.pythonhosted.org/packages/ef/1d/09a5df8e0c4b7970f5d1f3aff1b640df6d4be28a64cae970d56c6cf1c772/cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2", size = 3894499 }, - { url = "https://files.pythonhosted.org/packages/79/62/120842ab20d9150a9d3a6bdc07fe2870384e82f5266d41c53b08a3a96b34/cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08", size = 4458484 }, - { url = "https://files.pythonhosted.org/packages/fd/80/1bc3634d45ddfed0871bfba52cf8f1ad724761662a0c792b97a951fb1b30/cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402", size = 4210281 }, - { url = "https://files.pythonhosted.org/packages/7d/fe/ffb12c2d83d0ee625f124880a1f023b5878f79da92e64c37962bbbe35f3f/cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42", size = 4456890 }, - { url = "https://files.pythonhosted.org/packages/8c/8e/b3f3fe0dc82c77a0deb5f493b23311e09193f2268b77196ec0f7a36e3f3e/cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05", size = 4333247 }, - { url = "https://files.pythonhosted.org/packages/b3/a6/c3ef2ab9e334da27a1d7b56af4a2417d77e7806b2e0f90d6267ce120d2e4/cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453", size = 4565045 }, - { url = "https://files.pythonhosted.org/packages/31/c3/77722446b13fa71dddd820a5faab4ce6db49e7e0bf8312ef4192a3f78e2f/cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159", size = 2928923 }, - { url = "https://files.pythonhosted.org/packages/38/63/a025c3225188a811b82932a4dcc8457a26c3729d81578ccecbcce2cb784e/cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec", size = 3403805 }, - { url = "https://files.pythonhosted.org/packages/5b/af/bcfbea93a30809f126d51c074ee0fac5bd9d57d068edf56c2a73abedbea4/cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0", size = 7020111 }, - { url = "https://files.pythonhosted.org/packages/98/c6/ea5173689e014f1a8470899cd5beeb358e22bb3cf5a876060f9d1ca78af4/cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394", size = 4198169 }, - { url = "https://files.pythonhosted.org/packages/ba/73/b12995edc0c7e2311ffb57ebd3b351f6b268fed37d93bfc6f9856e01c473/cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9", size = 4421273 }, - { url = "https://files.pythonhosted.org/packages/f7/6e/286894f6f71926bc0da67408c853dd9ba953f662dcb70993a59fd499f111/cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3", size = 4199211 }, - { url = "https://files.pythonhosted.org/packages/de/34/a7f55e39b9623c5cb571d77a6a90387fe557908ffc44f6872f26ca8ae270/cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3", size = 3883732 }, - { url = "https://files.pythonhosted.org/packages/f9/b9/c6d32edbcba0cd9f5df90f29ed46a65c4631c4fbe11187feb9169c6ff506/cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301", size = 4450655 }, - { url = "https://files.pythonhosted.org/packages/77/2d/09b097adfdee0227cfd4c699b3375a842080f065bab9014248933497c3f9/cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5", size = 4198956 }, - { url = "https://files.pythonhosted.org/packages/55/66/061ec6689207d54effdff535bbdf85cc380d32dd5377173085812565cf38/cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016", size = 4449859 }, - { url = "https://files.pythonhosted.org/packages/41/ff/e7d5a2ad2d035e5a2af116e1a3adb4d8fcd0be92a18032917a089c6e5028/cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3", size = 4320254 }, - { url = "https://files.pythonhosted.org/packages/82/27/092d311af22095d288f4db89fcaebadfb2f28944f3d790a4cf51fe5ddaeb/cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9", size = 4554815 }, - { url = "https://files.pythonhosted.org/packages/7e/01/aa2f4940262d588a8fdf4edabe4cda45854d00ebc6eaac12568b3a491a16/cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02", size = 2912147 }, - { url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459 }, - { url = "https://files.pythonhosted.org/packages/56/d2/4482d97c948c029be08cb29854a91bd2ae8da7eb9c4152461f1244dcea70/cryptography-45.0.6-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:705bb7c7ecc3d79a50f236adda12ca331c8e7ecfbea51edd931ce5a7a7c4f012", size = 3576812 }, - { url = "https://files.pythonhosted.org/packages/ec/24/55fc238fcaa122855442604b8badb2d442367dfbd5a7ca4bb0bd346e263a/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:826b46dae41a1155a0c0e66fafba43d0ede1dc16570b95e40c4d83bfcf0a451d", size = 4141694 }, - { url = "https://files.pythonhosted.org/packages/f9/7e/3ea4fa6fbe51baf3903806a0241c666b04c73d2358a3ecce09ebee8b9622/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cc4d66f5dc4dc37b89cfef1bd5044387f7a1f6f0abb490815628501909332d5d", size = 4375010 }, - { url = "https://files.pythonhosted.org/packages/50/42/ec5a892d82d2a2c29f80fc19ced4ba669bca29f032faf6989609cff1f8dc/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f68f833a9d445cc49f01097d95c83a850795921b3f7cc6488731e69bde3288da", size = 4141377 }, - { url = "https://files.pythonhosted.org/packages/e7/d7/246c4c973a22b9c2931999da953a2c19cae7c66b9154c2d62ffed811225e/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3b5bf5267e98661b9b888a9250d05b063220dfa917a8203744454573c7eb79db", size = 4374609 }, - { url = "https://files.pythonhosted.org/packages/78/6d/c49ccf243f0a1b0781c2a8de8123ee552f0c8a417c6367a24d2ecb7c11b3/cryptography-45.0.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2384f2ab18d9be88a6e4f8972923405e2dbb8d3e16c6b43f15ca491d7831bd18", size = 3322156 }, - { url = "https://files.pythonhosted.org/packages/61/69/c252de4ec047ba2f567ecb53149410219577d408c2aea9c989acae7eafce/cryptography-45.0.6-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fc022c1fa5acff6def2fc6d7819bbbd31ccddfe67d075331a65d9cfb28a20983", size = 3584669 }, - { url = "https://files.pythonhosted.org/packages/e3/fe/deea71e9f310a31fe0a6bfee670955152128d309ea2d1c79e2a5ae0f0401/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3de77e4df42ac8d4e4d6cdb342d989803ad37707cf8f3fbf7b088c9cbdd46427", size = 4153022 }, - { url = "https://files.pythonhosted.org/packages/60/45/a77452f5e49cb580feedba6606d66ae7b82c128947aa754533b3d1bd44b0/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:599c8d7df950aa68baa7e98f7b73f4f414c9f02d0e8104a30c0182a07732638b", size = 4386802 }, - { url = "https://files.pythonhosted.org/packages/a3/b9/a2f747d2acd5e3075fdf5c145c7c3568895daaa38b3b0c960ef830db6cdc/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:31a2b9a10530a1cb04ffd6aa1cd4d3be9ed49f7d77a4dafe198f3b382f41545c", size = 4152706 }, - { url = "https://files.pythonhosted.org/packages/81/ec/381b3e8d0685a3f3f304a382aa3dfce36af2d76467da0fd4bb21ddccc7b2/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e5b3dda1b00fb41da3af4c5ef3f922a200e33ee5ba0f0bc9ecf0b0c173958385", size = 4386740 }, - { url = "https://files.pythonhosted.org/packages/0a/76/cf8d69da8d0b5ecb0db406f24a63a3f69ba5e791a11b782aeeefef27ccbb/cryptography-45.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:629127cfdcdc6806dfe234734d7cb8ac54edaf572148274fa377a7d3405b0043", size = 3331874 }, +sdist = { url = "https://files.pythonhosted.org/packages/d6/0d/d13399c94234ee8f3df384819dc67e0c5ce215fb751d567a55a1f4b028c7/cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719", size = 744949, upload-time = "2025-08-05T23:59:27.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/29/2793d178d0eda1ca4a09a7c4e09a5185e75738cc6d526433e8663b460ea6/cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74", size = 7042702, upload-time = "2025-08-05T23:58:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b6/cabd07410f222f32c8d55486c464f432808abaa1f12af9afcbe8f2f19030/cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f", size = 4206483, upload-time = "2025-08-05T23:58:27.132Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/f9c7d36a38b1cfeb1cc74849aabe9bf817990f7603ff6eb485e0d70e0b27/cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf", size = 4429679, upload-time = "2025-08-05T23:58:29.152Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2a/4434c17eb32ef30b254b9e8b9830cee4e516f08b47fdd291c5b1255b8101/cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5", size = 4210553, upload-time = "2025-08-05T23:58:30.596Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1d/09a5df8e0c4b7970f5d1f3aff1b640df6d4be28a64cae970d56c6cf1c772/cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2", size = 3894499, upload-time = "2025-08-05T23:58:32.03Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/120842ab20d9150a9d3a6bdc07fe2870384e82f5266d41c53b08a3a96b34/cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08", size = 4458484, upload-time = "2025-08-05T23:58:33.526Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/1bc3634d45ddfed0871bfba52cf8f1ad724761662a0c792b97a951fb1b30/cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402", size = 4210281, upload-time = "2025-08-05T23:58:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/ffb12c2d83d0ee625f124880a1f023b5878f79da92e64c37962bbbe35f3f/cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42", size = 4456890, upload-time = "2025-08-05T23:58:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8e/b3f3fe0dc82c77a0deb5f493b23311e09193f2268b77196ec0f7a36e3f3e/cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05", size = 4333247, upload-time = "2025-08-05T23:58:38.781Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a6/c3ef2ab9e334da27a1d7b56af4a2417d77e7806b2e0f90d6267ce120d2e4/cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453", size = 4565045, upload-time = "2025-08-05T23:58:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/77722446b13fa71dddd820a5faab4ce6db49e7e0bf8312ef4192a3f78e2f/cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159", size = 2928923, upload-time = "2025-08-05T23:58:41.919Z" }, + { url = "https://files.pythonhosted.org/packages/38/63/a025c3225188a811b82932a4dcc8457a26c3729d81578ccecbcce2cb784e/cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec", size = 3403805, upload-time = "2025-08-05T23:58:43.792Z" }, + { url = "https://files.pythonhosted.org/packages/5b/af/bcfbea93a30809f126d51c074ee0fac5bd9d57d068edf56c2a73abedbea4/cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0", size = 7020111, upload-time = "2025-08-05T23:58:45.316Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/ea5173689e014f1a8470899cd5beeb358e22bb3cf5a876060f9d1ca78af4/cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394", size = 4198169, upload-time = "2025-08-05T23:58:47.121Z" }, + { url = "https://files.pythonhosted.org/packages/ba/73/b12995edc0c7e2311ffb57ebd3b351f6b268fed37d93bfc6f9856e01c473/cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9", size = 4421273, upload-time = "2025-08-05T23:58:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/f7/6e/286894f6f71926bc0da67408c853dd9ba953f662dcb70993a59fd499f111/cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3", size = 4199211, upload-time = "2025-08-05T23:58:50.139Z" }, + { url = "https://files.pythonhosted.org/packages/de/34/a7f55e39b9623c5cb571d77a6a90387fe557908ffc44f6872f26ca8ae270/cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3", size = 3883732, upload-time = "2025-08-05T23:58:52.253Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b9/c6d32edbcba0cd9f5df90f29ed46a65c4631c4fbe11187feb9169c6ff506/cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301", size = 4450655, upload-time = "2025-08-05T23:58:53.848Z" }, + { url = "https://files.pythonhosted.org/packages/77/2d/09b097adfdee0227cfd4c699b3375a842080f065bab9014248933497c3f9/cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5", size = 4198956, upload-time = "2025-08-05T23:58:55.209Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/061ec6689207d54effdff535bbdf85cc380d32dd5377173085812565cf38/cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016", size = 4449859, upload-time = "2025-08-05T23:58:56.639Z" }, + { url = "https://files.pythonhosted.org/packages/41/ff/e7d5a2ad2d035e5a2af116e1a3adb4d8fcd0be92a18032917a089c6e5028/cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3", size = 4320254, upload-time = "2025-08-05T23:58:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/82/27/092d311af22095d288f4db89fcaebadfb2f28944f3d790a4cf51fe5ddaeb/cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9", size = 4554815, upload-time = "2025-08-05T23:59:00.283Z" }, + { url = "https://files.pythonhosted.org/packages/7e/01/aa2f4940262d588a8fdf4edabe4cda45854d00ebc6eaac12568b3a491a16/cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02", size = 2912147, upload-time = "2025-08-05T23:59:01.716Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/56/d2/4482d97c948c029be08cb29854a91bd2ae8da7eb9c4152461f1244dcea70/cryptography-45.0.6-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:705bb7c7ecc3d79a50f236adda12ca331c8e7ecfbea51edd931ce5a7a7c4f012", size = 3576812, upload-time = "2025-08-05T23:59:04.833Z" }, + { url = "https://files.pythonhosted.org/packages/ec/24/55fc238fcaa122855442604b8badb2d442367dfbd5a7ca4bb0bd346e263a/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:826b46dae41a1155a0c0e66fafba43d0ede1dc16570b95e40c4d83bfcf0a451d", size = 4141694, upload-time = "2025-08-05T23:59:06.66Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7e/3ea4fa6fbe51baf3903806a0241c666b04c73d2358a3ecce09ebee8b9622/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cc4d66f5dc4dc37b89cfef1bd5044387f7a1f6f0abb490815628501909332d5d", size = 4375010, upload-time = "2025-08-05T23:59:08.14Z" }, + { url = "https://files.pythonhosted.org/packages/50/42/ec5a892d82d2a2c29f80fc19ced4ba669bca29f032faf6989609cff1f8dc/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f68f833a9d445cc49f01097d95c83a850795921b3f7cc6488731e69bde3288da", size = 4141377, upload-time = "2025-08-05T23:59:09.584Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d7/246c4c973a22b9c2931999da953a2c19cae7c66b9154c2d62ffed811225e/cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3b5bf5267e98661b9b888a9250d05b063220dfa917a8203744454573c7eb79db", size = 4374609, upload-time = "2025-08-05T23:59:11.923Z" }, + { url = "https://files.pythonhosted.org/packages/78/6d/c49ccf243f0a1b0781c2a8de8123ee552f0c8a417c6367a24d2ecb7c11b3/cryptography-45.0.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2384f2ab18d9be88a6e4f8972923405e2dbb8d3e16c6b43f15ca491d7831bd18", size = 3322156, upload-time = "2025-08-05T23:59:13.597Z" }, + { url = "https://files.pythonhosted.org/packages/61/69/c252de4ec047ba2f567ecb53149410219577d408c2aea9c989acae7eafce/cryptography-45.0.6-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fc022c1fa5acff6def2fc6d7819bbbd31ccddfe67d075331a65d9cfb28a20983", size = 3584669, upload-time = "2025-08-05T23:59:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/e3/fe/deea71e9f310a31fe0a6bfee670955152128d309ea2d1c79e2a5ae0f0401/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3de77e4df42ac8d4e4d6cdb342d989803ad37707cf8f3fbf7b088c9cbdd46427", size = 4153022, upload-time = "2025-08-05T23:59:16.954Z" }, + { url = "https://files.pythonhosted.org/packages/60/45/a77452f5e49cb580feedba6606d66ae7b82c128947aa754533b3d1bd44b0/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:599c8d7df950aa68baa7e98f7b73f4f414c9f02d0e8104a30c0182a07732638b", size = 4386802, upload-time = "2025-08-05T23:59:18.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/a2f747d2acd5e3075fdf5c145c7c3568895daaa38b3b0c960ef830db6cdc/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:31a2b9a10530a1cb04ffd6aa1cd4d3be9ed49f7d77a4dafe198f3b382f41545c", size = 4152706, upload-time = "2025-08-05T23:59:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/81/ec/381b3e8d0685a3f3f304a382aa3dfce36af2d76467da0fd4bb21ddccc7b2/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e5b3dda1b00fb41da3af4c5ef3f922a200e33ee5ba0f0bc9ecf0b0c173958385", size = 4386740, upload-time = "2025-08-05T23:59:21.525Z" }, + { url = "https://files.pythonhosted.org/packages/0a/76/cf8d69da8d0b5ecb0db406f24a63a3f69ba5e791a11b782aeeefef27ccbb/cryptography-45.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:629127cfdcdc6806dfe234734d7cb8ac54edaf572148274fa377a7d3405b0043", size = 3331874, upload-time = "2025-08-05T23:59:23.017Z" }, ] [[package]] name = "debugpy" version = "1.8.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/d4/722d0bcc7986172ac2ef3c979ad56a1030e3afd44ced136d45f8142b1f4a/debugpy-1.8.16.tar.gz", hash = "sha256:31e69a1feb1cf6b51efbed3f6c9b0ef03bc46ff050679c4be7ea6d2e23540870", size = 1643809 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/fd/f1b75ebc61d90882595b81d808efd3573c082e1c3407850d9dccac4ae904/debugpy-1.8.16-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:2a3958fb9c2f40ed8ea48a0d34895b461de57a1f9862e7478716c35d76f56c65", size = 2085511 }, - { url = "https://files.pythonhosted.org/packages/df/5e/c5c1934352871128b30a1a144a58b5baa546e1b57bd47dbed788bad4431c/debugpy-1.8.16-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5ca7314042e8a614cc2574cd71f6ccd7e13a9708ce3c6d8436959eae56f2378", size = 3562094 }, - { url = "https://files.pythonhosted.org/packages/c9/d5/2ebe42377e5a78dc786afc25e61ee83c5628d63f32dfa41092597d52fe83/debugpy-1.8.16-cp310-cp310-win32.whl", hash = "sha256:8624a6111dc312ed8c363347a0b59c5acc6210d897e41a7c069de3c53235c9a6", size = 5234277 }, - { url = "https://files.pythonhosted.org/packages/54/f8/e774ad16a60b9913213dbabb7472074c5a7b0d84f07c1f383040a9690057/debugpy-1.8.16-cp310-cp310-win_amd64.whl", hash = "sha256:fee6db83ea5c978baf042440cfe29695e1a5d48a30147abf4c3be87513609817", size = 5266011 }, - { url = "https://files.pythonhosted.org/packages/63/d6/ad70ba8b49b23fa286fb21081cf732232cc19374af362051da9c7537ae52/debugpy-1.8.16-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:67371b28b79a6a12bcc027d94a06158f2fde223e35b5c4e0783b6f9d3b39274a", size = 2184063 }, - { url = "https://files.pythonhosted.org/packages/aa/49/7b03e88dea9759a4c7910143f87f92beb494daaae25560184ff4ae883f9e/debugpy-1.8.16-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2abae6dd02523bec2dee16bd6b0781cccb53fd4995e5c71cc659b5f45581898", size = 3134837 }, - { url = "https://files.pythonhosted.org/packages/5d/52/b348930316921de7565fbe37a487d15409041713004f3d74d03eb077dbd4/debugpy-1.8.16-cp311-cp311-win32.whl", hash = "sha256:f8340a3ac2ed4f5da59e064aa92e39edd52729a88fbde7bbaa54e08249a04493", size = 5159142 }, - { url = "https://files.pythonhosted.org/packages/d8/ef/9aa9549ce1e10cea696d980292e71672a91ee4a6a691ce5f8629e8f48c49/debugpy-1.8.16-cp311-cp311-win_amd64.whl", hash = "sha256:70f5fcd6d4d0c150a878d2aa37391c52de788c3dc680b97bdb5e529cb80df87a", size = 5183117 }, - { url = "https://files.pythonhosted.org/packages/61/fb/0387c0e108d842c902801bc65ccc53e5b91d8c169702a9bbf4f7efcedf0c/debugpy-1.8.16-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:b202e2843e32e80b3b584bcebfe0e65e0392920dc70df11b2bfe1afcb7a085e4", size = 2511822 }, - { url = "https://files.pythonhosted.org/packages/37/44/19e02745cae22bf96440141f94e15a69a1afaa3a64ddfc38004668fcdebf/debugpy-1.8.16-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64473c4a306ba11a99fe0bb14622ba4fbd943eb004847d9b69b107bde45aa9ea", size = 4230135 }, - { url = "https://files.pythonhosted.org/packages/f3/0b/19b1ba5ee4412f303475a2c7ad5858efb99c90eae5ec627aa6275c439957/debugpy-1.8.16-cp312-cp312-win32.whl", hash = "sha256:833a61ed446426e38b0dd8be3e9d45ae285d424f5bf6cd5b2b559c8f12305508", size = 5281271 }, - { url = "https://files.pythonhosted.org/packages/b1/e0/bc62e2dc141de53bd03e2c7cb9d7011de2e65e8bdcdaa26703e4d28656ba/debugpy-1.8.16-cp312-cp312-win_amd64.whl", hash = "sha256:75f204684581e9ef3dc2f67687c3c8c183fde2d6675ab131d94084baf8084121", size = 5323149 }, - { url = "https://files.pythonhosted.org/packages/62/66/607ab45cc79e60624df386e233ab64a6d8d39ea02e7f80e19c1d451345bb/debugpy-1.8.16-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:85df3adb1de5258dca910ae0bb185e48c98801ec15018a263a92bb06be1c8787", size = 2496157 }, - { url = "https://files.pythonhosted.org/packages/4d/a0/c95baae08a75bceabb79868d663a0736655e427ab9c81fb848da29edaeac/debugpy-1.8.16-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bee89e948bc236a5c43c4214ac62d28b29388453f5fd328d739035e205365f0b", size = 4222491 }, - { url = "https://files.pythonhosted.org/packages/5b/2f/1c8db6ddd8a257c3cd2c46413b267f1d5fa3df910401c899513ce30392d6/debugpy-1.8.16-cp313-cp313-win32.whl", hash = "sha256:cf358066650439847ec5ff3dae1da98b5461ea5da0173d93d5e10f477c94609a", size = 5281126 }, - { url = "https://files.pythonhosted.org/packages/d3/ba/c3e154ab307366d6c5a9c1b68de04914e2ce7fa2f50d578311d8cc5074b2/debugpy-1.8.16-cp313-cp313-win_amd64.whl", hash = "sha256:b5aea1083f6f50023e8509399d7dc6535a351cc9f2e8827d1e093175e4d9fa4c", size = 5323094 }, - { url = "https://files.pythonhosted.org/packages/35/40/acdad5944e508d5e936979ad3e96e56b78ba6d7fa75aaffc4426cb921e12/debugpy-1.8.16-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:135ccd2b1161bade72a7a099c9208811c137a150839e970aeaf121c2467debe8", size = 2086696 }, - { url = "https://files.pythonhosted.org/packages/2d/eb/8d6a2cf3b29e272b5dfebe6f384f8457977d4fd7a02dab2cae4d421dbae2/debugpy-1.8.16-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:211238306331a9089e253fd997213bc4a4c65f949271057d6695953254095376", size = 3557329 }, - { url = "https://files.pythonhosted.org/packages/00/7b/63b9cc4d3c6980c702911c0f6a9748933ce4e4f16ae0ec4fdef7690f6662/debugpy-1.8.16-cp39-cp39-win32.whl", hash = "sha256:88eb9ffdfb59bf63835d146c183d6dba1f722b3ae2a5f4b9fc03e925b3358922", size = 5235114 }, - { url = "https://files.pythonhosted.org/packages/05/cf/80947f57e0ef4d6e33ec9c3a109a542678eba465723bf8b599719238eb93/debugpy-1.8.16-cp39-cp39-win_amd64.whl", hash = "sha256:c2c47c2e52b40449552843b913786499efcc3dbc21d6c49287d939cd0dbc49fd", size = 5266799 }, - { url = "https://files.pythonhosted.org/packages/52/57/ecc9ae29fa5b2d90107cd1d9bf8ed19aacb74b2264d986ae9d44fe9bdf87/debugpy-1.8.16-py2.py3-none-any.whl", hash = "sha256:19c9521962475b87da6f673514f7fd610328757ec993bf7ec0d8c96f9a325f9e", size = 5287700 }, +sdist = { url = "https://files.pythonhosted.org/packages/ca/d4/722d0bcc7986172ac2ef3c979ad56a1030e3afd44ced136d45f8142b1f4a/debugpy-1.8.16.tar.gz", hash = "sha256:31e69a1feb1cf6b51efbed3f6c9b0ef03bc46ff050679c4be7ea6d2e23540870", size = 1643809, upload-time = "2025-08-06T18:00:02.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/fd/f1b75ebc61d90882595b81d808efd3573c082e1c3407850d9dccac4ae904/debugpy-1.8.16-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:2a3958fb9c2f40ed8ea48a0d34895b461de57a1f9862e7478716c35d76f56c65", size = 2085511, upload-time = "2025-08-06T18:00:05.067Z" }, + { url = "https://files.pythonhosted.org/packages/df/5e/c5c1934352871128b30a1a144a58b5baa546e1b57bd47dbed788bad4431c/debugpy-1.8.16-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5ca7314042e8a614cc2574cd71f6ccd7e13a9708ce3c6d8436959eae56f2378", size = 3562094, upload-time = "2025-08-06T18:00:06.66Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/2ebe42377e5a78dc786afc25e61ee83c5628d63f32dfa41092597d52fe83/debugpy-1.8.16-cp310-cp310-win32.whl", hash = "sha256:8624a6111dc312ed8c363347a0b59c5acc6210d897e41a7c069de3c53235c9a6", size = 5234277, upload-time = "2025-08-06T18:00:08.429Z" }, + { url = "https://files.pythonhosted.org/packages/54/f8/e774ad16a60b9913213dbabb7472074c5a7b0d84f07c1f383040a9690057/debugpy-1.8.16-cp310-cp310-win_amd64.whl", hash = "sha256:fee6db83ea5c978baf042440cfe29695e1a5d48a30147abf4c3be87513609817", size = 5266011, upload-time = "2025-08-06T18:00:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/ad70ba8b49b23fa286fb21081cf732232cc19374af362051da9c7537ae52/debugpy-1.8.16-cp311-cp311-macosx_14_0_universal2.whl", hash = "sha256:67371b28b79a6a12bcc027d94a06158f2fde223e35b5c4e0783b6f9d3b39274a", size = 2184063, upload-time = "2025-08-06T18:00:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/aa/49/7b03e88dea9759a4c7910143f87f92beb494daaae25560184ff4ae883f9e/debugpy-1.8.16-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2abae6dd02523bec2dee16bd6b0781cccb53fd4995e5c71cc659b5f45581898", size = 3134837, upload-time = "2025-08-06T18:00:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/5d/52/b348930316921de7565fbe37a487d15409041713004f3d74d03eb077dbd4/debugpy-1.8.16-cp311-cp311-win32.whl", hash = "sha256:f8340a3ac2ed4f5da59e064aa92e39edd52729a88fbde7bbaa54e08249a04493", size = 5159142, upload-time = "2025-08-06T18:00:15.391Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ef/9aa9549ce1e10cea696d980292e71672a91ee4a6a691ce5f8629e8f48c49/debugpy-1.8.16-cp311-cp311-win_amd64.whl", hash = "sha256:70f5fcd6d4d0c150a878d2aa37391c52de788c3dc680b97bdb5e529cb80df87a", size = 5183117, upload-time = "2025-08-06T18:00:17.251Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/0387c0e108d842c902801bc65ccc53e5b91d8c169702a9bbf4f7efcedf0c/debugpy-1.8.16-cp312-cp312-macosx_14_0_universal2.whl", hash = "sha256:b202e2843e32e80b3b584bcebfe0e65e0392920dc70df11b2bfe1afcb7a085e4", size = 2511822, upload-time = "2025-08-06T18:00:18.526Z" }, + { url = "https://files.pythonhosted.org/packages/37/44/19e02745cae22bf96440141f94e15a69a1afaa3a64ddfc38004668fcdebf/debugpy-1.8.16-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64473c4a306ba11a99fe0bb14622ba4fbd943eb004847d9b69b107bde45aa9ea", size = 4230135, upload-time = "2025-08-06T18:00:19.997Z" }, + { url = "https://files.pythonhosted.org/packages/f3/0b/19b1ba5ee4412f303475a2c7ad5858efb99c90eae5ec627aa6275c439957/debugpy-1.8.16-cp312-cp312-win32.whl", hash = "sha256:833a61ed446426e38b0dd8be3e9d45ae285d424f5bf6cd5b2b559c8f12305508", size = 5281271, upload-time = "2025-08-06T18:00:21.281Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e0/bc62e2dc141de53bd03e2c7cb9d7011de2e65e8bdcdaa26703e4d28656ba/debugpy-1.8.16-cp312-cp312-win_amd64.whl", hash = "sha256:75f204684581e9ef3dc2f67687c3c8c183fde2d6675ab131d94084baf8084121", size = 5323149, upload-time = "2025-08-06T18:00:23.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/66/607ab45cc79e60624df386e233ab64a6d8d39ea02e7f80e19c1d451345bb/debugpy-1.8.16-cp313-cp313-macosx_14_0_universal2.whl", hash = "sha256:85df3adb1de5258dca910ae0bb185e48c98801ec15018a263a92bb06be1c8787", size = 2496157, upload-time = "2025-08-06T18:00:24.361Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a0/c95baae08a75bceabb79868d663a0736655e427ab9c81fb848da29edaeac/debugpy-1.8.16-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bee89e948bc236a5c43c4214ac62d28b29388453f5fd328d739035e205365f0b", size = 4222491, upload-time = "2025-08-06T18:00:25.806Z" }, + { url = "https://files.pythonhosted.org/packages/5b/2f/1c8db6ddd8a257c3cd2c46413b267f1d5fa3df910401c899513ce30392d6/debugpy-1.8.16-cp313-cp313-win32.whl", hash = "sha256:cf358066650439847ec5ff3dae1da98b5461ea5da0173d93d5e10f477c94609a", size = 5281126, upload-time = "2025-08-06T18:00:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ba/c3e154ab307366d6c5a9c1b68de04914e2ce7fa2f50d578311d8cc5074b2/debugpy-1.8.16-cp313-cp313-win_amd64.whl", hash = "sha256:b5aea1083f6f50023e8509399d7dc6535a351cc9f2e8827d1e093175e4d9fa4c", size = 5323094, upload-time = "2025-08-06T18:00:29.03Z" }, + { url = "https://files.pythonhosted.org/packages/35/40/acdad5944e508d5e936979ad3e96e56b78ba6d7fa75aaffc4426cb921e12/debugpy-1.8.16-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:135ccd2b1161bade72a7a099c9208811c137a150839e970aeaf121c2467debe8", size = 2086696, upload-time = "2025-08-06T18:00:36.469Z" }, + { url = "https://files.pythonhosted.org/packages/2d/eb/8d6a2cf3b29e272b5dfebe6f384f8457977d4fd7a02dab2cae4d421dbae2/debugpy-1.8.16-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:211238306331a9089e253fd997213bc4a4c65f949271057d6695953254095376", size = 3557329, upload-time = "2025-08-06T18:00:38.189Z" }, + { url = "https://files.pythonhosted.org/packages/00/7b/63b9cc4d3c6980c702911c0f6a9748933ce4e4f16ae0ec4fdef7690f6662/debugpy-1.8.16-cp39-cp39-win32.whl", hash = "sha256:88eb9ffdfb59bf63835d146c183d6dba1f722b3ae2a5f4b9fc03e925b3358922", size = 5235114, upload-time = "2025-08-06T18:00:39.586Z" }, + { url = "https://files.pythonhosted.org/packages/05/cf/80947f57e0ef4d6e33ec9c3a109a542678eba465723bf8b599719238eb93/debugpy-1.8.16-cp39-cp39-win_amd64.whl", hash = "sha256:c2c47c2e52b40449552843b913786499efcc3dbc21d6c49287d939cd0dbc49fd", size = 5266799, upload-time = "2025-08-06T18:00:41.013Z" }, + { url = "https://files.pythonhosted.org/packages/52/57/ecc9ae29fa5b2d90107cd1d9bf8ed19aacb74b2264d986ae9d44fe9bdf87/debugpy-1.8.16-py2.py3-none-any.whl", hash = "sha256:19c9521962475b87da6f673514f7fd610328757ec993bf7ec0d8c96f9a325f9e", size = 5287700, upload-time = "2025-08-06T18:00:42.333Z" }, ] [[package]] name = "decorator" version = "5.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] [[package]] name = "dill" version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976 } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668 }, + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, ] [[package]] name = "distlib" version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605 } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] [[package]] name = "ds-platform-utils" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "jinja2" }, @@ -527,9 +528,9 @@ dev = [ name = "durationpy" version = "0.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/a4/e44218c2b394e31a6dd0d6b095c4e1f32d0be54c2a4b250032d717647bab/durationpy-0.10.tar.gz", hash = "sha256:1fa6893409a6e739c9c72334fc65cca1f355dbdd93405d30f726deb5bde42fba", size = 3335, upload-time = "2025-05-17T13:52:37.26Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922 }, + { url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" }, ] [[package]] @@ -539,27 +540,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] [[package]] name = "executing" version = "2.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693 } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693, upload-time = "2025-01-22T15:41:29.403Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 }, + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, ] [[package]] name = "filelock" version = "3.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687 } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988 }, + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] [[package]] @@ -571,27 +572,27 @@ dependencies = [ { name = "pyasn1-modules" }, { name = "rsa" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/9b/e92ef23b84fa10a64ce4831390b7a4c2e53c0132568d99d4ae61d04c8855/google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77", size = 281029 } +sdist = { url = "https://files.pythonhosted.org/packages/9e/9b/e92ef23b84fa10a64ce4831390b7a4c2e53c0132568d99d4ae61d04c8855/google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77", size = 281029, upload-time = "2025-06-04T18:04:57.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137 }, + { url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137, upload-time = "2025-06-04T18:04:55.573Z" }, ] [[package]] name = "identify" version = "2.6.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ca/ffbabe3635bb839aa36b3a893c91a9b0d368cb4d8073e03a12896970af82/identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32", size = 99243 } +sdist = { url = "https://files.pythonhosted.org/packages/82/ca/ffbabe3635bb839aa36b3a893c91a9b0d368cb4d8073e03a12896970af82/identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32", size = 99243, upload-time = "2025-08-09T19:35:00.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ce/461b60a3ee109518c055953729bf9ed089a04db895d47e95444071dcdef2/identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", size = 99153 }, + { url = "https://files.pythonhosted.org/packages/e7/ce/461b60a3ee109518c055953729bf9ed089a04db895d47e95444071dcdef2/identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", size = 99153, upload-time = "2025-08-09T19:34:59.1Z" }, ] [[package]] name = "idna" version = "3.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] [[package]] @@ -601,18 +602,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp", marker = "python_full_version < '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641 } +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656 }, + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, ] [[package]] name = "iniconfig" version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, ] [[package]] @@ -636,9 +637,9 @@ dependencies = [ { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/76/11082e338e0daadc89c8ff866185de11daf67d181901038f9e139d109761/ipykernel-6.30.1.tar.gz", hash = "sha256:6abb270161896402e76b91394fcdce5d1be5d45f456671e5080572f8505be39b", size = 166260 } +sdist = { url = "https://files.pythonhosted.org/packages/bb/76/11082e338e0daadc89c8ff866185de11daf67d181901038f9e139d109761/ipykernel-6.30.1.tar.gz", hash = "sha256:6abb270161896402e76b91394fcdce5d1be5d45f456671e5080572f8505be39b", size = 166260, upload-time = "2025-08-04T15:47:35.018Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/c7/b445faca8deb954fe536abebff4ece5b097b923de482b26e78448c89d1dd/ipykernel-6.30.1-py3-none-any.whl", hash = "sha256:aa6b9fb93dca949069d8b85b6c79b2518e32ac583ae9c7d37c51d119e18b3fb4", size = 117484 }, + { url = "https://files.pythonhosted.org/packages/fc/c7/b445faca8deb954fe536abebff4ece5b097b923de482b26e78448c89d1dd/ipykernel-6.30.1-py3-none-any.whl", hash = "sha256:aa6b9fb93dca949069d8b85b6c79b2518e32ac583ae9c7d37c51d119e18b3fb4", size = 117484, upload-time = "2025-08-04T15:47:32.622Z" }, ] [[package]] @@ -661,9 +662,9 @@ dependencies = [ { name = "traitlets", marker = "python_full_version < '3.10'" }, { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/b9/3ba6c45a6df813c09a48bac313c22ff83efa26cbb55011218d925a46e2ad/ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27", size = 5486330 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/b9/3ba6c45a6df813c09a48bac313c22ff83efa26cbb55011218d925a46e2ad/ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27", size = 5486330, upload-time = "2023-11-27T09:58:34.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/6b/d9fdcdef2eb6a23f391251fde8781c38d42acd82abe84d054cb74f7863b0/ipython-8.18.1-py3-none-any.whl", hash = "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397", size = 808161 }, + { url = "https://files.pythonhosted.org/packages/47/6b/d9fdcdef2eb6a23f391251fde8781c38d42acd82abe84d054cb74f7863b0/ipython-8.18.1-py3-none-any.whl", hash = "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397", size = 808161, upload-time = "2023-11-27T09:58:30.538Z" }, ] [[package]] @@ -686,9 +687,9 @@ dependencies = [ { name = "traitlets", marker = "python_full_version == '3.10.*'" }, { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088 } +sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/d0/274fbf7b0b12643cbbc001ce13e6a5b1607ac4929d1b11c72460152c9fc3/ipython-8.37.0-py3-none-any.whl", hash = "sha256:ed87326596b878932dbcb171e3e698845434d8c61b8d8cd474bf663041a9dcf2", size = 831864 }, + { url = "https://files.pythonhosted.org/packages/91/d0/274fbf7b0b12643cbbc001ce13e6a5b1607ac4929d1b11c72460152c9fc3/ipython-8.37.0-py3-none-any.whl", hash = "sha256:ed87326596b878932dbcb171e3e698845434d8c61b8d8cd474bf663041a9dcf2", size = 831864, upload-time = "2025-05-31T16:39:06.38Z" }, ] [[package]] @@ -712,9 +713,9 @@ dependencies = [ { name = "traitlets", marker = "python_full_version >= '3.11'" }, { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/80/406f9e3bde1c1fd9bf5a0be9d090f8ae623e401b7670d8f6fdf2ab679891/ipython-9.4.0.tar.gz", hash = "sha256:c033c6d4e7914c3d9768aabe76bbe87ba1dc66a92a05db6bfa1125d81f2ee270", size = 4385338 } +sdist = { url = "https://files.pythonhosted.org/packages/54/80/406f9e3bde1c1fd9bf5a0be9d090f8ae623e401b7670d8f6fdf2ab679891/ipython-9.4.0.tar.gz", hash = "sha256:c033c6d4e7914c3d9768aabe76bbe87ba1dc66a92a05db6bfa1125d81f2ee270", size = 4385338, upload-time = "2025-07-01T11:11:30.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/63/f8/0031ee2b906a15a33d6bfc12dd09c3dfa966b3cb5b284ecfb7549e6ac3c4/ipython-9.4.0-py3-none-any.whl", hash = "sha256:25850f025a446d9b359e8d296ba175a36aedd32e83ca9b5060430fe16801f066", size = 611021 }, + { url = "https://files.pythonhosted.org/packages/63/f8/0031ee2b906a15a33d6bfc12dd09c3dfa966b3cb5b284ecfb7549e6ac3c4/ipython-9.4.0-py3-none-any.whl", hash = "sha256:25850f025a446d9b359e8d296ba175a36aedd32e83ca9b5060430fe16801f066", size = 611021, upload-time = "2025-07-01T11:11:27.85Z" }, ] [[package]] @@ -724,18 +725,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pygments", marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393 } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074 }, + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, ] [[package]] name = "isort" version = "6.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955 } +sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload-time = "2025-02-26T21:13:16.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186 }, + { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload-time = "2025-02-26T21:13:14.911Z" }, ] [[package]] @@ -745,9 +746,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, ] [[package]] @@ -757,18 +758,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, + { 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 = "jmespath" version = "1.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, ] [[package]] @@ -783,9 +784,9 @@ dependencies = [ { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019 } +sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019, upload-time = "2024-09-17T10:44:17.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105 }, + { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105, upload-time = "2024-09-17T10:44:15.218Z" }, ] [[package]] @@ -797,9 +798,9 @@ dependencies = [ { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/1b/72906d554acfeb588332eaaa6f61577705e9ec752ddb486f302dafa292d9/jupyter_core-5.8.1.tar.gz", hash = "sha256:0a5f9706f70e64786b75acba995988915ebd4601c8a52e534a40b51c95f59941", size = 88923 } +sdist = { url = "https://files.pythonhosted.org/packages/99/1b/72906d554acfeb588332eaaa6f61577705e9ec752ddb486f302dafa292d9/jupyter_core-5.8.1.tar.gz", hash = "sha256:0a5f9706f70e64786b75acba995988915ebd4601c8a52e534a40b51c95f59941", size = 88923, upload-time = "2025-05-27T07:38:16.655Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/57/6bffd4b20b88da3800c5d691e0337761576ee688eb01299eae865689d2df/jupyter_core-5.8.1-py3-none-any.whl", hash = "sha256:c28d268fc90fb53f1338ded2eb410704c5449a358406e8a948b75706e24863d0", size = 28880 }, + { url = "https://files.pythonhosted.org/packages/2f/57/6bffd4b20b88da3800c5d691e0337761576ee688eb01299eae865689d2df/jupyter_core-5.8.1-py3-none-any.whl", hash = "sha256:c28d268fc90fb53f1338ded2eb410704c5449a358406e8a948b75706e24863d0", size = 28880, upload-time = "2025-05-27T07:38:15.137Z" }, ] [[package]] @@ -820,77 +821,77 @@ dependencies = [ { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/52/19ebe8004c243fdfa78268a96727c71e08f00ff6fe69a301d0b7fcbce3c2/kubernetes-33.1.0.tar.gz", hash = "sha256:f64d829843a54c251061a8e7a14523b521f2dc5c896cf6d65ccf348648a88993", size = 1036779 } +sdist = { url = "https://files.pythonhosted.org/packages/ae/52/19ebe8004c243fdfa78268a96727c71e08f00ff6fe69a301d0b7fcbce3c2/kubernetes-33.1.0.tar.gz", hash = "sha256:f64d829843a54c251061a8e7a14523b521f2dc5c896cf6d65ccf348648a88993", size = 1036779, upload-time = "2025-06-09T21:57:58.521Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/43/d9bebfc3db7dea6ec80df5cb2aad8d274dd18ec2edd6c4f21f32c237cbbb/kubernetes-33.1.0-py2.py3-none-any.whl", hash = "sha256:544de42b24b64287f7e0aa9513c93cb503f7f40eea39b20f66810011a86eabc5", size = 1941335 }, + { url = "https://files.pythonhosted.org/packages/89/43/d9bebfc3db7dea6ec80df5cb2aad8d274dd18ec2edd6c4f21f32c237cbbb/kubernetes-33.1.0-py2.py3-none-any.whl", hash = "sha256:544de42b24b64287f7e0aa9513c93cb503f7f40eea39b20f66810011a86eabc5", size = 1941335, upload-time = "2025-06-09T21:57:56.327Z" }, ] [[package]] name = "markupsafe" version = "3.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 }, - { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 }, - { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 }, - { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 }, - { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 }, - { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 }, - { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 }, - { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 }, - { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 }, - { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 }, - { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, - { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, - { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, - { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, - { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, - { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, - { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, - { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, - { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, - { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, - { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, - { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, - { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, - { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, - { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, - { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, - { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, - { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, - { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, - { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, - { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, - { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, - { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, - { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, - { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, - { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, - { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, - { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, - { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, - { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, - { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, - { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, - { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, - { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, - { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, - { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, - { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, - { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, - { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, - { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344 }, - { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389 }, - { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607 }, - { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728 }, - { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826 }, - { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843 }, - { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219 }, - { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946 }, - { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063 }, - { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, + { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" }, + { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" }, + { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" }, + { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" }, + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344, upload-time = "2024-10-18T15:21:43.721Z" }, + { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389, upload-time = "2024-10-18T15:21:44.666Z" }, + { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607, upload-time = "2024-10-18T15:21:45.452Z" }, + { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728, upload-time = "2024-10-18T15:21:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826, upload-time = "2024-10-18T15:21:47.134Z" }, + { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843, upload-time = "2024-10-18T15:21:48.334Z" }, + { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219, upload-time = "2024-10-18T15:21:49.587Z" }, + { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946, upload-time = "2024-10-18T15:21:50.441Z" }, + { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063, upload-time = "2024-10-18T15:21:51.385Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506, upload-time = "2024-10-18T15:21:52.974Z" }, ] [[package]] @@ -900,45 +901,45 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159 } +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159, upload-time = "2024-04-15T13:44:44.803Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899, upload-time = "2024-04-15T13:44:43.265Z" }, ] [[package]] name = "mccabe" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 }, + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, ] [[package]] name = "metaflow-checkpoint" version = "0.2.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/f5/700dd3780d2b1c04fbf69604e944f1295d71784d78397b59ba721db21e37/metaflow_checkpoint-0.2.4.tar.gz", hash = "sha256:cbf5e8c5ad1789f6084c4085e1bd86068479f0aac374381fdc94d6d6c1c432bb", size = 69667 } +sdist = { url = "https://files.pythonhosted.org/packages/82/f5/700dd3780d2b1c04fbf69604e944f1295d71784d78397b59ba721db21e37/metaflow_checkpoint-0.2.4.tar.gz", hash = "sha256:cbf5e8c5ad1789f6084c4085e1bd86068479f0aac374381fdc94d6d6c1c432bb", size = 69667, upload-time = "2025-07-01T08:38:30.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/ca/8f810998d1168381ba1cf96a17b736a4261776c6f343b2730da2df411dc9/metaflow_checkpoint-0.2.4-py2.py3-none-any.whl", hash = "sha256:be51046e1470c6efc71923f34caf2dc1880056fa0446cfaacd51e66c99fb5883", size = 93780 }, + { url = "https://files.pythonhosted.org/packages/db/ca/8f810998d1168381ba1cf96a17b736a4261776c6f343b2730da2df411dc9/metaflow_checkpoint-0.2.4-py2.py3-none-any.whl", hash = "sha256:be51046e1470c6efc71923f34caf2dc1880056fa0446cfaacd51e66c99fb5883", size = 93780, upload-time = "2025-07-01T08:38:28.754Z" }, ] [[package]] name = "nest-asyncio" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] [[package]] name = "nodeenv" version = "1.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] [[package]] @@ -948,52 +949,52 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.10'", ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245 }, - { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540 }, - { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623 }, - { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774 }, - { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081 }, - { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451 }, - { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572 }, - { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722 }, - { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170 }, - { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558 }, - { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137 }, - { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552 }, - { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957 }, - { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573 }, - { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330 }, - { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895 }, - { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253 }, - { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074 }, - { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640 }, - { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230 }, - { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803 }, - { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835 }, - { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499 }, - { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497 }, - { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158 }, - { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173 }, - { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174 }, - { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701 }, - { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313 }, - { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179 }, - { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942 }, - { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512 }, - { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976 }, - { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494 }, - { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596 }, - { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099 }, - { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823 }, - { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424 }, - { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809 }, - { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314 }, - { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288 }, - { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793 }, - { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885 }, - { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784 }, +sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245, upload-time = "2024-08-26T20:04:14.625Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540, upload-time = "2024-08-26T20:04:36.784Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623, upload-time = "2024-08-26T20:04:46.491Z" }, + { url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774, upload-time = "2024-08-26T20:04:58.173Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081, upload-time = "2024-08-26T20:05:19.098Z" }, + { url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451, upload-time = "2024-08-26T20:05:47.479Z" }, + { url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572, upload-time = "2024-08-26T20:06:17.137Z" }, + { url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722, upload-time = "2024-08-26T20:06:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170, upload-time = "2024-08-26T20:06:50.361Z" }, + { url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558, upload-time = "2024-08-26T20:07:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137, upload-time = "2024-08-26T20:07:45.345Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552, upload-time = "2024-08-26T20:08:06.666Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957, upload-time = "2024-08-26T20:08:15.83Z" }, + { url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573, upload-time = "2024-08-26T20:08:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330, upload-time = "2024-08-26T20:08:48.058Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895, upload-time = "2024-08-26T20:09:16.536Z" }, + { url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253, upload-time = "2024-08-26T20:09:46.263Z" }, + { url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074, upload-time = "2024-08-26T20:10:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640, upload-time = "2024-08-26T20:10:19.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230, upload-time = "2024-08-26T20:10:43.413Z" }, + { url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803, upload-time = "2024-08-26T20:11:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835, upload-time = "2024-08-26T20:11:34.779Z" }, + { url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499, upload-time = "2024-08-26T20:11:43.902Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497, upload-time = "2024-08-26T20:11:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158, upload-time = "2024-08-26T20:12:14.95Z" }, + { url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173, upload-time = "2024-08-26T20:12:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174, upload-time = "2024-08-26T20:13:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701, upload-time = "2024-08-26T20:13:34.851Z" }, + { url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313, upload-time = "2024-08-26T20:13:45.653Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179, upload-time = "2024-08-26T20:14:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942, upload-time = "2024-08-26T20:14:40.108Z" }, + { url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512, upload-time = "2024-08-26T20:15:00.985Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976, upload-time = "2024-08-26T20:15:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494, upload-time = "2024-08-26T20:15:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596, upload-time = "2024-08-26T20:15:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099, upload-time = "2024-08-26T20:16:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823, upload-time = "2024-08-26T20:16:40.171Z" }, + { url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424, upload-time = "2024-08-26T20:17:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809, upload-time = "2024-08-26T20:17:13.553Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314, upload-time = "2024-08-26T20:17:36.72Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288, upload-time = "2024-08-26T20:18:07.732Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793, upload-time = "2024-08-26T20:18:19.125Z" }, + { url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885, upload-time = "2024-08-26T20:18:47.237Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784, upload-time = "2024-08-26T20:19:11.19Z" }, ] [[package]] @@ -1003,62 +1004,62 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version == '3.10.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245 }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048 }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542 }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301 }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320 }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050 }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034 }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185 }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149 }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620 }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963 }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743 }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616 }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579 }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005 }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570 }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548 }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521 }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866 }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455 }, - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348 }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362 }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103 }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382 }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462 }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618 }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511 }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783 }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506 }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190 }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828 }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006 }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765 }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736 }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719 }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072 }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213 }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632 }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532 }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885 }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467 }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144 }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217 }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014 }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935 }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122 }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143 }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260 }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225 }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374 }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391 }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754 }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476 }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666 }, +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, ] [[package]] @@ -1069,90 +1070,90 @@ resolution-markers = [ "python_full_version >= '3.12'", "python_full_version == '3.11.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/26/1320083986108998bd487e2931eed2aeedf914b6e8905431487543ec911d/numpy-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:852ae5bed3478b92f093e30f785c98e0cb62fa0a939ed057c31716e18a7a22b9", size = 21259016 }, - { url = "https://files.pythonhosted.org/packages/c4/2b/792b341463fa93fc7e55abbdbe87dac316c5b8cb5e94fb7a59fb6fa0cda5/numpy-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a0e27186e781a69959d0230dd9909b5e26024f8da10683bd6344baea1885168", size = 14451158 }, - { url = "https://files.pythonhosted.org/packages/b7/13/e792d7209261afb0c9f4759ffef6135b35c77c6349a151f488f531d13595/numpy-2.3.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f0a1a8476ad77a228e41619af2fa9505cf69df928e9aaa165746584ea17fed2b", size = 5379817 }, - { url = "https://files.pythonhosted.org/packages/49/ce/055274fcba4107c022b2113a213c7287346563f48d62e8d2a5176ad93217/numpy-2.3.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cbc95b3813920145032412f7e33d12080f11dc776262df1712e1638207dde9e8", size = 6913606 }, - { url = "https://files.pythonhosted.org/packages/17/f2/e4d72e6bc5ff01e2ab613dc198d560714971900c03674b41947e38606502/numpy-2.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75018be4980a7324edc5930fe39aa391d5734531b1926968605416ff58c332d", size = 14589652 }, - { url = "https://files.pythonhosted.org/packages/c8/b0/fbeee3000a51ebf7222016e2939b5c5ecf8000a19555d04a18f1e02521b8/numpy-2.3.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b8200721840f5621b7bd03f8dcd78de33ec522fc40dc2641aa09537df010c3", size = 16938816 }, - { url = "https://files.pythonhosted.org/packages/a9/ec/2f6c45c3484cc159621ea8fc000ac5a86f1575f090cac78ac27193ce82cd/numpy-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f91e5c028504660d606340a084db4b216567ded1056ea2b4be4f9d10b67197f", size = 16370512 }, - { url = "https://files.pythonhosted.org/packages/b5/01/dd67cf511850bd7aefd6347aaae0956ed415abea741ae107834aae7d6d4e/numpy-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fb1752a3bb9a3ad2d6b090b88a9a0ae1cd6f004ef95f75825e2f382c183b2097", size = 18884947 }, - { url = "https://files.pythonhosted.org/packages/a7/17/2cf60fd3e6a61d006778735edf67a222787a8c1a7842aed43ef96d777446/numpy-2.3.2-cp311-cp311-win32.whl", hash = "sha256:4ae6863868aaee2f57503c7a5052b3a2807cf7a3914475e637a0ecd366ced220", size = 6599494 }, - { url = "https://files.pythonhosted.org/packages/d5/03/0eade211c504bda872a594f045f98ddcc6caef2b7c63610946845e304d3f/numpy-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:240259d6564f1c65424bcd10f435145a7644a65a6811cfc3201c4a429ba79170", size = 13087889 }, - { url = "https://files.pythonhosted.org/packages/13/32/2c7979d39dafb2a25087e12310fc7f3b9d3c7d960df4f4bc97955ae0ce1d/numpy-2.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:4209f874d45f921bde2cff1ffcd8a3695f545ad2ffbef6d3d3c6768162efab89", size = 10459560 }, - { url = "https://files.pythonhosted.org/packages/00/6d/745dd1c1c5c284d17725e5c802ca4d45cfc6803519d777f087b71c9f4069/numpy-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b", size = 20956420 }, - { url = "https://files.pythonhosted.org/packages/bc/96/e7b533ea5740641dd62b07a790af5d9d8fec36000b8e2d0472bd7574105f/numpy-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f", size = 14184660 }, - { url = "https://files.pythonhosted.org/packages/2b/53/102c6122db45a62aa20d1b18c9986f67e6b97e0d6fbc1ae13e3e4c84430c/numpy-2.3.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0", size = 5113382 }, - { url = "https://files.pythonhosted.org/packages/2b/21/376257efcbf63e624250717e82b4fae93d60178f09eb03ed766dbb48ec9c/numpy-2.3.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b", size = 6647258 }, - { url = "https://files.pythonhosted.org/packages/91/ba/f4ebf257f08affa464fe6036e13f2bf9d4642a40228781dc1235da81be9f/numpy-2.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370", size = 14281409 }, - { url = "https://files.pythonhosted.org/packages/59/ef/f96536f1df42c668cbacb727a8c6da7afc9c05ece6d558927fb1722693e1/numpy-2.3.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73", size = 16641317 }, - { url = "https://files.pythonhosted.org/packages/f6/a7/af813a7b4f9a42f498dde8a4c6fcbff8100eed00182cc91dbaf095645f38/numpy-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc", size = 16056262 }, - { url = "https://files.pythonhosted.org/packages/8b/5d/41c4ef8404caaa7f05ed1cfb06afe16a25895260eacbd29b4d84dff2920b/numpy-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be", size = 18579342 }, - { url = "https://files.pythonhosted.org/packages/a1/4f/9950e44c5a11636f4a3af6e825ec23003475cc9a466edb7a759ed3ea63bd/numpy-2.3.2-cp312-cp312-win32.whl", hash = "sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036", size = 6320610 }, - { url = "https://files.pythonhosted.org/packages/7c/2f/244643a5ce54a94f0a9a2ab578189c061e4a87c002e037b0829dd77293b6/numpy-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f", size = 12786292 }, - { url = "https://files.pythonhosted.org/packages/54/cd/7b5f49d5d78db7badab22d8323c1b6ae458fbf86c4fdfa194ab3cd4eb39b/numpy-2.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07", size = 10194071 }, - { url = "https://files.pythonhosted.org/packages/1c/c0/c6bb172c916b00700ed3bf71cb56175fd1f7dbecebf8353545d0b5519f6c/numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3", size = 20949074 }, - { url = "https://files.pythonhosted.org/packages/20/4e/c116466d22acaf4573e58421c956c6076dc526e24a6be0903219775d862e/numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b", size = 14177311 }, - { url = "https://files.pythonhosted.org/packages/78/45/d4698c182895af189c463fc91d70805d455a227261d950e4e0f1310c2550/numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6", size = 5106022 }, - { url = "https://files.pythonhosted.org/packages/9f/76/3e6880fef4420179309dba72a8c11f6166c431cf6dee54c577af8906f914/numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089", size = 6640135 }, - { url = "https://files.pythonhosted.org/packages/34/fa/87ff7f25b3c4ce9085a62554460b7db686fef1e0207e8977795c7b7d7ba1/numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2", size = 14278147 }, - { url = "https://files.pythonhosted.org/packages/1d/0f/571b2c7a3833ae419fe69ff7b479a78d313581785203cc70a8db90121b9a/numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f", size = 16635989 }, - { url = "https://files.pythonhosted.org/packages/24/5a/84ae8dca9c9a4c592fe11340b36a86ffa9fd3e40513198daf8a97839345c/numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee", size = 16053052 }, - { url = "https://files.pythonhosted.org/packages/57/7c/e5725d99a9133b9813fcf148d3f858df98511686e853169dbaf63aec6097/numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6", size = 18577955 }, - { url = "https://files.pythonhosted.org/packages/ae/11/7c546fcf42145f29b71e4d6f429e96d8d68e5a7ba1830b2e68d7418f0bbd/numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b", size = 6311843 }, - { url = "https://files.pythonhosted.org/packages/aa/6f/a428fd1cb7ed39b4280d057720fed5121b0d7754fd2a9768640160f5517b/numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56", size = 12782876 }, - { url = "https://files.pythonhosted.org/packages/65/85/4ea455c9040a12595fb6c43f2c217257c7b52dd0ba332c6a6c1d28b289fe/numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2", size = 10192786 }, - { url = "https://files.pythonhosted.org/packages/80/23/8278f40282d10c3f258ec3ff1b103d4994bcad78b0cba9208317f6bb73da/numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab", size = 21047395 }, - { url = "https://files.pythonhosted.org/packages/1f/2d/624f2ce4a5df52628b4ccd16a4f9437b37c35f4f8a50d00e962aae6efd7a/numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2", size = 14300374 }, - { url = "https://files.pythonhosted.org/packages/f6/62/ff1e512cdbb829b80a6bd08318a58698867bca0ca2499d101b4af063ee97/numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a", size = 5228864 }, - { url = "https://files.pythonhosted.org/packages/7d/8e/74bc18078fff03192d4032cfa99d5a5ca937807136d6f5790ce07ca53515/numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286", size = 6737533 }, - { url = "https://files.pythonhosted.org/packages/19/ea/0731efe2c9073ccca5698ef6a8c3667c4cf4eea53fcdcd0b50140aba03bc/numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8", size = 14352007 }, - { url = "https://files.pythonhosted.org/packages/cf/90/36be0865f16dfed20f4bc7f75235b963d5939707d4b591f086777412ff7b/numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a", size = 16701914 }, - { url = "https://files.pythonhosted.org/packages/94/30/06cd055e24cb6c38e5989a9e747042b4e723535758e6153f11afea88c01b/numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91", size = 16132708 }, - { url = "https://files.pythonhosted.org/packages/9a/14/ecede608ea73e58267fd7cb78f42341b3b37ba576e778a1a06baffbe585c/numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5", size = 18651678 }, - { url = "https://files.pythonhosted.org/packages/40/f3/2fe6066b8d07c3685509bc24d56386534c008b462a488b7f503ba82b8923/numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5", size = 6441832 }, - { url = "https://files.pythonhosted.org/packages/0b/ba/0937d66d05204d8f28630c9c60bc3eda68824abde4cf756c4d6aad03b0c6/numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450", size = 12927049 }, - { url = "https://files.pythonhosted.org/packages/e9/ed/13542dd59c104d5e654dfa2ac282c199ba64846a74c2c4bcdbc3a0f75df1/numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a", size = 10262935 }, - { url = "https://files.pythonhosted.org/packages/c9/7c/7659048aaf498f7611b783e000c7268fcc4dcf0ce21cd10aad7b2e8f9591/numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a", size = 20950906 }, - { url = "https://files.pythonhosted.org/packages/80/db/984bea9d4ddf7112a04cfdfb22b1050af5757864cfffe8e09e44b7f11a10/numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b", size = 14185607 }, - { url = "https://files.pythonhosted.org/packages/e4/76/b3d6f414f4eca568f469ac112a3b510938d892bc5a6c190cb883af080b77/numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125", size = 5114110 }, - { url = "https://files.pythonhosted.org/packages/9e/d2/6f5e6826abd6bca52392ed88fe44a4b52aacb60567ac3bc86c67834c3a56/numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19", size = 6642050 }, - { url = "https://files.pythonhosted.org/packages/c4/43/f12b2ade99199e39c73ad182f103f9d9791f48d885c600c8e05927865baf/numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f", size = 14296292 }, - { url = "https://files.pythonhosted.org/packages/5d/f9/77c07d94bf110a916b17210fac38680ed8734c236bfed9982fd8524a7b47/numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5", size = 16638913 }, - { url = "https://files.pythonhosted.org/packages/9b/d1/9d9f2c8ea399cc05cfff8a7437453bd4e7d894373a93cdc46361bbb49a7d/numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58", size = 16071180 }, - { url = "https://files.pythonhosted.org/packages/4c/41/82e2c68aff2a0c9bf315e47d61951099fed65d8cb2c8d9dc388cb87e947e/numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0", size = 18576809 }, - { url = "https://files.pythonhosted.org/packages/14/14/4b4fd3efb0837ed252d0f583c5c35a75121038a8c4e065f2c259be06d2d8/numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2", size = 6366410 }, - { url = "https://files.pythonhosted.org/packages/11/9e/b4c24a6b8467b61aced5c8dc7dcfce23621baa2e17f661edb2444a418040/numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b", size = 12918821 }, - { url = "https://files.pythonhosted.org/packages/0e/0f/0dc44007c70b1007c1cef86b06986a3812dd7106d8f946c09cfa75782556/numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910", size = 10477303 }, - { url = "https://files.pythonhosted.org/packages/8b/3e/075752b79140b78ddfc9c0a1634d234cfdbc6f9bbbfa6b7504e445ad7d19/numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e", size = 21047524 }, - { url = "https://files.pythonhosted.org/packages/fe/6d/60e8247564a72426570d0e0ea1151b95ce5bd2f1597bb878a18d32aec855/numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45", size = 14300519 }, - { url = "https://files.pythonhosted.org/packages/4d/73/d8326c442cd428d47a067070c3ac6cc3b651a6e53613a1668342a12d4479/numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b", size = 5228972 }, - { url = "https://files.pythonhosted.org/packages/34/2e/e71b2d6dad075271e7079db776196829019b90ce3ece5c69639e4f6fdc44/numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2", size = 6737439 }, - { url = "https://files.pythonhosted.org/packages/15/b0/d004bcd56c2c5e0500ffc65385eb6d569ffd3363cb5e593ae742749b2daa/numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0", size = 14352479 }, - { url = "https://files.pythonhosted.org/packages/11/e3/285142fcff8721e0c99b51686426165059874c150ea9ab898e12a492e291/numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0", size = 16702805 }, - { url = "https://files.pythonhosted.org/packages/33/c3/33b56b0e47e604af2c7cd065edca892d180f5899599b76830652875249a3/numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2", size = 16133830 }, - { url = "https://files.pythonhosted.org/packages/6e/ae/7b1476a1f4d6a48bc669b8deb09939c56dd2a439db1ab03017844374fb67/numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf", size = 18652665 }, - { url = "https://files.pythonhosted.org/packages/14/ba/5b5c9978c4bb161034148ade2de9db44ec316fab89ce8c400db0e0c81f86/numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1", size = 6514777 }, - { url = "https://files.pythonhosted.org/packages/eb/46/3dbaf0ae7c17cdc46b9f662c56da2054887b8d9e737c1476f335c83d33db/numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b", size = 13111856 }, - { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226 }, - { url = "https://files.pythonhosted.org/packages/cf/ea/50ebc91d28b275b23b7128ef25c3d08152bc4068f42742867e07a870a42a/numpy-2.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:14a91ebac98813a49bc6aa1a0dfc09513dcec1d97eaf31ca21a87221a1cdcb15", size = 21130338 }, - { url = "https://files.pythonhosted.org/packages/9f/57/cdd5eac00dd5f137277355c318a955c0d8fb8aa486020c22afd305f8b88f/numpy-2.3.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:71669b5daae692189540cffc4c439468d35a3f84f0c88b078ecd94337f6cb0ec", size = 14375776 }, - { url = "https://files.pythonhosted.org/packages/83/85/27280c7f34fcd305c2209c0cdca4d70775e4859a9eaa92f850087f8dea50/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:69779198d9caee6e547adb933941ed7520f896fd9656834c300bdf4dd8642712", size = 5304882 }, - { url = "https://files.pythonhosted.org/packages/48/b4/6500b24d278e15dd796f43824e69939d00981d37d9779e32499e823aa0aa/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2c3271cc4097beb5a60f010bcc1cc204b300bb3eafb4399376418a83a1c6373c", size = 6818405 }, - { url = "https://files.pythonhosted.org/packages/9b/c9/142c1e03f199d202da8e980c2496213509291b6024fd2735ad28ae7065c7/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446acd11fe3dc1830568c941d44449fd5cb83068e5c70bd5a470d323d448296", size = 14419651 }, - { url = "https://files.pythonhosted.org/packages/8b/95/8023e87cbea31a750a6c00ff9427d65ebc5fef104a136bfa69f76266d614/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa098a5ab53fa407fded5870865c6275a5cd4101cfdef8d6fafc48286a96e981", size = 16760166 }, - { url = "https://files.pythonhosted.org/packages/78/e3/6690b3f85a05506733c7e90b577e4762517404ea78bab2ca3a5cb1aeb78d/numpy-2.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6936aff90dda378c09bea075af0d9c675fe3a977a9d2402f95a87f440f59f619", size = 12977811 }, +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306, upload-time = "2025-07-24T21:32:07.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/26/1320083986108998bd487e2931eed2aeedf914b6e8905431487543ec911d/numpy-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:852ae5bed3478b92f093e30f785c98e0cb62fa0a939ed057c31716e18a7a22b9", size = 21259016, upload-time = "2025-07-24T20:24:35.214Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2b/792b341463fa93fc7e55abbdbe87dac316c5b8cb5e94fb7a59fb6fa0cda5/numpy-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a0e27186e781a69959d0230dd9909b5e26024f8da10683bd6344baea1885168", size = 14451158, upload-time = "2025-07-24T20:24:58.397Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/e792d7209261afb0c9f4759ffef6135b35c77c6349a151f488f531d13595/numpy-2.3.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f0a1a8476ad77a228e41619af2fa9505cf69df928e9aaa165746584ea17fed2b", size = 5379817, upload-time = "2025-07-24T20:25:07.746Z" }, + { url = "https://files.pythonhosted.org/packages/49/ce/055274fcba4107c022b2113a213c7287346563f48d62e8d2a5176ad93217/numpy-2.3.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cbc95b3813920145032412f7e33d12080f11dc776262df1712e1638207dde9e8", size = 6913606, upload-time = "2025-07-24T20:25:18.84Z" }, + { url = "https://files.pythonhosted.org/packages/17/f2/e4d72e6bc5ff01e2ab613dc198d560714971900c03674b41947e38606502/numpy-2.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75018be4980a7324edc5930fe39aa391d5734531b1926968605416ff58c332d", size = 14589652, upload-time = "2025-07-24T20:25:40.356Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b0/fbeee3000a51ebf7222016e2939b5c5ecf8000a19555d04a18f1e02521b8/numpy-2.3.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b8200721840f5621b7bd03f8dcd78de33ec522fc40dc2641aa09537df010c3", size = 16938816, upload-time = "2025-07-24T20:26:05.721Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ec/2f6c45c3484cc159621ea8fc000ac5a86f1575f090cac78ac27193ce82cd/numpy-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f91e5c028504660d606340a084db4b216567ded1056ea2b4be4f9d10b67197f", size = 16370512, upload-time = "2025-07-24T20:26:30.545Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/dd67cf511850bd7aefd6347aaae0956ed415abea741ae107834aae7d6d4e/numpy-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fb1752a3bb9a3ad2d6b090b88a9a0ae1cd6f004ef95f75825e2f382c183b2097", size = 18884947, upload-time = "2025-07-24T20:26:58.24Z" }, + { url = "https://files.pythonhosted.org/packages/a7/17/2cf60fd3e6a61d006778735edf67a222787a8c1a7842aed43ef96d777446/numpy-2.3.2-cp311-cp311-win32.whl", hash = "sha256:4ae6863868aaee2f57503c7a5052b3a2807cf7a3914475e637a0ecd366ced220", size = 6599494, upload-time = "2025-07-24T20:27:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/d5/03/0eade211c504bda872a594f045f98ddcc6caef2b7c63610946845e304d3f/numpy-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:240259d6564f1c65424bcd10f435145a7644a65a6811cfc3201c4a429ba79170", size = 13087889, upload-time = "2025-07-24T20:27:29.558Z" }, + { url = "https://files.pythonhosted.org/packages/13/32/2c7979d39dafb2a25087e12310fc7f3b9d3c7d960df4f4bc97955ae0ce1d/numpy-2.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:4209f874d45f921bde2cff1ffcd8a3695f545ad2ffbef6d3d3c6768162efab89", size = 10459560, upload-time = "2025-07-24T20:27:46.803Z" }, + { url = "https://files.pythonhosted.org/packages/00/6d/745dd1c1c5c284d17725e5c802ca4d45cfc6803519d777f087b71c9f4069/numpy-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b", size = 20956420, upload-time = "2025-07-24T20:28:18.002Z" }, + { url = "https://files.pythonhosted.org/packages/bc/96/e7b533ea5740641dd62b07a790af5d9d8fec36000b8e2d0472bd7574105f/numpy-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f", size = 14184660, upload-time = "2025-07-24T20:28:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/2b/53/102c6122db45a62aa20d1b18c9986f67e6b97e0d6fbc1ae13e3e4c84430c/numpy-2.3.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0", size = 5113382, upload-time = "2025-07-24T20:28:48.544Z" }, + { url = "https://files.pythonhosted.org/packages/2b/21/376257efcbf63e624250717e82b4fae93d60178f09eb03ed766dbb48ec9c/numpy-2.3.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b", size = 6647258, upload-time = "2025-07-24T20:28:59.104Z" }, + { url = "https://files.pythonhosted.org/packages/91/ba/f4ebf257f08affa464fe6036e13f2bf9d4642a40228781dc1235da81be9f/numpy-2.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370", size = 14281409, upload-time = "2025-07-24T20:40:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/59/ef/f96536f1df42c668cbacb727a8c6da7afc9c05ece6d558927fb1722693e1/numpy-2.3.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73", size = 16641317, upload-time = "2025-07-24T20:40:56.625Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a7/af813a7b4f9a42f498dde8a4c6fcbff8100eed00182cc91dbaf095645f38/numpy-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc", size = 16056262, upload-time = "2025-07-24T20:41:20.797Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/41c4ef8404caaa7f05ed1cfb06afe16a25895260eacbd29b4d84dff2920b/numpy-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be", size = 18579342, upload-time = "2025-07-24T20:41:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/9950e44c5a11636f4a3af6e825ec23003475cc9a466edb7a759ed3ea63bd/numpy-2.3.2-cp312-cp312-win32.whl", hash = "sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036", size = 6320610, upload-time = "2025-07-24T20:42:01.551Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/244643a5ce54a94f0a9a2ab578189c061e4a87c002e037b0829dd77293b6/numpy-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f", size = 12786292, upload-time = "2025-07-24T20:42:20.738Z" }, + { url = "https://files.pythonhosted.org/packages/54/cd/7b5f49d5d78db7badab22d8323c1b6ae458fbf86c4fdfa194ab3cd4eb39b/numpy-2.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07", size = 10194071, upload-time = "2025-07-24T20:42:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c0/c6bb172c916b00700ed3bf71cb56175fd1f7dbecebf8353545d0b5519f6c/numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3", size = 20949074, upload-time = "2025-07-24T20:43:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/20/4e/c116466d22acaf4573e58421c956c6076dc526e24a6be0903219775d862e/numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b", size = 14177311, upload-time = "2025-07-24T20:43:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/78/45/d4698c182895af189c463fc91d70805d455a227261d950e4e0f1310c2550/numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6", size = 5106022, upload-time = "2025-07-24T20:43:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/9f/76/3e6880fef4420179309dba72a8c11f6166c431cf6dee54c577af8906f914/numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089", size = 6640135, upload-time = "2025-07-24T20:43:49.28Z" }, + { url = "https://files.pythonhosted.org/packages/34/fa/87ff7f25b3c4ce9085a62554460b7db686fef1e0207e8977795c7b7d7ba1/numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2", size = 14278147, upload-time = "2025-07-24T20:44:10.328Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0f/571b2c7a3833ae419fe69ff7b479a78d313581785203cc70a8db90121b9a/numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f", size = 16635989, upload-time = "2025-07-24T20:44:34.88Z" }, + { url = "https://files.pythonhosted.org/packages/24/5a/84ae8dca9c9a4c592fe11340b36a86ffa9fd3e40513198daf8a97839345c/numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee", size = 16053052, upload-time = "2025-07-24T20:44:58.872Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/e5725d99a9133b9813fcf148d3f858df98511686e853169dbaf63aec6097/numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6", size = 18577955, upload-time = "2025-07-24T20:45:26.714Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7c546fcf42145f29b71e4d6f429e96d8d68e5a7ba1830b2e68d7418f0bbd/numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b", size = 6311843, upload-time = "2025-07-24T20:49:24.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6f/a428fd1cb7ed39b4280d057720fed5121b0d7754fd2a9768640160f5517b/numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56", size = 12782876, upload-time = "2025-07-24T20:49:43.227Z" }, + { url = "https://files.pythonhosted.org/packages/65/85/4ea455c9040a12595fb6c43f2c217257c7b52dd0ba332c6a6c1d28b289fe/numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2", size = 10192786, upload-time = "2025-07-24T20:49:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/80/23/8278f40282d10c3f258ec3ff1b103d4994bcad78b0cba9208317f6bb73da/numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab", size = 21047395, upload-time = "2025-07-24T20:45:58.821Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2d/624f2ce4a5df52628b4ccd16a4f9437b37c35f4f8a50d00e962aae6efd7a/numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2", size = 14300374, upload-time = "2025-07-24T20:46:20.207Z" }, + { url = "https://files.pythonhosted.org/packages/f6/62/ff1e512cdbb829b80a6bd08318a58698867bca0ca2499d101b4af063ee97/numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a", size = 5228864, upload-time = "2025-07-24T20:46:30.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8e/74bc18078fff03192d4032cfa99d5a5ca937807136d6f5790ce07ca53515/numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286", size = 6737533, upload-time = "2025-07-24T20:46:46.111Z" }, + { url = "https://files.pythonhosted.org/packages/19/ea/0731efe2c9073ccca5698ef6a8c3667c4cf4eea53fcdcd0b50140aba03bc/numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8", size = 14352007, upload-time = "2025-07-24T20:47:07.1Z" }, + { url = "https://files.pythonhosted.org/packages/cf/90/36be0865f16dfed20f4bc7f75235b963d5939707d4b591f086777412ff7b/numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a", size = 16701914, upload-time = "2025-07-24T20:47:32.459Z" }, + { url = "https://files.pythonhosted.org/packages/94/30/06cd055e24cb6c38e5989a9e747042b4e723535758e6153f11afea88c01b/numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91", size = 16132708, upload-time = "2025-07-24T20:47:58.129Z" }, + { url = "https://files.pythonhosted.org/packages/9a/14/ecede608ea73e58267fd7cb78f42341b3b37ba576e778a1a06baffbe585c/numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5", size = 18651678, upload-time = "2025-07-24T20:48:25.402Z" }, + { url = "https://files.pythonhosted.org/packages/40/f3/2fe6066b8d07c3685509bc24d56386534c008b462a488b7f503ba82b8923/numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5", size = 6441832, upload-time = "2025-07-24T20:48:37.181Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ba/0937d66d05204d8f28630c9c60bc3eda68824abde4cf756c4d6aad03b0c6/numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450", size = 12927049, upload-time = "2025-07-24T20:48:56.24Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ed/13542dd59c104d5e654dfa2ac282c199ba64846a74c2c4bcdbc3a0f75df1/numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a", size = 10262935, upload-time = "2025-07-24T20:49:13.136Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7c/7659048aaf498f7611b783e000c7268fcc4dcf0ce21cd10aad7b2e8f9591/numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a", size = 20950906, upload-time = "2025-07-24T20:50:30.346Z" }, + { url = "https://files.pythonhosted.org/packages/80/db/984bea9d4ddf7112a04cfdfb22b1050af5757864cfffe8e09e44b7f11a10/numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b", size = 14185607, upload-time = "2025-07-24T20:50:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/b3d6f414f4eca568f469ac112a3b510938d892bc5a6c190cb883af080b77/numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125", size = 5114110, upload-time = "2025-07-24T20:51:01.041Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d2/6f5e6826abd6bca52392ed88fe44a4b52aacb60567ac3bc86c67834c3a56/numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19", size = 6642050, upload-time = "2025-07-24T20:51:11.64Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/f12b2ade99199e39c73ad182f103f9d9791f48d885c600c8e05927865baf/numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f", size = 14296292, upload-time = "2025-07-24T20:51:33.488Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f9/77c07d94bf110a916b17210fac38680ed8734c236bfed9982fd8524a7b47/numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5", size = 16638913, upload-time = "2025-07-24T20:51:58.517Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d1/9d9f2c8ea399cc05cfff8a7437453bd4e7d894373a93cdc46361bbb49a7d/numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58", size = 16071180, upload-time = "2025-07-24T20:52:22.827Z" }, + { url = "https://files.pythonhosted.org/packages/4c/41/82e2c68aff2a0c9bf315e47d61951099fed65d8cb2c8d9dc388cb87e947e/numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0", size = 18576809, upload-time = "2025-07-24T20:52:51.015Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/4b4fd3efb0837ed252d0f583c5c35a75121038a8c4e065f2c259be06d2d8/numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2", size = 6366410, upload-time = "2025-07-24T20:56:44.949Z" }, + { url = "https://files.pythonhosted.org/packages/11/9e/b4c24a6b8467b61aced5c8dc7dcfce23621baa2e17f661edb2444a418040/numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b", size = 12918821, upload-time = "2025-07-24T20:57:06.479Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0f/0dc44007c70b1007c1cef86b06986a3812dd7106d8f946c09cfa75782556/numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910", size = 10477303, upload-time = "2025-07-24T20:57:22.879Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3e/075752b79140b78ddfc9c0a1634d234cfdbc6f9bbbfa6b7504e445ad7d19/numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e", size = 21047524, upload-time = "2025-07-24T20:53:22.086Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6d/60e8247564a72426570d0e0ea1151b95ce5bd2f1597bb878a18d32aec855/numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45", size = 14300519, upload-time = "2025-07-24T20:53:44.053Z" }, + { url = "https://files.pythonhosted.org/packages/4d/73/d8326c442cd428d47a067070c3ac6cc3b651a6e53613a1668342a12d4479/numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b", size = 5228972, upload-time = "2025-07-24T20:53:53.81Z" }, + { url = "https://files.pythonhosted.org/packages/34/2e/e71b2d6dad075271e7079db776196829019b90ce3ece5c69639e4f6fdc44/numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2", size = 6737439, upload-time = "2025-07-24T20:54:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/15/b0/d004bcd56c2c5e0500ffc65385eb6d569ffd3363cb5e593ae742749b2daa/numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0", size = 14352479, upload-time = "2025-07-24T20:54:25.819Z" }, + { url = "https://files.pythonhosted.org/packages/11/e3/285142fcff8721e0c99b51686426165059874c150ea9ab898e12a492e291/numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0", size = 16702805, upload-time = "2025-07-24T20:54:50.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/c3/33b56b0e47e604af2c7cd065edca892d180f5899599b76830652875249a3/numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2", size = 16133830, upload-time = "2025-07-24T20:55:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/7b1476a1f4d6a48bc669b8deb09939c56dd2a439db1ab03017844374fb67/numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf", size = 18652665, upload-time = "2025-07-24T20:55:46.665Z" }, + { url = "https://files.pythonhosted.org/packages/14/ba/5b5c9978c4bb161034148ade2de9db44ec316fab89ce8c400db0e0c81f86/numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1", size = 6514777, upload-time = "2025-07-24T20:55:57.66Z" }, + { url = "https://files.pythonhosted.org/packages/eb/46/3dbaf0ae7c17cdc46b9f662c56da2054887b8d9e737c1476f335c83d33db/numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b", size = 13111856, upload-time = "2025-07-24T20:56:17.318Z" }, + { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226, upload-time = "2025-07-24T20:56:34.509Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ea/50ebc91d28b275b23b7128ef25c3d08152bc4068f42742867e07a870a42a/numpy-2.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:14a91ebac98813a49bc6aa1a0dfc09513dcec1d97eaf31ca21a87221a1cdcb15", size = 21130338, upload-time = "2025-07-24T20:57:54.37Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/cdd5eac00dd5f137277355c318a955c0d8fb8aa486020c22afd305f8b88f/numpy-2.3.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:71669b5daae692189540cffc4c439468d35a3f84f0c88b078ecd94337f6cb0ec", size = 14375776, upload-time = "2025-07-24T20:58:16.303Z" }, + { url = "https://files.pythonhosted.org/packages/83/85/27280c7f34fcd305c2209c0cdca4d70775e4859a9eaa92f850087f8dea50/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:69779198d9caee6e547adb933941ed7520f896fd9656834c300bdf4dd8642712", size = 5304882, upload-time = "2025-07-24T20:58:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/b4/6500b24d278e15dd796f43824e69939d00981d37d9779e32499e823aa0aa/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2c3271cc4097beb5a60f010bcc1cc204b300bb3eafb4399376418a83a1c6373c", size = 6818405, upload-time = "2025-07-24T20:58:37.341Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c9/142c1e03f199d202da8e980c2496213509291b6024fd2735ad28ae7065c7/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446acd11fe3dc1830568c941d44449fd5cb83068e5c70bd5a470d323d448296", size = 14419651, upload-time = "2025-07-24T20:58:59.048Z" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8023e87cbea31a750a6c00ff9427d65ebc5fef104a136bfa69f76266d614/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa098a5ab53fa407fded5870865c6275a5cd4101cfdef8d6fafc48286a96e981", size = 16760166, upload-time = "2025-07-24T21:28:56.38Z" }, + { url = "https://files.pythonhosted.org/packages/78/e3/6690b3f85a05506733c7e90b577e4762517404ea78bab2ca3a5cb1aeb78d/numpy-2.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6936aff90dda378c09bea075af0d9c675fe3a977a9d2402f95a87f440f59f619", size = 12977811, upload-time = "2025-07-24T21:29:18.234Z" }, ] [[package]] name = "oauthlib" version = "3.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 }, + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] [[package]] @@ -1165,9 +1166,9 @@ dependencies = [ { name = "pylint" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/34/4b1c9f89fb7b20d82986e24c7939773d590ab2794bfe5b072ad9f68f1557/ob_metaflow-2.18.0.1.tar.gz", hash = "sha256:a531c0e433a5118c9b817634a040e96f6713193b02fe336336de08c8483e3367", size = 1526056 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/34/4b1c9f89fb7b20d82986e24c7939773d590ab2794bfe5b072ad9f68f1557/ob_metaflow-2.18.0.1.tar.gz", hash = "sha256:a531c0e433a5118c9b817634a040e96f6713193b02fe336336de08c8483e3367", size = 1526056, upload-time = "2025-08-27T21:37:21.468Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/1a/e4516bfaca55034224d85927939054390a32218984c447da0fd64409f854/ob_metaflow-2.18.0.1-py2.py3-none-any.whl", hash = "sha256:ddb3ec5e2b380d95d24764b080332238460a6b6c1af63c210dd03cd8f23957c6", size = 1704870 }, + { url = "https://files.pythonhosted.org/packages/83/1a/e4516bfaca55034224d85927939054390a32218984c447da0fd64409f854/ob_metaflow-2.18.0.1-py2.py3-none-any.whl", hash = "sha256:ddb3ec5e2b380d95d24764b080332238460a6b6c1af63c210dd03cd8f23957c6", size = 1704870, upload-time = "2025-08-27T21:37:19.717Z" }, ] [[package]] @@ -1179,18 +1180,18 @@ dependencies = [ { name = "kubernetes" }, { name = "ob-metaflow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c5/59/215b1de06e19c1e42b26531a82f043e91823890fa15789d9e5da53df7295/ob_metaflow_extensions-1.4.9.tar.gz", hash = "sha256:891cc87dc349f36148215314bf443860817ff4c3893f8adba578a87319b3486e", size = 198581 } +sdist = { url = "https://files.pythonhosted.org/packages/c5/59/215b1de06e19c1e42b26531a82f043e91823890fa15789d9e5da53df7295/ob_metaflow_extensions-1.4.9.tar.gz", hash = "sha256:891cc87dc349f36148215314bf443860817ff4c3893f8adba578a87319b3486e", size = 198581, upload-time = "2025-08-27T22:05:46.198Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/f7/0f80e9a6f2d72d921e614b4afccbbc3966418f4114fa8f66b419d2fd7dd5/ob_metaflow_extensions-1.4.9-py2.py3-none-any.whl", hash = "sha256:4e3043b59dd61d27ba3b1cf1a5ff993ea58ae6238a00bf400e8136caf5b7f52a", size = 250460 }, + { url = "https://files.pythonhosted.org/packages/3d/f7/0f80e9a6f2d72d921e614b4afccbbc3966418f4114fa8f66b419d2fd7dd5/ob_metaflow_extensions-1.4.9-py2.py3-none-any.whl", hash = "sha256:4e3043b59dd61d27ba3b1cf1a5ff993ea58ae6238a00bf400e8136caf5b7f52a", size = 250460, upload-time = "2025-08-27T22:05:44.328Z" }, ] [[package]] name = "ob-metaflow-stubs" version = "6.0.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/87/5e5b8d2761e54996dd9b620f9bfefd82d58cacd7750a1dfdbaff924abaf4/ob-metaflow-stubs-6.0.9.1.tar.gz", hash = "sha256:24babeec7ca3662cfe7307e91c042daa5ffa7b90b314bedef4819d8bdf6e8592", size = 168721 } +sdist = { url = "https://files.pythonhosted.org/packages/d1/87/5e5b8d2761e54996dd9b620f9bfefd82d58cacd7750a1dfdbaff924abaf4/ob-metaflow-stubs-6.0.9.1.tar.gz", hash = "sha256:24babeec7ca3662cfe7307e91c042daa5ffa7b90b314bedef4819d8bdf6e8592", size = 168721, upload-time = "2025-08-28T00:53:47.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/bb/ba011b0593ca547eabc55cf8abe252caf11fda5f91d823835931e95070d2/ob_metaflow_stubs-6.0.9.1-py2.py3-none-any.whl", hash = "sha256:ba315d14274b30a2631d32d46ef504f64824573719efe0ebdac4013fe9c92f20", size = 298865 }, + { url = "https://files.pythonhosted.org/packages/6e/bb/ba011b0593ca547eabc55cf8abe252caf11fda5f91d823835931e95070d2/ob_metaflow_stubs-6.0.9.1-py2.py3-none-any.whl", hash = "sha256:ba315d14274b30a2631d32d46ef504f64824573719efe0ebdac4013fe9c92f20", size = 298865, upload-time = "2025-08-28T00:53:46.002Z" }, ] [[package]] @@ -1201,9 +1202,9 @@ dependencies = [ { name = "requests" }, { name = "toml", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/31/6875ed9d70b96744e8a0558be296fdc18a028720f9458ac9021c507c0b75/ob_project_utils-0.1.35.tar.gz", hash = "sha256:50fd00ff0e80e545919b013b5203cd39eda30fe08705ccc9c16f7499b01e3f1b", size = 544009 } +sdist = { url = "https://files.pythonhosted.org/packages/c6/31/6875ed9d70b96744e8a0558be296fdc18a028720f9458ac9021c507c0b75/ob_project_utils-0.1.35.tar.gz", hash = "sha256:50fd00ff0e80e545919b013b5203cd39eda30fe08705ccc9c16f7499b01e3f1b", size = 544009, upload-time = "2025-08-22T02:06:56.765Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/93/2bf911ffa18e5c8a203492f791a33412deac77f329aa95bffdcaf343b2fb/ob_project_utils-0.1.35-py3-none-any.whl", hash = "sha256:f1686b6f10e36799b758b3da6750fbae173888f4496ef9b52f0dab2f6945f5a7", size = 547257 }, + { url = "https://files.pythonhosted.org/packages/e1/93/2bf911ffa18e5c8a203492f791a33412deac77f329aa95bffdcaf343b2fb/ob_project_utils-0.1.35-py3-none-any.whl", hash = "sha256:f1686b6f10e36799b758b3da6750fbae173888f4496ef9b52f0dab2f6945f5a7", size = 547257, upload-time = "2025-08-22T02:06:55.119Z" }, ] [[package]] @@ -1219,16 +1220,16 @@ dependencies = [ { name = "ob-project-utils" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/7e/3af24ffb4aeb06554aee2dfb516e25d3900980bf02db5387573f6d70e8e7/outerbounds-0.9.1-py3-none-any.whl", hash = "sha256:a2acb14d1a342a538f3a2ce04c3b850e5861b97c0a1ea567a46fdbfcd1c18384", size = 311471 }, + { url = "https://files.pythonhosted.org/packages/9f/7e/3af24ffb4aeb06554aee2dfb516e25d3900980bf02db5387573f6d70e8e7/outerbounds-0.9.1-py3-none-any.whl", hash = "sha256:a2acb14d1a342a538f3a2ce04c3b850e5861b97c0a1ea567a46fdbfcd1c18384", size = 311471, upload-time = "2025-08-28T00:54:16.787Z" }, ] [[package]] name = "packaging" version = "25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] [[package]] @@ -1243,67 +1244,67 @@ dependencies = [ { name = "pytz" }, { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/79/8e/0e90233ac205ad182bd6b422532695d2b9414944a280488105d598c70023/pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb", size = 4488684 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/16/a8eeb70aad84ccbf14076793f90e0031eded63c1899aeae9fdfbf37881f4/pandas-2.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52bc29a946304c360561974c6542d1dd628ddafa69134a7131fdfd6a5d7a1a35", size = 11539648 }, - { url = "https://files.pythonhosted.org/packages/47/f1/c5bdaea13bf3708554d93e948b7ea74121ce6e0d59537ca4c4f77731072b/pandas-2.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:220cc5c35ffaa764dd5bb17cf42df283b5cb7fdf49e10a7b053a06c9cb48ee2b", size = 10786923 }, - { url = "https://files.pythonhosted.org/packages/bb/10/811fa01476d29ffed692e735825516ad0e56d925961819e6126b4ba32147/pandas-2.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c05e15111221384019897df20c6fe893b2f697d03c811ee67ec9e0bb5a3424", size = 11726241 }, - { url = "https://files.pythonhosted.org/packages/c4/6a/40b043b06e08df1ea1b6d20f0e0c2f2c4ec8c4f07d1c92948273d943a50b/pandas-2.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc03acc273c5515ab69f898df99d9d4f12c4d70dbfc24c3acc6203751d0804cf", size = 12349533 }, - { url = "https://files.pythonhosted.org/packages/e2/ea/2e081a2302e41a9bca7056659fdd2b85ef94923723e41665b42d65afd347/pandas-2.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d25c20a03e8870f6339bcf67281b946bd20b86f1a544ebbebb87e66a8d642cba", size = 13202407 }, - { url = "https://files.pythonhosted.org/packages/f4/12/7ff9f6a79e2ee8869dcf70741ef998b97ea20050fe25f83dc759764c1e32/pandas-2.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21bb612d148bb5860b7eb2c10faacf1a810799245afd342cf297d7551513fbb6", size = 13837212 }, - { url = "https://files.pythonhosted.org/packages/d8/df/5ab92fcd76455a632b3db34a746e1074d432c0cdbbd28d7cd1daba46a75d/pandas-2.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:b62d586eb25cb8cb70a5746a378fc3194cb7f11ea77170d59f889f5dfe3cec7a", size = 11338099 }, - { url = "https://files.pythonhosted.org/packages/7a/59/f3e010879f118c2d400902d2d871c2226cef29b08c09fb8dc41111730400/pandas-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1333e9c299adcbb68ee89a9bb568fc3f20f9cbb419f1dd5225071e6cddb2a743", size = 11563308 }, - { url = "https://files.pythonhosted.org/packages/38/18/48f10f1cc5c397af59571d638d211f494dba481f449c19adbd282aa8f4ca/pandas-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:76972bcbd7de8e91ad5f0ca884a9f2c477a2125354af624e022c49e5bd0dfff4", size = 10820319 }, - { url = "https://files.pythonhosted.org/packages/95/3b/1e9b69632898b048e223834cd9702052bcf06b15e1ae716eda3196fb972e/pandas-2.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b98bdd7c456a05eef7cd21fd6b29e3ca243591fe531c62be94a2cc987efb5ac2", size = 11790097 }, - { url = "https://files.pythonhosted.org/packages/8b/ef/0e2ffb30b1f7fbc9a588bd01e3c14a0d96854d09a887e15e30cc19961227/pandas-2.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d81573b3f7db40d020983f78721e9bfc425f411e616ef019a10ebf597aedb2e", size = 12397958 }, - { url = "https://files.pythonhosted.org/packages/23/82/e6b85f0d92e9afb0e7f705a51d1399b79c7380c19687bfbf3d2837743249/pandas-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e190b738675a73b581736cc8ec71ae113d6c3768d0bd18bffa5b9a0927b0b6ea", size = 13225600 }, - { url = "https://files.pythonhosted.org/packages/e8/f1/f682015893d9ed51611948bd83683670842286a8edd4f68c2c1c3b231eef/pandas-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c253828cb08f47488d60f43c5fc95114c771bbfff085da54bfc79cb4f9e3a372", size = 13879433 }, - { url = "https://files.pythonhosted.org/packages/a7/e7/ae86261695b6c8a36d6a4c8d5f9b9ede8248510d689a2f379a18354b37d7/pandas-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:9467697b8083f9667b212633ad6aa4ab32436dcbaf4cd57325debb0ddef2012f", size = 11336557 }, - { url = "https://files.pythonhosted.org/packages/ec/db/614c20fb7a85a14828edd23f1c02db58a30abf3ce76f38806155d160313c/pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9", size = 11587652 }, - { url = "https://files.pythonhosted.org/packages/99/b0/756e52f6582cade5e746f19bad0517ff27ba9c73404607c0306585c201b3/pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b", size = 10717686 }, - { url = "https://files.pythonhosted.org/packages/37/4c/dd5ccc1e357abfeee8353123282de17997f90ff67855f86154e5a13b81e5/pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175", size = 11278722 }, - { url = "https://files.pythonhosted.org/packages/d3/a4/f7edcfa47e0a88cda0be8b068a5bae710bf264f867edfdf7b71584ace362/pandas-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d31a6b4354e3b9b8a2c848af75d31da390657e3ac6f30c05c82068b9ed79b9", size = 11987803 }, - { url = "https://files.pythonhosted.org/packages/f6/61/1bce4129f93ab66f1c68b7ed1c12bac6a70b1b56c5dab359c6bbcd480b52/pandas-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df4df0b9d02bb873a106971bb85d448378ef14b86ba96f035f50bbd3688456b4", size = 12766345 }, - { url = "https://files.pythonhosted.org/packages/8e/46/80d53de70fee835531da3a1dae827a1e76e77a43ad22a8cd0f8142b61587/pandas-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:213a5adf93d020b74327cb2c1b842884dbdd37f895f42dcc2f09d451d949f811", size = 13439314 }, - { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326 }, - { url = "https://files.pythonhosted.org/packages/27/64/a2f7bf678af502e16b472527735d168b22b7824e45a4d7e96a4fbb634b59/pandas-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c6ecbac99a354a051ef21c5307601093cb9e0f4b1855984a084bfec9302699e", size = 11531061 }, - { url = "https://files.pythonhosted.org/packages/54/4c/c3d21b2b7769ef2f4c2b9299fcadd601efa6729f1357a8dbce8dd949ed70/pandas-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6f048aa0fd080d6a06cc7e7537c09b53be6642d330ac6f54a600c3ace857ee9", size = 10668666 }, - { url = "https://files.pythonhosted.org/packages/50/e2/f775ba76ecfb3424d7f5862620841cf0edb592e9abd2d2a5387d305fe7a8/pandas-2.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0064187b80a5be6f2f9c9d6bdde29372468751dfa89f4211a3c5871854cfbf7a", size = 11332835 }, - { url = "https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac8c320bded4718b298281339c1a50fb00a6ba78cb2a63521c39bec95b0209b", size = 12057211 }, - { url = "https://files.pythonhosted.org/packages/0b/9d/2df913f14b2deb9c748975fdb2491da1a78773debb25abbc7cbc67c6b549/pandas-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:114c2fe4f4328cf98ce5716d1532f3ab79c5919f95a9cfee81d9140064a2e4d6", size = 12749277 }, - { url = "https://files.pythonhosted.org/packages/87/af/da1a2417026bd14d98c236dba88e39837182459d29dcfcea510b2ac9e8a1/pandas-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:48fa91c4dfb3b2b9bfdb5c24cd3567575f4e13f9636810462ffed8925352be5a", size = 13415256 }, - { url = "https://files.pythonhosted.org/packages/22/3c/f2af1ce8840ef648584a6156489636b5692c162771918aa95707c165ad2b/pandas-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:12d039facec710f7ba305786837d0225a3444af7bbd9c15c32ca2d40d157ed8b", size = 10982579 }, - { url = "https://files.pythonhosted.org/packages/f3/98/8df69c4097a6719e357dc249bf437b8efbde808038268e584421696cbddf/pandas-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c624b615ce97864eb588779ed4046186f967374185c047070545253a52ab2d57", size = 12028163 }, - { url = "https://files.pythonhosted.org/packages/0e/23/f95cbcbea319f349e10ff90db488b905c6883f03cbabd34f6b03cbc3c044/pandas-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0cee69d583b9b128823d9514171cabb6861e09409af805b54459bd0c821a35c2", size = 11391860 }, - { url = "https://files.pythonhosted.org/packages/ad/1b/6a984e98c4abee22058aa75bfb8eb90dce58cf8d7296f8bc56c14bc330b0/pandas-2.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2319656ed81124982900b4c37f0e0c58c015af9a7bbc62342ba5ad07ace82ba9", size = 11309830 }, - { url = "https://files.pythonhosted.org/packages/15/d5/f0486090eb18dd8710bf60afeaf638ba6817047c0c8ae5c6a25598665609/pandas-2.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b37205ad6f00d52f16b6d09f406434ba928c1a1966e2771006a9033c736d30d2", size = 11883216 }, - { url = "https://files.pythonhosted.org/packages/10/86/692050c119696da19e20245bbd650d8dfca6ceb577da027c3a73c62a047e/pandas-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:837248b4fc3a9b83b9c6214699a13f069dc13510a6a6d7f9ba33145d2841a012", size = 12699743 }, - { url = "https://files.pythonhosted.org/packages/cd/d7/612123674d7b17cf345aad0a10289b2a384bff404e0463a83c4a3a59d205/pandas-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d2c3554bd31b731cd6490d94a28f3abb8dd770634a9e06eb6d2911b9827db370", size = 13186141 }, - { url = "https://files.pythonhosted.org/packages/e0/c3/b37e090d0aceda9b4dd85c8dbd1bea65b1de9e7a4f690d6bd3a40bd16390/pandas-2.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:88080a0ff8a55eac9c84e3ff3c7665b3b5476c6fbc484775ca1910ce1c3e0b87", size = 11551511 }, - { url = "https://files.pythonhosted.org/packages/b9/47/381fb1e7adcfcf4230fa6dc3a741acbac6c6fe072f19f4e7a46bddf3e5f6/pandas-2.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d4a558c7620340a0931828d8065688b3cc5b4c8eb674bcaf33d18ff4a6870b4a", size = 10797930 }, - { url = "https://files.pythonhosted.org/packages/36/ca/d42467829080b92fc46d451288af8068f129fbcfb6578d573f45120de5cf/pandas-2.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45178cf09d1858a1509dc73ec261bf5b25a625a389b65be2e47b559905f0ab6a", size = 11738470 }, - { url = "https://files.pythonhosted.org/packages/60/76/7d0f0a0deed7867c51163982d7b79c0a089096cd7ad50e1b87c2c82220e9/pandas-2.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77cefe00e1b210f9c76c697fedd8fdb8d3dd86563e9c8adc9fa72b90f5e9e4c2", size = 12366640 }, - { url = "https://files.pythonhosted.org/packages/21/31/56784743e421cf51e34358fe7e5954345e5942168897bf8eb5707b71eedb/pandas-2.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:13bd629c653856f00c53dc495191baa59bcafbbf54860a46ecc50d3a88421a96", size = 13211567 }, - { url = "https://files.pythonhosted.org/packages/7a/4e/50a399dc7d9dd4aa09a03b163751d428026cf0f16c419b4010f6aca26ebd/pandas-2.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:36d627906fd44b5fd63c943264e11e96e923f8de77d6016dc2f667b9ad193438", size = 13854073 }, - { url = "https://files.pythonhosted.org/packages/29/72/8978a84861a5124e56ce1048376569545412501fcb9a83f035393d6d85bc/pandas-2.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:a9d7ec92d71a420185dec44909c32e9a362248c4ae2238234b76d5be37f208cc", size = 11346452 }, +sdist = { url = "https://files.pythonhosted.org/packages/79/8e/0e90233ac205ad182bd6b422532695d2b9414944a280488105d598c70023/pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb", size = 4488684, upload-time = "2025-08-21T10:28:29.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/16/a8eeb70aad84ccbf14076793f90e0031eded63c1899aeae9fdfbf37881f4/pandas-2.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52bc29a946304c360561974c6542d1dd628ddafa69134a7131fdfd6a5d7a1a35", size = 11539648, upload-time = "2025-08-21T10:26:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/c5bdaea13bf3708554d93e948b7ea74121ce6e0d59537ca4c4f77731072b/pandas-2.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:220cc5c35ffaa764dd5bb17cf42df283b5cb7fdf49e10a7b053a06c9cb48ee2b", size = 10786923, upload-time = "2025-08-21T10:26:40.518Z" }, + { url = "https://files.pythonhosted.org/packages/bb/10/811fa01476d29ffed692e735825516ad0e56d925961819e6126b4ba32147/pandas-2.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c05e15111221384019897df20c6fe893b2f697d03c811ee67ec9e0bb5a3424", size = 11726241, upload-time = "2025-08-21T10:26:43.175Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6a/40b043b06e08df1ea1b6d20f0e0c2f2c4ec8c4f07d1c92948273d943a50b/pandas-2.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc03acc273c5515ab69f898df99d9d4f12c4d70dbfc24c3acc6203751d0804cf", size = 12349533, upload-time = "2025-08-21T10:26:46.611Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/2e081a2302e41a9bca7056659fdd2b85ef94923723e41665b42d65afd347/pandas-2.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d25c20a03e8870f6339bcf67281b946bd20b86f1a544ebbebb87e66a8d642cba", size = 13202407, upload-time = "2025-08-21T10:26:49.068Z" }, + { url = "https://files.pythonhosted.org/packages/f4/12/7ff9f6a79e2ee8869dcf70741ef998b97ea20050fe25f83dc759764c1e32/pandas-2.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21bb612d148bb5860b7eb2c10faacf1a810799245afd342cf297d7551513fbb6", size = 13837212, upload-time = "2025-08-21T10:26:51.832Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/5ab92fcd76455a632b3db34a746e1074d432c0cdbbd28d7cd1daba46a75d/pandas-2.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:b62d586eb25cb8cb70a5746a378fc3194cb7f11ea77170d59f889f5dfe3cec7a", size = 11338099, upload-time = "2025-08-21T10:26:54.382Z" }, + { url = "https://files.pythonhosted.org/packages/7a/59/f3e010879f118c2d400902d2d871c2226cef29b08c09fb8dc41111730400/pandas-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1333e9c299adcbb68ee89a9bb568fc3f20f9cbb419f1dd5225071e6cddb2a743", size = 11563308, upload-time = "2025-08-21T10:26:56.656Z" }, + { url = "https://files.pythonhosted.org/packages/38/18/48f10f1cc5c397af59571d638d211f494dba481f449c19adbd282aa8f4ca/pandas-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:76972bcbd7de8e91ad5f0ca884a9f2c477a2125354af624e022c49e5bd0dfff4", size = 10820319, upload-time = "2025-08-21T10:26:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/95/3b/1e9b69632898b048e223834cd9702052bcf06b15e1ae716eda3196fb972e/pandas-2.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b98bdd7c456a05eef7cd21fd6b29e3ca243591fe531c62be94a2cc987efb5ac2", size = 11790097, upload-time = "2025-08-21T10:27:02.204Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/0e2ffb30b1f7fbc9a588bd01e3c14a0d96854d09a887e15e30cc19961227/pandas-2.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d81573b3f7db40d020983f78721e9bfc425f411e616ef019a10ebf597aedb2e", size = 12397958, upload-time = "2025-08-21T10:27:05.409Z" }, + { url = "https://files.pythonhosted.org/packages/23/82/e6b85f0d92e9afb0e7f705a51d1399b79c7380c19687bfbf3d2837743249/pandas-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e190b738675a73b581736cc8ec71ae113d6c3768d0bd18bffa5b9a0927b0b6ea", size = 13225600, upload-time = "2025-08-21T10:27:07.791Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f1/f682015893d9ed51611948bd83683670842286a8edd4f68c2c1c3b231eef/pandas-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c253828cb08f47488d60f43c5fc95114c771bbfff085da54bfc79cb4f9e3a372", size = 13879433, upload-time = "2025-08-21T10:27:10.347Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/ae86261695b6c8a36d6a4c8d5f9b9ede8248510d689a2f379a18354b37d7/pandas-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:9467697b8083f9667b212633ad6aa4ab32436dcbaf4cd57325debb0ddef2012f", size = 11336557, upload-time = "2025-08-21T10:27:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/ec/db/614c20fb7a85a14828edd23f1c02db58a30abf3ce76f38806155d160313c/pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9", size = 11587652, upload-time = "2025-08-21T10:27:15.888Z" }, + { url = "https://files.pythonhosted.org/packages/99/b0/756e52f6582cade5e746f19bad0517ff27ba9c73404607c0306585c201b3/pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b", size = 10717686, upload-time = "2025-08-21T10:27:18.486Z" }, + { url = "https://files.pythonhosted.org/packages/37/4c/dd5ccc1e357abfeee8353123282de17997f90ff67855f86154e5a13b81e5/pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175", size = 11278722, upload-time = "2025-08-21T10:27:21.149Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a4/f7edcfa47e0a88cda0be8b068a5bae710bf264f867edfdf7b71584ace362/pandas-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d31a6b4354e3b9b8a2c848af75d31da390657e3ac6f30c05c82068b9ed79b9", size = 11987803, upload-time = "2025-08-21T10:27:23.767Z" }, + { url = "https://files.pythonhosted.org/packages/f6/61/1bce4129f93ab66f1c68b7ed1c12bac6a70b1b56c5dab359c6bbcd480b52/pandas-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df4df0b9d02bb873a106971bb85d448378ef14b86ba96f035f50bbd3688456b4", size = 12766345, upload-time = "2025-08-21T10:27:26.6Z" }, + { url = "https://files.pythonhosted.org/packages/8e/46/80d53de70fee835531da3a1dae827a1e76e77a43ad22a8cd0f8142b61587/pandas-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:213a5adf93d020b74327cb2c1b842884dbdd37f895f42dcc2f09d451d949f811", size = 13439314, upload-time = "2025-08-21T10:27:29.213Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" }, + { url = "https://files.pythonhosted.org/packages/27/64/a2f7bf678af502e16b472527735d168b22b7824e45a4d7e96a4fbb634b59/pandas-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c6ecbac99a354a051ef21c5307601093cb9e0f4b1855984a084bfec9302699e", size = 11531061, upload-time = "2025-08-21T10:27:34.647Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/c3d21b2b7769ef2f4c2b9299fcadd601efa6729f1357a8dbce8dd949ed70/pandas-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6f048aa0fd080d6a06cc7e7537c09b53be6642d330ac6f54a600c3ace857ee9", size = 10668666, upload-time = "2025-08-21T10:27:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/f775ba76ecfb3424d7f5862620841cf0edb592e9abd2d2a5387d305fe7a8/pandas-2.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0064187b80a5be6f2f9c9d6bdde29372468751dfa89f4211a3c5871854cfbf7a", size = 11332835, upload-time = "2025-08-21T10:27:40.188Z" }, + { url = "https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac8c320bded4718b298281339c1a50fb00a6ba78cb2a63521c39bec95b0209b", size = 12057211, upload-time = "2025-08-21T10:27:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/0b/9d/2df913f14b2deb9c748975fdb2491da1a78773debb25abbc7cbc67c6b549/pandas-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:114c2fe4f4328cf98ce5716d1532f3ab79c5919f95a9cfee81d9140064a2e4d6", size = 12749277, upload-time = "2025-08-21T10:27:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/da1a2417026bd14d98c236dba88e39837182459d29dcfcea510b2ac9e8a1/pandas-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:48fa91c4dfb3b2b9bfdb5c24cd3567575f4e13f9636810462ffed8925352be5a", size = 13415256, upload-time = "2025-08-21T10:27:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/22/3c/f2af1ce8840ef648584a6156489636b5692c162771918aa95707c165ad2b/pandas-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:12d039facec710f7ba305786837d0225a3444af7bbd9c15c32ca2d40d157ed8b", size = 10982579, upload-time = "2025-08-21T10:28:08.435Z" }, + { url = "https://files.pythonhosted.org/packages/f3/98/8df69c4097a6719e357dc249bf437b8efbde808038268e584421696cbddf/pandas-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c624b615ce97864eb588779ed4046186f967374185c047070545253a52ab2d57", size = 12028163, upload-time = "2025-08-21T10:27:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/0e/23/f95cbcbea319f349e10ff90db488b905c6883f03cbabd34f6b03cbc3c044/pandas-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0cee69d583b9b128823d9514171cabb6861e09409af805b54459bd0c821a35c2", size = 11391860, upload-time = "2025-08-21T10:27:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1b/6a984e98c4abee22058aa75bfb8eb90dce58cf8d7296f8bc56c14bc330b0/pandas-2.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2319656ed81124982900b4c37f0e0c58c015af9a7bbc62342ba5ad07ace82ba9", size = 11309830, upload-time = "2025-08-21T10:27:56.957Z" }, + { url = "https://files.pythonhosted.org/packages/15/d5/f0486090eb18dd8710bf60afeaf638ba6817047c0c8ae5c6a25598665609/pandas-2.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b37205ad6f00d52f16b6d09f406434ba928c1a1966e2771006a9033c736d30d2", size = 11883216, upload-time = "2025-08-21T10:27:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/10/86/692050c119696da19e20245bbd650d8dfca6ceb577da027c3a73c62a047e/pandas-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:837248b4fc3a9b83b9c6214699a13f069dc13510a6a6d7f9ba33145d2841a012", size = 12699743, upload-time = "2025-08-21T10:28:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/612123674d7b17cf345aad0a10289b2a384bff404e0463a83c4a3a59d205/pandas-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d2c3554bd31b731cd6490d94a28f3abb8dd770634a9e06eb6d2911b9827db370", size = 13186141, upload-time = "2025-08-21T10:28:05.377Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/b37e090d0aceda9b4dd85c8dbd1bea65b1de9e7a4f690d6bd3a40bd16390/pandas-2.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:88080a0ff8a55eac9c84e3ff3c7665b3b5476c6fbc484775ca1910ce1c3e0b87", size = 11551511, upload-time = "2025-08-21T10:28:11.111Z" }, + { url = "https://files.pythonhosted.org/packages/b9/47/381fb1e7adcfcf4230fa6dc3a741acbac6c6fe072f19f4e7a46bddf3e5f6/pandas-2.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d4a558c7620340a0931828d8065688b3cc5b4c8eb674bcaf33d18ff4a6870b4a", size = 10797930, upload-time = "2025-08-21T10:28:13.436Z" }, + { url = "https://files.pythonhosted.org/packages/36/ca/d42467829080b92fc46d451288af8068f129fbcfb6578d573f45120de5cf/pandas-2.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45178cf09d1858a1509dc73ec261bf5b25a625a389b65be2e47b559905f0ab6a", size = 11738470, upload-time = "2025-08-21T10:28:16.065Z" }, + { url = "https://files.pythonhosted.org/packages/60/76/7d0f0a0deed7867c51163982d7b79c0a089096cd7ad50e1b87c2c82220e9/pandas-2.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77cefe00e1b210f9c76c697fedd8fdb8d3dd86563e9c8adc9fa72b90f5e9e4c2", size = 12366640, upload-time = "2025-08-21T10:28:18.557Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/56784743e421cf51e34358fe7e5954345e5942168897bf8eb5707b71eedb/pandas-2.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:13bd629c653856f00c53dc495191baa59bcafbbf54860a46ecc50d3a88421a96", size = 13211567, upload-time = "2025-08-21T10:28:20.998Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4e/50a399dc7d9dd4aa09a03b163751d428026cf0f16c419b4010f6aca26ebd/pandas-2.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:36d627906fd44b5fd63c943264e11e96e923f8de77d6016dc2f667b9ad193438", size = 13854073, upload-time = "2025-08-21T10:28:24.056Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/8978a84861a5124e56ce1048376569545412501fcb9a83f035393d6d85bc/pandas-2.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:a9d7ec92d71a420185dec44909c32e9a362248c4ae2238234b76d5be37f208cc", size = 11346452, upload-time = "2025-08-21T10:28:26.691Z" }, ] [[package]] name = "parso" version = "0.8.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205 } +sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205, upload-time = "2025-08-23T15:15:28.028Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668 }, + { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, ] [[package]] name = "pastel" version = "0.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555 } +sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955 }, + { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, ] [[package]] @@ -1313,27 +1314,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ptyprocess" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] [[package]] name = "platformdirs" version = "4.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634 } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654 }, + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] @@ -1345,9 +1346,9 @@ dependencies = [ { name = "pyyaml" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/f2/273fe54a78dc5c6c8dd63db71f5a6ceb95e4648516b5aeaeff4bde804e44/poethepoet-0.37.0.tar.gz", hash = "sha256:73edf458707c674a079baa46802e21455bda3a7f82a408e58c31b9f4fe8e933d", size = 68570 } +sdist = { url = "https://files.pythonhosted.org/packages/a5/f2/273fe54a78dc5c6c8dd63db71f5a6ceb95e4648516b5aeaeff4bde804e44/poethepoet-0.37.0.tar.gz", hash = "sha256:73edf458707c674a079baa46802e21455bda3a7f82a408e58c31b9f4fe8e933d", size = 68570, upload-time = "2025-08-11T18:00:29.103Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/1b/5337af1a6a478d25a3e3c56b9b4b42b0a160314e02f4a0498d5322c8dac4/poethepoet-0.37.0-py3-none-any.whl", hash = "sha256:861790276315abcc8df1b4bd60e28c3d48a06db273edd3092f3c94e1a46e5e22", size = 90062 }, + { url = "https://files.pythonhosted.org/packages/92/1b/5337af1a6a478d25a3e3c56b9b4b42b0a160314e02f4a0498d5322c8dac4/poethepoet-0.37.0-py3-none-any.whl", hash = "sha256:861790276315abcc8df1b4bd60e28c3d48a06db273edd3092f3c94e1a46e5e22", size = 90062, upload-time = "2025-08-11T18:00:27.595Z" }, ] [[package]] @@ -1361,9 +1362,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965 }, + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, ] [[package]] @@ -1373,101 +1374,101 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] [[package]] name = "psutil" version = "7.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003 } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051 }, - { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535 }, - { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004 }, - { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986 }, - { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544 }, - { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053 }, - { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885 }, + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, ] [[package]] name = "ptyprocess" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] [[package]] name = "pure-eval" version = "0.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] [[package]] name = "pyarrow" version = "21.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/c2/ea068b8f00905c06329a3dfcd40d0fcc2b7d0f2e355bdb25b65e0a0e4cd4/pyarrow-21.0.0.tar.gz", hash = "sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc", size = 1133487 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/d9/110de31880016e2afc52d8580b397dbe47615defbf09ca8cf55f56c62165/pyarrow-21.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e563271e2c5ff4d4a4cbeb2c83d5cf0d4938b891518e676025f7268c6fe5fe26", size = 31196837 }, - { url = "https://files.pythonhosted.org/packages/df/5f/c1c1997613abf24fceb087e79432d24c19bc6f7259cab57c2c8e5e545fab/pyarrow-21.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79", size = 32659470 }, - { url = "https://files.pythonhosted.org/packages/3e/ed/b1589a777816ee33ba123ba1e4f8f02243a844fed0deec97bde9fb21a5cf/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:7be45519b830f7c24b21d630a31d48bcebfd5d4d7f9d3bdb49da9cdf6d764edb", size = 41055619 }, - { url = "https://files.pythonhosted.org/packages/44/28/b6672962639e85dc0ac36f71ab3a8f5f38e01b51343d7aa372a6b56fa3f3/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:26bfd95f6bff443ceae63c65dc7e048670b7e98bc892210acba7e4995d3d4b51", size = 42733488 }, - { url = "https://files.pythonhosted.org/packages/f8/cc/de02c3614874b9089c94eac093f90ca5dfa6d5afe45de3ba847fd950fdf1/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd04ec08f7f8bd113c55868bd3fc442a9db67c27af098c5f814a3091e71cc61a", size = 43329159 }, - { url = "https://files.pythonhosted.org/packages/a6/3e/99473332ac40278f196e105ce30b79ab8affab12f6194802f2593d6b0be2/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9b0b14b49ac10654332a805aedfc0147fb3469cbf8ea951b3d040dab12372594", size = 45050567 }, - { url = "https://files.pythonhosted.org/packages/7b/f5/c372ef60593d713e8bfbb7e0c743501605f0ad00719146dc075faf11172b/pyarrow-21.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d9f8bcb4c3be7738add259738abdeddc363de1b80e3310e04067aa1ca596634", size = 26217959 }, - { url = "https://files.pythonhosted.org/packages/94/dc/80564a3071a57c20b7c32575e4a0120e8a330ef487c319b122942d665960/pyarrow-21.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c077f48aab61738c237802836fc3844f85409a46015635198761b0d6a688f87b", size = 31243234 }, - { url = "https://files.pythonhosted.org/packages/ea/cc/3b51cb2db26fe535d14f74cab4c79b191ed9a8cd4cbba45e2379b5ca2746/pyarrow-21.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:689f448066781856237eca8d1975b98cace19b8dd2ab6145bf49475478bcaa10", size = 32714370 }, - { url = "https://files.pythonhosted.org/packages/24/11/a4431f36d5ad7d83b87146f515c063e4d07ef0b7240876ddb885e6b44f2e/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:479ee41399fcddc46159a551705b89c05f11e8b8cb8e968f7fec64f62d91985e", size = 41135424 }, - { url = "https://files.pythonhosted.org/packages/74/dc/035d54638fc5d2971cbf1e987ccd45f1091c83bcf747281cf6cc25e72c88/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:40ebfcb54a4f11bcde86bc586cbd0272bac0d516cfa539c799c2453768477569", size = 42823810 }, - { url = "https://files.pythonhosted.org/packages/2e/3b/89fced102448a9e3e0d4dded1f37fa3ce4700f02cdb8665457fcc8015f5b/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8d58d8497814274d3d20214fbb24abcad2f7e351474357d552a8d53bce70c70e", size = 43391538 }, - { url = "https://files.pythonhosted.org/packages/fb/bb/ea7f1bd08978d39debd3b23611c293f64a642557e8141c80635d501e6d53/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:585e7224f21124dd57836b1530ac8f2df2afc43c861d7bf3d58a4870c42ae36c", size = 45120056 }, - { url = "https://files.pythonhosted.org/packages/6e/0b/77ea0600009842b30ceebc3337639a7380cd946061b620ac1a2f3cb541e2/pyarrow-21.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:555ca6935b2cbca2c0e932bedd853e9bc523098c39636de9ad4693b5b1df86d6", size = 26220568 }, - { url = "https://files.pythonhosted.org/packages/ca/d4/d4f817b21aacc30195cf6a46ba041dd1be827efa4a623cc8bf39a1c2a0c0/pyarrow-21.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd", size = 31160305 }, - { url = "https://files.pythonhosted.org/packages/a2/9c/dcd38ce6e4b4d9a19e1d36914cb8e2b1da4e6003dd075474c4cfcdfe0601/pyarrow-21.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876", size = 32684264 }, - { url = "https://files.pythonhosted.org/packages/4f/74/2a2d9f8d7a59b639523454bec12dba35ae3d0a07d8ab529dc0809f74b23c/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d", size = 41108099 }, - { url = "https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e", size = 42829529 }, - { url = "https://files.pythonhosted.org/packages/33/27/1a93a25c92717f6aa0fca06eb4700860577d016cd3ae51aad0e0488ac899/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82", size = 43367883 }, - { url = "https://files.pythonhosted.org/packages/05/d9/4d09d919f35d599bc05c6950095e358c3e15148ead26292dfca1fb659b0c/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623", size = 45133802 }, - { url = "https://files.pythonhosted.org/packages/71/30/f3795b6e192c3ab881325ffe172e526499eb3780e306a15103a2764916a2/pyarrow-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18", size = 26203175 }, - { url = "https://files.pythonhosted.org/packages/16/ca/c7eaa8e62db8fb37ce942b1ea0c6d7abfe3786ca193957afa25e71b81b66/pyarrow-21.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e99310a4ebd4479bcd1964dff9e14af33746300cb014aa4a3781738ac63baf4a", size = 31154306 }, - { url = "https://files.pythonhosted.org/packages/ce/e8/e87d9e3b2489302b3a1aea709aaca4b781c5252fcb812a17ab6275a9a484/pyarrow-21.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:d2fe8e7f3ce329a71b7ddd7498b3cfac0eeb200c2789bd840234f0dc271a8efe", size = 32680622 }, - { url = "https://files.pythonhosted.org/packages/84/52/79095d73a742aa0aba370c7942b1b655f598069489ab387fe47261a849e1/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f522e5709379d72fb3da7785aa489ff0bb87448a9dc5a75f45763a795a089ebd", size = 41104094 }, - { url = "https://files.pythonhosted.org/packages/89/4b/7782438b551dbb0468892a276b8c789b8bbdb25ea5c5eb27faadd753e037/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:69cbbdf0631396e9925e048cfa5bce4e8c3d3b41562bbd70c685a8eb53a91e61", size = 42825576 }, - { url = "https://files.pythonhosted.org/packages/b3/62/0f29de6e0a1e33518dec92c65be0351d32d7ca351e51ec5f4f837a9aab91/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:731c7022587006b755d0bdb27626a1a3bb004bb56b11fb30d98b6c1b4718579d", size = 43368342 }, - { url = "https://files.pythonhosted.org/packages/90/c7/0fa1f3f29cf75f339768cc698c8ad4ddd2481c1742e9741459911c9ac477/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc56bc708f2d8ac71bd1dcb927e458c93cec10b98eb4120206a4091db7b67b99", size = 45131218 }, - { url = "https://files.pythonhosted.org/packages/01/63/581f2076465e67b23bc5a37d4a2abff8362d389d29d8105832e82c9c811c/pyarrow-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:186aa00bca62139f75b7de8420f745f2af12941595bbbfa7ed3870ff63e25636", size = 26087551 }, - { url = "https://files.pythonhosted.org/packages/c9/ab/357d0d9648bb8241ee7348e564f2479d206ebe6e1c47ac5027c2e31ecd39/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:a7a102574faa3f421141a64c10216e078df467ab9576684d5cd696952546e2da", size = 31290064 }, - { url = "https://files.pythonhosted.org/packages/3f/8a/5685d62a990e4cac2043fc76b4661bf38d06efed55cf45a334b455bd2759/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:1e005378c4a2c6db3ada3ad4c217b381f6c886f0a80d6a316fe586b90f77efd7", size = 32727837 }, - { url = "https://files.pythonhosted.org/packages/fc/de/c0828ee09525c2bafefd3e736a248ebe764d07d0fd762d4f0929dbc516c9/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:65f8e85f79031449ec8706b74504a316805217b35b6099155dd7e227eef0d4b6", size = 41014158 }, - { url = "https://files.pythonhosted.org/packages/6e/26/a2865c420c50b7a3748320b614f3484bfcde8347b2639b2b903b21ce6a72/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3a81486adc665c7eb1a2bde0224cfca6ceaba344a82a971ef059678417880eb8", size = 42667885 }, - { url = "https://files.pythonhosted.org/packages/0a/f9/4ee798dc902533159250fb4321267730bc0a107d8c6889e07c3add4fe3a5/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fc0d2f88b81dcf3ccf9a6ae17f89183762c8a94a5bdcfa09e05cfe413acf0503", size = 43276625 }, - { url = "https://files.pythonhosted.org/packages/5a/da/e02544d6997037a4b0d22d8e5f66bc9315c3671371a8b18c79ade1cefe14/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6299449adf89df38537837487a4f8d3bd91ec94354fdd2a7d30bc11c48ef6e79", size = 44951890 }, - { url = "https://files.pythonhosted.org/packages/e5/4e/519c1bc1876625fe6b71e9a28287c43ec2f20f73c658b9ae1d485c0c206e/pyarrow-21.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:222c39e2c70113543982c6b34f3077962b44fca38c0bd9e68bb6781534425c10", size = 26371006 }, - { url = "https://files.pythonhosted.org/packages/3e/cc/ce4939f4b316457a083dc5718b3982801e8c33f921b3c98e7a93b7c7491f/pyarrow-21.0.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:a7f6524e3747e35f80744537c78e7302cd41deee8baa668d56d55f77d9c464b3", size = 31211248 }, - { url = "https://files.pythonhosted.org/packages/1f/c2/7a860931420d73985e2f340f06516b21740c15b28d24a0e99a900bb27d2b/pyarrow-21.0.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:203003786c9fd253ebcafa44b03c06983c9c8d06c3145e37f1b76a1f317aeae1", size = 32676896 }, - { url = "https://files.pythonhosted.org/packages/68/a8/197f989b9a75e59b4ca0db6a13c56f19a0ad8a298c68da9cc28145e0bb97/pyarrow-21.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:3b4d97e297741796fead24867a8dabf86c87e4584ccc03167e4a811f50fdf74d", size = 41067862 }, - { url = "https://files.pythonhosted.org/packages/fa/82/6ecfa89487b35aa21accb014b64e0a6b814cc860d5e3170287bf5135c7d8/pyarrow-21.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:898afce396b80fdda05e3086b4256f8677c671f7b1d27a6976fa011d3fd0a86e", size = 42747508 }, - { url = "https://files.pythonhosted.org/packages/3b/b7/ba252f399bbf3addc731e8643c05532cf32e74cebb5e32f8f7409bc243cf/pyarrow-21.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:067c66ca29aaedae08218569a114e413b26e742171f526e828e1064fcdec13f4", size = 43345293 }, - { url = "https://files.pythonhosted.org/packages/ff/0a/a20819795bd702b9486f536a8eeb70a6aa64046fce32071c19ec8230dbaa/pyarrow-21.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0c4e75d13eb76295a49e0ea056eb18dbd87d81450bfeb8afa19a7e5a75ae2ad7", size = 45060670 }, - { url = "https://files.pythonhosted.org/packages/10/15/6b30e77872012bbfe8265d42a01d5b3c17ef0ac0f2fae531ad91b6a6c02e/pyarrow-21.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdc4c17afda4dab2a9c0b79148a43a7f4e1094916b3e18d8975bfd6d6d52241f", size = 26227521 }, +sdist = { url = "https://files.pythonhosted.org/packages/ef/c2/ea068b8f00905c06329a3dfcd40d0fcc2b7d0f2e355bdb25b65e0a0e4cd4/pyarrow-21.0.0.tar.gz", hash = "sha256:5051f2dccf0e283ff56335760cbc8622cf52264d67e359d5569541ac11b6d5bc", size = 1133487, upload-time = "2025-07-18T00:57:31.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d9/110de31880016e2afc52d8580b397dbe47615defbf09ca8cf55f56c62165/pyarrow-21.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:e563271e2c5ff4d4a4cbeb2c83d5cf0d4938b891518e676025f7268c6fe5fe26", size = 31196837, upload-time = "2025-07-18T00:54:34.755Z" }, + { url = "https://files.pythonhosted.org/packages/df/5f/c1c1997613abf24fceb087e79432d24c19bc6f7259cab57c2c8e5e545fab/pyarrow-21.0.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fee33b0ca46f4c85443d6c450357101e47d53e6c3f008d658c27a2d020d44c79", size = 32659470, upload-time = "2025-07-18T00:54:38.329Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ed/b1589a777816ee33ba123ba1e4f8f02243a844fed0deec97bde9fb21a5cf/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:7be45519b830f7c24b21d630a31d48bcebfd5d4d7f9d3bdb49da9cdf6d764edb", size = 41055619, upload-time = "2025-07-18T00:54:42.172Z" }, + { url = "https://files.pythonhosted.org/packages/44/28/b6672962639e85dc0ac36f71ab3a8f5f38e01b51343d7aa372a6b56fa3f3/pyarrow-21.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:26bfd95f6bff443ceae63c65dc7e048670b7e98bc892210acba7e4995d3d4b51", size = 42733488, upload-time = "2025-07-18T00:54:47.132Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/de02c3614874b9089c94eac093f90ca5dfa6d5afe45de3ba847fd950fdf1/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd04ec08f7f8bd113c55868bd3fc442a9db67c27af098c5f814a3091e71cc61a", size = 43329159, upload-time = "2025-07-18T00:54:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3e/99473332ac40278f196e105ce30b79ab8affab12f6194802f2593d6b0be2/pyarrow-21.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9b0b14b49ac10654332a805aedfc0147fb3469cbf8ea951b3d040dab12372594", size = 45050567, upload-time = "2025-07-18T00:54:56.679Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f5/c372ef60593d713e8bfbb7e0c743501605f0ad00719146dc075faf11172b/pyarrow-21.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:9d9f8bcb4c3be7738add259738abdeddc363de1b80e3310e04067aa1ca596634", size = 26217959, upload-time = "2025-07-18T00:55:00.482Z" }, + { url = "https://files.pythonhosted.org/packages/94/dc/80564a3071a57c20b7c32575e4a0120e8a330ef487c319b122942d665960/pyarrow-21.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:c077f48aab61738c237802836fc3844f85409a46015635198761b0d6a688f87b", size = 31243234, upload-time = "2025-07-18T00:55:03.812Z" }, + { url = "https://files.pythonhosted.org/packages/ea/cc/3b51cb2db26fe535d14f74cab4c79b191ed9a8cd4cbba45e2379b5ca2746/pyarrow-21.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:689f448066781856237eca8d1975b98cace19b8dd2ab6145bf49475478bcaa10", size = 32714370, upload-time = "2025-07-18T00:55:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/24/11/a4431f36d5ad7d83b87146f515c063e4d07ef0b7240876ddb885e6b44f2e/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:479ee41399fcddc46159a551705b89c05f11e8b8cb8e968f7fec64f62d91985e", size = 41135424, upload-time = "2025-07-18T00:55:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/74/dc/035d54638fc5d2971cbf1e987ccd45f1091c83bcf747281cf6cc25e72c88/pyarrow-21.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:40ebfcb54a4f11bcde86bc586cbd0272bac0d516cfa539c799c2453768477569", size = 42823810, upload-time = "2025-07-18T00:55:16.301Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3b/89fced102448a9e3e0d4dded1f37fa3ce4700f02cdb8665457fcc8015f5b/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8d58d8497814274d3d20214fbb24abcad2f7e351474357d552a8d53bce70c70e", size = 43391538, upload-time = "2025-07-18T00:55:23.82Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/ea7f1bd08978d39debd3b23611c293f64a642557e8141c80635d501e6d53/pyarrow-21.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:585e7224f21124dd57836b1530ac8f2df2afc43c861d7bf3d58a4870c42ae36c", size = 45120056, upload-time = "2025-07-18T00:55:28.231Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0b/77ea0600009842b30ceebc3337639a7380cd946061b620ac1a2f3cb541e2/pyarrow-21.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:555ca6935b2cbca2c0e932bedd853e9bc523098c39636de9ad4693b5b1df86d6", size = 26220568, upload-time = "2025-07-18T00:55:32.122Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d4/d4f817b21aacc30195cf6a46ba041dd1be827efa4a623cc8bf39a1c2a0c0/pyarrow-21.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:3a302f0e0963db37e0a24a70c56cf91a4faa0bca51c23812279ca2e23481fccd", size = 31160305, upload-time = "2025-07-18T00:55:35.373Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9c/dcd38ce6e4b4d9a19e1d36914cb8e2b1da4e6003dd075474c4cfcdfe0601/pyarrow-21.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b6b27cf01e243871390474a211a7922bfbe3bda21e39bc9160daf0da3fe48876", size = 32684264, upload-time = "2025-07-18T00:55:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/4f/74/2a2d9f8d7a59b639523454bec12dba35ae3d0a07d8ab529dc0809f74b23c/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e72a8ec6b868e258a2cd2672d91f2860ad532d590ce94cdf7d5e7ec674ccf03d", size = 41108099, upload-time = "2025-07-18T00:55:42.889Z" }, + { url = "https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e", size = 42829529, upload-time = "2025-07-18T00:55:47.069Z" }, + { url = "https://files.pythonhosted.org/packages/33/27/1a93a25c92717f6aa0fca06eb4700860577d016cd3ae51aad0e0488ac899/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:58c30a1729f82d201627c173d91bd431db88ea74dcaa3885855bc6203e433b82", size = 43367883, upload-time = "2025-07-18T00:55:53.069Z" }, + { url = "https://files.pythonhosted.org/packages/05/d9/4d09d919f35d599bc05c6950095e358c3e15148ead26292dfca1fb659b0c/pyarrow-21.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:072116f65604b822a7f22945a7a6e581cfa28e3454fdcc6939d4ff6090126623", size = 45133802, upload-time = "2025-07-18T00:55:57.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/f3795b6e192c3ab881325ffe172e526499eb3780e306a15103a2764916a2/pyarrow-21.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf56ec8b0a5c8c9d7021d6fd754e688104f9ebebf1bf4449613c9531f5346a18", size = 26203175, upload-time = "2025-07-18T00:56:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/16/ca/c7eaa8e62db8fb37ce942b1ea0c6d7abfe3786ca193957afa25e71b81b66/pyarrow-21.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e99310a4ebd4479bcd1964dff9e14af33746300cb014aa4a3781738ac63baf4a", size = 31154306, upload-time = "2025-07-18T00:56:04.42Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e8/e87d9e3b2489302b3a1aea709aaca4b781c5252fcb812a17ab6275a9a484/pyarrow-21.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:d2fe8e7f3ce329a71b7ddd7498b3cfac0eeb200c2789bd840234f0dc271a8efe", size = 32680622, upload-time = "2025-07-18T00:56:07.505Z" }, + { url = "https://files.pythonhosted.org/packages/84/52/79095d73a742aa0aba370c7942b1b655f598069489ab387fe47261a849e1/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f522e5709379d72fb3da7785aa489ff0bb87448a9dc5a75f45763a795a089ebd", size = 41104094, upload-time = "2025-07-18T00:56:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/89/4b/7782438b551dbb0468892a276b8c789b8bbdb25ea5c5eb27faadd753e037/pyarrow-21.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:69cbbdf0631396e9925e048cfa5bce4e8c3d3b41562bbd70c685a8eb53a91e61", size = 42825576, upload-time = "2025-07-18T00:56:15.569Z" }, + { url = "https://files.pythonhosted.org/packages/b3/62/0f29de6e0a1e33518dec92c65be0351d32d7ca351e51ec5f4f837a9aab91/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:731c7022587006b755d0bdb27626a1a3bb004bb56b11fb30d98b6c1b4718579d", size = 43368342, upload-time = "2025-07-18T00:56:19.531Z" }, + { url = "https://files.pythonhosted.org/packages/90/c7/0fa1f3f29cf75f339768cc698c8ad4ddd2481c1742e9741459911c9ac477/pyarrow-21.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc56bc708f2d8ac71bd1dcb927e458c93cec10b98eb4120206a4091db7b67b99", size = 45131218, upload-time = "2025-07-18T00:56:23.347Z" }, + { url = "https://files.pythonhosted.org/packages/01/63/581f2076465e67b23bc5a37d4a2abff8362d389d29d8105832e82c9c811c/pyarrow-21.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:186aa00bca62139f75b7de8420f745f2af12941595bbbfa7ed3870ff63e25636", size = 26087551, upload-time = "2025-07-18T00:56:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ab/357d0d9648bb8241ee7348e564f2479d206ebe6e1c47ac5027c2e31ecd39/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:a7a102574faa3f421141a64c10216e078df467ab9576684d5cd696952546e2da", size = 31290064, upload-time = "2025-07-18T00:56:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8a/5685d62a990e4cac2043fc76b4661bf38d06efed55cf45a334b455bd2759/pyarrow-21.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:1e005378c4a2c6db3ada3ad4c217b381f6c886f0a80d6a316fe586b90f77efd7", size = 32727837, upload-time = "2025-07-18T00:56:33.935Z" }, + { url = "https://files.pythonhosted.org/packages/fc/de/c0828ee09525c2bafefd3e736a248ebe764d07d0fd762d4f0929dbc516c9/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:65f8e85f79031449ec8706b74504a316805217b35b6099155dd7e227eef0d4b6", size = 41014158, upload-time = "2025-07-18T00:56:37.528Z" }, + { url = "https://files.pythonhosted.org/packages/6e/26/a2865c420c50b7a3748320b614f3484bfcde8347b2639b2b903b21ce6a72/pyarrow-21.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:3a81486adc665c7eb1a2bde0224cfca6ceaba344a82a971ef059678417880eb8", size = 42667885, upload-time = "2025-07-18T00:56:41.483Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f9/4ee798dc902533159250fb4321267730bc0a107d8c6889e07c3add4fe3a5/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fc0d2f88b81dcf3ccf9a6ae17f89183762c8a94a5bdcfa09e05cfe413acf0503", size = 43276625, upload-time = "2025-07-18T00:56:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/5a/da/e02544d6997037a4b0d22d8e5f66bc9315c3671371a8b18c79ade1cefe14/pyarrow-21.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6299449adf89df38537837487a4f8d3bd91ec94354fdd2a7d30bc11c48ef6e79", size = 44951890, upload-time = "2025-07-18T00:56:52.568Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4e/519c1bc1876625fe6b71e9a28287c43ec2f20f73c658b9ae1d485c0c206e/pyarrow-21.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:222c39e2c70113543982c6b34f3077962b44fca38c0bd9e68bb6781534425c10", size = 26371006, upload-time = "2025-07-18T00:56:56.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cc/ce4939f4b316457a083dc5718b3982801e8c33f921b3c98e7a93b7c7491f/pyarrow-21.0.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:a7f6524e3747e35f80744537c78e7302cd41deee8baa668d56d55f77d9c464b3", size = 31211248, upload-time = "2025-07-18T00:56:59.7Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c2/7a860931420d73985e2f340f06516b21740c15b28d24a0e99a900bb27d2b/pyarrow-21.0.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:203003786c9fd253ebcafa44b03c06983c9c8d06c3145e37f1b76a1f317aeae1", size = 32676896, upload-time = "2025-07-18T00:57:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/197f989b9a75e59b4ca0db6a13c56f19a0ad8a298c68da9cc28145e0bb97/pyarrow-21.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:3b4d97e297741796fead24867a8dabf86c87e4584ccc03167e4a811f50fdf74d", size = 41067862, upload-time = "2025-07-18T00:57:07.587Z" }, + { url = "https://files.pythonhosted.org/packages/fa/82/6ecfa89487b35aa21accb014b64e0a6b814cc860d5e3170287bf5135c7d8/pyarrow-21.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:898afce396b80fdda05e3086b4256f8677c671f7b1d27a6976fa011d3fd0a86e", size = 42747508, upload-time = "2025-07-18T00:57:13.917Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b7/ba252f399bbf3addc731e8643c05532cf32e74cebb5e32f8f7409bc243cf/pyarrow-21.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:067c66ca29aaedae08218569a114e413b26e742171f526e828e1064fcdec13f4", size = 43345293, upload-time = "2025-07-18T00:57:19.828Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0a/a20819795bd702b9486f536a8eeb70a6aa64046fce32071c19ec8230dbaa/pyarrow-21.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0c4e75d13eb76295a49e0ea056eb18dbd87d81450bfeb8afa19a7e5a75ae2ad7", size = 45060670, upload-time = "2025-07-18T00:57:24.477Z" }, + { url = "https://files.pythonhosted.org/packages/10/15/6b30e77872012bbfe8265d42a01d5b3c17ef0ac0f2fae531ad91b6a6c02e/pyarrow-21.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:cdc4c17afda4dab2a9c0b79148a43a7f4e1094916b3e18d8975bfd6d6d52241f", size = 26227521, upload-time = "2025-07-18T00:57:29.119Z" }, ] [[package]] name = "pyasn1" version = "0.6.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, ] [[package]] @@ -1477,18 +1478,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] [[package]] name = "pycparser" version = "2.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] [[package]] @@ -1501,9 +1502,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350 } +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782 }, + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, ] [[package]] @@ -1513,124 +1514,124 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817 }, - { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357 }, - { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011 }, - { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730 }, - { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178 }, - { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462 }, - { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652 }, - { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306 }, - { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720 }, - { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915 }, - { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884 }, - { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496 }, - { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019 }, - { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584 }, - { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071 }, - { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823 }, - { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792 }, - { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338 }, - { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998 }, - { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200 }, - { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890 }, - { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359 }, - { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883 }, - { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074 }, - { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538 }, - { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909 }, - { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786 }, - { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000 }, - { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996 }, - { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957 }, - { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199 }, - { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296 }, - { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109 }, - { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028 }, - { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044 }, - { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881 }, - { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034 }, - { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187 }, - { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628 }, - { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866 }, - { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894 }, - { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688 }, - { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808 }, - { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580 }, - { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859 }, - { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810 }, - { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498 }, - { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611 }, - { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924 }, - { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196 }, - { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389 }, - { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223 }, - { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473 }, - { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269 }, - { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921 }, - { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 }, - { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 }, - { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 }, - { url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677 }, - { url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735 }, - { url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467 }, - { url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041 }, - { url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503 }, - { url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079 }, - { url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508 }, - { url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693 }, - { url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224 }, - { url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403 }, - { url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331 }, - { url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571 }, - { url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504 }, - { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982 }, - { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412 }, - { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749 }, - { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527 }, - { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225 }, - { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490 }, - { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525 }, - { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446 }, - { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678 }, - { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200 }, - { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123 }, - { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852 }, - { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484 }, - { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896 }, - { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475 }, - { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013 }, - { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715 }, - { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757 }, - { url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034 }, - { url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578 }, - { url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858 }, - { url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498 }, - { url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428 }, - { url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854 }, - { url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859 }, - { url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059 }, - { url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661 }, +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, + { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, + { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, + { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, + { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677, upload-time = "2025-04-23T18:32:27.227Z" }, + { url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735, upload-time = "2025-04-23T18:32:29.019Z" }, + { url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467, upload-time = "2025-04-23T18:32:31.119Z" }, + { url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041, upload-time = "2025-04-23T18:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503, upload-time = "2025-04-23T18:32:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079, upload-time = "2025-04-23T18:32:37.659Z" }, + { url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508, upload-time = "2025-04-23T18:32:39.637Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693, upload-time = "2025-04-23T18:32:41.818Z" }, + { url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224, upload-time = "2025-04-23T18:32:44.033Z" }, + { url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403, upload-time = "2025-04-23T18:32:45.836Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331, upload-time = "2025-04-23T18:32:47.618Z" }, + { url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571, upload-time = "2025-04-23T18:32:49.401Z" }, + { url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504, upload-time = "2025-04-23T18:32:51.287Z" }, + { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, + { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, + { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, + { url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034, upload-time = "2025-04-23T18:33:32.843Z" }, + { url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578, upload-time = "2025-04-23T18:33:34.912Z" }, + { url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858, upload-time = "2025-04-23T18:33:36.933Z" }, + { url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498, upload-time = "2025-04-23T18:33:38.997Z" }, + { url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428, upload-time = "2025-04-23T18:33:41.18Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854, upload-time = "2025-04-23T18:33:43.446Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859, upload-time = "2025-04-23T18:33:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059, upload-time = "2025-04-23T18:33:47.735Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661, upload-time = "2025-04-23T18:33:49.995Z" }, ] [[package]] name = "pygments" version = "2.19.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] [[package]] name = "pyjwt" version = "2.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] [[package]] @@ -1648,9 +1649,9 @@ dependencies = [ { name = "tomlkit" }, { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/58/1f614a84d3295c542e9f6e2c764533eea3f318f4592dc1ea06c797114767/pylint-3.3.8.tar.gz", hash = "sha256:26698de19941363037e2937d3db9ed94fb3303fdadf7d98847875345a8bb6b05", size = 1523947 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/58/1f614a84d3295c542e9f6e2c764533eea3f318f4592dc1ea06c797114767/pylint-3.3.8.tar.gz", hash = "sha256:26698de19941363037e2937d3db9ed94fb3303fdadf7d98847875345a8bb6b05", size = 1523947, upload-time = "2025-08-09T09:12:57.234Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/1a/711e93a7ab6c392e349428ea56e794a3902bb4e0284c1997cff2d7efdbc1/pylint-3.3.8-py3-none-any.whl", hash = "sha256:7ef94aa692a600e82fabdd17102b73fc226758218c97473c7ad67bd4cb905d83", size = 523153 }, + { url = "https://files.pythonhosted.org/packages/2d/1a/711e93a7ab6c392e349428ea56e794a3902bb4e0284c1997cff2d7efdbc1/pylint-3.3.8-py3-none-any.whl", hash = "sha256:7ef94aa692a600e82fabdd17102b73fc226758218c97473c7ad67bd4cb905d83", size = 523153, upload-time = "2025-08-09T09:12:54.836Z" }, ] [[package]] @@ -1661,9 +1662,9 @@ dependencies = [ { name = "cryptography" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/8c/cd89ad05804f8e3c17dea8f178c3f40eeab5694c30e0c9f5bcd49f576fc3/pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b", size = 179937 } +sdist = { url = "https://files.pythonhosted.org/packages/04/8c/cd89ad05804f8e3c17dea8f178c3f40eeab5694c30e0c9f5bcd49f576fc3/pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b", size = 179937, upload-time = "2025-05-17T16:28:31.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/28/2659c02301b9500751f8d42f9a6632e1508aa5120de5e43042b8b30f8d5d/pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab", size = 56771 }, + { url = "https://files.pythonhosted.org/packages/80/28/2659c02301b9500751f8d42f9a6632e1508aa5120de5e43042b8b30f8d5d/pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab", size = 56771, upload-time = "2025-05-17T16:28:29.197Z" }, ] [[package]] @@ -1679,9 +1680,9 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714 } +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474 }, + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, ] [[package]] @@ -1693,9 +1694,9 @@ dependencies = [ { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432 } +sdist = { url = "https://files.pythonhosted.org/packages/18/99/668cade231f434aaa59bbfbf49469068d2ddd945000621d3d165d2e7dd7b/pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2", size = 69432, upload-time = "2025-06-12T10:47:47.684Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644 }, + { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, ] [[package]] @@ -1705,18 +1706,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "pytz" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 } +sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 }, + { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] [[package]] @@ -1724,77 +1725,77 @@ name = "pywin32" version = "311" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432 }, - { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103 }, - { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557 }, - { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031 }, - { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308 }, - { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930 }, - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543 }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040 }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102 }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700 }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700 }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318 }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714 }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800 }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540 }, - { url = "https://files.pythonhosted.org/packages/59/42/b86689aac0cdaee7ae1c58d464b0ff04ca909c19bb6502d4973cdd9f9544/pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b", size = 8760837 }, - { url = "https://files.pythonhosted.org/packages/9f/8a/1403d0353f8c5a2f0829d2b1c4becbf9da2f0a4d040886404fc4a5431e4d/pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91", size = 9590187 }, - { url = "https://files.pythonhosted.org/packages/60/22/e0e8d802f124772cec9c75430b01a212f86f9de7546bda715e54140d5aeb/pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d", size = 8778162 }, + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557, upload-time = "2025-07-14T20:13:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, + { url = "https://files.pythonhosted.org/packages/59/42/b86689aac0cdaee7ae1c58d464b0ff04ca909c19bb6502d4973cdd9f9544/pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b", size = 8760837, upload-time = "2025-07-14T20:12:59.59Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8a/1403d0353f8c5a2f0829d2b1c4becbf9da2f0a4d040886404fc4a5431e4d/pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91", size = 9590187, upload-time = "2025-07-14T20:13:01.419Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/e0e8d802f124772cec9c75430b01a212f86f9de7546bda715e54140d5aeb/pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d", size = 8778162, upload-time = "2025-07-14T20:13:03.544Z" }, ] [[package]] name = "pyyaml" version = "6.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 }, - { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 }, - { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 }, - { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 }, - { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 }, - { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 }, - { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 }, - { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 }, - { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 }, - { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 }, - { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 }, - { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 }, - { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 }, - { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 }, - { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 }, - { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 }, - { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 }, - { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 }, - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, - { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 }, - { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 }, - { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 }, - { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 }, - { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 }, - { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 }, - { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 }, - { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 }, - { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 }, +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, + { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, + { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, + { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, + { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" }, + { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" }, + { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" }, + { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" }, ] [[package]] @@ -1804,85 +1805,85 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "implementation_name == 'pypy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/66/159f38d184f08b5f971b467f87b1ab142ab1320d5200825c824b32b84b66/pyzmq-27.0.2.tar.gz", hash = "sha256:b398dd713b18de89730447347e96a0240225e154db56e35b6bb8447ffdb07798", size = 281440 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/4d/2081cd7e41e340004d2051821efe1d0d67d31bdb5ac33bffc7e628d5f1bd/pyzmq-27.0.2-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:8b32c4636ced87dce0ac3d671e578b3400215efab372f1b4be242e8cf0b11384", size = 1329839 }, - { url = "https://files.pythonhosted.org/packages/ad/f1/1300b7e932671e31accb3512c19b43e6a3e8d08c54ab8b920308e53427ce/pyzmq-27.0.2-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f9528a4b3e24189cb333a9850fddbbafaa81df187297cfbddee50447cdb042cf", size = 906367 }, - { url = "https://files.pythonhosted.org/packages/e6/80/61662db85eb3255a58c1bb59f6d4fc0d31c9c75b9a14983deafab12b2329/pyzmq-27.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b02ba0c0b2b9ebe74688002e6c56c903429924a25630804b9ede1f178aa5a3f", size = 666545 }, - { url = "https://files.pythonhosted.org/packages/5c/6e/49fb9c75b039978cbb1f3657811d8056b0ebe6ecafd78a4457fc6de19799/pyzmq-27.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4dc5c9a6167617251dea0d024d67559795761aabb4b7ea015518be898be076", size = 854219 }, - { url = "https://files.pythonhosted.org/packages/b0/3c/9951b302d221e471b7c659e70f9cb64db5f68fa3b7da45809ec4e6c6ef17/pyzmq-27.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f1151b33aaf3b4fa9da26f4d696e38eebab67d1b43c446184d733c700b3ff8ce", size = 1655103 }, - { url = "https://files.pythonhosted.org/packages/88/ca/d7adea6100fdf7f87f3856db02d2a0a45ce2764b9f60ba08c48c655b762f/pyzmq-27.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4ecfc7999ac44c9ef92b5ae8f0b44fb935297977df54d8756b195a3cd12f38f0", size = 2033712 }, - { url = "https://files.pythonhosted.org/packages/e9/63/b34e601b36ba4864d02ac1460443fc39bf533dedbdeead2a4e0df7dfc8ee/pyzmq-27.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:31c26a5d0b00befcaeeb600d8b15ad09f5604b6f44e2057ec5e521a9e18dcd9a", size = 1891847 }, - { url = "https://files.pythonhosted.org/packages/cf/a2/9479e6af779da44f788d5fcda5f77dff1af988351ef91682b92524eab2db/pyzmq-27.0.2-cp310-cp310-win32.whl", hash = "sha256:25a100d2de2ac0c644ecf4ce0b509a720d12e559c77aff7e7e73aa684f0375bc", size = 567136 }, - { url = "https://files.pythonhosted.org/packages/58/46/e1c2be469781fc56ba092fecb1bb336cedde0fd87d9e1a547aaeb5d1a968/pyzmq-27.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a1acf091f53bb406e9e5e7383e467d1dd1b94488b8415b890917d30111a1fef3", size = 631969 }, - { url = "https://files.pythonhosted.org/packages/d5/8d/d20a62f1f77e3f04633a80bb83df085e4314f0e9404619cc458d0005d6ab/pyzmq-27.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:b38e01f11e9e95f6668dc8a62dccf9483f454fed78a77447507a0e8dcbd19a63", size = 559459 }, - { url = "https://files.pythonhosted.org/packages/42/73/034429ab0f4316bf433eb6c20c3f49d1dc13b2ed4e4d951b283d300a0f35/pyzmq-27.0.2-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:063845960df76599ad4fad69fa4d884b3ba38304272104fdcd7e3af33faeeb1d", size = 1333169 }, - { url = "https://files.pythonhosted.org/packages/35/02/c42b3b526eb03a570c889eea85a5602797f800a50ba8b09ddbf7db568b78/pyzmq-27.0.2-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:845a35fb21b88786aeb38af8b271d41ab0967985410f35411a27eebdc578a076", size = 909176 }, - { url = "https://files.pythonhosted.org/packages/1b/35/a1c0b988fabbdf2dc5fe94b7c2bcfd61e3533e5109297b8e0daf1d7a8d2d/pyzmq-27.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:515d20b5c3c86db95503faa989853a8ab692aab1e5336db011cd6d35626c4cb1", size = 668972 }, - { url = "https://files.pythonhosted.org/packages/a0/63/908ac865da32ceaeecea72adceadad28ca25b23a2ca5ff018e5bff30116f/pyzmq-27.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:862aedec0b0684a5050cdb5ec13c2da96d2f8dffda48657ed35e312a4e31553b", size = 856962 }, - { url = "https://files.pythonhosted.org/packages/2f/5a/90b3cc20b65cdf9391896fcfc15d8db21182eab810b7ea05a2986912fbe2/pyzmq-27.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2cb5bcfc51c7a4fce335d3bc974fd1d6a916abbcdd2b25f6e89d37b8def25f57", size = 1657712 }, - { url = "https://files.pythonhosted.org/packages/c4/3c/32a5a80f9be4759325b8d7b22ce674bb87e586b4c80c6a9d77598b60d6f0/pyzmq-27.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:38ff75b2a36e3a032e9fef29a5871e3e1301a37464e09ba364e3c3193f62982a", size = 2035054 }, - { url = "https://files.pythonhosted.org/packages/13/61/71084fe2ff2d7dc5713f8740d735336e87544845dae1207a8e2e16d9af90/pyzmq-27.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7a5709abe8d23ca158a9d0a18c037f4193f5b6afeb53be37173a41e9fb885792", size = 1894010 }, - { url = "https://files.pythonhosted.org/packages/cb/6b/77169cfb13b696e50112ca496b2ed23c4b7d8860a1ec0ff3e4b9f9926221/pyzmq-27.0.2-cp311-cp311-win32.whl", hash = "sha256:47c5dda2018c35d87be9b83de0890cb92ac0791fd59498847fc4eca6ff56671d", size = 566819 }, - { url = "https://files.pythonhosted.org/packages/37/cd/86c4083e0f811f48f11bc0ddf1e7d13ef37adfd2fd4f78f2445f1cc5dec0/pyzmq-27.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:f54ca3e98f8f4d23e989c7d0edcf9da7a514ff261edaf64d1d8653dd5feb0a8b", size = 633264 }, - { url = "https://files.pythonhosted.org/packages/a0/69/5b8bb6a19a36a569fac02153a9e083738785892636270f5f68a915956aea/pyzmq-27.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:2ef3067cb5b51b090fb853f423ad7ed63836ec154374282780a62eb866bf5768", size = 559316 }, - { url = "https://files.pythonhosted.org/packages/68/69/b3a729e7b03e412bee2b1823ab8d22e20a92593634f664afd04c6c9d9ac0/pyzmq-27.0.2-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:5da05e3c22c95e23bfc4afeee6ff7d4be9ff2233ad6cb171a0e8257cd46b169a", size = 1305910 }, - { url = "https://files.pythonhosted.org/packages/15/b7/f6a6a285193d489b223c340b38ee03a673467cb54914da21c3d7849f1b10/pyzmq-27.0.2-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4e4520577971d01d47e2559bb3175fce1be9103b18621bf0b241abe0a933d040", size = 895507 }, - { url = "https://files.pythonhosted.org/packages/17/e6/c4ed2da5ef9182cde1b1f5d0051a986e76339d71720ec1a00be0b49275ad/pyzmq-27.0.2-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d7de7bf73165b90bd25a8668659ccb134dd28449116bf3c7e9bab5cf8a8ec9", size = 652670 }, - { url = "https://files.pythonhosted.org/packages/0e/66/d781ab0636570d32c745c4e389b1c6b713115905cca69ab6233508622edd/pyzmq-27.0.2-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340e7cddc32f147c6c00d116a3f284ab07ee63dbd26c52be13b590520434533c", size = 840581 }, - { url = "https://files.pythonhosted.org/packages/a6/df/f24790caf565d72544f5c8d8500960b9562c1dc848d6f22f3c7e122e73d4/pyzmq-27.0.2-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ba95693f9df8bb4a9826464fb0fe89033936f35fd4a8ff1edff09a473570afa0", size = 1641931 }, - { url = "https://files.pythonhosted.org/packages/65/65/77d27b19fc5e845367f9100db90b9fce924f611b14770db480615944c9c9/pyzmq-27.0.2-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:ca42a6ce2d697537da34f77a1960d21476c6a4af3e539eddb2b114c3cf65a78c", size = 2021226 }, - { url = "https://files.pythonhosted.org/packages/5b/65/1ed14421ba27a4207fa694772003a311d1142b7f543179e4d1099b7eb746/pyzmq-27.0.2-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3e44e665d78a07214b2772ccbd4b9bcc6d848d7895f1b2d7653f047b6318a4f6", size = 1878047 }, - { url = "https://files.pythonhosted.org/packages/dd/dc/e578549b89b40dc78a387ec471c2a360766690c0a045cd8d1877d401012d/pyzmq-27.0.2-cp312-abi3-win32.whl", hash = "sha256:272d772d116615397d2be2b1417b3b8c8bc8671f93728c2f2c25002a4530e8f6", size = 558757 }, - { url = "https://files.pythonhosted.org/packages/b5/89/06600980aefcc535c758414da969f37a5194ea4cdb73b745223f6af3acfb/pyzmq-27.0.2-cp312-abi3-win_amd64.whl", hash = "sha256:734be4f44efba0aa69bf5f015ed13eb69ff29bf0d17ea1e21588b095a3147b8e", size = 619281 }, - { url = "https://files.pythonhosted.org/packages/30/84/df8a5c089552d17c9941d1aea4314b606edf1b1622361dae89aacedc6467/pyzmq-27.0.2-cp312-abi3-win_arm64.whl", hash = "sha256:41f0bd56d9279392810950feb2785a419c2920bbf007fdaaa7f4a07332ae492d", size = 552680 }, - { url = "https://files.pythonhosted.org/packages/b4/7b/b79e976508517ab80dc800f7021ef1fb602a6d55e4caa2d47fb3dca5d8b6/pyzmq-27.0.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:7f01118133427cd7f34ee133b5098e2af5f70303fa7519785c007bca5aa6f96a", size = 1122259 }, - { url = "https://files.pythonhosted.org/packages/2b/1c/777217b9940ebcb7e71c924184ca5f31e410580a58d9fd93798589f0d31c/pyzmq-27.0.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:e4b860edf6379a7234ccbb19b4ed2c57e3ff569c3414fadfb49ae72b61a8ef07", size = 1156113 }, - { url = "https://files.pythonhosted.org/packages/59/7d/654657a4c6435f41538182e71b61eac386a789a2bbb6f30171915253a9a7/pyzmq-27.0.2-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:cb77923ea163156da14295c941930bd525df0d29c96c1ec2fe3c3806b1e17cb3", size = 1341437 }, - { url = "https://files.pythonhosted.org/packages/20/a0/5ed7710037f9c096017adc748bcb1698674a2d297f8b9422d38816f7b56a/pyzmq-27.0.2-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:61678b7407b04df8f9423f188156355dc94d0fb52d360ae79d02ed7e0d431eea", size = 897888 }, - { url = "https://files.pythonhosted.org/packages/2c/8a/6e4699a60931c17e7406641d201d7f2c121e2a38979bc83226a6d8f1ba32/pyzmq-27.0.2-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3c824b70925963bdc8e39a642672c15ffaa67e7d4b491f64662dd56d6271263", size = 660727 }, - { url = "https://files.pythonhosted.org/packages/7b/d8/d761e438c186451bd89ce63a665cde5690c084b61cd8f5d7b51e966e875a/pyzmq-27.0.2-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4833e02fcf2751975457be1dfa2f744d4d09901a8cc106acaa519d868232175", size = 848136 }, - { url = "https://files.pythonhosted.org/packages/43/f1/a0f31684efdf3eb92f46b7dd2117e752208115e89d278f8ca5f413c5bb85/pyzmq-27.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b18045668d09cf0faa44918af2a67f0dbbef738c96f61c2f1b975b1ddb92ccfc", size = 1650402 }, - { url = "https://files.pythonhosted.org/packages/41/fd/0d7f2a1732812df02c85002770da4a7864c79b210084bcdab01ea57e8d92/pyzmq-27.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:bbbb7e2f3ac5a22901324e7b086f398b8e16d343879a77b15ca3312e8cd8e6d5", size = 2024587 }, - { url = "https://files.pythonhosted.org/packages/f1/73/358be69e279a382dd09e46dda29df8446365cddee4f79ef214e71e5b2b5a/pyzmq-27.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b751914a73604d40d88a061bab042a11d4511b3ddbb7624cd83c39c8a498564c", size = 1885493 }, - { url = "https://files.pythonhosted.org/packages/c5/7b/e9951ad53b3dfed8cfb4c2cfd6e0097c9b454e5c0d0e6df5f2b60d7c8c3d/pyzmq-27.0.2-cp313-cp313t-win32.whl", hash = "sha256:3e8f833dd82af11db5321c414638045c70f61009f72dd61c88db4a713c1fb1d2", size = 574934 }, - { url = "https://files.pythonhosted.org/packages/55/33/1a7fc3a92f2124a63e6e2a6afa0af471a5c0c713e776b476d4eda5111b13/pyzmq-27.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5b45153cb8eadcab14139970643a84f7a7b08dda541fbc1f6f4855c49334b549", size = 640932 }, - { url = "https://files.pythonhosted.org/packages/2a/52/2598a94ac251a7c83f3887866225eea1952b0d4463a68df5032eb00ff052/pyzmq-27.0.2-cp313-cp313t-win_arm64.whl", hash = "sha256:86898f5c9730df23427c1ee0097d8aa41aa5f89539a79e48cd0d2c22d059f1b7", size = 561315 }, - { url = "https://files.pythonhosted.org/packages/42/7d/10ef02ea36590b29d48ef88eb0831f0af3eb240cccca2752556faec55f59/pyzmq-27.0.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d2b4b261dce10762be5c116b6ad1f267a9429765b493c454f049f33791dd8b8a", size = 1341463 }, - { url = "https://files.pythonhosted.org/packages/94/36/115d18dade9a3d4d3d08dd8bfe5459561b8e02815f99df040555fdd7768e/pyzmq-27.0.2-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4e4d88b6cff156fed468903006b24bbd85322612f9c2f7b96e72d5016fd3f543", size = 897840 }, - { url = "https://files.pythonhosted.org/packages/39/66/083b37839b95c386a95f1537bb41bdbf0c002b7c55b75ee737949cecb11f/pyzmq-27.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8426c0ebbc11ed8416a6e9409c194142d677c2c5c688595f2743664e356d9e9b", size = 660704 }, - { url = "https://files.pythonhosted.org/packages/76/5a/196ab46e549ba35bf3268f575e10cfac0dc86b78dcaa7a3e36407ecda752/pyzmq-27.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565bee96a155fe6452caed5fb5f60c9862038e6b51a59f4f632562081cdb4004", size = 848037 }, - { url = "https://files.pythonhosted.org/packages/70/ea/a27b9eb44b2e615a9ecb8510ebb023cc1d2d251181e4a1e50366bfbf94d6/pyzmq-27.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5de735c745ca5cefe9c2d1547d8f28cfe1b1926aecb7483ab1102fd0a746c093", size = 1650278 }, - { url = "https://files.pythonhosted.org/packages/62/ac/3e9af036bfaf718ab5e69ded8f6332da392c5450ad43e8e3ca66797f145a/pyzmq-27.0.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ea4f498f8115fd90d7bf03a3e83ae3e9898e43362f8e8e8faec93597206e15cc", size = 2024504 }, - { url = "https://files.pythonhosted.org/packages/ae/e9/3202d31788df8ebaa176b23d846335eb9c768d8b43c0506bbd6265ad36a0/pyzmq-27.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d00e81cb0afd672915257a3927124ee2ad117ace3c256d39cd97ca3f190152ad", size = 1885381 }, - { url = "https://files.pythonhosted.org/packages/4b/ed/42de80b7ab4e8fcf13376f81206cf8041740672ac1fd2e1c598d63f595bf/pyzmq-27.0.2-cp314-cp314t-win32.whl", hash = "sha256:0f6e9b00d81b58f859fffc112365d50413954e02aefe36c5b4c8fb4af79f8cc3", size = 587526 }, - { url = "https://files.pythonhosted.org/packages/ed/c8/8f3c72d6f0bfbf090aa5e283576073ca5c59839b85a5cc8c66ddb9b59801/pyzmq-27.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:2e73cf3b127a437fef4100eb3ac2ebe6b49e655bb721329f667f59eca0a26221", size = 661368 }, - { url = "https://files.pythonhosted.org/packages/69/a4/7ee652ea1c77d872f5d99ed937fa8bbd1f6f4b7a39a6d3a0076c286e0c3e/pyzmq-27.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:4108785f2e5ac865d06f678a07a1901e3465611356df21a545eeea8b45f56265", size = 574901 }, - { url = "https://files.pythonhosted.org/packages/c8/57/a4aff0dbb29001cebf94848a980ac36bea8e1cb65ac0f72ff03e484df208/pyzmq-27.0.2-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:aa9c1c208c263b84386ac25bed6af5672397dc3c232638114fc09bca5c7addf9", size = 1330291 }, - { url = "https://files.pythonhosted.org/packages/e1/a6/8519ed48b5bdd72e1a3946a76987afb6659a355180cd15e51cea6afe1983/pyzmq-27.0.2-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:795c4884cfe7ea59f2b67d82b417e899afab889d332bfda13b02f8e0c155b2e4", size = 906596 }, - { url = "https://files.pythonhosted.org/packages/3c/19/cb6f463d6454079a566160d10cf175c3c59274b1378104d2b01a5a1ca560/pyzmq-27.0.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47eb65bb25478358ba3113dd9a08344f616f417ad3ffcbb190cd874fae72b1b1", size = 863605 }, - { url = "https://files.pythonhosted.org/packages/9d/37/afe25d5f9b91b7422067bb7cd3f75691d78baa0c2c2f76bd0643389ac600/pyzmq-27.0.2-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6fc24f00293f10aff04d55ca37029b280474c91f4de2cad5e911e5e10d733b7", size = 666777 }, - { url = "https://files.pythonhosted.org/packages/15/4a/97b0b59a7eacfd9e61c40abaa9c7e2587bbb39671816a9fb0c5dc32d87bc/pyzmq-27.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:58d4cc9b6b768478adfc40a5cbee545303db8dbc81ba688474e0f499cc581028", size = 1655404 }, - { url = "https://files.pythonhosted.org/packages/27/db/bcf03bea232839ea749562f76bda3807b16a25c68fdd153339a56699bb1d/pyzmq-27.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cea2f26c5972796e02b222968a21a378d09eb4ff590eb3c5fafa8913f8c2bdf5", size = 2034051 }, - { url = "https://files.pythonhosted.org/packages/57/3f/9cc291101ee766ab41bae25654af84c11d259f27d73b1ed985256dae8a16/pyzmq-27.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a0621ec020c49fc1b6e31304f1a820900d54e7d9afa03ea1634264bf9387519e", size = 1892080 }, - { url = "https://files.pythonhosted.org/packages/16/52/51d4c56c553e7368abc2d010a783092c68a1888045f899eabcfd3bc345f3/pyzmq-27.0.2-cp39-cp39-win32.whl", hash = "sha256:1326500792a9cb0992db06bbaf5d0098459133868932b81a6e90d45c39eca99d", size = 567463 }, - { url = "https://files.pythonhosted.org/packages/93/20/3b0ac853b5bbe1fd05e1898ea60335f9d402312328fa17514a876418ab11/pyzmq-27.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:5ee9560cb1e3094ef01fc071b361121a57ebb8d4232912b6607a6d7d2d0a97b4", size = 632200 }, - { url = "https://files.pythonhosted.org/packages/b0/14/78b192a7864a38c71e30c3fd668eff949b44db13c1906035e6095fecd00d/pyzmq-27.0.2-cp39-cp39-win_arm64.whl", hash = "sha256:85e3c6fb0d25ea046ebcfdc2bcb9683d663dc0280645c79a616ff5077962a15b", size = 559638 }, - { url = "https://files.pythonhosted.org/packages/19/d7/e388e80107b7c438c9698ce59c2a3b950021cd4ab3fe641485e4ed6b0960/pyzmq-27.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d67a0960803a37b60f51b460c58444bc7033a804c662f5735172e21e74ee4902", size = 836008 }, - { url = "https://files.pythonhosted.org/packages/65/ef/58d3eb85f1b67a16e22adb07d084f975a7b9641463d18e27230550bb436a/pyzmq-27.0.2-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:dd4d3e6a567ffd0d232cfc667c49d0852d0ee7481458a2a1593b9b1bc5acba88", size = 799932 }, - { url = "https://files.pythonhosted.org/packages/3c/63/66b9f6db19ee8c86105ffd4475a4f5d93cdd62b1edcb1e894d971df0728c/pyzmq-27.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e558be423631704803bc6a642e2caa96083df759e25fe6eb01f2d28725f80bd", size = 567458 }, - { url = "https://files.pythonhosted.org/packages/10/af/d92207fe8b6e3d9f588d0591219a86dd7b4ed27bb3e825c1d9cf48467fc0/pyzmq-27.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4c20ba8389f495c7b4f6b896bb1ca1e109a157d4f189267a902079699aaf787", size = 747087 }, - { url = "https://files.pythonhosted.org/packages/82/e9/d9f8b4b191c6733e31de28974d608a2475a6598136ac901a8c5b67c11432/pyzmq-27.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c5be232f7219414ff672ff7ab8c5a7e8632177735186d8a42b57b491fafdd64e", size = 544641 }, - { url = "https://files.pythonhosted.org/packages/c7/60/027d0032a1e3b1aabcef0e309b9ff8a4099bdd5a60ab38b36a676ff2bd7b/pyzmq-27.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e297784aea724294fe95e442e39a4376c2f08aa4fae4161c669f047051e31b02", size = 836007 }, - { url = "https://files.pythonhosted.org/packages/25/20/2ed1e6168aaea323df9bb2c451309291f53ba3af372ffc16edd4ce15b9e5/pyzmq-27.0.2-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e3659a79ded9745bc9c2aef5b444ac8805606e7bc50d2d2eb16dc3ab5483d91f", size = 799932 }, - { url = "https://files.pythonhosted.org/packages/fd/25/5c147307de546b502c9373688ce5b25dc22288d23a1ebebe5d587bf77610/pyzmq-27.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3dba49ff037d02373a9306b58d6c1e0be031438f822044e8767afccfdac4c6b", size = 567459 }, - { url = "https://files.pythonhosted.org/packages/71/06/0dc56ffc615c8095cd089c9b98ce5c733e990f09ce4e8eea4aaf1041a532/pyzmq-27.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de84e1694f9507b29e7b263453a2255a73e3d099d258db0f14539bad258abe41", size = 747088 }, - { url = "https://files.pythonhosted.org/packages/06/f6/4a50187e023b8848edd3f0a8e197b1a7fb08d261d8c60aae7cb6c3d71612/pyzmq-27.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f0944d65ba2b872b9fcece08411d6347f15a874c775b4c3baae7f278550da0fb", size = 544639 }, - { url = "https://files.pythonhosted.org/packages/c6/ce/36c82244b111c408c99037b61eedcaac9a3f17300c3f857cf175a7b8832f/pyzmq-27.0.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:400f34321e3bd89b1165b91ea6b18ad26042ba9ad0dfed8b35049e2e24eeab9b", size = 836001 }, - { url = "https://files.pythonhosted.org/packages/6f/dc/4aa8e518f1c0d0ee7fb98cf54cf3854e471815040062d26d948df638d9bc/pyzmq-27.0.2-pp39-pypy39_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9cbad4ef12e4c15c94d2c24ecd15a8ed56bf091c62f121a2b0c618ddd4b7402b", size = 799927 }, - { url = "https://files.pythonhosted.org/packages/43/4a/cca781133ce015898b7c4ad63240d05af5c54e8736d099c95b0a6c9aaca9/pyzmq-27.0.2-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6b2b74aac3392b8cf508ccb68c980a8555298cd378434a2d065d6ce0f4211dff", size = 758427 }, - { url = "https://files.pythonhosted.org/packages/e3/66/772bf1e3aed8b20587f2e0122bc929711d44ddd27dbbd6eb308a5badd0c3/pyzmq-27.0.2-pp39-pypy39_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7db5db88c24cf9253065d69229a148ff60821e5d6f8ff72579b1f80f8f348bab", size = 567453 }, - { url = "https://files.pythonhosted.org/packages/e0/fb/fdc3dd8ec46f5226b4cde299839f10c625886bd18adbeaa8a59ffe104356/pyzmq-27.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8ffe40c216c41756ca05188c3e24a23142334b304f7aebd75c24210385e35573", size = 544636 }, +sdist = { url = "https://files.pythonhosted.org/packages/f8/66/159f38d184f08b5f971b467f87b1ab142ab1320d5200825c824b32b84b66/pyzmq-27.0.2.tar.gz", hash = "sha256:b398dd713b18de89730447347e96a0240225e154db56e35b6bb8447ffdb07798", size = 281440, upload-time = "2025-08-21T04:23:26.334Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4d/2081cd7e41e340004d2051821efe1d0d67d31bdb5ac33bffc7e628d5f1bd/pyzmq-27.0.2-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:8b32c4636ced87dce0ac3d671e578b3400215efab372f1b4be242e8cf0b11384", size = 1329839, upload-time = "2025-08-21T04:20:55.8Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f1/1300b7e932671e31accb3512c19b43e6a3e8d08c54ab8b920308e53427ce/pyzmq-27.0.2-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f9528a4b3e24189cb333a9850fddbbafaa81df187297cfbddee50447cdb042cf", size = 906367, upload-time = "2025-08-21T04:20:58.476Z" }, + { url = "https://files.pythonhosted.org/packages/e6/80/61662db85eb3255a58c1bb59f6d4fc0d31c9c75b9a14983deafab12b2329/pyzmq-27.0.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b02ba0c0b2b9ebe74688002e6c56c903429924a25630804b9ede1f178aa5a3f", size = 666545, upload-time = "2025-08-21T04:20:59.775Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6e/49fb9c75b039978cbb1f3657811d8056b0ebe6ecafd78a4457fc6de19799/pyzmq-27.0.2-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4dc5c9a6167617251dea0d024d67559795761aabb4b7ea015518be898be076", size = 854219, upload-time = "2025-08-21T04:21:01.807Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3c/9951b302d221e471b7c659e70f9cb64db5f68fa3b7da45809ec4e6c6ef17/pyzmq-27.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f1151b33aaf3b4fa9da26f4d696e38eebab67d1b43c446184d733c700b3ff8ce", size = 1655103, upload-time = "2025-08-21T04:21:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/88/ca/d7adea6100fdf7f87f3856db02d2a0a45ce2764b9f60ba08c48c655b762f/pyzmq-27.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4ecfc7999ac44c9ef92b5ae8f0b44fb935297977df54d8756b195a3cd12f38f0", size = 2033712, upload-time = "2025-08-21T04:21:05.121Z" }, + { url = "https://files.pythonhosted.org/packages/e9/63/b34e601b36ba4864d02ac1460443fc39bf533dedbdeead2a4e0df7dfc8ee/pyzmq-27.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:31c26a5d0b00befcaeeb600d8b15ad09f5604b6f44e2057ec5e521a9e18dcd9a", size = 1891847, upload-time = "2025-08-21T04:21:06.586Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a2/9479e6af779da44f788d5fcda5f77dff1af988351ef91682b92524eab2db/pyzmq-27.0.2-cp310-cp310-win32.whl", hash = "sha256:25a100d2de2ac0c644ecf4ce0b509a720d12e559c77aff7e7e73aa684f0375bc", size = 567136, upload-time = "2025-08-21T04:21:07.885Z" }, + { url = "https://files.pythonhosted.org/packages/58/46/e1c2be469781fc56ba092fecb1bb336cedde0fd87d9e1a547aaeb5d1a968/pyzmq-27.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a1acf091f53bb406e9e5e7383e467d1dd1b94488b8415b890917d30111a1fef3", size = 631969, upload-time = "2025-08-21T04:21:09.5Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/d20a62f1f77e3f04633a80bb83df085e4314f0e9404619cc458d0005d6ab/pyzmq-27.0.2-cp310-cp310-win_arm64.whl", hash = "sha256:b38e01f11e9e95f6668dc8a62dccf9483f454fed78a77447507a0e8dcbd19a63", size = 559459, upload-time = "2025-08-21T04:21:11.208Z" }, + { url = "https://files.pythonhosted.org/packages/42/73/034429ab0f4316bf433eb6c20c3f49d1dc13b2ed4e4d951b283d300a0f35/pyzmq-27.0.2-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:063845960df76599ad4fad69fa4d884b3ba38304272104fdcd7e3af33faeeb1d", size = 1333169, upload-time = "2025-08-21T04:21:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/35/02/c42b3b526eb03a570c889eea85a5602797f800a50ba8b09ddbf7db568b78/pyzmq-27.0.2-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:845a35fb21b88786aeb38af8b271d41ab0967985410f35411a27eebdc578a076", size = 909176, upload-time = "2025-08-21T04:21:13.835Z" }, + { url = "https://files.pythonhosted.org/packages/1b/35/a1c0b988fabbdf2dc5fe94b7c2bcfd61e3533e5109297b8e0daf1d7a8d2d/pyzmq-27.0.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:515d20b5c3c86db95503faa989853a8ab692aab1e5336db011cd6d35626c4cb1", size = 668972, upload-time = "2025-08-21T04:21:15.315Z" }, + { url = "https://files.pythonhosted.org/packages/a0/63/908ac865da32ceaeecea72adceadad28ca25b23a2ca5ff018e5bff30116f/pyzmq-27.0.2-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:862aedec0b0684a5050cdb5ec13c2da96d2f8dffda48657ed35e312a4e31553b", size = 856962, upload-time = "2025-08-21T04:21:16.652Z" }, + { url = "https://files.pythonhosted.org/packages/2f/5a/90b3cc20b65cdf9391896fcfc15d8db21182eab810b7ea05a2986912fbe2/pyzmq-27.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2cb5bcfc51c7a4fce335d3bc974fd1d6a916abbcdd2b25f6e89d37b8def25f57", size = 1657712, upload-time = "2025-08-21T04:21:18.666Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3c/32a5a80f9be4759325b8d7b22ce674bb87e586b4c80c6a9d77598b60d6f0/pyzmq-27.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:38ff75b2a36e3a032e9fef29a5871e3e1301a37464e09ba364e3c3193f62982a", size = 2035054, upload-time = "2025-08-21T04:21:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/13/61/71084fe2ff2d7dc5713f8740d735336e87544845dae1207a8e2e16d9af90/pyzmq-27.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7a5709abe8d23ca158a9d0a18c037f4193f5b6afeb53be37173a41e9fb885792", size = 1894010, upload-time = "2025-08-21T04:21:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/cb/6b/77169cfb13b696e50112ca496b2ed23c4b7d8860a1ec0ff3e4b9f9926221/pyzmq-27.0.2-cp311-cp311-win32.whl", hash = "sha256:47c5dda2018c35d87be9b83de0890cb92ac0791fd59498847fc4eca6ff56671d", size = 566819, upload-time = "2025-08-21T04:21:23.31Z" }, + { url = "https://files.pythonhosted.org/packages/37/cd/86c4083e0f811f48f11bc0ddf1e7d13ef37adfd2fd4f78f2445f1cc5dec0/pyzmq-27.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:f54ca3e98f8f4d23e989c7d0edcf9da7a514ff261edaf64d1d8653dd5feb0a8b", size = 633264, upload-time = "2025-08-21T04:21:24.761Z" }, + { url = "https://files.pythonhosted.org/packages/a0/69/5b8bb6a19a36a569fac02153a9e083738785892636270f5f68a915956aea/pyzmq-27.0.2-cp311-cp311-win_arm64.whl", hash = "sha256:2ef3067cb5b51b090fb853f423ad7ed63836ec154374282780a62eb866bf5768", size = 559316, upload-time = "2025-08-21T04:21:26.1Z" }, + { url = "https://files.pythonhosted.org/packages/68/69/b3a729e7b03e412bee2b1823ab8d22e20a92593634f664afd04c6c9d9ac0/pyzmq-27.0.2-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:5da05e3c22c95e23bfc4afeee6ff7d4be9ff2233ad6cb171a0e8257cd46b169a", size = 1305910, upload-time = "2025-08-21T04:21:27.609Z" }, + { url = "https://files.pythonhosted.org/packages/15/b7/f6a6a285193d489b223c340b38ee03a673467cb54914da21c3d7849f1b10/pyzmq-27.0.2-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4e4520577971d01d47e2559bb3175fce1be9103b18621bf0b241abe0a933d040", size = 895507, upload-time = "2025-08-21T04:21:29.005Z" }, + { url = "https://files.pythonhosted.org/packages/17/e6/c4ed2da5ef9182cde1b1f5d0051a986e76339d71720ec1a00be0b49275ad/pyzmq-27.0.2-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d7de7bf73165b90bd25a8668659ccb134dd28449116bf3c7e9bab5cf8a8ec9", size = 652670, upload-time = "2025-08-21T04:21:30.71Z" }, + { url = "https://files.pythonhosted.org/packages/0e/66/d781ab0636570d32c745c4e389b1c6b713115905cca69ab6233508622edd/pyzmq-27.0.2-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340e7cddc32f147c6c00d116a3f284ab07ee63dbd26c52be13b590520434533c", size = 840581, upload-time = "2025-08-21T04:21:32.008Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/f24790caf565d72544f5c8d8500960b9562c1dc848d6f22f3c7e122e73d4/pyzmq-27.0.2-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ba95693f9df8bb4a9826464fb0fe89033936f35fd4a8ff1edff09a473570afa0", size = 1641931, upload-time = "2025-08-21T04:21:33.371Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/77d27b19fc5e845367f9100db90b9fce924f611b14770db480615944c9c9/pyzmq-27.0.2-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:ca42a6ce2d697537da34f77a1960d21476c6a4af3e539eddb2b114c3cf65a78c", size = 2021226, upload-time = "2025-08-21T04:21:35.301Z" }, + { url = "https://files.pythonhosted.org/packages/5b/65/1ed14421ba27a4207fa694772003a311d1142b7f543179e4d1099b7eb746/pyzmq-27.0.2-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3e44e665d78a07214b2772ccbd4b9bcc6d848d7895f1b2d7653f047b6318a4f6", size = 1878047, upload-time = "2025-08-21T04:21:36.749Z" }, + { url = "https://files.pythonhosted.org/packages/dd/dc/e578549b89b40dc78a387ec471c2a360766690c0a045cd8d1877d401012d/pyzmq-27.0.2-cp312-abi3-win32.whl", hash = "sha256:272d772d116615397d2be2b1417b3b8c8bc8671f93728c2f2c25002a4530e8f6", size = 558757, upload-time = "2025-08-21T04:21:38.2Z" }, + { url = "https://files.pythonhosted.org/packages/b5/89/06600980aefcc535c758414da969f37a5194ea4cdb73b745223f6af3acfb/pyzmq-27.0.2-cp312-abi3-win_amd64.whl", hash = "sha256:734be4f44efba0aa69bf5f015ed13eb69ff29bf0d17ea1e21588b095a3147b8e", size = 619281, upload-time = "2025-08-21T04:21:39.909Z" }, + { url = "https://files.pythonhosted.org/packages/30/84/df8a5c089552d17c9941d1aea4314b606edf1b1622361dae89aacedc6467/pyzmq-27.0.2-cp312-abi3-win_arm64.whl", hash = "sha256:41f0bd56d9279392810950feb2785a419c2920bbf007fdaaa7f4a07332ae492d", size = 552680, upload-time = "2025-08-21T04:21:41.571Z" }, + { url = "https://files.pythonhosted.org/packages/b4/7b/b79e976508517ab80dc800f7021ef1fb602a6d55e4caa2d47fb3dca5d8b6/pyzmq-27.0.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:7f01118133427cd7f34ee133b5098e2af5f70303fa7519785c007bca5aa6f96a", size = 1122259, upload-time = "2025-08-21T04:21:43.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/1c/777217b9940ebcb7e71c924184ca5f31e410580a58d9fd93798589f0d31c/pyzmq-27.0.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:e4b860edf6379a7234ccbb19b4ed2c57e3ff569c3414fadfb49ae72b61a8ef07", size = 1156113, upload-time = "2025-08-21T04:21:44.566Z" }, + { url = "https://files.pythonhosted.org/packages/59/7d/654657a4c6435f41538182e71b61eac386a789a2bbb6f30171915253a9a7/pyzmq-27.0.2-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:cb77923ea163156da14295c941930bd525df0d29c96c1ec2fe3c3806b1e17cb3", size = 1341437, upload-time = "2025-08-21T04:21:46.019Z" }, + { url = "https://files.pythonhosted.org/packages/20/a0/5ed7710037f9c096017adc748bcb1698674a2d297f8b9422d38816f7b56a/pyzmq-27.0.2-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:61678b7407b04df8f9423f188156355dc94d0fb52d360ae79d02ed7e0d431eea", size = 897888, upload-time = "2025-08-21T04:21:47.362Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/6e4699a60931c17e7406641d201d7f2c121e2a38979bc83226a6d8f1ba32/pyzmq-27.0.2-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3c824b70925963bdc8e39a642672c15ffaa67e7d4b491f64662dd56d6271263", size = 660727, upload-time = "2025-08-21T04:21:48.734Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/d761e438c186451bd89ce63a665cde5690c084b61cd8f5d7b51e966e875a/pyzmq-27.0.2-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4833e02fcf2751975457be1dfa2f744d4d09901a8cc106acaa519d868232175", size = 848136, upload-time = "2025-08-21T04:21:50.416Z" }, + { url = "https://files.pythonhosted.org/packages/43/f1/a0f31684efdf3eb92f46b7dd2117e752208115e89d278f8ca5f413c5bb85/pyzmq-27.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b18045668d09cf0faa44918af2a67f0dbbef738c96f61c2f1b975b1ddb92ccfc", size = 1650402, upload-time = "2025-08-21T04:21:52.235Z" }, + { url = "https://files.pythonhosted.org/packages/41/fd/0d7f2a1732812df02c85002770da4a7864c79b210084bcdab01ea57e8d92/pyzmq-27.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:bbbb7e2f3ac5a22901324e7b086f398b8e16d343879a77b15ca3312e8cd8e6d5", size = 2024587, upload-time = "2025-08-21T04:21:54.07Z" }, + { url = "https://files.pythonhosted.org/packages/f1/73/358be69e279a382dd09e46dda29df8446365cddee4f79ef214e71e5b2b5a/pyzmq-27.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b751914a73604d40d88a061bab042a11d4511b3ddbb7624cd83c39c8a498564c", size = 1885493, upload-time = "2025-08-21T04:21:55.588Z" }, + { url = "https://files.pythonhosted.org/packages/c5/7b/e9951ad53b3dfed8cfb4c2cfd6e0097c9b454e5c0d0e6df5f2b60d7c8c3d/pyzmq-27.0.2-cp313-cp313t-win32.whl", hash = "sha256:3e8f833dd82af11db5321c414638045c70f61009f72dd61c88db4a713c1fb1d2", size = 574934, upload-time = "2025-08-21T04:21:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/1a7fc3a92f2124a63e6e2a6afa0af471a5c0c713e776b476d4eda5111b13/pyzmq-27.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5b45153cb8eadcab14139970643a84f7a7b08dda541fbc1f6f4855c49334b549", size = 640932, upload-time = "2025-08-21T04:21:59.527Z" }, + { url = "https://files.pythonhosted.org/packages/2a/52/2598a94ac251a7c83f3887866225eea1952b0d4463a68df5032eb00ff052/pyzmq-27.0.2-cp313-cp313t-win_arm64.whl", hash = "sha256:86898f5c9730df23427c1ee0097d8aa41aa5f89539a79e48cd0d2c22d059f1b7", size = 561315, upload-time = "2025-08-21T04:22:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/10ef02ea36590b29d48ef88eb0831f0af3eb240cccca2752556faec55f59/pyzmq-27.0.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d2b4b261dce10762be5c116b6ad1f267a9429765b493c454f049f33791dd8b8a", size = 1341463, upload-time = "2025-08-21T04:22:02.712Z" }, + { url = "https://files.pythonhosted.org/packages/94/36/115d18dade9a3d4d3d08dd8bfe5459561b8e02815f99df040555fdd7768e/pyzmq-27.0.2-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4e4d88b6cff156fed468903006b24bbd85322612f9c2f7b96e72d5016fd3f543", size = 897840, upload-time = "2025-08-21T04:22:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/39/66/083b37839b95c386a95f1537bb41bdbf0c002b7c55b75ee737949cecb11f/pyzmq-27.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8426c0ebbc11ed8416a6e9409c194142d677c2c5c688595f2743664e356d9e9b", size = 660704, upload-time = "2025-08-21T04:22:06.389Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/196ab46e549ba35bf3268f575e10cfac0dc86b78dcaa7a3e36407ecda752/pyzmq-27.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565bee96a155fe6452caed5fb5f60c9862038e6b51a59f4f632562081cdb4004", size = 848037, upload-time = "2025-08-21T04:22:07.817Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/a27b9eb44b2e615a9ecb8510ebb023cc1d2d251181e4a1e50366bfbf94d6/pyzmq-27.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5de735c745ca5cefe9c2d1547d8f28cfe1b1926aecb7483ab1102fd0a746c093", size = 1650278, upload-time = "2025-08-21T04:22:09.269Z" }, + { url = "https://files.pythonhosted.org/packages/62/ac/3e9af036bfaf718ab5e69ded8f6332da392c5450ad43e8e3ca66797f145a/pyzmq-27.0.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ea4f498f8115fd90d7bf03a3e83ae3e9898e43362f8e8e8faec93597206e15cc", size = 2024504, upload-time = "2025-08-21T04:22:10.778Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e9/3202d31788df8ebaa176b23d846335eb9c768d8b43c0506bbd6265ad36a0/pyzmq-27.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d00e81cb0afd672915257a3927124ee2ad117ace3c256d39cd97ca3f190152ad", size = 1885381, upload-time = "2025-08-21T04:22:12.718Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ed/42de80b7ab4e8fcf13376f81206cf8041740672ac1fd2e1c598d63f595bf/pyzmq-27.0.2-cp314-cp314t-win32.whl", hash = "sha256:0f6e9b00d81b58f859fffc112365d50413954e02aefe36c5b4c8fb4af79f8cc3", size = 587526, upload-time = "2025-08-21T04:22:14.18Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c8/8f3c72d6f0bfbf090aa5e283576073ca5c59839b85a5cc8c66ddb9b59801/pyzmq-27.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:2e73cf3b127a437fef4100eb3ac2ebe6b49e655bb721329f667f59eca0a26221", size = 661368, upload-time = "2025-08-21T04:22:15.677Z" }, + { url = "https://files.pythonhosted.org/packages/69/a4/7ee652ea1c77d872f5d99ed937fa8bbd1f6f4b7a39a6d3a0076c286e0c3e/pyzmq-27.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:4108785f2e5ac865d06f678a07a1901e3465611356df21a545eeea8b45f56265", size = 574901, upload-time = "2025-08-21T04:22:17.423Z" }, + { url = "https://files.pythonhosted.org/packages/c8/57/a4aff0dbb29001cebf94848a980ac36bea8e1cb65ac0f72ff03e484df208/pyzmq-27.0.2-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:aa9c1c208c263b84386ac25bed6af5672397dc3c232638114fc09bca5c7addf9", size = 1330291, upload-time = "2025-08-21T04:22:34.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a6/8519ed48b5bdd72e1a3946a76987afb6659a355180cd15e51cea6afe1983/pyzmq-27.0.2-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:795c4884cfe7ea59f2b67d82b417e899afab889d332bfda13b02f8e0c155b2e4", size = 906596, upload-time = "2025-08-21T04:22:36.168Z" }, + { url = "https://files.pythonhosted.org/packages/3c/19/cb6f463d6454079a566160d10cf175c3c59274b1378104d2b01a5a1ca560/pyzmq-27.0.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47eb65bb25478358ba3113dd9a08344f616f417ad3ffcbb190cd874fae72b1b1", size = 863605, upload-time = "2025-08-21T04:22:37.825Z" }, + { url = "https://files.pythonhosted.org/packages/9d/37/afe25d5f9b91b7422067bb7cd3f75691d78baa0c2c2f76bd0643389ac600/pyzmq-27.0.2-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6fc24f00293f10aff04d55ca37029b280474c91f4de2cad5e911e5e10d733b7", size = 666777, upload-time = "2025-08-21T04:22:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/15/4a/97b0b59a7eacfd9e61c40abaa9c7e2587bbb39671816a9fb0c5dc32d87bc/pyzmq-27.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:58d4cc9b6b768478adfc40a5cbee545303db8dbc81ba688474e0f499cc581028", size = 1655404, upload-time = "2025-08-21T04:22:41.082Z" }, + { url = "https://files.pythonhosted.org/packages/27/db/bcf03bea232839ea749562f76bda3807b16a25c68fdd153339a56699bb1d/pyzmq-27.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cea2f26c5972796e02b222968a21a378d09eb4ff590eb3c5fafa8913f8c2bdf5", size = 2034051, upload-time = "2025-08-21T04:22:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/57/3f/9cc291101ee766ab41bae25654af84c11d259f27d73b1ed985256dae8a16/pyzmq-27.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a0621ec020c49fc1b6e31304f1a820900d54e7d9afa03ea1634264bf9387519e", size = 1892080, upload-time = "2025-08-21T04:22:44.81Z" }, + { url = "https://files.pythonhosted.org/packages/16/52/51d4c56c553e7368abc2d010a783092c68a1888045f899eabcfd3bc345f3/pyzmq-27.0.2-cp39-cp39-win32.whl", hash = "sha256:1326500792a9cb0992db06bbaf5d0098459133868932b81a6e90d45c39eca99d", size = 567463, upload-time = "2025-08-21T04:22:46.853Z" }, + { url = "https://files.pythonhosted.org/packages/93/20/3b0ac853b5bbe1fd05e1898ea60335f9d402312328fa17514a876418ab11/pyzmq-27.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:5ee9560cb1e3094ef01fc071b361121a57ebb8d4232912b6607a6d7d2d0a97b4", size = 632200, upload-time = "2025-08-21T04:22:48.34Z" }, + { url = "https://files.pythonhosted.org/packages/b0/14/78b192a7864a38c71e30c3fd668eff949b44db13c1906035e6095fecd00d/pyzmq-27.0.2-cp39-cp39-win_arm64.whl", hash = "sha256:85e3c6fb0d25ea046ebcfdc2bcb9683d663dc0280645c79a616ff5077962a15b", size = 559638, upload-time = "2025-08-21T04:22:50.002Z" }, + { url = "https://files.pythonhosted.org/packages/19/d7/e388e80107b7c438c9698ce59c2a3b950021cd4ab3fe641485e4ed6b0960/pyzmq-27.0.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:d67a0960803a37b60f51b460c58444bc7033a804c662f5735172e21e74ee4902", size = 836008, upload-time = "2025-08-21T04:22:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/65/ef/58d3eb85f1b67a16e22adb07d084f975a7b9641463d18e27230550bb436a/pyzmq-27.0.2-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:dd4d3e6a567ffd0d232cfc667c49d0852d0ee7481458a2a1593b9b1bc5acba88", size = 799932, upload-time = "2025-08-21T04:22:53.529Z" }, + { url = "https://files.pythonhosted.org/packages/3c/63/66b9f6db19ee8c86105ffd4475a4f5d93cdd62b1edcb1e894d971df0728c/pyzmq-27.0.2-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e558be423631704803bc6a642e2caa96083df759e25fe6eb01f2d28725f80bd", size = 567458, upload-time = "2025-08-21T04:22:55.289Z" }, + { url = "https://files.pythonhosted.org/packages/10/af/d92207fe8b6e3d9f588d0591219a86dd7b4ed27bb3e825c1d9cf48467fc0/pyzmq-27.0.2-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4c20ba8389f495c7b4f6b896bb1ca1e109a157d4f189267a902079699aaf787", size = 747087, upload-time = "2025-08-21T04:22:56.994Z" }, + { url = "https://files.pythonhosted.org/packages/82/e9/d9f8b4b191c6733e31de28974d608a2475a6598136ac901a8c5b67c11432/pyzmq-27.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c5be232f7219414ff672ff7ab8c5a7e8632177735186d8a42b57b491fafdd64e", size = 544641, upload-time = "2025-08-21T04:22:58.87Z" }, + { url = "https://files.pythonhosted.org/packages/c7/60/027d0032a1e3b1aabcef0e309b9ff8a4099bdd5a60ab38b36a676ff2bd7b/pyzmq-27.0.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e297784aea724294fe95e442e39a4376c2f08aa4fae4161c669f047051e31b02", size = 836007, upload-time = "2025-08-21T04:23:00.447Z" }, + { url = "https://files.pythonhosted.org/packages/25/20/2ed1e6168aaea323df9bb2c451309291f53ba3af372ffc16edd4ce15b9e5/pyzmq-27.0.2-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e3659a79ded9745bc9c2aef5b444ac8805606e7bc50d2d2eb16dc3ab5483d91f", size = 799932, upload-time = "2025-08-21T04:23:02.052Z" }, + { url = "https://files.pythonhosted.org/packages/fd/25/5c147307de546b502c9373688ce5b25dc22288d23a1ebebe5d587bf77610/pyzmq-27.0.2-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3dba49ff037d02373a9306b58d6c1e0be031438f822044e8767afccfdac4c6b", size = 567459, upload-time = "2025-08-21T04:23:03.593Z" }, + { url = "https://files.pythonhosted.org/packages/71/06/0dc56ffc615c8095cd089c9b98ce5c733e990f09ce4e8eea4aaf1041a532/pyzmq-27.0.2-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de84e1694f9507b29e7b263453a2255a73e3d099d258db0f14539bad258abe41", size = 747088, upload-time = "2025-08-21T04:23:05.334Z" }, + { url = "https://files.pythonhosted.org/packages/06/f6/4a50187e023b8848edd3f0a8e197b1a7fb08d261d8c60aae7cb6c3d71612/pyzmq-27.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f0944d65ba2b872b9fcece08411d6347f15a874c775b4c3baae7f278550da0fb", size = 544639, upload-time = "2025-08-21T04:23:07.279Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ce/36c82244b111c408c99037b61eedcaac9a3f17300c3f857cf175a7b8832f/pyzmq-27.0.2-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:400f34321e3bd89b1165b91ea6b18ad26042ba9ad0dfed8b35049e2e24eeab9b", size = 836001, upload-time = "2025-08-21T04:23:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/6f/dc/4aa8e518f1c0d0ee7fb98cf54cf3854e471815040062d26d948df638d9bc/pyzmq-27.0.2-pp39-pypy39_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9cbad4ef12e4c15c94d2c24ecd15a8ed56bf091c62f121a2b0c618ddd4b7402b", size = 799927, upload-time = "2025-08-21T04:23:19.218Z" }, + { url = "https://files.pythonhosted.org/packages/43/4a/cca781133ce015898b7c4ad63240d05af5c54e8736d099c95b0a6c9aaca9/pyzmq-27.0.2-pp39-pypy39_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6b2b74aac3392b8cf508ccb68c980a8555298cd378434a2d065d6ce0f4211dff", size = 758427, upload-time = "2025-08-21T04:23:21.204Z" }, + { url = "https://files.pythonhosted.org/packages/e3/66/772bf1e3aed8b20587f2e0122bc929711d44ddd27dbbd6eb308a5badd0c3/pyzmq-27.0.2-pp39-pypy39_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7db5db88c24cf9253065d69229a148ff60821e5d6f8ff72579b1f80f8f348bab", size = 567453, upload-time = "2025-08-21T04:23:22.825Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fb/fdc3dd8ec46f5226b4cde299839f10c625886bd18adbeaa8a59ffe104356/pyzmq-27.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8ffe40c216c41756ca05188c3e24a23142334b304f7aebd75c24210385e35573", size = 544636, upload-time = "2025-08-21T04:23:24.736Z" }, ] [[package]] @@ -1896,9 +1897,9 @@ dependencies = [ { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] [[package]] @@ -1909,9 +1910,9 @@ dependencies = [ { name = "oauthlib" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, ] [[package]] @@ -1921,35 +1922,35 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 } +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 }, + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] [[package]] name = "ruff" version = "0.12.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/55/16ab6a7d88d93001e1ae4c34cbdcfb376652d761799459ff27c1dc20f6fa/ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d", size = 5347103 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/a2/3b3573e474de39a7a475f3fbaf36a25600bfeb238e1a90392799163b64a0/ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065", size = 11979885 }, - { url = "https://files.pythonhosted.org/packages/76/e4/235ad6d1785a2012d3ded2350fd9bc5c5af8c6f56820e696b0118dfe7d24/ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93", size = 12742364 }, - { url = "https://files.pythonhosted.org/packages/2c/0d/15b72c5fe6b1e402a543aa9d8960e0a7e19dfb079f5b0b424db48b7febab/ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd", size = 11920111 }, - { url = "https://files.pythonhosted.org/packages/3e/c0/f66339d7893798ad3e17fa5a1e587d6fd9806f7c1c062b63f8b09dda6702/ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee", size = 12160060 }, - { url = "https://files.pythonhosted.org/packages/03/69/9870368326db26f20c946205fb2d0008988aea552dbaec35fbacbb46efaa/ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0", size = 11799848 }, - { url = "https://files.pythonhosted.org/packages/25/8c/dd2c7f990e9b3a8a55eee09d4e675027d31727ce33cdb29eab32d025bdc9/ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644", size = 13536288 }, - { url = "https://files.pythonhosted.org/packages/7a/30/d5496fa09aba59b5e01ea76775a4c8897b13055884f56f1c35a4194c2297/ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211", size = 14490633 }, - { url = "https://files.pythonhosted.org/packages/9b/2f/81f998180ad53445d403c386549d6946d0748e536d58fce5b5e173511183/ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793", size = 13888430 }, - { url = "https://files.pythonhosted.org/packages/87/71/23a0d1d5892a377478c61dbbcffe82a3476b050f38b5162171942a029ef3/ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee", size = 12913133 }, - { url = "https://files.pythonhosted.org/packages/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8", size = 13169082 }, - { url = "https://files.pythonhosted.org/packages/05/b5/68b3ff96160d8b49e8dd10785ff3186be18fd650d356036a3770386e6c7f/ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f", size = 13139490 }, - { url = "https://files.pythonhosted.org/packages/59/b9/050a3278ecd558f74f7ee016fbdf10591d50119df8d5f5da45a22c6afafc/ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000", size = 11958928 }, - { url = "https://files.pythonhosted.org/packages/f9/bc/93be37347db854806904a43b0493af8d6873472dfb4b4b8cbb27786eb651/ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2", size = 11764513 }, - { url = "https://files.pythonhosted.org/packages/7a/a1/1471751e2015a81fd8e166cd311456c11df74c7e8769d4aabfbc7584c7ac/ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39", size = 12745154 }, - { url = "https://files.pythonhosted.org/packages/68/ab/2542b14890d0f4872dd81b7b2a6aed3ac1786fae1ce9b17e11e6df9e31e3/ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9", size = 13227653 }, - { url = "https://files.pythonhosted.org/packages/22/16/2fbfc61047dbfd009c58a28369a693a1484ad15441723be1cd7fe69bb679/ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3", size = 11944270 }, - { url = "https://files.pythonhosted.org/packages/08/a5/34276984705bfe069cd383101c45077ee029c3fe3b28225bf67aa35f0647/ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd", size = 13046600 }, - { url = "https://files.pythonhosted.org/packages/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290 }, +sdist = { url = "https://files.pythonhosted.org/packages/de/55/16ab6a7d88d93001e1ae4c34cbdcfb376652d761799459ff27c1dc20f6fa/ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d", size = 5347103, upload-time = "2025-08-28T13:59:08.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/a2/3b3573e474de39a7a475f3fbaf36a25600bfeb238e1a90392799163b64a0/ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065", size = 11979885, upload-time = "2025-08-28T13:58:26.654Z" }, + { url = "https://files.pythonhosted.org/packages/76/e4/235ad6d1785a2012d3ded2350fd9bc5c5af8c6f56820e696b0118dfe7d24/ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93", size = 12742364, upload-time = "2025-08-28T13:58:30.256Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0d/15b72c5fe6b1e402a543aa9d8960e0a7e19dfb079f5b0b424db48b7febab/ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd", size = 11920111, upload-time = "2025-08-28T13:58:33.677Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/f66339d7893798ad3e17fa5a1e587d6fd9806f7c1c062b63f8b09dda6702/ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee", size = 12160060, upload-time = "2025-08-28T13:58:35.74Z" }, + { url = "https://files.pythonhosted.org/packages/03/69/9870368326db26f20c946205fb2d0008988aea552dbaec35fbacbb46efaa/ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0", size = 11799848, upload-time = "2025-08-28T13:58:38.051Z" }, + { url = "https://files.pythonhosted.org/packages/25/8c/dd2c7f990e9b3a8a55eee09d4e675027d31727ce33cdb29eab32d025bdc9/ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644", size = 13536288, upload-time = "2025-08-28T13:58:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/7a/30/d5496fa09aba59b5e01ea76775a4c8897b13055884f56f1c35a4194c2297/ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211", size = 14490633, upload-time = "2025-08-28T13:58:42.285Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2f/81f998180ad53445d403c386549d6946d0748e536d58fce5b5e173511183/ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793", size = 13888430, upload-time = "2025-08-28T13:58:44.641Z" }, + { url = "https://files.pythonhosted.org/packages/87/71/23a0d1d5892a377478c61dbbcffe82a3476b050f38b5162171942a029ef3/ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee", size = 12913133, upload-time = "2025-08-28T13:58:47.039Z" }, + { url = "https://files.pythonhosted.org/packages/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8", size = 13169082, upload-time = "2025-08-28T13:58:49.157Z" }, + { url = "https://files.pythonhosted.org/packages/05/b5/68b3ff96160d8b49e8dd10785ff3186be18fd650d356036a3770386e6c7f/ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f", size = 13139490, upload-time = "2025-08-28T13:58:51.593Z" }, + { url = "https://files.pythonhosted.org/packages/59/b9/050a3278ecd558f74f7ee016fbdf10591d50119df8d5f5da45a22c6afafc/ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000", size = 11958928, upload-time = "2025-08-28T13:58:53.943Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bc/93be37347db854806904a43b0493af8d6873472dfb4b4b8cbb27786eb651/ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2", size = 11764513, upload-time = "2025-08-28T13:58:55.976Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a1/1471751e2015a81fd8e166cd311456c11df74c7e8769d4aabfbc7584c7ac/ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39", size = 12745154, upload-time = "2025-08-28T13:58:58.16Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/2542b14890d0f4872dd81b7b2a6aed3ac1786fae1ce9b17e11e6df9e31e3/ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9", size = 13227653, upload-time = "2025-08-28T13:59:00.276Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/2fbfc61047dbfd009c58a28369a693a1484ad15441723be1cd7fe69bb679/ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3", size = 11944270, upload-time = "2025-08-28T13:59:02.347Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/34276984705bfe069cd383101c45077ee029c3fe3b28225bf67aa35f0647/ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd", size = 13046600, upload-time = "2025-08-28T13:59:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290, upload-time = "2025-08-28T13:59:06.933Z" }, ] [[package]] @@ -1959,18 +1960,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/05/d52bf1e65044b4e5e27d4e63e8d1579dbdec54fce685908ae09bc3720030/s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf", size = 150589 } +sdist = { url = "https://files.pythonhosted.org/packages/6d/05/d52bf1e65044b4e5e27d4e63e8d1579dbdec54fce685908ae09bc3720030/s3transfer-0.13.1.tar.gz", hash = "sha256:c3fdba22ba1bd367922f27ec8032d6a1cf5f10c934fb5d68cf60fd5a23d936cf", size = 150589, upload-time = "2025-07-18T19:22:42.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/4f/d073e09df851cfa251ef7840007d04db3293a0482ce607d2b993926089be/s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724", size = 85308 }, + { url = "https://files.pythonhosted.org/packages/6d/4f/d073e09df851cfa251ef7840007d04db3293a0482ce607d2b993926089be/s3transfer-0.13.1-py3-none-any.whl", hash = "sha256:a981aa7429be23fe6dfc13e80e4020057cbab622b08c0315288758d67cabc724", size = 85308, upload-time = "2025-07-18T19:22:40.947Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] @@ -1998,51 +1999,51 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3", version = "1.26.20", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/92/68a0e5cba0991c8d375a9bf8a2b6a6cd1d1149e4a32075a9b6e723cc6ca2/snowflake_connector_python-3.17.2.tar.gz", hash = "sha256:0708de0f64e3c6789fdf0d75f22c55eb0215b60b125a5d7d18e48a1d5b2e7def", size = 794385 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/31/70/325040e309c308a220e86cafb021156264b4778f78d4af5897874df80db1/snowflake_connector_python-3.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:58c273b610ba25d6386134238c27290ea9ed468443da4061d80050288dc7b01d", size = 1007927 }, - { url = "https://files.pythonhosted.org/packages/a2/ba/2b35750aa1a3238735b7a5d525190ec11c3444accb10ced873688d6b5244/snowflake_connector_python-3.17.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ed2c183058245a63125ac1ebb20062e41c0cc5b0ab6cde5df1250e1c332d3da1", size = 1020560 }, - { url = "https://files.pythonhosted.org/packages/91/e3/f61cb425536ec22f92c7956ef6d4d836f510fd5020ca1f35c6b0612b1aae/snowflake_connector_python-3.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d5001de2b708ba3941cf1a2a33e391ba3c11b4f5176bf89ee0c76590eb0900", size = 2633845 }, - { url = "https://files.pythonhosted.org/packages/9b/d4/4251c85998fa19cac5345a7b2e7c644507acd1e88217f780663e6d7d064f/snowflake_connector_python-3.17.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3d216fa04f3f9126207d71aa150f219367fc44759caabec4d2d96687242135", size = 2661519 }, - { url = "https://files.pythonhosted.org/packages/b2/ca/69972cda6b58049c7eb2b0c3df30fff1ec6b32fa4b99fafff5aaadedda05/snowflake_connector_python-3.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:b6b5956b59f3c379c72a008a9d413e878188979c003e2aced0e1eebf27ca4cc0", size = 1155233 }, - { url = "https://files.pythonhosted.org/packages/fa/42/810ec5d744002563873a505ea524be2ce487c48c6a675fc0ad2338b6cf03/snowflake_connector_python-3.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a97d7dc4abf194ca540564f75b4b4a79004a553767001e1d70fae49bb7bf4f", size = 1008087 }, - { url = "https://files.pythonhosted.org/packages/2b/e8/72d2bbdc3aa05cc35d76081da365516f7173cf04b312afc1c6ad01ad2fc4/snowflake_connector_python-3.17.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:d06d9c4ade7fb8f9d1e237cab9c5d38408785d3d2ddfe94001bbef9bb076203f", size = 1020548 }, - { url = "https://files.pythonhosted.org/packages/a8/52/d616ad8dbc729f7acb5bfc422f730e6b3d16af01575c6940c96f960c7db4/snowflake_connector_python-3.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6e737d8b66f0bdbb698f8e1a394f191eadcd16b9145abc08fdf8959a77d6a70", size = 2647033 }, - { url = "https://files.pythonhosted.org/packages/28/63/81529e7ae2125962823223e082c2e35c5ec489b189c0ea68e8d34b0bf351/snowflake_connector_python-3.17.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53ad13df88b2dd568e8f9c2944386c8446482152141dff83f27e680ceffa1b10", size = 2672278 }, - { url = "https://files.pythonhosted.org/packages/7d/2d/69fbfac3142c871407de3deceb74ccc4ab3a90fb7e80b8de0ea736e57cbe/snowflake_connector_python-3.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:a601dfe86ab4b7900b68fa1cfadcc674b23f6f6b4a1c57c3202e57761de6b183", size = 1155288 }, - { url = "https://files.pythonhosted.org/packages/2a/f6/af023d21530bbe6e3c8ec89226480007f6ec9a6fabd19fba2d0c9d518f08/snowflake_connector_python-3.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:63a57cb67d14c7da6b91b8db0db3a92dfa5cf5082c388006d2f9f480c2df0234", size = 1007126 }, - { url = "https://files.pythonhosted.org/packages/8d/f5/489b41a8c0b9c270b203c991e90f3db7d2ffb08a1810f7722773f52b6f7c/snowflake_connector_python-3.17.2-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:c6f59c47e43bf889fd5a2ead8ffeacd447cd792d711f010c91b23b4804c67f6b", size = 1018904 }, - { url = "https://files.pythonhosted.org/packages/8c/c9/fa7a45e16f48f2d5beaccc383ffa8ecd9a6071d0ee3a4228901fc1667c29/snowflake_connector_python-3.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4934a4f552876592696ab8def7e2d33163f99bb059bac4b90b5cfce165aa6499", size = 2667918 }, - { url = "https://files.pythonhosted.org/packages/32/6c/4a2f6564b614745d26735854806f33ce9029b380d2ae6d72b771bacf1e60/snowflake_connector_python-3.17.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f7cf3dcfcceea99d4b9d4fb8ef67788790832cbeceb222fbdf5595da52103b", size = 2696625 }, - { url = "https://files.pythonhosted.org/packages/7f/8e/94286f36c3877ca8c1e50760b49530dec3033db6792206dffbb681d61df4/snowflake_connector_python-3.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e05c9d55b234c8a903d96397b9eb4accbddf7d2fcfac2e27239f0f49d16f475", size = 1154225 }, - { url = "https://files.pythonhosted.org/packages/54/d4/6f8810e686b6cc1b6f9e39bda3b6cb1472f5c3ef5379dd3e544b887f513d/snowflake_connector_python-3.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e21a68c5fb04a16f48fd5146ab7a72d17b2d3d09c9b537f64fffa99316abd670", size = 1008338 }, - { url = "https://files.pythonhosted.org/packages/70/9a/d5647e2f13e753511bbd0c4c1c17fe9814a2e07262894640e2f3f1ca201d/snowflake_connector_python-3.17.2-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:1691f5f7ff508b1fefc491f0fb85524165681bb29242f508731515057a1b4f9d", size = 1019604 }, - { url = "https://files.pythonhosted.org/packages/f3/e1/ed67c43e081009f80c469714b2551e774c41f01dc674c6af1b8076ecb950/snowflake_connector_python-3.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a668e5e9ae04ec0ab0f7cdd450ef3757283f37856655ae7e971ee645a25b4b3", size = 2670328 }, - { url = "https://files.pythonhosted.org/packages/bf/84/7968b5d6a674f97e08e9ffe5a660194eb1e2d0457602c5622776059b7ae6/snowflake_connector_python-3.17.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed6a05c55238d076ad186e27889a72fe888f02e9c8c341127f828439cbf1e42f", size = 2698215 }, - { url = "https://files.pythonhosted.org/packages/d5/fa/f85298096284893a328d4005997e98dd66a8b2921ca6aeec91f92195bb84/snowflake_connector_python-3.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:360f21c576847636c3560a2607c5388f9e57e607a3558f5f5a188a71c82108e1", size = 1154275 }, - { url = "https://files.pythonhosted.org/packages/d9/cd/f84f6c8bfac7a5f1d43c936183ae64c2a19f1e0bae213a4df08738abe670/snowflake_connector_python-3.17.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c526f18487fc1e7d4f6b294198079cceefa47a77084c9d2a87a2e617fceb999d", size = 1008145 }, - { url = "https://files.pythonhosted.org/packages/56/cc/3c54b4efe1a5779ebbad3ffc03ee6ba7e87867c2bb9772ab60d96acc1701/snowflake_connector_python-3.17.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:54dfeba63d8ccef4182951ab2dc605cc7cb2a811e68e98f1146281f4721e3af0", size = 1020818 }, - { url = "https://files.pythonhosted.org/packages/ab/9a/f0b639e528fb00b3451498d8d24f2e334d000831d3bb00481acc75e1706a/snowflake_connector_python-3.17.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3620f33f6a453775fb527d32308ffdf167cc426a81fc1dfdadd4cd657dc6ac9", size = 2629245 }, - { url = "https://files.pythonhosted.org/packages/ca/b3/3085aa134691913cc2680af75aa715801f4bb54d3e66d1df6ffbd096c73e/snowflake_connector_python-3.17.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07195dcde0a8265bc26f404e124ff514b47c16964e1a84625ab53d3ea52359db", size = 2657234 }, - { url = "https://files.pythonhosted.org/packages/95/ed/85586ac385fe1fb83c5899b34bb360e1ef9f08fa0cf2694329b9807e24bb/snowflake_connector_python-3.17.2-cp39-cp39-win_amd64.whl", hash = "sha256:3be73a65f5fdb8f074eef129c7828134aac9b429afab18f142ecc0f7260668b8", size = 1156318 }, +sdist = { url = "https://files.pythonhosted.org/packages/48/92/68a0e5cba0991c8d375a9bf8a2b6a6cd1d1149e4a32075a9b6e723cc6ca2/snowflake_connector_python-3.17.2.tar.gz", hash = "sha256:0708de0f64e3c6789fdf0d75f22c55eb0215b60b125a5d7d18e48a1d5b2e7def", size = 794385, upload-time = "2025-08-20T14:41:22.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/70/325040e309c308a220e86cafb021156264b4778f78d4af5897874df80db1/snowflake_connector_python-3.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:58c273b610ba25d6386134238c27290ea9ed468443da4061d80050288dc7b01d", size = 1007927, upload-time = "2025-08-20T14:41:24.906Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ba/2b35750aa1a3238735b7a5d525190ec11c3444accb10ced873688d6b5244/snowflake_connector_python-3.17.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ed2c183058245a63125ac1ebb20062e41c0cc5b0ab6cde5df1250e1c332d3da1", size = 1020560, upload-time = "2025-08-20T14:41:26.574Z" }, + { url = "https://files.pythonhosted.org/packages/91/e3/f61cb425536ec22f92c7956ef6d4d836f510fd5020ca1f35c6b0612b1aae/snowflake_connector_python-3.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d5001de2b708ba3941cf1a2a33e391ba3c11b4f5176bf89ee0c76590eb0900", size = 2633845, upload-time = "2025-08-20T14:41:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d4/4251c85998fa19cac5345a7b2e7c644507acd1e88217f780663e6d7d064f/snowflake_connector_python-3.17.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3d216fa04f3f9126207d71aa150f219367fc44759caabec4d2d96687242135", size = 2661519, upload-time = "2025-08-20T14:41:07.999Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ca/69972cda6b58049c7eb2b0c3df30fff1ec6b32fa4b99fafff5aaadedda05/snowflake_connector_python-3.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:b6b5956b59f3c379c72a008a9d413e878188979c003e2aced0e1eebf27ca4cc0", size = 1155233, upload-time = "2025-08-20T14:41:42.269Z" }, + { url = "https://files.pythonhosted.org/packages/fa/42/810ec5d744002563873a505ea524be2ce487c48c6a675fc0ad2338b6cf03/snowflake_connector_python-3.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d8a97d7dc4abf194ca540564f75b4b4a79004a553767001e1d70fae49bb7bf4f", size = 1008087, upload-time = "2025-08-20T14:41:27.905Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e8/72d2bbdc3aa05cc35d76081da365516f7173cf04b312afc1c6ad01ad2fc4/snowflake_connector_python-3.17.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:d06d9c4ade7fb8f9d1e237cab9c5d38408785d3d2ddfe94001bbef9bb076203f", size = 1020548, upload-time = "2025-08-20T14:41:29.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/52/d616ad8dbc729f7acb5bfc422f730e6b3d16af01575c6940c96f960c7db4/snowflake_connector_python-3.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6e737d8b66f0bdbb698f8e1a394f191eadcd16b9145abc08fdf8959a77d6a70", size = 2647033, upload-time = "2025-08-20T14:41:09.612Z" }, + { url = "https://files.pythonhosted.org/packages/28/63/81529e7ae2125962823223e082c2e35c5ec489b189c0ea68e8d34b0bf351/snowflake_connector_python-3.17.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53ad13df88b2dd568e8f9c2944386c8446482152141dff83f27e680ceffa1b10", size = 2672278, upload-time = "2025-08-20T14:41:11.49Z" }, + { url = "https://files.pythonhosted.org/packages/7d/2d/69fbfac3142c871407de3deceb74ccc4ab3a90fb7e80b8de0ea736e57cbe/snowflake_connector_python-3.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:a601dfe86ab4b7900b68fa1cfadcc674b23f6f6b4a1c57c3202e57761de6b183", size = 1155288, upload-time = "2025-08-20T14:41:43.645Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f6/af023d21530bbe6e3c8ec89226480007f6ec9a6fabd19fba2d0c9d518f08/snowflake_connector_python-3.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:63a57cb67d14c7da6b91b8db0db3a92dfa5cf5082c388006d2f9f480c2df0234", size = 1007126, upload-time = "2025-08-20T14:41:30.637Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f5/489b41a8c0b9c270b203c991e90f3db7d2ffb08a1810f7722773f52b6f7c/snowflake_connector_python-3.17.2-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:c6f59c47e43bf889fd5a2ead8ffeacd447cd792d711f010c91b23b4804c67f6b", size = 1018904, upload-time = "2025-08-20T14:41:31.952Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c9/fa7a45e16f48f2d5beaccc383ffa8ecd9a6071d0ee3a4228901fc1667c29/snowflake_connector_python-3.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4934a4f552876592696ab8def7e2d33163f99bb059bac4b90b5cfce165aa6499", size = 2667918, upload-time = "2025-08-20T14:41:13.454Z" }, + { url = "https://files.pythonhosted.org/packages/32/6c/4a2f6564b614745d26735854806f33ce9029b380d2ae6d72b771bacf1e60/snowflake_connector_python-3.17.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70f7cf3dcfcceea99d4b9d4fb8ef67788790832cbeceb222fbdf5595da52103b", size = 2696625, upload-time = "2025-08-20T14:41:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8e/94286f36c3877ca8c1e50760b49530dec3033db6792206dffbb681d61df4/snowflake_connector_python-3.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e05c9d55b234c8a903d96397b9eb4accbddf7d2fcfac2e27239f0f49d16f475", size = 1154225, upload-time = "2025-08-20T14:41:45.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/6f8810e686b6cc1b6f9e39bda3b6cb1472f5c3ef5379dd3e544b887f513d/snowflake_connector_python-3.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e21a68c5fb04a16f48fd5146ab7a72d17b2d3d09c9b537f64fffa99316abd670", size = 1008338, upload-time = "2025-08-20T14:41:33.296Z" }, + { url = "https://files.pythonhosted.org/packages/70/9a/d5647e2f13e753511bbd0c4c1c17fe9814a2e07262894640e2f3f1ca201d/snowflake_connector_python-3.17.2-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:1691f5f7ff508b1fefc491f0fb85524165681bb29242f508731515057a1b4f9d", size = 1019604, upload-time = "2025-08-20T14:41:37.557Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e1/ed67c43e081009f80c469714b2551e774c41f01dc674c6af1b8076ecb950/snowflake_connector_python-3.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a668e5e9ae04ec0ab0f7cdd450ef3757283f37856655ae7e971ee645a25b4b3", size = 2670328, upload-time = "2025-08-20T14:41:16.206Z" }, + { url = "https://files.pythonhosted.org/packages/bf/84/7968b5d6a674f97e08e9ffe5a660194eb1e2d0457602c5622776059b7ae6/snowflake_connector_python-3.17.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed6a05c55238d076ad186e27889a72fe888f02e9c8c341127f828439cbf1e42f", size = 2698215, upload-time = "2025-08-20T14:41:18.105Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/f85298096284893a328d4005997e98dd66a8b2921ca6aeec91f92195bb84/snowflake_connector_python-3.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:360f21c576847636c3560a2607c5388f9e57e607a3558f5f5a188a71c82108e1", size = 1154275, upload-time = "2025-08-20T14:41:47.069Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cd/f84f6c8bfac7a5f1d43c936183ae64c2a19f1e0bae213a4df08738abe670/snowflake_connector_python-3.17.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c526f18487fc1e7d4f6b294198079cceefa47a77084c9d2a87a2e617fceb999d", size = 1008145, upload-time = "2025-08-20T14:41:39.041Z" }, + { url = "https://files.pythonhosted.org/packages/56/cc/3c54b4efe1a5779ebbad3ffc03ee6ba7e87867c2bb9772ab60d96acc1701/snowflake_connector_python-3.17.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:54dfeba63d8ccef4182951ab2dc605cc7cb2a811e68e98f1146281f4721e3af0", size = 1020818, upload-time = "2025-08-20T14:41:40.356Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/f0b639e528fb00b3451498d8d24f2e334d000831d3bb00481acc75e1706a/snowflake_connector_python-3.17.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3620f33f6a453775fb527d32308ffdf167cc426a81fc1dfdadd4cd657dc6ac9", size = 2629245, upload-time = "2025-08-20T14:41:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b3/3085aa134691913cc2680af75aa715801f4bb54d3e66d1df6ffbd096c73e/snowflake_connector_python-3.17.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07195dcde0a8265bc26f404e124ff514b47c16964e1a84625ab53d3ea52359db", size = 2657234, upload-time = "2025-08-20T14:41:21.663Z" }, + { url = "https://files.pythonhosted.org/packages/95/ed/85586ac385fe1fb83c5899b34bb360e1ef9f08fa0cf2694329b9807e24bb/snowflake_connector_python-3.17.2-cp39-cp39-win_amd64.whl", hash = "sha256:3be73a65f5fdb8f074eef129c7828134aac9b429afab18f142ecc0f7260668b8", size = 1156318, upload-time = "2025-08-20T14:41:48.439Z" }, ] [[package]] name = "sortedcontainers" version = "2.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575 }, + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] [[package]] name = "sqlparse" version = "0.5.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999 } +sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415 }, + { url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" }, ] [[package]] @@ -2054,103 +2055,103 @@ dependencies = [ { name = "executing" }, { name = "pure-eval" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] [[package]] name = "toml" version = "0.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253 } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 }, + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, ] [[package]] name = "tomli" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, - { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, - { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, - { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, - { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, - { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, - { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, - { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, - { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, - { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, - { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, - { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, - { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, - { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, - { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, - { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, - { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, - { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, - { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, - { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, - { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, - { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, - { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, - { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, - { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, - { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, - { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, - { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, - { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, - { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, - { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] [[package]] name = "tomlkit" version = "0.13.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207 } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901 }, + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, ] [[package]] name = "tornado" version = "6.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821 } +sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563 }, - { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729 }, - { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295 }, - { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644 }, - { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878 }, - { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549 }, - { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973 }, - { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954 }, - { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023 }, - { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427 }, - { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456 }, + { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, + { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, + { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, + { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, ] [[package]] name = "traitlets" version = "5.14.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] [[package]] name = "typing-extensions" version = "4.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, + { 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]] @@ -2160,18 +2161,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726 } +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552 }, + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] [[package]] name = "tzdata" version = "2025.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] [[package]] @@ -2182,9 +2183,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", "python_full_version < '3.10'", ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/e8/6ff5e6bc22095cfc59b6ea711b687e2b7ed4bdb373f7eeec370a97d7392f/urllib3-1.26.20.tar.gz", hash = "sha256:40c2dc0c681e47eb8f90e7e27bf6ff7df2e677421fd46756da1161c39ca70d32", size = 307380, upload-time = "2024-08-29T15:43:11.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225 }, + { url = "https://files.pythonhosted.org/packages/33/cf/8435d5a7159e2a9c83a95896ed596f68cf798005fe107cc655b5c5c14704/urllib3-1.26.20-py2.py3-none-any.whl", hash = "sha256:0ed14ccfbf1c30a9072c7ca157e4319b70d65f623e91e7b32fadb2853431016e", size = 144225, upload-time = "2024-08-29T15:43:08.921Z" }, ] [[package]] @@ -2195,9 +2196,9 @@ resolution-markers = [ "python_full_version >= '3.12'", "python_full_version == '3.11.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] [[package]] @@ -2210,34 +2211,34 @@ dependencies = [ { name = "platformdirs" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808 } +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279 }, + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, ] [[package]] name = "wcwidth" version = "0.2.13" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, ] [[package]] name = "websocket-client" version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 } +sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648, upload-time = "2024-04-23T22:16:16.976Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 }, + { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826, upload-time = "2024-04-23T22:16:14.422Z" }, ] [[package]] name = "zipp" version = "3.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ] From 675ca8f550eed022d43ebc95c156e4212426a10c Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:11:47 +0530 Subject: [PATCH 042/167] feat: add pytest-xdist for parallel test execution and update test command --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 98ff0dd..1c94360 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "pytest-cov>=6.1.1", "ruff>=0.11.7", "ipykernel>=6.30.0", + "pytest-xdist>=3.8.0", ] @@ -141,7 +142,7 @@ cmd = "uvx pre-commit run --all-files" help = "Run linting, formatting, projen synth, and other static code quality tools" [tool.poe.tasks.test] -cmd = "pytest" +cmd = "pytest -n 4" help = "Run all tests with optional additional arguments" From 393abafbe761df9f47be22875c0d35ac07425440 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:18:51 +0530 Subject: [PATCH 043/167] fix: update flow steps in test to ensure correct execution order --- tests/functional_tests/metaflow/test__pandas_s3.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/functional_tests/metaflow/test__pandas_s3.py b/tests/functional_tests/metaflow/test__pandas_s3.py index 7b85d41..a69afc6 100644 --- a/tests/functional_tests/metaflow/test__pandas_s3.py +++ b/tests/functional_tests/metaflow/test__pandas_s3.py @@ -14,7 +14,7 @@ class TestPandasReadWriteFlowViaS3(FlowSpec): @step def start(self): """Start the flow.""" - self.next(self.test_publish_pandas) + self.next(self.test_publish_pandas_with_schema) @step def test_publish_pandas_with_schema(self): @@ -45,7 +45,7 @@ def test_publish_pandas_with_schema(self): ], ) - self.next(self.test_publish_pandas_with_warehouse) + self.next(self.test_publish_pandas_without_schema) @step def test_publish_pandas_without_schema(self): @@ -104,7 +104,7 @@ def end(self): @pytest.mark.slow def test_pandas_read_write_flow_via_s3(): - """Test that the publish flow runs successfully.""" + """Test the pandas read/write flow via S3.""" cmd = [sys.executable, __file__, "--environment=local", "--with=card", "run"] print("\n=== Metaflow Output ===") From d0a231c569fe690da4807a3f9c9e16c36d0f5701 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:27:34 +0530 Subject: [PATCH 044/167] fix: update table name and query for publishing and querying DataFrame via S3 --- tests/functional_tests/metaflow/test__pandas_s3.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/functional_tests/metaflow/test__pandas_s3.py b/tests/functional_tests/metaflow/test__pandas_s3.py index a69afc6..9361ff0 100644 --- a/tests/functional_tests/metaflow/test__pandas_s3.py +++ b/tests/functional_tests/metaflow/test__pandas_s3.py @@ -33,7 +33,7 @@ def test_publish_pandas_with_schema(self): # Publish the DataFrame to Snowflake publish_pandas( - table_name="pandas_test_table", + table_name="pandas_test_table_via_s3", df=df, auto_create_table=True, overwrite=True, @@ -64,7 +64,7 @@ def test_publish_pandas_without_schema(self): # Publish the DataFrame to Snowflake with a specific warehouse publish_pandas( - table_name="pandas_test_table", + table_name="pandas_test_table_via_s3", df=df, auto_create_table=True, overwrite=True, @@ -79,7 +79,7 @@ def test_query_pandas(self): from ds_platform_utils.metaflow import query_pandas_from_snowflake # Query to retrieve the data we just published - query = "SELECT * FROM PATTERN_DB.{{schema}}.PANDAS_TEST_TABLE;" + query = "SELECT * FROM PATTERN_DB.{{schema}}.PANDAS_TEST_TABLE_VIA_S3;" # Query the data back result_df = query_pandas_from_snowflake(query, use_s3_stage=True) From 71b3169e4d7bed32e4558a759966814d95006334 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:30:26 +0530 Subject: [PATCH 045/167] fix: correct SQL syntax for creating temporary file format in Snowflake --- src/ds_platform_utils/metaflow/pandas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 51eba65..9d9229e 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -146,7 +146,7 @@ def _infer_table_schema(conn, snowflake_stage_path: str, use_logical_type: bool) """ _execute_sql( conn, - f"CREATE OR REPLACE TEMP FILE FORMAT PQT_FILE_FORMATTYPE = PARQUETUSE_LOGICAL_TYPE = {use_logical_type};", + f"CREATE OR REPLACE TEMP FILE FORMAT PQT_FILE_FORMATTYPE = PARQUET USE_LOGICAL_TYPE = {use_logical_type};", ) infer_schema_query = f""" SELECT column_name, data_type From d023186d6c1282ddb4b64b102d01ad614f4e1e7a Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:36:08 +0530 Subject: [PATCH 046/167] fix: correct SQL syntax for creating temporary file format in Snowflake --- src/ds_platform_utils/metaflow/pandas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 9d9229e..dca6496 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -146,7 +146,7 @@ def _infer_table_schema(conn, snowflake_stage_path: str, use_logical_type: bool) """ _execute_sql( conn, - f"CREATE OR REPLACE TEMP FILE FORMAT PQT_FILE_FORMATTYPE = PARQUET USE_LOGICAL_TYPE = {use_logical_type};", + f"CREATE OR REPLACE TEMP FILE FORMAT PQT_FILE_FORMAT TYPE = PARQUET USE_LOGICAL_TYPE = {use_logical_type};", ) infer_schema_query = f""" SELECT column_name, data_type From 46ea94d0e61b2226545c31fb381573bbe934522b Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:45:08 +0530 Subject: [PATCH 047/167] fix: handle empty DataFrame in S3 write functions and adjust SQL query for schema inference --- src/ds_platform_utils/metaflow/pandas.py | 2 +- src/ds_platform_utils/metaflow/s3.py | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index dca6496..73be79d 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -149,7 +149,7 @@ def _infer_table_schema(conn, snowflake_stage_path: str, use_logical_type: bool) f"CREATE OR REPLACE TEMP FILE FORMAT PQT_FILE_FORMAT TYPE = PARQUET USE_LOGICAL_TYPE = {use_logical_type};", ) infer_schema_query = f""" - SELECT column_name, data_type + SELECT COLUMN_NAME, TYPE FROM TABLE( INFER_SCHEMA( LOCATION => '@{snowflake_stage_path}', diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index c286995..f9608fc 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -45,9 +45,11 @@ def _put_df_to_s3_file(df: pd.DataFrame, path: str) -> None: if not path.startswith("s3://"): raise ValueError("Invalid S3 URI. Must start with 's3://'.") + if len(df) == 0: + raise ValueError("DataFrame is empty. Cannot write empty DataFrame to S3.") with _get_metaflow_s3_client() as s3: with tempfile.NamedTemporaryFile(suffix=".parquet") as tmp_file: - df.to_parquet(tmp_file.name) + df.to_parquet(tmp_file.name, index=False) s3.put_files(key_paths=[[path, tmp_file.name]]) @@ -55,12 +57,14 @@ def _put_df_to_s3_folder(df: pd.DataFrame, path: str, chunk_size=None, compressi if not path.startswith("s3://"): raise ValueError("Invalid S3 URI. Must start with 's3://'.") - if not path.endswith("/"): - path = path.removesuffix("/") + path = path.rstrip("/") # Remove trailing slash if present target_chunk_size_mb = 50 target_chunk_size_bytes = target_chunk_size_mb * 1024 * 1024 + if len(df) == 0: + raise ValueError("DataFrame is empty. Cannot write empty DataFrame to S3.") + def estimate_bytes_per_row(df_sample): return df_sample.memory_usage(deep=True).sum() / len(df_sample) From 4c969531ad1e9e08d3778146fa72a1de2cebdd2c Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 13 Feb 2026 01:09:43 +0530 Subject: [PATCH 048/167] fix: ensure DataFrame columns are lowercase for consistent processing in batch inference --- src/ds_platform_utils/metaflow/batch_inference.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 3f78f52..d1422ba 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -87,6 +87,7 @@ def process_file(batch_id, input_s3_files): print(f"Reading input files for batch {batch_id} from S3...") t1 = time.time() df = s3._get_df_from_s3_files(input_s3_files) + df.columns = [col.lower() for col in df.columns] # Ensure columns are lowercase for consistent processing t2 = time.time() print(f"Read file with {len(df)} rows in {t2 - t1:.2f} seconds.") predictions_df = model_predictor_function(df) From 5a479bbb40028dfe5d3d942d12ec502476e9f86e Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 13 Feb 2026 01:50:09 +0530 Subject: [PATCH 049/167] fix: add prefix to temporary file paths for S3 operations --- src/ds_platform_utils/metaflow/s3.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index f9608fc..07b3632 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -1,7 +1,8 @@ import tempfile +from pathlib import Path import pandas as pd -from metaflow import S3 +from metaflow import S3, current def _get_metaflow_s3_client(): @@ -48,7 +49,10 @@ def _put_df_to_s3_file(df: pd.DataFrame, path: str) -> None: if len(df) == 0: raise ValueError("DataFrame is empty. Cannot write empty DataFrame to S3.") with _get_metaflow_s3_client() as s3: - with tempfile.NamedTemporaryFile(suffix=".parquet") as tmp_file: + with tempfile.NamedTemporaryFile( + prefix=str(Path(current.tempdir).absolute()) + "/", # type: ignore + suffix=".parquet", + ) as tmp_file: df.to_parquet(tmp_file.name, index=False) s3.put_files(key_paths=[[path, tmp_file.name]]) @@ -74,7 +78,7 @@ def estimate_bytes_per_row(df_sample): chunk_size = int(target_chunk_size_bytes / bytes_per_row) chunk_size = max(1, chunk_size) - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(prefix=str(Path(current.tempdir).absolute()) + "/") as temp_dir: # type: ignore with _get_metaflow_s3_client() as s3: template_path = f"{temp_dir}/data_part_{{}}.parquet" key_paths = [] From 15b9148b1c4e6d76964a964cf1dcd56cc2651097 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 13 Feb 2026 02:07:48 +0530 Subject: [PATCH 050/167] fix: update S3 file listing method to use recursive listing --- src/ds_platform_utils/metaflow/s3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 07b3632..587337e 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -14,7 +14,7 @@ def _list_files_in_s3_folder(path: str) -> list: raise ValueError("Invalid S3 URI. Must start with 's3://'.") with _get_metaflow_s3_client() as s3: - return [path.url for path in s3.list_paths([path])] + return [path.url for path in s3.list_recursive([path])] def _get_df_from_s3_file(path: str) -> pd.DataFrame: From 162926e4248309dbb82aaa6ce02c807f63374642 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 16 Feb 2026 08:10:32 +0530 Subject: [PATCH 051/167] fix: update test command and add pytest-xdist dependency for improved test parallelism --- .../workflows/ci-cd-ds-platform-utils.yaml | 2 +- pyproject.toml | 2 +- uv.lock | 24 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-cd-ds-platform-utils.yaml b/.github/workflows/ci-cd-ds-platform-utils.yaml index 4a7031f..bc1a001 100644 --- a/.github/workflows/ci-cd-ds-platform-utils.yaml +++ b/.github/workflows/ci-cd-ds-platform-utils.yaml @@ -110,7 +110,7 @@ jobs: uv pip install --group dev COVERAGE_DIR="$(python -c 'import ds_platform_utils; print(ds_platform_utils.__path__[0])')" poe clean - poe test --cov="$COVERAGE_DIR" --no-cov + poe test --cov="$COVERAGE_DIR" --no-cov -n auto tag-version: needs: [check-version, code-quality-checks, build-wheel, execute-tests] diff --git a/pyproject.toml b/pyproject.toml index 1c94360..eff2544 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,7 +142,7 @@ cmd = "uvx pre-commit run --all-files" help = "Run linting, formatting, projen synth, and other static code quality tools" [tool.poe.tasks.test] -cmd = "pytest -n 4" +cmd = "pytest" help = "Run all tests with optional additional arguments" diff --git a/uv.lock b/uv.lock index 45ba74f..2bdc757 100644 --- a/uv.lock +++ b/uv.lock @@ -499,6 +499,7 @@ dev = [ { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-xdist" }, { name = "ruff" }, ] @@ -521,6 +522,7 @@ dev = [ { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-cov", specifier = ">=6.1.1" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "ruff", specifier = ">=0.11.7" }, ] @@ -545,6 +547,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "executing" version = "2.2.0" @@ -1699,6 +1710,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" From 10e583e8aa81713a17b510e609a972b8713ebb6d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 16 Feb 2026 08:30:38 +0530 Subject: [PATCH 052/167] fix: implement multithreading for batch inference processing to improve performance --- .../metaflow/batch_inference.py | 81 +++++++++++++------ 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index d1422ba..5a8f2ca 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -1,5 +1,6 @@ +import queue +import threading import time -from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Callable, List, Optional, Tuple, Union @@ -79,36 +80,66 @@ def batch_inference( # noqa: PLR0913, PLR0915 batch_size = max(1, batch_size_in_mb // default_file_size_in_mb) input_s3_files = s3._list_files_in_s3_folder(input_s3_path) + input_s3_batches = [input_s3_files[i : i + batch_size] for i in range(0, len(input_s3_files), batch_size)] current.card.append(Markdown("#### Input query results")) current.card.append(Table.from_dataframe(s3._get_df_from_s3_file(input_s3_files[0]).head(5))) - def process_file(batch_id, input_s3_files): - print(f"Processing batch {batch_id}") - print(f"Reading input files for batch {batch_id} from S3...") - t1 = time.time() - df = s3._get_df_from_s3_files(input_s3_files) - df.columns = [col.lower() for col in df.columns] # Ensure columns are lowercase for consistent processing - t2 = time.time() - print(f"Read file with {len(df)} rows in {t2 - t1:.2f} seconds.") - predictions_df = model_predictor_function(df) - t3 = time.time() - print(f"Generated predictions for batch {batch_id} in {t3 - t2:.2f} seconds.") - s3_output_file = f"{output_s3_path}/predictions_batch_{batch_id}.parquet" - s3._put_df_to_s3_file(predictions_df, s3_output_file) - t4 = time.time() - print(f"Uploaded predictions for batch {batch_id} to S3 in {t4 - t3:.2f} seconds.") + download_queue = queue.Queue(maxsize=5) # Adjust maxsize per memory limits + inference_queue = queue.Queue(maxsize=5) + + def download_worker(file_keys): + for batch_id, key in enumerate(file_keys): + print(f"Processing batch {batch_id}") + print(f"Reading input files for batch {batch_id} from S3...") + t1 = time.time() + df = s3._get_df_from_s3_files(key) + df.columns = [col.lower() for col in df.columns] # Ensure columns are lowercase for consistent processing + t2 = time.time() + print(f"Read file with {len(df)} rows in {t2 - t1:.2f} seconds.") + download_queue.put((batch_id, df)) + download_queue.put(None) # Sentinel for end + + def inference_worker(): + while True: + item = download_queue.get() + if item is None: + inference_queue.put(None) + break + batch_id, df = item + print(f"Generating predictions for batch {batch_id}...") + t2 = time.time() + predictions_df = model_predictor_function(df) + t3 = time.time() + print(f"Generated predictions for batch {batch_id} in {t3 - t2:.2f} seconds.") + inference_queue.put((batch_id, predictions_df)) + + def upload_worker(): + while True: + item = inference_queue.get() + if item is None: + break + batch_id, predictions_df = item + t3 = time.time() + s3_output_file = f"{output_s3_path}/predictions_batch_{batch_id}.parquet" + s3._put_df_to_s3_file(predictions_df, s3_output_file) + t4 = time.time() + print(f"Uploaded predictions for batch {batch_id} to S3 in {t4 - t3:.2f} seconds.") print("Starting batch inference...") print(f"Total files to process: {len(input_s3_files)}") - with ThreadPoolExecutor(max_workers=parallelism) as executor: - futures = [] - for i in range(0, len(input_s3_files), batch_size): - batch_id = i // batch_size - batch_files = input_s3_files[i : i + batch_size] - futures.append(executor.submit(process_file, batch_id, batch_files)) - # Wait for all futures to complete - for future in futures: - future.result() + + # Start pipeline threads + t1 = threading.Thread(target=download_worker, args=(input_s3_batches,)) + t2 = threading.Thread(target=inference_worker) + t3 = threading.Thread(target=upload_worker) + + t1.start() + t2.start() + t3.start() + + t1.join() + t2.join() + t3.join() print("Batch inference completed. Uploading results to S3...") From d29988ec87835b8d465efe0f00067a676e78b276 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:24:23 +0530 Subject: [PATCH 053/167] feat: implement multi-threaded batch inference processing with detailed docstring --- .../metaflow/batch_inference.py | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 5a8f2ca..4427f91 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -1,3 +1,4 @@ +import os import queue import threading import time @@ -27,17 +28,53 @@ default_file_size_in_mb = 16 -def batch_inference( # noqa: PLR0913, PLR0915 +def debug(*args, **kwargs): + if os.getenv("DEBUG"): + print(*args, **kwargs) + + +def snowflake_batch_transform( # noqa: PLR0913, PLR0915 input_query: Union[str, Path], output_table_name: str, model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], output_table_schema: Optional[List[Tuple[str, str]]] = None, use_utc: bool = True, batch_size_in_mb: int = 128, - parallelism: int = 1, warehouse: Optional[str] = None, ctx: Optional[dict] = None, ): + """Execute batch inference on data from Snowflake, process it through a model, and upload results back to Snowflake. + + This function orchestrates a multi-threaded pipeline that: + 1. Exports data from Snowflake to S3 using COPY INTO + 2. Downloads data from S3 in batches + 3. Runs model predictions on each batch + 4. Uploads predictions back to S3 + 5. Imports results into a Snowflake table using COPY INTO + + Args: + input_query (Union[str, Path]): SQL query string or file path to query that defines the data to process. + output_table_name (str): Name of the Snowflake table where predictions will be written. + model_predictor_function (Callable[[pd.DataFrame], pd.DataFrame]): Function that takes a DataFrame and returns predictions DataFrame. + output_table_schema (Optional[List[Tuple[str, str]]], optional): Snowflake table schema as list of (column_name, column_type) tuples. + If None, schema is inferred from the first predictions file. Defaults to None. + use_utc (bool, optional): Whether to use UTC timezone for Snowflake connection. Defaults to True. + batch_size_in_mb (int, optional): Target batch size in megabytes for processing. Defaults to 128. + parallelism (int, optional): Reserved for future parallel processing capability. Defaults to 1. + warehouse (Optional[str], optional): Snowflake warehouse to use for queries. If None, uses default warehouse. Defaults to None. + ctx (Optional[dict], optional): Dictionary of variable substitutions for the input query template. Defaults to None. + + Raises: + Exceptions from Snowflake connection, S3 operations, or model prediction function may propagate. + + Notes: + - Uses production or non-production schema based on execution context (Metaflow). + - Creates temporary S3 and Snowflake stage folders with timestamps for isolation. + - Implements a three-threaded pipeline: download -> inference -> upload. + - Column names are normalized to lowercase for consistent processing. + - Displays input query, sample data, and progress messages via Metaflow cards. + + """ is_production = current.is_production if hasattr(current, "is_production") else False s3_bucket, snowflake_stage = _get_s3_config(is_production) schema = PROD_SCHEMA if is_production else NON_PROD_SCHEMA @@ -126,7 +163,7 @@ def upload_worker(): print(f"Uploaded predictions for batch {batch_id} to S3 in {t4 - t3:.2f} seconds.") print("Starting batch inference...") - print(f"Total files to process: {len(input_s3_files)}") + print(f"Total files to process: {len(input_s3_batches)}") # Start pipeline threads t1 = threading.Thread(target=download_worker, args=(input_s3_batches,)) From eafad1b40df7108fbb4f763f5710f9f4f9c2126d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:30:50 +0530 Subject: [PATCH 054/167] fix: replace print statements with debug function for improved logging --- .../metaflow/batch_inference.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 4427f91..3d11888 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -30,6 +30,7 @@ def debug(*args, **kwargs): if os.getenv("DEBUG"): + print("DEBUG: ", end="") print(*args, **kwargs) @@ -126,13 +127,13 @@ def snowflake_batch_transform( # noqa: PLR0913, PLR0915 def download_worker(file_keys): for batch_id, key in enumerate(file_keys): - print(f"Processing batch {batch_id}") - print(f"Reading input files for batch {batch_id} from S3...") + debug(f"Processing batch {batch_id}") + debug(f"Reading input files for batch {batch_id} from S3...") t1 = time.time() df = s3._get_df_from_s3_files(key) df.columns = [col.lower() for col in df.columns] # Ensure columns are lowercase for consistent processing t2 = time.time() - print(f"Read file with {len(df)} rows in {t2 - t1:.2f} seconds.") + debug(f"Read file with {len(df)} rows in {t2 - t1:.2f} seconds.") download_queue.put((batch_id, df)) download_queue.put(None) # Sentinel for end @@ -143,11 +144,11 @@ def inference_worker(): inference_queue.put(None) break batch_id, df = item - print(f"Generating predictions for batch {batch_id}...") + debug(f"Generating predictions for batch {batch_id}...") t2 = time.time() predictions_df = model_predictor_function(df) t3 = time.time() - print(f"Generated predictions for batch {batch_id} in {t3 - t2:.2f} seconds.") + debug(f"Generated predictions for batch {batch_id} in {t3 - t2:.2f} seconds.") inference_queue.put((batch_id, predictions_df)) def upload_worker(): @@ -160,10 +161,10 @@ def upload_worker(): s3_output_file = f"{output_s3_path}/predictions_batch_{batch_id}.parquet" s3._put_df_to_s3_file(predictions_df, s3_output_file) t4 = time.time() - print(f"Uploaded predictions for batch {batch_id} to S3 in {t4 - t3:.2f} seconds.") + debug(f"Uploaded predictions for batch {batch_id} to S3 in {t4 - t3:.2f} seconds.") - print("Starting batch inference...") - print(f"Total files to process: {len(input_s3_batches)}") + debug("Starting batch inference...") + debug(f"Total files to process: {len(input_s3_batches)}") # Start pipeline threads t1 = threading.Thread(target=download_worker, args=(input_s3_batches,)) From 45e882ccff3fb9c19b4a61e5069c29ac7bcdc43b Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:34:19 +0530 Subject: [PATCH 055/167] fix: rename snowflake_batch_transform function to batch_inference for consistency --- src/ds_platform_utils/metaflow/batch_inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 3d11888..4c723e7 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -34,7 +34,7 @@ def debug(*args, **kwargs): print(*args, **kwargs) -def snowflake_batch_transform( # noqa: PLR0913, PLR0915 +def batch_inference( # noqa: PLR0913, PLR0915 input_query: Union[str, Path], output_table_name: str, model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], From 74b6b87c4cab43706e7216b49a7e4d8cb45cdbe2 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:47:50 +0530 Subject: [PATCH 056/167] fix: reduce queue maxsize in batch_inference for optimized memory usage --- src/ds_platform_utils/metaflow/batch_inference.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 4c723e7..951423c 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -122,8 +122,8 @@ def batch_inference( # noqa: PLR0913, PLR0915 current.card.append(Markdown("#### Input query results")) current.card.append(Table.from_dataframe(s3._get_df_from_s3_file(input_s3_files[0]).head(5))) - download_queue = queue.Queue(maxsize=5) # Adjust maxsize per memory limits - inference_queue = queue.Queue(maxsize=5) + download_queue = queue.Queue(maxsize=3) # Adjust maxsize per memory limits + inference_queue = queue.Queue(maxsize=3) def download_worker(file_keys): for batch_id, key in enumerate(file_keys): From 246d41e2e29193d41052ea1f8cf69ff4f643bd6a Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:07:33 +0530 Subject: [PATCH 057/167] fix: reduce queue maxsize in batch_inference for optimized memory usage --- src/ds_platform_utils/metaflow/batch_inference.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 951423c..12fff86 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -122,8 +122,8 @@ def batch_inference( # noqa: PLR0913, PLR0915 current.card.append(Markdown("#### Input query results")) current.card.append(Table.from_dataframe(s3._get_df_from_s3_file(input_s3_files[0]).head(5))) - download_queue = queue.Queue(maxsize=3) # Adjust maxsize per memory limits - inference_queue = queue.Queue(maxsize=3) + download_queue = queue.Queue(maxsize=1) # Adjust maxsize per memory limits + inference_queue = queue.Queue(maxsize=1) def download_worker(file_keys): for batch_id, key in enumerate(file_keys): From 7501517991c6883a9480eabfad4b187f6122e853 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:18:29 +0530 Subject: [PATCH 058/167] feat: enhance batch inference with timer context manager and improved S3 handling --- .../metaflow/batch_inference.py | 192 +++++++++++------- 1 file changed, 118 insertions(+), 74 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 12fff86..994b877 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -2,18 +2,18 @@ import queue import threading import time +from contextlib import contextmanager from pathlib import Path from typing import Callable, List, Optional, Tuple, Union import pandas as pd from metaflow import current -from metaflow.cards import Markdown, Table from ds_platform_utils._snowflake.run_query import _execute_sql from ds_platform_utils._snowflake.write_audit_publish import get_query_from_string_or_fpath, substitute_map_into_string from ds_platform_utils.metaflow import s3 from ds_platform_utils.metaflow._consts import ( - NON_PROD_SCHEMA, + DEV_SCHEMA, PROD_SCHEMA, S3_DATA_FOLDER, ) @@ -25,16 +25,45 @@ _infer_table_schema, ) -default_file_size_in_mb = 16 +default_file_size_in_mb = 10 -def debug(*args, **kwargs): +def _debug(*args, **kwargs): if os.getenv("DEBUG"): print("DEBUG: ", end="") print(*args, **kwargs) -def batch_inference( # noqa: PLR0913, PLR0915 +@contextmanager +def timer(message: str): + t0 = time.time() + yield + t1 = time.time() + _debug(f"{message}: Completed in {t1 - t0:.2f} seconds") + + +def make_batches_of_files(files_list, batch_size_in_mb): + with s3._get_metaflow_s3_client() as s3_client: + file_sizes = [(file.key, file.size) for file in s3_client.info_many(files_list)] + + batches = [] + current_batch = [] + current_batch_size = 0 + for file_key, file_size in file_sizes: + if current_batch_size + file_size > batch_size_in_mb * 1024 * 1024: + batches.append(current_batch) + current_batch = [] + current_batch_size = 0 + + current_batch.append(file_key) + current_batch_size += file_size + + if current_batch: + batches.append(current_batch) + return batches + + +def snowflake_batch_inference( # noqa: PLR0913, PLR0915 input_query: Union[str, Path], output_table_name: str, model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], @@ -43,6 +72,7 @@ def batch_inference( # noqa: PLR0913, PLR0915 batch_size_in_mb: int = 128, warehouse: Optional[str] = None, ctx: Optional[dict] = None, + timeout_per_batch: int = 300, ): """Execute batch inference on data from Snowflake, process it through a model, and upload results back to Snowflake. @@ -76,10 +106,12 @@ def batch_inference( # noqa: PLR0913, PLR0915 - Displays input query, sample data, and progress messages via Metaflow cards. """ + ## Define S3 paths and Snowflake schema based on environment is_production = current.is_production if hasattr(current, "is_production") else False s3_bucket, snowflake_stage = _get_s3_config(is_production) - schema = PROD_SCHEMA if is_production else NON_PROD_SCHEMA + schema = PROD_SCHEMA if is_production else DEV_SCHEMA + ## Create unique S3 paths for this batch inference run using timestamp timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") upload_folder = f"publish_{timestamp}" download_folder = f"query_{timestamp}" @@ -89,15 +121,10 @@ def batch_inference( # noqa: PLR0913, PLR0915 output_snowflake_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{upload_folder}" # Step 1: Build COPY INTO query to export data from Snowflake to S3 - input_query = get_query_from_string_or_fpath(input_query) input_query = substitute_map_into_string(input_query, {"schema": schema} | (ctx or {})) _debug_print_query(input_query) - - current.card.append(Markdown("### Batch Predictions From Snowflake via S3 Stage")) - current.card.append(Markdown(input_query)) - current.card.append(Markdown(f"#### Input S3 staging path: `{input_s3_path}`")) conn = get_snowflake_connection(use_utc) if warehouse is not None: _execute_sql(conn, f"USE WAREHOUSE {warehouse};") @@ -108,66 +135,108 @@ def batch_inference( # noqa: PLR0913, PLR0915 snowflake_stage_path=input_snowflake_stage_path, batch_size_in_mb=default_file_size_in_mb, ) - t0 = time.time() - print("Exporting data from Snowflake to S3...") - _execute_sql(conn, copy_to_s3_query) + + with timer("Exporting data from Snowflake to S3"): + _execute_sql(conn, copy_to_s3_query) conn.close() - t1 = time.time() - print(f"Data export completed in {t1 - t0:.2f} seconds. Starting batch inference...") - batch_size = max(1, batch_size_in_mb // default_file_size_in_mb) + batch_inference_from_s3( + input_s3_path=input_s3_path, + output_s3_folder_path=output_s3_path, + model_predictor_function=model_predictor_function, + timeout_per_batch=timeout_per_batch, + ) - input_s3_files = s3._list_files_in_s3_folder(input_s3_path) - input_s3_batches = [input_s3_files[i : i + batch_size] for i in range(0, len(input_s3_files), batch_size)] - current.card.append(Markdown("#### Input query results")) - current.card.append(Table.from_dataframe(s3._get_df_from_s3_file(input_s3_files[0]).head(5))) + conn = get_snowflake_connection(use_utc) + if warehouse is not None: + _execute_sql(conn, f"USE WAREHOUSE {warehouse};") + _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") + + if output_table_schema is None: + # Infer schema from the first predictions file + output_table_schema = _infer_table_schema(conn, output_snowflake_stage_path, True) + + copy_from_s3_query = _generate_s3_to_snowflake_copy_query( + schema=schema, + table_name=output_table_name, + snowflake_stage_path=output_snowflake_stage_path, + overwrite=True, + auto_create_table=True, + table_schema=output_table_schema, + ) + + with timer("Uploading predictions from s3 to Snowflake"): + _execute_sql(conn, copy_from_s3_query) + + conn.close() + + print("✅ Batch inference completed successfully!") + + +def batch_inference_from_s3( + input_s3_path: str | List[str], + output_s3_folder_path: str, + model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], + timeout_per_batch: int = 300, + batch_size_in_mb: int = 128, +): + if isinstance(input_s3_path, str): + if str.endswith(input_s3_path, ".parquet"): + input_s3_files = [input_s3_path] + else: + input_s3_files = s3._list_files_in_s3_folder(input_s3_path) + + elif not isinstance(input_s3_path, list): + raise ValueError("input_s3_path must be a string or list of strings.") + + else: + input_s3_files = input_s3_path + + ## Check if all paths are valid S3 URIs + if any(not path.startswith("s3://") and path.endswith(".parquet") for path in input_s3_files): + raise ValueError("Invalid S3 URI. All paths or folder files must start with 's3://' and end with '.parquet'.") + + input_s3_batches = make_batches_of_files(input_s3_files, batch_size_in_mb) + + print(f"📊 Total Batches to process: {len(input_s3_batches)}") download_queue = queue.Queue(maxsize=1) # Adjust maxsize per memory limits inference_queue = queue.Queue(maxsize=1) def download_worker(file_keys): for batch_id, key in enumerate(file_keys): - debug(f"Processing batch {batch_id}") - debug(f"Reading input files for batch {batch_id} from S3...") - t1 = time.time() - df = s3._get_df_from_s3_files(key) - df.columns = [col.lower() for col in df.columns] # Ensure columns are lowercase for consistent processing - t2 = time.time() - debug(f"Read file with {len(df)} rows in {t2 - t1:.2f} seconds.") - download_queue.put((batch_id, df)) - download_queue.put(None) # Sentinel for end + with timer(f"Downloading batch {batch_id} from S3"): + df = s3._get_df_from_s3_files(key) + df.columns = [ + col.lower() for col in df.columns + ] # Ensure columns are lowercase for consistent processing + download_queue.put((batch_id, df), timeout=timeout_per_batch) + download_queue.put(None, timeout=timeout_per_batch) def inference_worker(): while True: - item = download_queue.get() + item = download_queue.get(timeout=timeout_per_batch) if item is None: - inference_queue.put(None) + inference_queue.put(None, timeout=timeout_per_batch) break batch_id, df = item - debug(f"Generating predictions for batch {batch_id}...") - t2 = time.time() - predictions_df = model_predictor_function(df) - t3 = time.time() - debug(f"Generated predictions for batch {batch_id} in {t3 - t2:.2f} seconds.") - inference_queue.put((batch_id, predictions_df)) + _debug(f"Generating predictions for batch {batch_id}...") + with timer(f"Generating predictions for batch {batch_id}"): + predictions_df = model_predictor_function(df) + inference_queue.put((batch_id, predictions_df), timeout=timeout_per_batch) def upload_worker(): while True: - item = inference_queue.get() + item = inference_queue.get(timeout=timeout_per_batch) if item is None: break batch_id, predictions_df = item - t3 = time.time() - s3_output_file = f"{output_s3_path}/predictions_batch_{batch_id}.parquet" - s3._put_df_to_s3_file(predictions_df, s3_output_file) - t4 = time.time() - debug(f"Uploaded predictions for batch {batch_id} to S3 in {t4 - t3:.2f} seconds.") - - debug("Starting batch inference...") - debug(f"Total files to process: {len(input_s3_batches)}") + s3_output_file = f"{output_s3_folder_path}/predictions_{batch_id}.parquet" + with timer(f"Uploading predictions for batch {batch_id} to S3"): + s3._put_df_to_s3_file(predictions_df, s3_output_file) # Start pipeline threads - t1 = threading.Thread(target=download_worker, args=(input_s3_batches,)) + t1 = threading.Thread(target=download_worker, args=(input_s3_path,)) t2 = threading.Thread(target=inference_worker) t3 = threading.Thread(target=upload_worker) @@ -179,29 +248,4 @@ def upload_worker(): t2.join() t3.join() - print("Batch inference completed. Uploading results to S3...") - - conn = get_snowflake_connection(use_utc) - if warehouse is not None: - _execute_sql(conn, f"USE WAREHOUSE {warehouse};") - _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") - - if output_table_schema is None: - # Infer schema from the first predictions file - output_table_schema = _infer_table_schema(conn, output_snowflake_stage_path, True) - - copy_from_s3_query = _generate_s3_to_snowflake_copy_query( - schema=schema, - table_name=output_table_name, - snowflake_stage_path=output_snowflake_stage_path, - overwrite=True, - auto_create_table=True, - table_schema=output_table_schema, - ) - t0 = time.time() - print("Copying predictions from S3 to Snowflake...") - _execute_sql(conn, copy_from_s3_query) - t1 = time.time() - print(f"Data import completed in {t1 - t0:.2f} seconds.") - - conn.close() + print("✅ All batches processed successfully!") From 031cd51631b15cbf6c251a11238b3380714325b1 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:18:50 +0530 Subject: [PATCH 059/167] fix: rename NON_PROD_SCHEMA to DEV_SCHEMA for clarity --- src/ds_platform_utils/metaflow/_consts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/_consts.py b/src/ds_platform_utils/metaflow/_consts.py index 945d644..7247200 100644 --- a/src/ds_platform_utils/metaflow/_consts.py +++ b/src/ds_platform_utils/metaflow/_consts.py @@ -1,5 +1,5 @@ PROD_SCHEMA = "DATA_SCIENCE" -NON_PROD_SCHEMA = "DATA_SCIENCE_STAGE" +DEV_SCHEMA = "DATA_SCIENCE_STAGE" SNOWFLAKE_INTEGRATION = "snowflake-default" From 637eba0c9702ce55878f5a6c2e849c2928b890a5 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:19:18 +0530 Subject: [PATCH 060/167] fix: replace NON_PROD_SCHEMA with DEV_SCHEMA for consistency in Snowflake operations --- .../_snowflake/write_audit_publish.py | 8 ++++---- src/ds_platform_utils/metaflow/pandas.py | 13 ++++--------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/ds_platform_utils/_snowflake/write_audit_publish.py b/src/ds_platform_utils/_snowflake/write_audit_publish.py index 5ae6046..ce62234 100644 --- a/src/ds_platform_utils/_snowflake/write_audit_publish.py +++ b/src/ds_platform_utils/_snowflake/write_audit_publish.py @@ -8,7 +8,7 @@ from snowflake.connector.cursor import SnowflakeCursor from ds_platform_utils._snowflake.run_query import _execute_sql -from ds_platform_utils.metaflow._consts import NON_PROD_SCHEMA, PROD_SCHEMA +from ds_platform_utils.metaflow._consts import DEV_SCHEMA, PROD_SCHEMA def write_audit_publish( # noqa: PLR0913 (too-many-arguments) this fn is an exception @@ -29,7 +29,7 @@ def write_audit_publish( # noqa: PLR0913 (too-many-arguments) this fn is an exc performed and the query is simply run against the final table. """ # gather inputs - publish_schema = PROD_SCHEMA if is_production else NON_PROD_SCHEMA + publish_schema = PROD_SCHEMA if is_production else DEV_SCHEMA query = get_query_from_string_or_fpath(query) audits = audits or [] @@ -119,8 +119,8 @@ def _write_audit_publish( # noqa: PLR0913 (too-many-arguments) this fn is an ex This function assumes all inputs have been validated and formatted correctly. """ - audit_schema = NON_PROD_SCHEMA - publish_schema = PROD_SCHEMA if is_production else NON_PROD_SCHEMA + audit_schema = DEV_SCHEMA + publish_schema = PROD_SCHEMA if is_production else DEV_SCHEMA # Generate unique branch name branch_name = branch_name or str(uuid.uuid4())[:8] diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 73be79d..3545e30 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -14,8 +14,8 @@ from ds_platform_utils._snowflake.run_query import _execute_sql from ds_platform_utils.metaflow._consts import ( DEV_S3_BUCKET, + DEV_SCHEMA, DEV_SNOWFLAKE_STAGE, - NON_PROD_SCHEMA, PROD_S3_BUCKET, PROD_SCHEMA, PROD_SNOWFLAKE_STAGE, @@ -168,7 +168,7 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) df: pd.DataFrame, add_created_date: bool = False, chunk_size: Optional[int] = None, - compression: Literal["snappy", "gzip"] = "gzip", + compression: Literal["snappy", "gzip"] = "snappy", warehouse: Optional[TWarehouse] = None, parallel: int = 4, quote_identifiers: bool = True, @@ -234,7 +234,7 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) df["created_date"] = datetime.now().astimezone(pytz.utc) table_name = table_name.upper() - schema = PROD_SCHEMA if current.is_production else NON_PROD_SCHEMA + schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA # Preview the DataFrame in the Metaflow card if warehouse is not None: @@ -335,7 +335,7 @@ def query_pandas_from_snowflake( substitute_map_into_string, ) - schema = PROD_SCHEMA if current.is_production else NON_PROD_SCHEMA + schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA # adding query tags comment in query for cost tracking in select.dev tags = get_select_dev_query_tags() @@ -357,11 +357,6 @@ def query_pandas_from_snowflake( current.card.append(Markdown("## Querying Snowflake Table")) current.card.append(Markdown(f"```sql\n{query}\n```")) - conn: SnowflakeConnection = get_snowflake_connection(use_utc) - if warehouse is not None: - _execute_sql(conn, f"USE WAREHOUSE {warehouse};") - _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") - if use_s3_stage: s3_bucket, snowflake_stage = _get_s3_config(current.is_production) data_folder = "query_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) From 758af9b754f420003a65ef51ca8907e67bd08b5f Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 08:37:36 +0530 Subject: [PATCH 061/167] refactor: replace threading with ThreadPoolExecutor for batch processing in S3 inference --- .../metaflow/batch_inference.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 994b877..29a9dc8 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -1,7 +1,7 @@ import os import queue -import threading import time +from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from pathlib import Path from typing import Callable, List, Optional, Tuple, Union @@ -235,17 +235,10 @@ def upload_worker(): with timer(f"Uploading predictions for batch {batch_id} to S3"): s3._put_df_to_s3_file(predictions_df, s3_output_file) - # Start pipeline threads - t1 = threading.Thread(target=download_worker, args=(input_s3_path,)) - t2 = threading.Thread(target=inference_worker) - t3 = threading.Thread(target=upload_worker) - - t1.start() - t2.start() - t3.start() - - t1.join() - t2.join() - t3.join() + with ThreadPoolExecutor(max_workers=3) as executor: + # Use .submit() for different functions with varying arguments + executor.submit(download_worker, input_s3_batches) + executor.submit(inference_worker) + executor.submit(upload_worker) print("✅ All batches processed successfully!") From 8bccfe69ebf98b7be1fa0bf708eb4badfa29fd68 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:56:11 +0530 Subject: [PATCH 062/167] feat: implement S3 to Snowflake and Snowflake to S3 data transfer functions --- src/ds_platform_utils/metaflow/s3_stage.py | 231 +++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 src/ds_platform_utils/metaflow/s3_stage.py diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py new file mode 100644 index 0000000..89074f5 --- /dev/null +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -0,0 +1,231 @@ +from typing import List, Optional, Tuple + +import pandas as pd +from metaflow import current + +from ds_platform_utils._snowflake.run_query import _execute_sql +from ds_platform_utils.metaflow import s3 +from ds_platform_utils.metaflow._consts import ( + DEV_S3_BUCKET, + DEV_SCHEMA, + DEV_SNOWFLAKE_STAGE, + PROD_S3_BUCKET, + PROD_SCHEMA, + PROD_SNOWFLAKE_STAGE, + S3_DATA_FOLDER, +) +from ds_platform_utils.metaflow.get_snowflake_connection import get_snowflake_connection + + +def _get_s3_config(is_production: bool) -> Tuple[str, str]: + """Return the appropriate S3 bucket and Snowflake stage based on the environment.""" + if is_production: + s3_bucket = PROD_S3_BUCKET + snowflake_stage = PROD_SNOWFLAKE_STAGE + else: + s3_bucket = DEV_S3_BUCKET + snowflake_stage = DEV_SNOWFLAKE_STAGE + + return s3_bucket, snowflake_stage + + +def _generate_snowflake_to_s3_copy_query( + query: str, + snowflake_stage_path: str, +) -> str: + """Generate SQL COPY INTO command to export Snowflake query results to S3. + + :param query: SQL query to execute + :param snowflake_stage_path: The path to the Snowflake stage where the data will be exported. This should include the stage name and any necessary subfolders (e.g., 'my_snowflake_stage/my_folder'). + :return: COPY INTO SQL command + """ + if snowflake_stage_path.endswith(".parquet"): + single = "TRUE" + max_file_size = 100 * 1024 * 1024 * 1024 # 100 GB + else: + single = "FALSE" + max_file_size = 16 * 1024 * 1024 # 16 MB + + if query.count(";") > 1: + raise ValueError("Multiple SQL statements detected. Please provide a single query statement.") + query = query.replace(";", "") # Remove trailing semicolon if present + copy_query = f""" + COPY INTO @{snowflake_stage_path} + FROM ( + {query} + ) + OVERWRITE = TRUE + FILE_FORMAT = (TYPE = 'parquet') + MAX_FILE_SIZE = {max_file_size} + SINGLE = {single} + HEADER = TRUE + DETAILED_OUTPUT = TRUE; + """ + return copy_query + + +def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 + snowflake_stage_path: str, + table_name: str, + table_defination: List[Tuple[str, str]], + overwrite: bool = True, + auto_create_table: bool = True, + use_logical_type: bool = True, +) -> str: + """Generate SQL commands to load data from S3 to Snowflake table. + + This function generates a complete SQL script that includes: + 1. DROP TABLE IF EXISTS (if overwrite=True) + 2. CREATE TABLE IF NOT EXISTS (if auto_create_table=True or overwrite=True) + 3. COPY INTO command to load data from S3 + + :param table_name: Target table name + :param snowflake_stage_path: The path to the Snowflake stage where the data will be exported. This should include the stage name and any necessary subfolders (e.g., 'my_snowflake_stage/my_folder'). + :param table_defination: List of tuples with column names and types + :param overwrite: If True, drop and recreate the table. Default True + :param auto_create_table: If True, create the table if it doesn't exist. Default True + :param use_logical_type: Whether to use Parquet logical types when reading the parquet files. Default True. + :return: Complete SQL script with table management and COPY INTO commands + """ + sql_statements = [] + + if auto_create_table and not overwrite: + table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_defination]) + create_table_query = f"""CREATE TABLE IF NOT EXISTS {table_name} ( {table_create_columns_str} );""" + sql_statements.append(create_table_query) + + if auto_create_table and overwrite: + table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_defination]) + create_table_query = f"""CREATE OR REPLACE TABLE {table_name} ( {table_create_columns_str} );""" + sql_statements.append(create_table_query) + + if not auto_create_table and overwrite: + sql_statements.append(f"TRUNCATE TABLE IF EXISTS {table_name};") + + columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_defination]) + + copy_query = f"""COPY INTO {table_name} FROM ( + SELECT {columns_str} + FROM @{snowflake_stage_path}) + FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = {use_logical_type}) + MATCH_BY_COLUMN_NAME = 'CASE_INSENSITIVE' + ;""" + sql_statements.append(copy_query) + + # Combine all statements into a single SQL script + return "\n\n".join(sql_statements) + + +def _infer_table_schema(conn, snowflake_stage_path: str, use_logical_type: bool) -> List[Tuple[str, str]]: + """Infer Snowflake table schema from Parquet files in a Snowflake stage. + + :param snowflake_stage_path: The path to the Snowflake stage where the Parquet files are located. This should include the stage name and any necessary subfolders (e.g., 'my_snowflake_stage/my_folder'). + :return: List of tuples with column names and inferred Snowflake data types + """ + _execute_sql( + conn, + f"CREATE OR REPLACE TEMP FILE FORMAT PQT_FILE_FORMAT TYPE = PARQUET USE_LOGICAL_TYPE = {use_logical_type};", + ) + infer_schema_query = f""" + SELECT COLUMN_NAME, TYPE + FROM TABLE( + INFER_SCHEMA( + LOCATION => '@{snowflake_stage_path}', + FILE_FORMAT => 'PQT_FILE_FORMAT' + )); + """ + cursor = _execute_sql(conn, infer_schema_query) + if cursor is None: + raise ValueError("Failed to infer schema: No cursor returned from Snowflake.") + result = cursor.fetch_pandas_all() + return list(zip(result["COLUMN_NAME"], result["TYPE"])) + + +def copy_snowflake_to_s3( + query: str, + warehouse: Optional[str] = None, + use_utc: bool = True, +) -> List[str]: + """Generate SQL COPY INTO command to export Snowflake query results to S3. + + :param query: SQL query to execute + :param warehouse: Snowflake warehouse to use + :param use_utc: Whether to use UTC time + + :return: List of S3 file paths where the data was exported + """ + schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA + s3_bucket, snowflake_stage = _get_s3_config(current.is_production) + + data_folder = "query_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) + s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{data_folder}" + sf_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{data_folder}" + query = _generate_snowflake_to_s3_copy_query( + query=query, + snowflake_stage_path=sf_stage_path, + ) + conn = get_snowflake_connection(use_utc) + if warehouse is not None: + _execute_sql(conn, f"USE WAREHOUSE {warehouse};") + _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") + + _execute_sql(conn, query) + + print(f"✅ Data exported to S3 path: {s3_path}") + + file_paths = s3._list_files_in_s3_folder(s3_path) + return file_paths + + +def copy_s3_to_snowflake( # noqa: PLR0913 + s3_path: str, + table_name: str, + table_defination: List[Tuple[str, str]], + warehouse: Optional[str] = None, + use_utc: bool = True, + auto_create_table: bool = False, + overwrite: bool = False, + use_logical_type: bool = True, +): + """Generate SQL commands to load data from S3 to Snowflake table. + + This function generates a complete SQL script that includes: + 1. DROP TABLE IF EXISTS (if overwrite=True) + 2. CREATE TABLE IF NOT EXISTS (if auto_create_table=True or overwrite=True) + 3. COPY INTO command to load data from S3 + + :param s3_path: The S3 path where the data is located. This should include the bucket name and any necessary subfolders (e.g., 's3://my_bucket/my_folder'). + :param table_name: Target table name + :param table_defination: List of tuples with column names and types + :param overwrite: If True, drop and recreate the table. Default True + :param auto_create_table: If True, create the table if it doesn't exist. Default True + :param use_logical_type: Whether to use Parquet logical types when reading the parquet files. Default True. + :return: Complete SQL script with table management and COPY INTO commands + """ + table_name = table_name.upper() + schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA + s3_bucket, snowflake_stage = _get_s3_config(current.is_production) + data_folder = "publish_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) + sf_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{data_folder}" + + conn = get_snowflake_connection(use_utc) + if warehouse is not None: + _execute_sql(conn, f"USE WAREHOUSE {warehouse};") + _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") + + if table_defination is None: + # Infer table schema from the Parquet files in the Snowflake stage + table_defination = _infer_table_schema(conn, sf_stage_path, use_logical_type) + + copy_query = _generate_s3_to_snowflake_copy_query( + schema=schema, + table_name=table_name, + snowflake_stage_path=sf_stage_path, + table_defination=table_defination, + overwrite=overwrite, + auto_create_table=auto_create_table, + use_logical_type=use_logical_type, + ) + _execute_sql(conn, copy_query) + + print(f"✅ Data loaded into Snowflake table: {schema}.{table_name}") From 48bfb2aac5e4e77b1db7ddd7f00875087d7baf9f Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:08:03 +0530 Subject: [PATCH 063/167] feat: add warning for oversized files in batch processing and make table definition optional in S3 copy function --- src/ds_platform_utils/metaflow/batch_inference.py | 15 +++++++++++---- src/ds_platform_utils/metaflow/s3_stage.py | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 29a9dc8..8f2005c 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -49,17 +49,24 @@ def make_batches_of_files(files_list, batch_size_in_mb): batches = [] current_batch = [] current_batch_size = 0 + warnings = False + + batch_size_in_bytes = batch_size_in_mb * 1024 * 1024 for file_key, file_size in file_sizes: - if current_batch_size + file_size > batch_size_in_mb * 1024 * 1024: + current_batch.append(file_key) + current_batch_size += file_size + if current_batch_size > batch_size_in_bytes: + if len(current_batch) == 1: + warnings = True batches.append(current_batch) current_batch = [] current_batch_size = 0 - current_batch.append(file_key) - current_batch_size += file_size - if current_batch: batches.append(current_batch) + if warnings: + print("⚠️ Files larger than batch size detected. Increase batch size to avoid this warning.") + return batches diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 89074f5..93c16d5 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -180,7 +180,7 @@ def copy_snowflake_to_s3( def copy_s3_to_snowflake( # noqa: PLR0913 s3_path: str, table_name: str, - table_defination: List[Tuple[str, str]], + table_defination: Optional[List[Tuple[str, str]]] = None, warehouse: Optional[str] = None, use_utc: bool = True, auto_create_table: bool = False, From 616be05f2b9523863002095b5a4e7eeb0c510e1f Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:12:54 +0530 Subject: [PATCH 064/167] fix: add trailing slash to Snowflake stage path in copy_snowflake_to_s3 function --- src/ds_platform_utils/metaflow/s3_stage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 93c16d5..2ce886a 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -159,7 +159,7 @@ def copy_snowflake_to_s3( data_folder = "query_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{data_folder}" - sf_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{data_folder}" + sf_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{data_folder}/" query = _generate_snowflake_to_s3_copy_query( query=query, snowflake_stage_path=sf_stage_path, From 3cf91c90a81300daaa23dff9d2eece0056cf2d3f Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:06:46 +0530 Subject: [PATCH 065/167] fix: remove unused schema parameter from copy_s3_to_snowflake function --- src/ds_platform_utils/metaflow/s3_stage.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 2ce886a..d85deb2 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -218,7 +218,6 @@ def copy_s3_to_snowflake( # noqa: PLR0913 table_defination = _infer_table_schema(conn, sf_stage_path, use_logical_type) copy_query = _generate_s3_to_snowflake_copy_query( - schema=schema, table_name=table_name, snowflake_stage_path=sf_stage_path, table_defination=table_defination, From 4afa9ffdc3119ecf146641eda6912f278dfc9efb Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:15:37 +0530 Subject: [PATCH 066/167] fix: correct Snowflake stage path syntax in S3 to Snowflake copy query --- src/ds_platform_utils/metaflow/s3_stage.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index d85deb2..f5f4cc2 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -104,9 +104,7 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_defination]) - copy_query = f"""COPY INTO {table_name} FROM ( - SELECT {columns_str} - FROM @{snowflake_stage_path}) + copy_query = f"""COPY INTO {table_name} FROM '@{snowflake_stage_path}' FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = {use_logical_type}) MATCH_BY_COLUMN_NAME = 'CASE_INSENSITIVE' ;""" From cff9710a787bf3d60e7a530b303da2aa692952bd Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:16:34 +0530 Subject: [PATCH 067/167] fix: comment out unused SQL generation code in S3 to Snowflake copy query --- src/ds_platform_utils/metaflow/s3_stage.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index f5f4cc2..dbb6c00 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -102,13 +102,13 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 if not auto_create_table and overwrite: sql_statements.append(f"TRUNCATE TABLE IF EXISTS {table_name};") - columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_defination]) + # columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_defination]) - copy_query = f"""COPY INTO {table_name} FROM '@{snowflake_stage_path}' - FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = {use_logical_type}) - MATCH_BY_COLUMN_NAME = 'CASE_INSENSITIVE' - ;""" - sql_statements.append(copy_query) + # copy_query = f"""COPY INTO {table_name} FROM '@{snowflake_stage_path}' + # FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = {use_logical_type}) + # MATCH_BY_COLUMN_NAME = 'CASE_INSENSITIVE' + # ;""" + # sql_statements.append(copy_query) # Combine all statements into a single SQL script return "\n\n".join(sql_statements) From a54a7f9649eed47d89308317ebf1c6ba5573664c Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:24:37 +0530 Subject: [PATCH 068/167] feat: enable generation of COPY INTO query in S3 to Snowflake function and log the output --- src/ds_platform_utils/metaflow/s3_stage.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index dbb6c00..2e491f2 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -104,11 +104,12 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 # columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_defination]) - # copy_query = f"""COPY INTO {table_name} FROM '@{snowflake_stage_path}' - # FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = {use_logical_type}) - # MATCH_BY_COLUMN_NAME = 'CASE_INSENSITIVE' - # ;""" - # sql_statements.append(copy_query) + copy_query = f"""COPY INTO {table_name} FROM '@{snowflake_stage_path}' + FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = {use_logical_type}) + MATCH_BY_COLUMN_NAME = 'CASE_INSENSITIVE' + ;""" + print(f"Generated COPY INTO query:\n{copy_query}") + sql_statements.append(copy_query) # Combine all statements into a single SQL script return "\n\n".join(sql_statements) From aca1ed01828c430fee72aeae63311fc687258f11 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:31:17 +0530 Subject: [PATCH 069/167] feat: add logging for generated SQL queries in S3 to Snowflake copy function --- src/ds_platform_utils/metaflow/s3_stage.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 2e491f2..7873e27 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -92,14 +92,17 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 if auto_create_table and not overwrite: table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_defination]) create_table_query = f"""CREATE TABLE IF NOT EXISTS {table_name} ( {table_create_columns_str} );""" + print(f"Generated CREATE TABLE query:\n{create_table_query}") sql_statements.append(create_table_query) if auto_create_table and overwrite: table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_defination]) create_table_query = f"""CREATE OR REPLACE TABLE {table_name} ( {table_create_columns_str} );""" + print(f"Generated CREATE OR REPLACE TABLE query:\n{create_table_query}") sql_statements.append(create_table_query) if not auto_create_table and overwrite: + print(f"Generated TRUNCATE TABLE query:\nTRUNCATE TABLE IF EXISTS {table_name};") sql_statements.append(f"TRUNCATE TABLE IF EXISTS {table_name};") # columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_defination]) From 1f1f7e15c3aff134b47da23049a888c9208d6056 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:33:05 +0530 Subject: [PATCH 070/167] feat: add logging for inferred table schema in copy_s3_to_snowflake function --- src/ds_platform_utils/metaflow/s3_stage.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 7873e27..54171de 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -218,6 +218,7 @@ def copy_s3_to_snowflake( # noqa: PLR0913 if table_defination is None: # Infer table schema from the Parquet files in the Snowflake stage table_defination = _infer_table_schema(conn, sf_stage_path, use_logical_type) + print(f"Inferred table schema: {table_defination}") copy_query = _generate_s3_to_snowflake_copy_query( table_name=table_name, From 11af56872cdf4f5ba07f8c4d2c3018578f24da1f Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:34:59 +0530 Subject: [PATCH 071/167] fix: update condition to infer table schema in copy_s3_to_snowflake function --- src/ds_platform_utils/metaflow/s3_stage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 54171de..69748de 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -215,7 +215,7 @@ def copy_s3_to_snowflake( # noqa: PLR0913 _execute_sql(conn, f"USE WAREHOUSE {warehouse};") _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") - if table_defination is None: + if table_defination: # Infer table schema from the Parquet files in the Snowflake stage table_defination = _infer_table_schema(conn, sf_stage_path, use_logical_type) print(f"Inferred table schema: {table_defination}") From 6eb342e01e233d529e57315de3104858db01ed43 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:36:39 +0530 Subject: [PATCH 072/167] feat: add logging for S3 upload path in copy_s3_to_snowflake function --- src/ds_platform_utils/metaflow/s3_stage.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 69748de..8ef7fd3 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -220,6 +220,7 @@ def copy_s3_to_snowflake( # noqa: PLR0913 table_defination = _infer_table_schema(conn, sf_stage_path, use_logical_type) print(f"Inferred table schema: {table_defination}") + print(f"Uploading data from S3 path: {s3_path} to Snowflake stage: @{sf_stage_path}") copy_query = _generate_s3_to_snowflake_copy_query( table_name=table_name, snowflake_stage_path=sf_stage_path, From 90e944e585d3e7509e0fced7fd7d1f89fdff15f2 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:37:44 +0530 Subject: [PATCH 073/167] fix: update condition to infer table schema in copy_s3_to_snowflake function --- src/ds_platform_utils/metaflow/s3_stage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 8ef7fd3..a6a1843 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -215,7 +215,7 @@ def copy_s3_to_snowflake( # noqa: PLR0913 _execute_sql(conn, f"USE WAREHOUSE {warehouse};") _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") - if table_defination: + if not table_defination: # Infer table schema from the Parquet files in the Snowflake stage table_defination = _infer_table_schema(conn, sf_stage_path, use_logical_type) print(f"Inferred table schema: {table_defination}") From 22db8f8eec15ec1ccba08a9e706ff1cc28fab4f8 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:22:02 +0530 Subject: [PATCH 074/167] fix: validate S3 path format in copy_s3_to_snowflake function --- src/ds_platform_utils/metaflow/s3_stage.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index a6a1843..114948b 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -206,21 +206,25 @@ def copy_s3_to_snowflake( # noqa: PLR0913 """ table_name = table_name.upper() schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA - s3_bucket, snowflake_stage = _get_s3_config(current.is_production) - data_folder = "publish_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) - sf_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{data_folder}" + if current.is_production: + if not s3_path.startswith(f"s3://{PROD_S3_BUCKET}"): + raise ValueError(f"In production environment, s3_path must start with s3://{PROD_S3_BUCKET}") + elif not s3_path.startswith(f"s3://{DEV_S3_BUCKET}"): + raise ValueError(f"In development environment, s3_path must start with s3://{DEV_S3_BUCKET}") + s3_bucket, snowflake_stage = _get_s3_config(current.is_production) + sf_stage_path = s3_path.replace(f"s3://{s3_bucket}", f"{snowflake_stage}") conn = get_snowflake_connection(use_utc) if warehouse is not None: _execute_sql(conn, f"USE WAREHOUSE {warehouse};") _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") - if not table_defination: + if table_defination is None: # Infer table schema from the Parquet files in the Snowflake stage table_defination = _infer_table_schema(conn, sf_stage_path, use_logical_type) print(f"Inferred table schema: {table_defination}") - print(f"Uploading data from S3 path: {s3_path} to Snowflake stage: @{sf_stage_path}") + print(f"Uploading data from S3 path: {s3_path}") copy_query = _generate_s3_to_snowflake_copy_query( table_name=table_name, snowflake_stage_path=sf_stage_path, From da74b40a5f8e64ac2b6325d87fe16f779f80179b Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:39:43 +0530 Subject: [PATCH 075/167] fix: update S3 path validation in copy_s3_to_snowflake function --- src/ds_platform_utils/metaflow/s3_stage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 114948b..27a2c2c 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -207,9 +207,9 @@ def copy_s3_to_snowflake( # noqa: PLR0913 table_name = table_name.upper() schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA if current.is_production: - if not s3_path.startswith(f"s3://{PROD_S3_BUCKET}"): + if not s3_path.startswith(PROD_S3_BUCKET): raise ValueError(f"In production environment, s3_path must start with s3://{PROD_S3_BUCKET}") - elif not s3_path.startswith(f"s3://{DEV_S3_BUCKET}"): + elif not s3_path.startswith(DEV_S3_BUCKET): raise ValueError(f"In development environment, s3_path must start with s3://{DEV_S3_BUCKET}") s3_bucket, snowflake_stage = _get_s3_config(current.is_production) From db9d610e610173123fc0d61640d923e25ecdfa26 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:41:23 +0530 Subject: [PATCH 076/167] fix: update S3 path replacement logic in copy_s3_to_snowflake function --- src/ds_platform_utils/metaflow/s3_stage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 27a2c2c..1202f21 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -213,7 +213,7 @@ def copy_s3_to_snowflake( # noqa: PLR0913 raise ValueError(f"In development environment, s3_path must start with s3://{DEV_S3_BUCKET}") s3_bucket, snowflake_stage = _get_s3_config(current.is_production) - sf_stage_path = s3_path.replace(f"s3://{s3_bucket}", f"{snowflake_stage}") + sf_stage_path = s3_path.replace(f"{s3_bucket}", f"{snowflake_stage}") conn = get_snowflake_connection(use_utc) if warehouse is not None: _execute_sql(conn, f"USE WAREHOUSE {warehouse};") From 99ae8ec74c0382902dbe70610111e7cbb5798df4 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:41:58 +0530 Subject: [PATCH 077/167] fix: simplify S3 bucket replacement logic in copy_s3_to_snowflake function --- src/ds_platform_utils/metaflow/s3_stage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 1202f21..6904c82 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -213,7 +213,7 @@ def copy_s3_to_snowflake( # noqa: PLR0913 raise ValueError(f"In development environment, s3_path must start with s3://{DEV_S3_BUCKET}") s3_bucket, snowflake_stage = _get_s3_config(current.is_production) - sf_stage_path = s3_path.replace(f"{s3_bucket}", f"{snowflake_stage}") + sf_stage_path = s3_path.replace(s3_bucket, snowflake_stage) conn = get_snowflake_connection(use_utc) if warehouse is not None: _execute_sql(conn, f"USE WAREHOUSE {warehouse};") From af71a43c593de99d141ba17dd4b7155adad2bcd1 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:56:53 +0530 Subject: [PATCH 078/167] fix: refactor S3 configuration and update copy functions in pandas module --- src/ds_platform_utils/metaflow/pandas.py | 173 ++++------------------- 1 file changed, 25 insertions(+), 148 deletions(-) diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 3545e30..301c2dd 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -13,22 +13,25 @@ from ds_platform_utils._snowflake.run_query import _execute_sql from ds_platform_utils.metaflow._consts import ( - DEV_S3_BUCKET, DEV_SCHEMA, - DEV_SNOWFLAKE_STAGE, - PROD_S3_BUCKET, PROD_SCHEMA, - PROD_SNOWFLAKE_STAGE, S3_DATA_FOLDER, ) from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query, get_snowflake_connection -from ds_platform_utils.metaflow.s3 import _get_df_from_s3_folder, _put_df_to_s3_folder +from ds_platform_utils.metaflow.s3 import _get_df_from_s3_files, _put_df_to_s3_folder +from ds_platform_utils.metaflow.s3_stage import ( + _get_s3_config, + copy_s3_to_snowflake, + copy_snowflake_to_s3, +) from ds_platform_utils.metaflow.write_audit_publish import ( _make_snowflake_table_url, add_comment_to_each_sql_statement, get_select_dev_query_tags, ) +copy_s3_to_snowflake +copy_snowflake_to_s3 TWarehouse = Literal[ "OUTERBOUNDS_DATA_SCIENCE_ADS_PROD_XS_WH", "OUTERBOUNDS_DATA_SCIENCE_ADS_PROD_MED_WH", @@ -45,124 +48,6 @@ ] -def _get_s3_config(is_production: bool) -> Tuple[str, str]: - """Return the appropriate S3 bucket and Snowflake stage based on the environment.""" - if is_production: - s3_bucket = PROD_S3_BUCKET - snowflake_stage = PROD_SNOWFLAKE_STAGE - else: - s3_bucket = DEV_S3_BUCKET - snowflake_stage = DEV_SNOWFLAKE_STAGE - - return s3_bucket, snowflake_stage - - -def _generate_snowflake_to_s3_copy_query( - query: str, - snowflake_stage_path: str, - file_name: str = "data.parquet", - batch_size_in_mb: int = 16, -) -> str: - """Generate SQL COPY INTO command to export Snowflake query results to S3. - - :param query: SQL query to execute - :param snowflake_stage_path: The path to the Snowflake stage where the data will be exported. This should include the stage name and any necessary subfolders (e.g., 'my_snowflake_stage/my_folder'). - :param file_name: Output file name. Default 'data.parquet' - :return: COPY INTO SQL command - """ - if query.count(";") > 1: - raise ValueError("Multiple SQL statements detected. Please provide a single query statement.") - query = query.replace(";", "") # Remove trailing semicolon if present - copy_query = f""" - COPY INTO @{snowflake_stage_path}/ - FROM ( - {query} - ) - OVERWRITE = TRUE - FILE_FORMAT = (TYPE = 'parquet') - MAX_FILE_SIZE = {batch_size_in_mb * 1024 * 1024} - HEADER = TRUE; - """ - return copy_query - - -def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 - schema: str, - table_name: str, - snowflake_stage_path: str, - table_schema: Optional[List[Tuple[str, str]]] = None, - overwrite: bool = True, - auto_create_table: bool = True, - use_logical_type: bool = True, -) -> str: - """Generate SQL commands to load data from S3 to Snowflake table. - - This function generates a complete SQL script that includes: - 1. DROP TABLE IF EXISTS (if overwrite=True) - 2. CREATE TABLE IF NOT EXISTS (if auto_create_table=True or overwrite=True) - 3. COPY INTO command to load data from S3 - - :param schema: Snowflake schema name (e.g., 'DATA_SCIENCE' or 'DATA_SCIENCE_STAGE') - :param table_name: Target table name - :param snowflake_stage_path: The path to the Snowflake stage where the data will be exported. This should include the stage name and any necessary subfolders (e.g., 'my_snowflake_stage/my_folder'). - :param table_schema: List of tuples with column names and types - :param overwrite: If True, drop and recreate the table. Default True - :param auto_create_table: If True, create the table if it doesn't exist. Default True - :param use_logical_type: Whether to use Parquet logical types when reading the parquet files. Default True. - :return: Complete SQL script with table management and COPY INTO commands - """ - sql_statements = [] - - # Step 1: Drop table if overwrite is True - if overwrite: - sql_statements.append(f"DROP TABLE IF EXISTS PATTERN_DB.{schema}.{table_name};") - - # Step 2: Create table if auto_create_table or overwrite - if auto_create_table or overwrite: - table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_schema]) - create_table_query = ( - f"""CREATE OR REPLACE TABLE PATTERN_DB.{schema}.{table_name} ( {table_create_columns_str} );""" - ) - sql_statements.append(create_table_query) - - # Step 3: Generate COPY INTO command - columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_schema]) - - copy_query = f"""COPY INTO PATTERN_DB.{schema}.{table_name} FROM ( - SELECT {columns_str} - FROM @{snowflake_stage_path} ) - FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = {use_logical_type});""" - sql_statements.append(copy_query) - - # Combine all statements - return "\n\n".join(sql_statements) - - -def _infer_table_schema(conn, snowflake_stage_path: str, use_logical_type: bool) -> List[Tuple[str, str]]: - """Infer Snowflake table schema from Parquet files in a Snowflake stage. - - :param snowflake_stage_path: The path to the Snowflake stage where the Parquet files are located. This should include the stage name and any necessary subfolders (e.g., 'my_snowflake_stage/my_folder'). - :return: List of tuples with column names and inferred Snowflake data types - """ - _execute_sql( - conn, - f"CREATE OR REPLACE TEMP FILE FORMAT PQT_FILE_FORMAT TYPE = PARQUET USE_LOGICAL_TYPE = {use_logical_type};", - ) - infer_schema_query = f""" - SELECT COLUMN_NAME, TYPE - FROM TABLE( - INFER_SCHEMA( - LOCATION => '@{snowflake_stage_path}', - FILE_FORMAT => 'PQT_FILE_FORMAT' - )); - """ - cursor = _execute_sql(conn, infer_schema_query) - if cursor is None: - raise ValueError("Failed to infer schema: No cursor returned from Snowflake.") - result = cursor.fetch_pandas_all() - return list(zip(result["COLUMN_NAME"], result["TYPE"])) - - def publish_pandas( # noqa: PLR0913 (too many arguments) table_name: str, df: pd.DataFrame, @@ -177,7 +62,7 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) use_logical_type: bool = True, # prevent date times with timezone from being written incorrectly use_utc: bool = True, use_s3_stage: bool = False, - table_schema: Optional[List[Tuple[str, str]]] = None, + table_defination: Optional[List[Tuple[str, str]]] = None, ) -> None: """Store a pandas dataframe as a Snowflake table. @@ -248,10 +133,9 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") if use_s3_stage: - s3_bucket, snowflake_stage = _get_s3_config(current.is_production) + s3_bucket, _ = _get_s3_config(current.is_production) data_folder = "publish_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{data_folder}" - sf_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{data_folder}" # Write DataFrame to S3 as Parquet # Upload DataFrame to S3 as parquet files @@ -262,20 +146,16 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) compression=compression, ) - if table_schema is None: - # Infer table schema from the Parquet files in the Snowflake stage - table_schema = _infer_table_schema(conn, sf_stage_path, use_logical_type) - # Generate and execute Snowflake SQL to load data from S3 to Snowflake - copy_query = _generate_s3_to_snowflake_copy_query( - schema=schema, + copy_s3_to_snowflake( + s3_path=s3_path, table_name=table_name, - snowflake_stage_path=sf_stage_path, - table_schema=table_schema, - overwrite=overwrite, + table_defination=table_defination, + warehouse=warehouse, + use_utc=use_utc, auto_create_table=auto_create_table, + overwrite=overwrite, use_logical_type=use_logical_type, ) - _execute_sql(conn, copy_query) else: # https://docs.snowflake.com/en/developer-guide/snowpark/reference/python/latest/snowpark/api/snowflake.snowpark.Session.write_pandas @@ -358,20 +238,17 @@ def query_pandas_from_snowflake( current.card.append(Markdown(f"```sql\n{query}\n```")) if use_s3_stage: - s3_bucket, snowflake_stage = _get_s3_config(current.is_production) - data_folder = "query_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) - s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{data_folder}" - sf_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{data_folder}" - - copy_query = _generate_snowflake_to_s3_copy_query( + s3_files = copy_snowflake_to_s3( query=query, - snowflake_stage_path=sf_stage_path, + warehouse=warehouse, + use_utc=use_utc, ) - # Copy data to S3 - _execute_sql(conn, copy_query) - - df = _get_df_from_s3_folder(s3_path) + df = _get_df_from_s3_files(s3_files) else: + conn: SnowflakeConnection = get_snowflake_connection(use_utc) + if warehouse is not None: + _execute_sql(conn, f"USE WAREHOUSE {warehouse};") + _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") cursor_result = _execute_sql(conn, query) if cursor_result is None: # No statements to execute, return empty DataFrame @@ -380,7 +257,7 @@ def query_pandas_from_snowflake( # force_return_table=True -- returns a Pyarrow Table always even if the result is empty result: pyarrow.Table = cursor_result.fetch_arrow_all(force_return_table=True) df = result.to_pandas() - conn.close() + conn.close() df.columns = df.columns.str.lower() current.card.append(Markdown("### Query Result")) current.card.append(Table.from_dataframe(df.head())) From e66664b7f81b87615408075dbcca9192d91e710a Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:11:16 +0530 Subject: [PATCH 079/167] fix: remove unused copy functions and update table schema definition in test cases --- src/ds_platform_utils/metaflow/pandas.py | 2 -- tests/functional_tests/metaflow/test__pandas_s3.py | 2 +- .../metaflow/{test_pandas_utc.py => test__pandas_utc.py} | 0 3 files changed, 1 insertion(+), 3 deletions(-) rename tests/functional_tests/metaflow/{test_pandas_utc.py => test__pandas_utc.py} (100%) diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 301c2dd..65835fd 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -30,8 +30,6 @@ get_select_dev_query_tags, ) -copy_s3_to_snowflake -copy_snowflake_to_s3 TWarehouse = Literal[ "OUTERBOUNDS_DATA_SCIENCE_ADS_PROD_XS_WH", "OUTERBOUNDS_DATA_SCIENCE_ADS_PROD_MED_WH", diff --git a/tests/functional_tests/metaflow/test__pandas_s3.py b/tests/functional_tests/metaflow/test__pandas_s3.py index 9361ff0..1ff3732 100644 --- a/tests/functional_tests/metaflow/test__pandas_s3.py +++ b/tests/functional_tests/metaflow/test__pandas_s3.py @@ -38,7 +38,7 @@ def test_publish_pandas_with_schema(self): auto_create_table=True, overwrite=True, use_s3_stage=True, - table_schema=[ + table_defination=[ ("id", "INTEGER"), ("name", "STRING"), ("score", "FLOAT"), diff --git a/tests/functional_tests/metaflow/test_pandas_utc.py b/tests/functional_tests/metaflow/test__pandas_utc.py similarity index 100% rename from tests/functional_tests/metaflow/test_pandas_utc.py rename to tests/functional_tests/metaflow/test__pandas_utc.py From 2fb612e67a81e6f8b677f1bb6007b0299c2a6fb6 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:12:57 +0530 Subject: [PATCH 080/167] fix: update import statement to use s3_stage module for copy query functions --- src/ds_platform_utils/metaflow/batch_inference.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 8f2005c..bcb9203 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -18,7 +18,7 @@ S3_DATA_FOLDER, ) from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query, get_snowflake_connection -from ds_platform_utils.metaflow.pandas import ( +from ds_platform_utils.metaflow.s3_stage import ( _generate_s3_to_snowflake_copy_query, _generate_snowflake_to_s3_copy_query, _get_s3_config, From c90b0430eb2e64e121821b5702299ecb05a47c1c Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:12:26 +0530 Subject: [PATCH 081/167] feat: implement BatchInferencePipeline class for orchestrating batch inference with S3 and Snowflake --- .../metaflow/batch_inference.py | 89 +++++++------------ 1 file changed, 34 insertions(+), 55 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index bcb9203..14dd4d9 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -9,7 +9,6 @@ import pandas as pd from metaflow import current -from ds_platform_utils._snowflake.run_query import _execute_sql from ds_platform_utils._snowflake.write_audit_publish import get_query_from_string_or_fpath, substitute_map_into_string from ds_platform_utils.metaflow import s3 from ds_platform_utils.metaflow._consts import ( @@ -17,12 +16,11 @@ PROD_SCHEMA, S3_DATA_FOLDER, ) -from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query, get_snowflake_connection +from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query from ds_platform_utils.metaflow.s3_stage import ( - _generate_s3_to_snowflake_copy_query, - _generate_snowflake_to_s3_copy_query, _get_s3_config, - _infer_table_schema, + copy_s3_to_snowflake, + copy_snowflake_to_s3, ) default_file_size_in_mb = 10 @@ -42,9 +40,9 @@ def timer(message: str): _debug(f"{message}: Completed in {t1 - t0:.2f} seconds") -def make_batches_of_files(files_list, batch_size_in_mb): +def make_batches_of_files(file_paths, batch_size_in_mb): with s3._get_metaflow_s3_client() as s3_client: - file_sizes = [(file.key, file.size) for file in s3_client.info_many(files_list)] + file_sizes = [(file.key, file.size) for file in s3_client.info_many(file_paths)] batches = [] current_batch = [] @@ -70,16 +68,18 @@ def make_batches_of_files(files_list, batch_size_in_mb): return batches -def snowflake_batch_inference( # noqa: PLR0913, PLR0915 +def batch_inference_from_snowflake( # noqa: PLR0913 input_query: Union[str, Path], output_table_name: str, - model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], - output_table_schema: Optional[List[Tuple[str, str]]] = None, + predict_fn: Callable[[pd.DataFrame], pd.DataFrame], + output_table_definition: Optional[List[Tuple[str, str]]] = None, use_utc: bool = True, batch_size_in_mb: int = 128, warehouse: Optional[str] = None, ctx: Optional[dict] = None, timeout_per_batch: int = 300, + auto_create_table: bool = True, + overwrite: bool = True, ): """Execute batch inference on data from Snowflake, process it through a model, and upload results back to Snowflake. @@ -98,9 +98,11 @@ def snowflake_batch_inference( # noqa: PLR0913, PLR0915 If None, schema is inferred from the first predictions file. Defaults to None. use_utc (bool, optional): Whether to use UTC timezone for Snowflake connection. Defaults to True. batch_size_in_mb (int, optional): Target batch size in megabytes for processing. Defaults to 128. - parallelism (int, optional): Reserved for future parallel processing capability. Defaults to 1. warehouse (Optional[str], optional): Snowflake warehouse to use for queries. If None, uses default warehouse. Defaults to None. ctx (Optional[dict], optional): Dictionary of variable substitutions for the input query template. Defaults to None. + timeout_per_batch (int, optional): Timeout in seconds for processing each batch. Defaults to 300. + auto_create_table (bool, optional): Whether to automatically create the output table if it doesn't exist. Defaults to True. + overwrite (bool, optional): Whether to overwrite existing data in the output table. Defaults to True. Raises: Exceptions from Snowflake connection, S3 operations, or model prediction function may propagate. @@ -115,77 +117,54 @@ def snowflake_batch_inference( # noqa: PLR0913, PLR0915 """ ## Define S3 paths and Snowflake schema based on environment is_production = current.is_production if hasattr(current, "is_production") else False - s3_bucket, snowflake_stage = _get_s3_config(is_production) + s3_bucket, _ = _get_s3_config(is_production) schema = PROD_SCHEMA if is_production else DEV_SCHEMA ## Create unique S3 paths for this batch inference run using timestamp timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") upload_folder = f"publish_{timestamp}" - download_folder = f"query_{timestamp}" - input_s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{download_folder}" - input_snowflake_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{download_folder}" output_s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{upload_folder}" - output_snowflake_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{upload_folder}" # Step 1: Build COPY INTO query to export data from Snowflake to S3 input_query = get_query_from_string_or_fpath(input_query) input_query = substitute_map_into_string(input_query, {"schema": schema} | (ctx or {})) _debug_print_query(input_query) - conn = get_snowflake_connection(use_utc) - if warehouse is not None: - _execute_sql(conn, f"USE WAREHOUSE {warehouse};") - _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") - copy_to_s3_query = _generate_snowflake_to_s3_copy_query( + input_s3_path = copy_snowflake_to_s3( query=input_query, - snowflake_stage_path=input_snowflake_stage_path, - batch_size_in_mb=default_file_size_in_mb, + warehouse=warehouse, + use_utc=use_utc, ) - with timer("Exporting data from Snowflake to S3"): - _execute_sql(conn, copy_to_s3_query) - conn.close() - batch_inference_from_s3( input_s3_path=input_s3_path, - output_s3_folder_path=output_s3_path, - model_predictor_function=model_predictor_function, + output_s3_path=output_s3_path, + predict_fn=predict_fn, timeout_per_batch=timeout_per_batch, + batch_size_in_mb=batch_size_in_mb, ) + ## Step 2: Build COPY INTO query to import predictions from S3 back to Snowflake - conn = get_snowflake_connection(use_utc) - if warehouse is not None: - _execute_sql(conn, f"USE WAREHOUSE {warehouse};") - _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") - - if output_table_schema is None: - # Infer schema from the first predictions file - output_table_schema = _infer_table_schema(conn, output_snowflake_stage_path, True) - - copy_from_s3_query = _generate_s3_to_snowflake_copy_query( - schema=schema, + copy_s3_to_snowflake( + s3_path=output_s3_path, table_name=output_table_name, - snowflake_stage_path=output_snowflake_stage_path, - overwrite=True, - auto_create_table=True, - table_schema=output_table_schema, + table_defination=output_table_definition, + warehouse=warehouse, + use_utc=use_utc, + auto_create_table=auto_create_table, + overwrite=overwrite, ) - with timer("Uploading predictions from s3 to Snowflake"): - _execute_sql(conn, copy_from_s3_query) - - conn.close() - print("✅ Batch inference completed successfully!") def batch_inference_from_s3( input_s3_path: str | List[str], - output_s3_folder_path: str, - model_predictor_function: Callable[[pd.DataFrame], pd.DataFrame], + output_s3_path: str, + predict_fn: Callable[[pd.DataFrame], pd.DataFrame], timeout_per_batch: int = 300, - batch_size_in_mb: int = 128, + batch_size_in_mb: int = 100, ): if isinstance(input_s3_path, str): if str.endswith(input_s3_path, ".parquet"): @@ -200,7 +179,7 @@ def batch_inference_from_s3( input_s3_files = input_s3_path ## Check if all paths are valid S3 URIs - if any(not path.startswith("s3://") and path.endswith(".parquet") for path in input_s3_files): + if any(not (path.startswith("s3://") and path.endswith(".parquet")) for path in input_s3_files): raise ValueError("Invalid S3 URI. All paths or folder files must start with 's3://' and end with '.parquet'.") input_s3_batches = make_batches_of_files(input_s3_files, batch_size_in_mb) @@ -229,7 +208,7 @@ def inference_worker(): batch_id, df = item _debug(f"Generating predictions for batch {batch_id}...") with timer(f"Generating predictions for batch {batch_id}"): - predictions_df = model_predictor_function(df) + predictions_df = predict_fn(df) inference_queue.put((batch_id, predictions_df), timeout=timeout_per_batch) def upload_worker(): @@ -238,7 +217,7 @@ def upload_worker(): if item is None: break batch_id, predictions_df = item - s3_output_file = f"{output_s3_folder_path}/predictions_{batch_id}.parquet" + s3_output_file = f"{output_s3_path}/predictions_{batch_id}.parquet" with timer(f"Uploading predictions for batch {batch_id} to S3"): s3._put_df_to_s3_file(predictions_df, s3_output_file) From 2660c54330f9f8a570993a26be0214a7006229c7 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:52:34 +0530 Subject: [PATCH 082/167] feat: add BatchInferencePipeline class for orchestrating batch inference with S3 and Snowflake --- .../metaflow/batch_inference_pipeline.py | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 src/ds_platform_utils/metaflow/batch_inference_pipeline.py diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py new file mode 100644 index 0000000..a7d0f55 --- /dev/null +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -0,0 +1,317 @@ +"""BatchInferencePipeline: A class for orchestrating batch inference across Metaflow steps.""" + +import os +import queue +import time +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable, List, Optional, Tuple, Union + +import pandas as pd +from metaflow import current + +from ds_platform_utils._snowflake.write_audit_publish import get_query_from_string_or_fpath, substitute_map_into_string +from ds_platform_utils.metaflow import s3 +from ds_platform_utils.metaflow._consts import ( + DEV_SCHEMA, + PROD_SCHEMA, + S3_DATA_FOLDER, +) +from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query +from ds_platform_utils.metaflow.s3_stage import ( + _get_s3_config, + copy_s3_to_snowflake, + copy_snowflake_to_s3, +) + + +def _debug(*args, **kwargs): + if os.getenv("DEBUG"): + print("DEBUG: ", end="") + print(*args, **kwargs) + + +@contextmanager +def _timer(message: str): + """Context manager to time and debug-print operation duration.""" + t0 = time.time() + yield + t1 = time.time() + _debug(f"{message}: Completed in {t1 - t0:.2f} seconds") + + +@dataclass +class BatchInferencePipeline: + """Orchestrates batch inference across Metaflow steps with foreach parallelization. + + This class manages a 3-step pipeline: + 1. `query_and_batch()` - Export data from Snowflake to S3, returns batch_ids for foreach + 2. `process_batch()` - Run inference on a single batch (called in foreach step) + 3. `publish_results()` - Write all results back to Snowflake + + Example Usage:: + + from metaflow import FlowSpec, step + from ds_platform_utils.metaflow import BatchInferencePipeline + + class MyPredictionFlow(FlowSpec): + + @step + def start(self): + # Initialize pipeline and export data to S3 + self.pipeline = BatchInferencePipeline(pipeline_id="my_model") + self.batch_ids = self.pipeline.query_and_batch( + input_query="SELECT * FROM my_table", + batch_size_in_mb=128, + ) + self.next(self.predict, foreach='batch_ids') + + @step + def predict(self): + # Process single batch (runs in parallel via foreach) + batch_id = self.input + self.pipeline.process_batch( + batch_id=batch_id, + predict_fn=my_model.predict, + ) + self.next(self.join) + + @step + def join(self, inputs): + # Merge and write results to Snowflake + self.pipeline = inputs[0].pipeline # Get pipeline from any input + self.pipeline.publish_results( + output_table_name="predictions_table", + ) + self.next(self.end) + + @step + def end(self): + print("Done!") + + Attributes: + pipeline_id: Unique identifier for this pipeline (for multiple pipelines in same flow) + warehouse: Snowflake warehouse to use + use_utc: Whether to use UTC timezone for Snowflake + batch_ids: List of batch IDs after prepare() is called + batches: Mapping of batch_id -> list of S3 file paths + + """ + + pipeline_id: str = "default" + warehouse: Optional[str] = None + use_utc: bool = True + + # Internal state (populated after prepare()) + _s3_bucket: str = field(default="", repr=False) + _base_path: str = field(default="", repr=False) + _input_path: str = field(default="", repr=False) + _output_path: str = field(default="", repr=False) + _schema: str = field(default="", repr=False) + batches: dict = field(default_factory=dict, repr=False) + batch_ids: List[int] = field(default_factory=list) + + def __post_init__(self): + """Initialize S3 paths based on Metaflow context.""" + is_production = current.is_production if hasattr(current, "is_production") else False + self._s3_bucket, _ = _get_s3_config(is_production) + self._schema = PROD_SCHEMA if is_production else DEV_SCHEMA + + # Build paths: s3://bucket/data/{flow}/{run_id}/{pipeline_id}/ + flow_name = current.flow_name if hasattr(current, "flow_name") else "local" + run_id = current.run_id if hasattr(current, "run_id") else "dev" + + self._base_path = f"{self._s3_bucket}/{S3_DATA_FOLDER}/{flow_name}/{run_id}/{self.pipeline_id}" + self._input_path = f"{self._base_path}/input" + self._output_path = f"{self._base_path}/output" + + @property + def input_path(self) -> str: + """S3 path where input data is stored.""" + return self._input_path + + @property + def output_path(self) -> str: + """S3 path where output predictions are stored.""" + return self._output_path + + def query_and_batch( + self, + input_query: Union[str, Path], + batch_size_in_mb: int = 128, + query_variables: Optional[dict] = None, + ) -> List[int]: + """Step 1: Export data from Snowflake to S3 and create batches for parallel processing. + + Args: + input_query: SQL query string or file path to query + batch_size_in_mb: Target size for each batch in MB + query_variables: Dict of variable substitutions for SQL template + + Returns: + List of batch_ids to use with foreach in next step + + """ + print(f"🚀 Preparing batch inference pipeline: {self.pipeline_id}") + + # Process input query + input_query = get_query_from_string_or_fpath(input_query) + input_query = substitute_map_into_string(input_query, {"schema": self._schema} | (query_variables or {})) + _debug_print_query(input_query) + + # Export from Snowflake to S3 + t0 = time.time() + input_files = copy_snowflake_to_s3( + query=input_query, + warehouse=self.warehouse, + use_utc=self.use_utc, + ) + t1 = time.time() + print(f"✅ Exported {len(input_files)} files to S3 in {t1 - t0:.2f}s") + + # Create batches based on file sizes + self.batches = self._make_batches(input_files, batch_size_in_mb) + self.batch_ids = list(self.batches.keys()) + + print(f"📊 Created {len(self.batch_ids)} batches for parallel processing") + return self.batch_ids + + def process_batch( + self, + batch_id: int, + predict_fn: Callable[[pd.DataFrame], pd.DataFrame], + timeout_per_file: int = 300, + ) -> str: + """Step 2: Process a single batch using parallel download→inference→upload pipeline. + + Uses a queue-based 3-thread pipeline for efficient processing: + - Download worker: Reads files from S3 + - Inference worker: Runs predict_fn on each downloaded file + - Upload worker: Writes predictions back to S3 + + Args: + batch_id: The batch ID to process (from self.input in foreach) + predict_fn: Function that takes DataFrame and returns predictions DataFrame + timeout_per_file: Timeout in seconds for each file operation (default: 300) + + Returns: + S3 path where predictions were written + + """ + if batch_id not in self.batches: + raise ValueError(f"Batch {batch_id} not found. Available: {list(self.batches.keys())}") + + file_paths = self.batches[batch_id] + print(f"🔄 Processing batch {batch_id} ({len(file_paths)} files)") + + download_queue: queue.Queue = queue.Queue(maxsize=1) + inference_queue: queue.Queue = queue.Queue(maxsize=1) + output_path = self._output_path + + def download_worker(files: List[str]): + for file_id, file_path in enumerate(files): + with _timer(f"Downloading file {file_id} from S3"): + df = s3._get_df_from_s3_files([file_path]) + df.columns = [col.lower() for col in df.columns] + download_queue.put((file_id, df), timeout=timeout_per_file) + download_queue.put(None, timeout=timeout_per_file) + + def inference_worker(): + while True: + item = download_queue.get(timeout=timeout_per_file) + if item is None: + inference_queue.put(None, timeout=timeout_per_file) + break + file_id, df = item + with _timer(f"Generating predictions for file {file_id}"): + predictions_df = predict_fn(df) + inference_queue.put((file_id, predictions_df), timeout=timeout_per_file) + + def upload_worker(): + while True: + item = inference_queue.get(timeout=timeout_per_file) + if item is None: + break + file_id, predictions_df = item + s3_output_file = f"{output_path}/predictions_{batch_id}_{file_id}.parquet" + with _timer(f"Uploading predictions for file {file_id} to S3"): + s3._put_df_to_s3_file(predictions_df, s3_output_file) + + t0 = time.time() + with ThreadPoolExecutor(max_workers=3) as executor: + executor.submit(download_worker, file_paths) + executor.submit(inference_worker) + executor.submit(upload_worker) + t1 = time.time() + + print(f"✅ Batch {batch_id} complete ({len(file_paths)} files processed in {t1 - t0:.2f}s)") + return self._output_path + + def publish_results( + self, + output_table_name: str, + output_table_definition: Optional[List[Tuple[str, str]]] = None, + auto_create_table: bool = True, + overwrite: bool = True, + ) -> None: + """Step 3: Write all predictions from S3 to Snowflake (call this in join step). + + Args: + output_table_name: Name of the Snowflake table + output_table_definition: Optional schema as list of (column, type) tuples + auto_create_table: Whether to auto-create table if not exists + overwrite: Whether to overwrite existing data + + """ + print(f"📤 Writing predictions to Snowflake table: {output_table_name}") + + t0 = time.time() + copy_s3_to_snowflake( + s3_path=self._output_path, + table_name=output_table_name, + table_defination=output_table_definition, + warehouse=self.warehouse, + use_utc=self.use_utc, + auto_create_table=auto_create_table, + overwrite=overwrite, + ) + t1 = time.time() + + print(f"✅ Pipeline complete! Data written to {output_table_name} in {t1 - t0:.2f}s") + + def _make_batches(self, file_paths: List[str], batch_size_in_mb: int) -> dict: + """Group files into batches based on size.""" + with s3._get_metaflow_s3_client() as s3_client: + file_infos = [(f.key, f.size) for f in s3_client.info_many(file_paths)] + + batches = {} + current_batch = [] + current_size = 0 + batch_id = 0 + batch_size_bytes = batch_size_in_mb * 1024 * 1024 + + for file_path, file_size in file_infos: + # Check if adding this file exceeds limit + if current_batch and (current_size + file_size) > batch_size_bytes: + batches[batch_id] = current_batch + batch_id += 1 + current_batch = [] + current_size = 0 + + current_batch.append(file_path) + current_size += file_size + + # Don't forget last batch + if current_batch: + batches[batch_id] = current_batch + + return batches + + def __repr__(self) -> str: + """Return string representation of the pipeline.""" + return ( + f"BatchInferencePipeline(pipeline_id='{self.pipeline_id}', " + f"batches={len(self.batches)}, batch_ids={self.batch_ids})" + ) From 3496dfa000a4e4cf1930e6c1237a3d71c23aac8d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:52:46 +0530 Subject: [PATCH 083/167] fix: update BatchInferencePipeline to use worker_ids instead of batch_ids for foreach processing --- .../metaflow/batch_inference_pipeline.py | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index a7d0f55..a8358ba 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -47,7 +47,7 @@ class BatchInferencePipeline: """Orchestrates batch inference across Metaflow steps with foreach parallelization. This class manages a 3-step pipeline: - 1. `query_and_batch()` - Export data from Snowflake to S3, returns batch_ids for foreach + 1. `query_and_batch()` - Export data from Snowflake to S3, returns worker_ids for foreach 2. `process_batch()` - Run inference on a single batch (called in foreach step) 3. `publish_results()` - Write all results back to Snowflake @@ -62,18 +62,18 @@ class MyPredictionFlow(FlowSpec): def start(self): # Initialize pipeline and export data to S3 self.pipeline = BatchInferencePipeline(pipeline_id="my_model") - self.batch_ids = self.pipeline.query_and_batch( + self.worker_ids = self.pipeline.query_and_batch( input_query="SELECT * FROM my_table", batch_size_in_mb=128, ) - self.next(self.predict, foreach='batch_ids') + self.next(self.predict, foreach='worker_ids') @step def predict(self): # Process single batch (runs in parallel via foreach) - batch_id = self.input + worker_id = self.input self.pipeline.process_batch( - batch_id=batch_id, + worker_id=worker_id, predict_fn=my_model.predict, ) self.next(self.join) @@ -95,8 +95,8 @@ def end(self): pipeline_id: Unique identifier for this pipeline (for multiple pipelines in same flow) warehouse: Snowflake warehouse to use use_utc: Whether to use UTC timezone for Snowflake - batch_ids: List of batch IDs after prepare() is called - batches: Mapping of batch_id -> list of S3 file paths + worker_ids: List of worker IDs after query_and_batch() is called + workers: Mapping of worker_id -> list of S3 file paths """ @@ -110,8 +110,8 @@ def end(self): _input_path: str = field(default="", repr=False) _output_path: str = field(default="", repr=False) _schema: str = field(default="", repr=False) - batches: dict = field(default_factory=dict, repr=False) - batch_ids: List[int] = field(default_factory=list) + workers: dict = field(default_factory=dict, repr=False) + worker_ids: List[int] = field(default_factory=list) def __post_init__(self): """Initialize S3 paths based on Metaflow context.""" @@ -142,6 +142,7 @@ def query_and_batch( input_query: Union[str, Path], batch_size_in_mb: int = 128, query_variables: Optional[dict] = None, + parallel_workers: int = 1, ) -> List[int]: """Step 1: Export data from Snowflake to S3 and create batches for parallel processing. @@ -149,9 +150,10 @@ def query_and_batch( input_query: SQL query string or file path to query batch_size_in_mb: Target size for each batch in MB query_variables: Dict of variable substitutions for SQL template + parallel_workers: Number of parallel workers to use for processing Returns: - List of batch_ids to use with foreach in next step + List of worker_ids to use with foreach in next step """ print(f"🚀 Preparing batch inference pipeline: {self.pipeline_id}") @@ -171,16 +173,16 @@ def query_and_batch( t1 = time.time() print(f"✅ Exported {len(input_files)} files to S3 in {t1 - t0:.2f}s") - # Create batches based on file sizes - self.batches = self._make_batches(input_files, batch_size_in_mb) - self.batch_ids = list(self.batches.keys()) + # Create worker batches based on file sizes + self.workers = self._make_batches(input_files, batch_size_in_mb) + self.worker_ids = list(self.workers.keys()) - print(f"📊 Created {len(self.batch_ids)} batches for parallel processing") - return self.batch_ids + print(f"📊 Created {len(self.worker_ids)} workers for parallel processing") + return self.worker_ids def process_batch( self, - batch_id: int, + worker_id: int, predict_fn: Callable[[pd.DataFrame], pd.DataFrame], timeout_per_file: int = 300, ) -> str: @@ -192,7 +194,7 @@ def process_batch( - Upload worker: Writes predictions back to S3 Args: - batch_id: The batch ID to process (from self.input in foreach) + worker_id: The worker ID to process (from self.input in foreach) predict_fn: Function that takes DataFrame and returns predictions DataFrame timeout_per_file: Timeout in seconds for each file operation (default: 300) @@ -200,11 +202,11 @@ def process_batch( S3 path where predictions were written """ - if batch_id not in self.batches: - raise ValueError(f"Batch {batch_id} not found. Available: {list(self.batches.keys())}") + if worker_id not in self.workers: + raise ValueError(f"Worker {worker_id} not found. Available: {list(self.workers.keys())}") - file_paths = self.batches[batch_id] - print(f"🔄 Processing batch {batch_id} ({len(file_paths)} files)") + file_paths = self.workers[worker_id] + print(f"🔄 Processing worker {worker_id} ({len(file_paths)} files)") download_queue: queue.Queue = queue.Queue(maxsize=1) inference_queue: queue.Queue = queue.Queue(maxsize=1) @@ -235,7 +237,7 @@ def upload_worker(): if item is None: break file_id, predictions_df = item - s3_output_file = f"{output_path}/predictions_{batch_id}_{file_id}.parquet" + s3_output_file = f"{output_path}/predictions_{worker_id}_{file_id}.parquet" with _timer(f"Uploading predictions for file {file_id} to S3"): s3._put_df_to_s3_file(predictions_df, s3_output_file) @@ -246,7 +248,7 @@ def upload_worker(): executor.submit(upload_worker) t1 = time.time() - print(f"✅ Batch {batch_id} complete ({len(file_paths)} files processed in {t1 - t0:.2f}s)") + print(f"✅ Worker {worker_id} complete ({len(file_paths)} files processed in {t1 - t0:.2f}s)") return self._output_path def publish_results( @@ -313,5 +315,5 @@ def __repr__(self) -> str: """Return string representation of the pipeline.""" return ( f"BatchInferencePipeline(pipeline_id='{self.pipeline_id}', " - f"batches={len(self.batches)}, batch_ids={self.batch_ids})" + f"workers={len(self.workers)}, worker_ids={self.worker_ids})" ) From f9c5622e53343c43058c258f5365a5a1b4cc6a0d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:10:28 +0530 Subject: [PATCH 084/167] feat: enhance BatchInferencePipeline with file splitting for worker processing and improve batch handling --- .../metaflow/batch_inference_pipeline.py | 76 +++++++++++-------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index a8358ba..f81d521 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -137,10 +137,17 @@ def output_path(self) -> str: """S3 path where output predictions are stored.""" return self._output_path + def _split_files_into_workers(self, files: List[str], parallel_workers: int) -> dict: + """Split list of files into batches for each worker.""" + if len(files) < parallel_workers: + print("⚠️ Fewer files than workers. Assigning one file per worker until files run out.") + parallel_workers = len(files) + + return {worker_id: files[worker_id::parallel_workers] for worker_id in range(parallel_workers)} + def query_and_batch( self, input_query: Union[str, Path], - batch_size_in_mb: int = 128, query_variables: Optional[dict] = None, parallel_workers: int = 1, ) -> List[int]: @@ -148,7 +155,7 @@ def query_and_batch( Args: input_query: SQL query string or file path to query - batch_size_in_mb: Target size for each batch in MB + query_variables: Dict of variable substitutions for SQL template parallel_workers: Number of parallel workers to use for processing @@ -174,16 +181,18 @@ def query_and_batch( print(f"✅ Exported {len(input_files)} files to S3 in {t1 - t0:.2f}s") # Create worker batches based on file sizes - self.workers = self._make_batches(input_files, batch_size_in_mb) - self.worker_ids = list(self.workers.keys()) + self.worker_files = self._split_files_into_workers(input_files, parallel_workers) + self.worker_ids = list(self.worker_files.keys()) print(f"📊 Created {len(self.worker_ids)} workers for parallel processing") + return self.worker_ids def process_batch( self, worker_id: int, predict_fn: Callable[[pd.DataFrame], pd.DataFrame], + batch_size_in_mb: int = 128, timeout_per_file: int = 300, ) -> str: """Step 2: Process a single batch using parallel download→inference→upload pipeline. @@ -196,26 +205,28 @@ def process_batch( Args: worker_id: The worker ID to process (from self.input in foreach) predict_fn: Function that takes DataFrame and returns predictions DataFrame + batch_size_in_mb: Target size for each batch in MB timeout_per_file: Timeout in seconds for each file operation (default: 300) Returns: S3 path where predictions were written """ - if worker_id not in self.workers: - raise ValueError(f"Worker {worker_id} not found. Available: {list(self.workers.keys())}") + if worker_id not in self.worker_files: + raise ValueError(f"Worker {worker_id} not found. Available: {list(self.worker_files.keys())}") - file_paths = self.workers[worker_id] - print(f"🔄 Processing worker {worker_id} ({len(file_paths)} files)") + file_paths = self.worker_files[worker_id] + file_batches = self._make_batches(file_paths, batch_size_in_mb=batch_size_in_mb) + print(f"🔄 Processing worker {worker_id} ({len(file_batches)} batches)") download_queue: queue.Queue = queue.Queue(maxsize=1) inference_queue: queue.Queue = queue.Queue(maxsize=1) output_path = self._output_path - def download_worker(files: List[str]): - for file_id, file_path in enumerate(files): + def download_worker(file_batches: List[List[str]]): + for file_id, file_batch in enumerate(file_batches): with _timer(f"Downloading file {file_id} from S3"): - df = s3._get_df_from_s3_files([file_path]) + df = s3._get_df_from_s3_files(file_batch) df.columns = [col.lower() for col in df.columns] download_queue.put((file_id, df), timeout=timeout_per_file) download_queue.put(None, timeout=timeout_per_file) @@ -243,12 +254,12 @@ def upload_worker(): t0 = time.time() with ThreadPoolExecutor(max_workers=3) as executor: - executor.submit(download_worker, file_paths) + executor.submit(download_worker, file_batches) executor.submit(inference_worker) executor.submit(upload_worker) t1 = time.time() - print(f"✅ Worker {worker_id} complete ({len(file_paths)} files processed in {t1 - t0:.2f}s)") + print(f"✅ Worker {worker_id} complete ({len(file_batches)} batches processed in {t1 - t0:.2f}s)") return self._output_path def publish_results( @@ -273,7 +284,7 @@ def publish_results( copy_s3_to_snowflake( s3_path=self._output_path, table_name=output_table_name, - table_defination=output_table_definition, + table_definition=output_table_definition, warehouse=self.warehouse, use_utc=self.use_utc, auto_create_table=auto_create_table, @@ -283,31 +294,30 @@ def publish_results( print(f"✅ Pipeline complete! Data written to {output_table_name} in {t1 - t0:.2f}s") - def _make_batches(self, file_paths: List[str], batch_size_in_mb: int) -> dict: - """Group files into batches based on size.""" + def _make_batches(self, file_paths: List[str], batch_size_in_mb: int) -> List[List[str]]: with s3._get_metaflow_s3_client() as s3_client: - file_infos = [(f.key, f.size) for f in s3_client.info_many(file_paths)] + file_sizes = [(file.key, file.size) for file in s3_client.info_many(file_paths)] - batches = {} + batches = [] current_batch = [] - current_size = 0 - batch_id = 0 - batch_size_bytes = batch_size_in_mb * 1024 * 1024 - - for file_path, file_size in file_infos: - # Check if adding this file exceeds limit - if current_batch and (current_size + file_size) > batch_size_bytes: - batches[batch_id] = current_batch - batch_id += 1 + current_batch_size = 0 + warnings = False + + batch_size_in_bytes = batch_size_in_mb * 1024 * 1024 + for file_key, file_size in file_sizes: + current_batch.append(file_key) + current_batch_size += file_size + if current_batch_size > batch_size_in_bytes: + if len(current_batch) == 1: + warnings = True + batches.append(current_batch) current_batch = [] - current_size = 0 - - current_batch.append(file_path) - current_size += file_size + current_batch_size = 0 - # Don't forget last batch if current_batch: - batches[batch_id] = current_batch + batches.append(current_batch) + if warnings: + print("⚠️ Files larger than batch size detected. Increase batch size to avoid this warning.") return batches From c2e27100c0a0ea43ba669d6944eee97b396a3a4c Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:30:13 +0530 Subject: [PATCH 085/167] fix: correct spelling of 'table_definition' in multiple files --- .../metaflow/batch_inference.py | 2 +- src/ds_platform_utils/metaflow/pandas.py | 4 ++-- src/ds_platform_utils/metaflow/s3_stage.py | 22 +++++++++---------- .../metaflow/test__pandas_s3.py | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py index 14dd4d9..d576bdc 100644 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ b/src/ds_platform_utils/metaflow/batch_inference.py @@ -149,7 +149,7 @@ def batch_inference_from_snowflake( # noqa: PLR0913 copy_s3_to_snowflake( s3_path=output_s3_path, table_name=output_table_name, - table_defination=output_table_definition, + table_definition=output_table_definition, warehouse=warehouse, use_utc=use_utc, auto_create_table=auto_create_table, diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 65835fd..8002d2d 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -60,7 +60,7 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) use_logical_type: bool = True, # prevent date times with timezone from being written incorrectly use_utc: bool = True, use_s3_stage: bool = False, - table_defination: Optional[List[Tuple[str, str]]] = None, + table_definition: Optional[List[Tuple[str, str]]] = None, ) -> None: """Store a pandas dataframe as a Snowflake table. @@ -147,7 +147,7 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) copy_s3_to_snowflake( s3_path=s3_path, table_name=table_name, - table_defination=table_defination, + table_definition=table_definition, warehouse=warehouse, use_utc=use_utc, auto_create_table=auto_create_table, diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 6904c82..7f878b0 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -67,7 +67,7 @@ def _generate_snowflake_to_s3_copy_query( def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 snowflake_stage_path: str, table_name: str, - table_defination: List[Tuple[str, str]], + table_definition: List[Tuple[str, str]], overwrite: bool = True, auto_create_table: bool = True, use_logical_type: bool = True, @@ -81,7 +81,7 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 :param table_name: Target table name :param snowflake_stage_path: The path to the Snowflake stage where the data will be exported. This should include the stage name and any necessary subfolders (e.g., 'my_snowflake_stage/my_folder'). - :param table_defination: List of tuples with column names and types + :param table_definition: List of tuples with column names and types :param overwrite: If True, drop and recreate the table. Default True :param auto_create_table: If True, create the table if it doesn't exist. Default True :param use_logical_type: Whether to use Parquet logical types when reading the parquet files. Default True. @@ -90,13 +90,13 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 sql_statements = [] if auto_create_table and not overwrite: - table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_defination]) + table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_definition]) create_table_query = f"""CREATE TABLE IF NOT EXISTS {table_name} ( {table_create_columns_str} );""" print(f"Generated CREATE TABLE query:\n{create_table_query}") sql_statements.append(create_table_query) if auto_create_table and overwrite: - table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_defination]) + table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_definition]) create_table_query = f"""CREATE OR REPLACE TABLE {table_name} ( {table_create_columns_str} );""" print(f"Generated CREATE OR REPLACE TABLE query:\n{create_table_query}") sql_statements.append(create_table_query) @@ -105,7 +105,7 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 print(f"Generated TRUNCATE TABLE query:\nTRUNCATE TABLE IF EXISTS {table_name};") sql_statements.append(f"TRUNCATE TABLE IF EXISTS {table_name};") - # columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_defination]) + # columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_definition]) copy_query = f"""COPY INTO {table_name} FROM '@{snowflake_stage_path}' FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = {use_logical_type}) @@ -182,7 +182,7 @@ def copy_snowflake_to_s3( def copy_s3_to_snowflake( # noqa: PLR0913 s3_path: str, table_name: str, - table_defination: Optional[List[Tuple[str, str]]] = None, + table_definition: Optional[List[Tuple[str, str]]] = None, warehouse: Optional[str] = None, use_utc: bool = True, auto_create_table: bool = False, @@ -198,7 +198,7 @@ def copy_s3_to_snowflake( # noqa: PLR0913 :param s3_path: The S3 path where the data is located. This should include the bucket name and any necessary subfolders (e.g., 's3://my_bucket/my_folder'). :param table_name: Target table name - :param table_defination: List of tuples with column names and types + :param table_definition: List of tuples with column names and types :param overwrite: If True, drop and recreate the table. Default True :param auto_create_table: If True, create the table if it doesn't exist. Default True :param use_logical_type: Whether to use Parquet logical types when reading the parquet files. Default True. @@ -219,16 +219,16 @@ def copy_s3_to_snowflake( # noqa: PLR0913 _execute_sql(conn, f"USE WAREHOUSE {warehouse};") _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") - if table_defination is None: + if table_definition is None: # Infer table schema from the Parquet files in the Snowflake stage - table_defination = _infer_table_schema(conn, sf_stage_path, use_logical_type) - print(f"Inferred table schema: {table_defination}") + table_definition = _infer_table_schema(conn, sf_stage_path, use_logical_type) + print(f"Inferred table schema: {table_definition}") print(f"Uploading data from S3 path: {s3_path}") copy_query = _generate_s3_to_snowflake_copy_query( table_name=table_name, snowflake_stage_path=sf_stage_path, - table_defination=table_defination, + table_definition=table_definition, overwrite=overwrite, auto_create_table=auto_create_table, use_logical_type=use_logical_type, diff --git a/tests/functional_tests/metaflow/test__pandas_s3.py b/tests/functional_tests/metaflow/test__pandas_s3.py index 1ff3732..824b069 100644 --- a/tests/functional_tests/metaflow/test__pandas_s3.py +++ b/tests/functional_tests/metaflow/test__pandas_s3.py @@ -38,7 +38,7 @@ def test_publish_pandas_with_schema(self): auto_create_table=True, overwrite=True, use_s3_stage=True, - table_defination=[ + table_definition=[ ("id", "INTEGER"), ("name", "STRING"), ("score", "FLOAT"), From b10f06113021ab736b00c03bc8539ab732f5151c Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:33:15 +0530 Subject: [PATCH 086/167] feat: add run method to BatchInferencePipeline for streamlined processing and publishing --- .../metaflow/batch_inference_pipeline.py | 74 +++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index f81d521..30f70a8 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -148,7 +148,9 @@ def _split_files_into_workers(self, files: List[str], parallel_workers: int) -> def query_and_batch( self, input_query: Union[str, Path], - query_variables: Optional[dict] = None, + ctx: Optional[dict] = None, + warehouse: Optional[str] = None, + use_utc: bool = True, parallel_workers: int = 1, ) -> List[int]: """Step 1: Export data from Snowflake to S3 and create batches for parallel processing. @@ -156,7 +158,7 @@ def query_and_batch( Args: input_query: SQL query string or file path to query - query_variables: Dict of variable substitutions for SQL template + ctx: Dict of variable substitutions for SQL template parallel_workers: Number of parallel workers to use for processing Returns: @@ -167,15 +169,15 @@ def query_and_batch( # Process input query input_query = get_query_from_string_or_fpath(input_query) - input_query = substitute_map_into_string(input_query, {"schema": self._schema} | (query_variables or {})) + input_query = substitute_map_into_string(input_query, {"schema": self._schema} | (ctx or {})) _debug_print_query(input_query) # Export from Snowflake to S3 t0 = time.time() input_files = copy_snowflake_to_s3( query=input_query, - warehouse=self.warehouse, - use_utc=self.use_utc, + warehouse=warehouse, + use_utc=use_utc, ) t1 = time.time() print(f"✅ Exported {len(input_files)} files to S3 in {t1 - t0:.2f}s") @@ -294,6 +296,68 @@ def publish_results( print(f"✅ Pipeline complete! Data written to {output_table_name} in {t1 - t0:.2f}s") + def run( # noqa: PLR0913 + self, + input_query: Union[str, Path], + output_table_name: str, + predict_fn: Callable[[pd.DataFrame], pd.DataFrame], + query_variables: Optional[dict] = None, + output_table_definition: Optional[List[Tuple[str, str]]] = None, + batch_size_in_mb: int = 128, + timeout_per_file: int = 300, + auto_create_table: bool = True, + overwrite: bool = True, + ) -> None: + """Run the complete pipeline: query → process → publish in a single call. + + This is a convenience method that combines query_and_batch(), process_batch(), + and publish_results() for cases where foreach parallelization is not needed. + + Args: + input_query: SQL query string or file path to query + output_table_name: Name of the Snowflake table for predictions + predict_fn: Function that takes DataFrame and returns predictions DataFrame + query_variables: Dict of variable substitutions for SQL template + output_table_definition: Optional schema as list of (column, type) tuples + batch_size_in_mb: Target size for each batch in MB + timeout_per_file: Timeout in seconds for each file operation + auto_create_table: Whether to auto-create table if not exists + overwrite: Whether to overwrite existing data + + Example:: + + pipeline = BatchInferencePipeline(pipeline_id="my_model") + pipeline.run( + input_query="SELECT * FROM my_table", + output_table_name="predictions_table", + predict_fn=my_model.predict, + ) + + """ + # Step 1: Query and batch + worker_ids = self.query_and_batch( + input_query=input_query, + query_variables=query_variables, + parallel_workers=1, + ) + + # Step 2: Process all batches sequentially + for worker_id in worker_ids: + self.process_batch( + worker_id=worker_id, + predict_fn=predict_fn, + batch_size_in_mb=batch_size_in_mb, + timeout_per_file=timeout_per_file, + ) + + # Step 3: Publish results + self.publish_results( + output_table_name=output_table_name, + output_table_definition=output_table_definition, + auto_create_table=auto_create_table, + overwrite=overwrite, + ) + def _make_batches(self, file_paths: List[str], batch_size_in_mb: int) -> List[List[str]]: with s3._get_metaflow_s3_client() as s3_client: file_sizes = [(file.key, file.size) for file in s3_client.info_many(file_paths)] From 2208b0acfc6316786713987796c3dcee636352e5 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:40:07 +0530 Subject: [PATCH 087/167] fix: adjust worker file assignment to start from 1 for correct indexing --- src/ds_platform_utils/metaflow/batch_inference_pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 30f70a8..12d8096 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -143,7 +143,7 @@ def _split_files_into_workers(self, files: List[str], parallel_workers: int) -> print("⚠️ Fewer files than workers. Assigning one file per worker until files run out.") parallel_workers = len(files) - return {worker_id: files[worker_id::parallel_workers] for worker_id in range(parallel_workers)} + return {worker_id: files[worker_id::parallel_workers] for worker_id in range(1, parallel_workers + 1)} def query_and_batch( self, From e171fc3d8f9cb3c62c422c8b2539f76f1de30b38 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:41:13 +0530 Subject: [PATCH 088/167] fix: adjust worker file assignment to start from 1 for correct indexing --- src/ds_platform_utils/metaflow/batch_inference_pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 12d8096..1bcc02d 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -143,7 +143,7 @@ def _split_files_into_workers(self, files: List[str], parallel_workers: int) -> print("⚠️ Fewer files than workers. Assigning one file per worker until files run out.") parallel_workers = len(files) - return {worker_id: files[worker_id::parallel_workers] for worker_id in range(1, parallel_workers + 1)} + return {worker_id + 1: files[worker_id::parallel_workers] for worker_id in range(parallel_workers)} def query_and_batch( self, From fb087e7ed22a186393899b7e541482eca36244e8 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:42:43 +0530 Subject: [PATCH 089/167] fix: remove debug print statements and add error handling for table schema inference --- src/ds_platform_utils/metaflow/s3_stage.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 7f878b0..16cb70d 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -92,17 +92,14 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 if auto_create_table and not overwrite: table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_definition]) create_table_query = f"""CREATE TABLE IF NOT EXISTS {table_name} ( {table_create_columns_str} );""" - print(f"Generated CREATE TABLE query:\n{create_table_query}") sql_statements.append(create_table_query) if auto_create_table and overwrite: table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_definition]) create_table_query = f"""CREATE OR REPLACE TABLE {table_name} ( {table_create_columns_str} );""" - print(f"Generated CREATE OR REPLACE TABLE query:\n{create_table_query}") sql_statements.append(create_table_query) if not auto_create_table and overwrite: - print(f"Generated TRUNCATE TABLE query:\nTRUNCATE TABLE IF EXISTS {table_name};") sql_statements.append(f"TRUNCATE TABLE IF EXISTS {table_name};") # columns_str = ",\n ".join([f"PARSE_JSON($1):{col_name}::{col_type}" for col_name, col_type in table_definition]) @@ -111,7 +108,6 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 FILE_FORMAT = (TYPE = 'parquet' USE_LOGICAL_TYPE = {use_logical_type}) MATCH_BY_COLUMN_NAME = 'CASE_INSENSITIVE' ;""" - print(f"Generated COPY INTO query:\n{copy_query}") sql_statements.append(copy_query) # Combine all statements into a single SQL script @@ -222,9 +218,11 @@ def copy_s3_to_snowflake( # noqa: PLR0913 if table_definition is None: # Infer table schema from the Parquet files in the Snowflake stage table_definition = _infer_table_schema(conn, sf_stage_path, use_logical_type) - print(f"Inferred table schema: {table_definition}") + if table_definition is None or len(table_definition) == 0: + raise ValueError( + "Failed to determine table schema. Please provide a valid table_definition or ensure that the S3 path contains valid Parquet files." + ) - print(f"Uploading data from S3 path: {s3_path}") copy_query = _generate_s3_to_snowflake_copy_query( table_name=table_name, snowflake_stage_path=sf_stage_path, From 7ad286c3edc4fcba24f7be016e887c98b8c0393a Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:43:30 +0530 Subject: [PATCH 090/167] feat: update BatchInferencePipeline and S3 stage for enhanced path handling and initialization --- src/ds_platform_utils/metaflow/__init__.py | 2 + .../metaflow/batch_inference_pipeline.py | 70 ++++++++----------- src/ds_platform_utils/metaflow/s3_stage.py | 16 ++++- 3 files changed, 46 insertions(+), 42 deletions(-) diff --git a/src/ds_platform_utils/metaflow/__init__.py b/src/ds_platform_utils/metaflow/__init__.py index c22e587..df0cd06 100644 --- a/src/ds_platform_utils/metaflow/__init__.py +++ b/src/ds_platform_utils/metaflow/__init__.py @@ -1,9 +1,11 @@ +from .batch_inference_pipeline import BatchInferenceManager from .pandas import publish_pandas, query_pandas_from_snowflake from .restore_step_state import restore_step_state from .validate_config import make_pydantic_parser_fn from .write_audit_publish import publish __all__ = [ + "BatchInferenceManager", "make_pydantic_parser_fn", "publish", "publish_pandas", diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 1bcc02d..5c1d9f7 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -5,7 +5,6 @@ import time from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager -from dataclasses import dataclass, field from pathlib import Path from typing import Callable, List, Optional, Tuple, Union @@ -42,7 +41,6 @@ def _timer(message: str): _debug(f"{message}: Completed in {t1 - t0:.2f} seconds") -@dataclass class BatchInferencePipeline: """Orchestrates batch inference across Metaflow steps with foreach parallelization. @@ -100,20 +98,7 @@ def end(self): """ - pipeline_id: str = "default" - warehouse: Optional[str] = None - use_utc: bool = True - - # Internal state (populated after prepare()) - _s3_bucket: str = field(default="", repr=False) - _base_path: str = field(default="", repr=False) - _input_path: str = field(default="", repr=False) - _output_path: str = field(default="", repr=False) - _schema: str = field(default="", repr=False) - workers: dict = field(default_factory=dict, repr=False) - worker_ids: List[int] = field(default_factory=list) - - def __post_init__(self): + def __init__(self): """Initialize S3 paths based on Metaflow context.""" is_production = current.is_production if hasattr(current, "is_production") else False self._s3_bucket, _ = _get_s3_config(is_production) @@ -123,7 +108,8 @@ def __post_init__(self): flow_name = current.flow_name if hasattr(current, "flow_name") else "local" run_id = current.run_id if hasattr(current, "run_id") else "dev" - self._base_path = f"{self._s3_bucket}/{S3_DATA_FOLDER}/{flow_name}/{run_id}/{self.pipeline_id}" + timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") + self._base_path = f"{self._s3_bucket}/{S3_DATA_FOLDER}/{flow_name}/{run_id}/{timestamp}" self._input_path = f"{self._base_path}/input" self._output_path = f"{self._base_path}/output" @@ -157,30 +143,28 @@ def query_and_batch( Args: input_query: SQL query string or file path to query - ctx: Dict of variable substitutions for SQL template + warehouse: Snowflake warehouse to use + use_utc: Whether to use UTC timezone for Snowflake parallel_workers: Number of parallel workers to use for processing Returns: List of worker_ids to use with foreach in next step """ - print(f"🚀 Preparing batch inference pipeline: {self.pipeline_id}") - + print("🚀 Starting batch inference pipeline...") # Process input query input_query = get_query_from_string_or_fpath(input_query) input_query = substitute_map_into_string(input_query, {"schema": self._schema} | (ctx or {})) _debug_print_query(input_query) # Export from Snowflake to S3 - t0 = time.time() input_files = copy_snowflake_to_s3( query=input_query, warehouse=warehouse, use_utc=use_utc, + s3_path=self._input_path, ) - t1 = time.time() - print(f"✅ Exported {len(input_files)} files to S3 in {t1 - t0:.2f}s") # Create worker batches based on file sizes self.worker_files = self._split_files_into_workers(input_files, parallel_workers) @@ -227,7 +211,7 @@ def process_batch( def download_worker(file_batches: List[List[str]]): for file_id, file_batch in enumerate(file_batches): - with _timer(f"Downloading file {file_id} from S3"): + with _timer(f"📥 Downloaded file {file_id} from S3"): df = s3._get_df_from_s3_files(file_batch) df.columns = [col.lower() for col in df.columns] download_queue.put((file_id, df), timeout=timeout_per_file) @@ -240,9 +224,10 @@ def inference_worker(): inference_queue.put(None, timeout=timeout_per_file) break file_id, df = item - with _timer(f"Generating predictions for file {file_id}"): + with _timer(f"🔹 Generated predictions for file {file_id}"): predictions_df = predict_fn(df) inference_queue.put((file_id, predictions_df), timeout=timeout_per_file) + print(f"🔘 Inference completed for batch {file_id}") def upload_worker(): while True: @@ -251,7 +236,7 @@ def upload_worker(): break file_id, predictions_df = item s3_output_file = f"{output_path}/predictions_{worker_id}_{file_id}.parquet" - with _timer(f"Uploading predictions for file {file_id} to S3"): + with _timer(f"📤 Uploaded predictions for file {file_id} to S3"): s3._put_df_to_s3_file(predictions_df, s3_output_file) t0 = time.time() @@ -270,6 +255,8 @@ def publish_results( output_table_definition: Optional[List[Tuple[str, str]]] = None, auto_create_table: bool = True, overwrite: bool = True, + warehouse: Optional[str] = None, + use_utc: bool = True, ) -> None: """Step 3: Write all predictions from S3 to Snowflake (call this in join step). @@ -278,35 +265,35 @@ def publish_results( output_table_definition: Optional schema as list of (column, type) tuples auto_create_table: Whether to auto-create table if not exists overwrite: Whether to overwrite existing data + warehouse: Snowflake warehouse to use + use_utc: Whether to use UTC timezone for Snowflake """ print(f"📤 Writing predictions to Snowflake table: {output_table_name}") - t0 = time.time() copy_s3_to_snowflake( s3_path=self._output_path, table_name=output_table_name, table_definition=output_table_definition, - warehouse=self.warehouse, - use_utc=self.use_utc, + warehouse=warehouse, + use_utc=use_utc, auto_create_table=auto_create_table, overwrite=overwrite, ) - t1 = time.time() - - print(f"✅ Pipeline complete! Data written to {output_table_name} in {t1 - t0:.2f}s") def run( # noqa: PLR0913 self, input_query: Union[str, Path], output_table_name: str, predict_fn: Callable[[pd.DataFrame], pd.DataFrame], - query_variables: Optional[dict] = None, + ctx: Optional[dict] = None, output_table_definition: Optional[List[Tuple[str, str]]] = None, batch_size_in_mb: int = 128, timeout_per_file: int = 300, auto_create_table: bool = True, overwrite: bool = True, + warehouse: Optional[str] = None, + use_utc: bool = True, ) -> None: """Run the complete pipeline: query → process → publish in a single call. @@ -317,12 +304,14 @@ def run( # noqa: PLR0913 input_query: SQL query string or file path to query output_table_name: Name of the Snowflake table for predictions predict_fn: Function that takes DataFrame and returns predictions DataFrame - query_variables: Dict of variable substitutions for SQL template + ctx: Dict of variable substitutions for SQL template output_table_definition: Optional schema as list of (column, type) tuples batch_size_in_mb: Target size for each batch in MB timeout_per_file: Timeout in seconds for each file operation auto_create_table: Whether to auto-create table if not exists overwrite: Whether to overwrite existing data + warehouse: Snowflake warehouse to use + use_utc: Whether to use UTC timezone for Snowflake Example:: @@ -337,8 +326,10 @@ def run( # noqa: PLR0913 # Step 1: Query and batch worker_ids = self.query_and_batch( input_query=input_query, - query_variables=query_variables, + ctx=ctx, parallel_workers=1, + use_utc=use_utc, + warehouse=warehouse, ) # Step 2: Process all batches sequentially @@ -356,6 +347,8 @@ def run( # noqa: PLR0913 output_table_definition=output_table_definition, auto_create_table=auto_create_table, overwrite=overwrite, + warehouse=warehouse, + use_utc=use_utc, ) def _make_batches(self, file_paths: List[str], batch_size_in_mb: int) -> List[List[str]]: @@ -387,7 +380,6 @@ def _make_batches(self, file_paths: List[str], batch_size_in_mb: int) -> List[Li def __repr__(self) -> str: """Return string representation of the pipeline.""" - return ( - f"BatchInferencePipeline(pipeline_id='{self.pipeline_id}', " - f"workers={len(self.workers)}, worker_ids={self.worker_ids})" - ) + worker_ids = getattr(self, "worker_ids", []) + worker_count = len(getattr(self, "worker_files", {})) + return f"BatchInferencePipeline(worker_count={worker_count}, worker_ids={worker_ids})" diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 16cb70d..c55e905 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -143,6 +143,7 @@ def copy_snowflake_to_s3( query: str, warehouse: Optional[str] = None, use_utc: bool = True, + s3_path: Optional[str] = None, ) -> List[str]: """Generate SQL COPY INTO command to export Snowflake query results to S3. @@ -155,9 +156,18 @@ def copy_snowflake_to_s3( schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA s3_bucket, snowflake_stage = _get_s3_config(current.is_production) - data_folder = "query_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) - s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{data_folder}" - sf_stage_path = f"{snowflake_stage}/{S3_DATA_FOLDER}/{data_folder}/" + if s3_path is not None and not s3_path.startswith(s3_bucket): + raise ValueError(f"s3_path must start with {s3_bucket}") + if s3_path is None: + # Build paths: s3://bucket/data/{flow}/{run_id}/{pipeline_id}/ + flow_name = current.flow_name if hasattr(current, "flow_name") else "local" + run_id = current.run_id if hasattr(current, "run_id") else "dev" + + timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") + s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{flow_name}/{run_id}/{timestamp}" + + sf_stage_path = s3_path.replace(s3_bucket, snowflake_stage) + query = _generate_snowflake_to_s3_copy_query( query=query, snowflake_stage_path=sf_stage_path, From 14975a6132396ce853856da02cdcfe216fd8d88d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:12:37 +0530 Subject: [PATCH 091/167] fix: update import from BatchInferenceManager to BatchInferencePipeline for consistency --- src/ds_platform_utils/metaflow/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ds_platform_utils/metaflow/__init__.py b/src/ds_platform_utils/metaflow/__init__.py index df0cd06..5a88f4f 100644 --- a/src/ds_platform_utils/metaflow/__init__.py +++ b/src/ds_platform_utils/metaflow/__init__.py @@ -1,11 +1,11 @@ -from .batch_inference_pipeline import BatchInferenceManager +from .batch_inference_pipeline import BatchInferencePipeline from .pandas import publish_pandas, query_pandas_from_snowflake from .restore_step_state import restore_step_state from .validate_config import make_pydantic_parser_fn from .write_audit_publish import publish __all__ = [ - "BatchInferenceManager", + "BatchInferencePipeline", "make_pydantic_parser_fn", "publish", "publish_pandas", From 6824499dfe46fb95bc42a61295d8586f51922bdb Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:18:59 +0530 Subject: [PATCH 092/167] feat: enhance logging for Snowflake to S3 export process and ensure consistent path formatting --- src/ds_platform_utils/metaflow/batch_inference_pipeline.py | 2 ++ src/ds_platform_utils/metaflow/s3_stage.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 5c1d9f7..b870507 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -158,6 +158,7 @@ def query_and_batch( input_query = substitute_map_into_string(input_query, {"schema": self._schema} | (ctx or {})) _debug_print_query(input_query) + _debug(f"⏳ Exporting data from Snowflake to S3 to {self._input_path}...") # Export from Snowflake to S3 input_files = copy_snowflake_to_s3( query=input_query, @@ -165,6 +166,7 @@ def query_and_batch( use_utc=use_utc, s3_path=self._input_path, ) + _debug(f"✅ Exported data to S3: {len(input_files)} files created.") # Create worker batches based on file sizes self.worker_files = self._split_files_into_workers(input_files, parallel_workers) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index c55e905..c3b2ad4 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -46,6 +46,8 @@ def _generate_snowflake_to_s3_copy_query( single = "FALSE" max_file_size = 16 * 1024 * 1024 # 16 MB + snowflake_stage_path = snowflake_stage_path.strip("/") + "/" + if query.count(";") > 1: raise ValueError("Multiple SQL statements detected. Please provide a single query statement.") query = query.replace(";", "") # Remove trailing semicolon if present @@ -162,7 +164,6 @@ def copy_snowflake_to_s3( # Build paths: s3://bucket/data/{flow}/{run_id}/{pipeline_id}/ flow_name = current.flow_name if hasattr(current, "flow_name") else "local" run_id = current.run_id if hasattr(current, "run_id") else "dev" - timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{flow_name}/{run_id}/{timestamp}" From 1cab798d3c73c7086461a0ea6dd6ae8d885fc65a Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:24:53 +0530 Subject: [PATCH 093/167] fix: adjust file indexing in download worker to start from 1 for accurate tracking --- src/ds_platform_utils/metaflow/batch_inference_pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index b870507..06d81d3 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -212,7 +212,7 @@ def process_batch( output_path = self._output_path def download_worker(file_batches: List[List[str]]): - for file_id, file_batch in enumerate(file_batches): + for file_id, file_batch in enumerate(file_batches, 1): with _timer(f"📥 Downloaded file {file_id} from S3"): df = s3._get_df_from_s3_files(file_batch) df.columns = [col.lower() for col in df.columns] From 3f966785cacb320ce01833cf99e33c9e2746020d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:42:43 +0530 Subject: [PATCH 094/167] fix: rename timeout parameter for clarity and update usage in batch processing --- .../metaflow/batch_inference_pipeline.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 06d81d3..040aabd 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -181,7 +181,7 @@ def process_batch( worker_id: int, predict_fn: Callable[[pd.DataFrame], pd.DataFrame], batch_size_in_mb: int = 128, - timeout_per_file: int = 300, + timeout_per_batch: int = 300, ) -> str: """Step 2: Process a single batch using parallel download→inference→upload pipeline. @@ -194,7 +194,7 @@ def process_batch( worker_id: The worker ID to process (from self.input in foreach) predict_fn: Function that takes DataFrame and returns predictions DataFrame batch_size_in_mb: Target size for each batch in MB - timeout_per_file: Timeout in seconds for each file operation (default: 300) + timeout_per_batch: Timeout in seconds for each batch operation (default: 300) Returns: S3 path where predictions were written @@ -216,24 +216,24 @@ def download_worker(file_batches: List[List[str]]): with _timer(f"📥 Downloaded file {file_id} from S3"): df = s3._get_df_from_s3_files(file_batch) df.columns = [col.lower() for col in df.columns] - download_queue.put((file_id, df), timeout=timeout_per_file) - download_queue.put(None, timeout=timeout_per_file) + download_queue.put((file_id, df), timeout=timeout_per_batch) + download_queue.put(None, timeout=timeout_per_batch) def inference_worker(): while True: - item = download_queue.get(timeout=timeout_per_file) + item = download_queue.get(timeout=timeout_per_batch) if item is None: - inference_queue.put(None, timeout=timeout_per_file) + inference_queue.put(None, timeout=timeout_per_batch) break file_id, df = item with _timer(f"🔹 Generated predictions for file {file_id}"): predictions_df = predict_fn(df) - inference_queue.put((file_id, predictions_df), timeout=timeout_per_file) + inference_queue.put((file_id, predictions_df), timeout=timeout_per_batch) print(f"🔘 Inference completed for batch {file_id}") def upload_worker(): while True: - item = inference_queue.get(timeout=timeout_per_file) + item = inference_queue.get(timeout=timeout_per_batch) if item is None: break file_id, predictions_df = item @@ -340,7 +340,7 @@ def run( # noqa: PLR0913 worker_id=worker_id, predict_fn=predict_fn, batch_size_in_mb=batch_size_in_mb, - timeout_per_file=timeout_per_file, + timeout_per_batch=timeout_per_file, ) # Step 3: Publish results From 82cc466d278184b41c5ea46ee827abeb74cd97b4 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:59:43 +0530 Subject: [PATCH 095/167] fix: ensure all worker futures are awaited and exceptions are propagated in BatchInferencePipeline --- .../metaflow/batch_inference_pipeline.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 040aabd..6fab397 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -243,9 +243,14 @@ def upload_worker(): t0 = time.time() with ThreadPoolExecutor(max_workers=3) as executor: - executor.submit(download_worker, file_batches) - executor.submit(inference_worker) - executor.submit(upload_worker) + futures = [ + executor.submit(download_worker, file_batches), + executor.submit(inference_worker), + executor.submit(upload_worker), + ] + # Wait for all futures and propagate any exceptions + for future in futures: + future.result() # Raises exception if worker failed t1 = time.time() print(f"✅ Worker {worker_id} complete ({len(file_batches)} batches processed in {t1 - t0:.2f}s)") From 5efb586cf0c174ea6ffd020d9f67b610cd8fd2c1 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:58:07 +0530 Subject: [PATCH 096/167] feat: add execution state flags to BatchInferencePipeline for better process tracking --- .../metaflow/batch_inference_pipeline.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 6fab397..4649a59 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -113,6 +113,11 @@ def __init__(self): self._input_path = f"{self._base_path}/input" self._output_path = f"{self._base_path}/output" + # Execution state flags + self._query_executed = False + self._batch_processed = False + self._results_published = False + @property def input_path(self) -> str: """S3 path where input data is stored.""" @@ -172,6 +177,10 @@ def query_and_batch( self.worker_files = self._split_files_into_workers(input_files, parallel_workers) self.worker_ids = list(self.worker_files.keys()) + # Mark query as executed + self._query_executed = True + self._batch_processed = False + print(f"📊 Created {len(self.worker_ids)} workers for parallel processing") return self.worker_ids @@ -200,6 +209,13 @@ def process_batch( S3 path where predictions were written """ + # Validate that query_and_batch was called first + if not self._query_executed: + raise RuntimeError( + "Cannot process batch: query_and_batch() must be called first. " + "Call query_and_batch() to export data from Snowflake before processing batches." + ) + if worker_id not in self.worker_files: raise ValueError(f"Worker {worker_id} not found. Available: {list(self.worker_files.keys())}") @@ -253,6 +269,9 @@ def upload_worker(): future.result() # Raises exception if worker failed t1 = time.time() + # Mark that at least one batch was processed + self._batch_processed = True + print(f"✅ Worker {worker_id} complete ({len(file_batches)} batches processed in {t1 - t0:.2f}s)") return self._output_path @@ -276,6 +295,19 @@ def publish_results( use_utc: Whether to use UTC timezone for Snowflake """ + # Validate that batches were processed + if not self._query_executed: + raise RuntimeError( + "Cannot publish results: query_and_batch() must be called first. " + "Call query_and_batch() to export data from Snowflake." + ) + + if not self._batch_processed: + raise RuntimeError( + "Cannot publish results: No batches have been processed. " + "Call process_batch() to process at least one batch before publishing." + ) + print(f"📤 Writing predictions to Snowflake table: {output_table_name}") copy_s3_to_snowflake( @@ -288,6 +320,9 @@ def publish_results( overwrite=overwrite, ) + # Mark results as published + self._results_published = True + def run( # noqa: PLR0913 self, input_query: Union[str, Path], From 1d88bfeb69c7e461f6ea273f3ae298a843cb039d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:22:54 +0530 Subject: [PATCH 097/167] fix: suppress pylint warning for publish_results method in BatchInferencePipeline --- .../metaflow/batch_inference_pipeline.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 4649a59..9e77a95 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -275,7 +275,7 @@ def upload_worker(): print(f"✅ Worker {worker_id} complete ({len(file_batches)} batches processed in {t1 - t0:.2f}s)") return self._output_path - def publish_results( + def publish_results( # noqa: PLR0913 self, output_table_name: str, output_table_definition: Optional[List[Tuple[str, str]]] = None, @@ -331,7 +331,7 @@ def run( # noqa: PLR0913 ctx: Optional[dict] = None, output_table_definition: Optional[List[Tuple[str, str]]] = None, batch_size_in_mb: int = 128, - timeout_per_file: int = 300, + timeout_per_batch: int = 300, auto_create_table: bool = True, overwrite: bool = True, warehouse: Optional[str] = None, @@ -349,7 +349,7 @@ def run( # noqa: PLR0913 ctx: Dict of variable substitutions for SQL template output_table_definition: Optional schema as list of (column, type) tuples batch_size_in_mb: Target size for each batch in MB - timeout_per_file: Timeout in seconds for each file operation + timeout_per_batch: Timeout in seconds for each batch operation auto_create_table: Whether to auto-create table if not exists overwrite: Whether to overwrite existing data warehouse: Snowflake warehouse to use @@ -380,7 +380,7 @@ def run( # noqa: PLR0913 worker_id=worker_id, predict_fn=predict_fn, batch_size_in_mb=batch_size_in_mb, - timeout_per_batch=timeout_per_file, + timeout_per_batch=timeout_per_batch, ) # Step 3: Publish results From f189b80cfd3886fd17b2bf86dc551822f2c2a0aa Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:13:39 +0530 Subject: [PATCH 098/167] fix: update BatchInferencePipeline initialization and enhance warnings for batch processing state --- .../metaflow/batch_inference_pipeline.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 9e77a95..33c6133 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -59,10 +59,10 @@ class MyPredictionFlow(FlowSpec): @step def start(self): # Initialize pipeline and export data to S3 - self.pipeline = BatchInferencePipeline(pipeline_id="my_model") + self.pipeline = BatchInferencePipeline() self.worker_ids = self.pipeline.query_and_batch( input_query="SELECT * FROM my_table", - batch_size_in_mb=128, + parallel_workers=4, ) self.next(self.predict, foreach='worker_ids') @@ -128,7 +128,7 @@ def output_path(self) -> str: """S3 path where output predictions are stored.""" return self._output_path - def _split_files_into_workers(self, files: List[str], parallel_workers: int) -> dict: + def _split_files_into_workers(self, files: List[str], parallel_workers: int) -> dict[int, List[str]]: """Split list of files into batches for each worker.""" if len(files) < parallel_workers: print("⚠️ Fewer files than workers. Assigning one file per worker until files run out.") @@ -157,6 +157,10 @@ def query_and_batch( List of worker_ids to use with foreach in next step """ + # Warn if re-executing query_and_batch after processing + if self._query_executed and self._batch_processed: + print("⚠️ Warning: Re-executing query_and_batch() will reset batch processing state.") + print("🚀 Starting batch inference pipeline...") # Process input query input_query = get_query_from_string_or_fpath(input_query) @@ -308,6 +312,9 @@ def publish_results( # noqa: PLR0913 "Call process_batch() to process at least one batch before publishing." ) + if self._results_published: + print("⚠️ Warning: Results have already been published. Publishing again may cause duplicate data.") + print(f"📤 Writing predictions to Snowflake table: {output_table_name}") copy_s3_to_snowflake( @@ -357,7 +364,7 @@ def run( # noqa: PLR0913 Example:: - pipeline = BatchInferencePipeline(pipeline_id="my_model") + pipeline = BatchInferencePipeline() pipeline.run( input_query="SELECT * FROM my_table", output_table_name="predictions_table", From 83a83321e5a30069e05ddb3f870e235a2db100b6 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:18:44 +0530 Subject: [PATCH 099/167] feat: enhance README with detailed BatchInferencePipeline usage examples and API reference --- README.md | 297 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) diff --git a/README.md b/README.md index 23dd103..6dca519 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,300 @@ ## ds-platform-utils Utility library to support Pattern's [data-science-projects](https://github.com/patterninc/data-science-projects/). + +## Features + +### BatchInferencePipeline + +A scalable batch inference pipeline for running ML predictions on large datasets using Metaflow, Snowflake, and S3. + +#### Key Features + +- **Snowflake Integration**: Query data directly from Snowflake and write results back +- **S3 Staging**: Efficient data transfer via S3 for large datasets +- **Parallel Processing**: Built-in support for Metaflow's foreach parallelization +- **Pipeline Orchestration**: Three-stage pipeline (query → process → publish) +- **Queue-based Processing**: Multi-threaded download→inference→upload pipeline for optimal throughput +- **Execution State Validation**: Prevents out-of-order execution with clear error messages + +#### Quick Start + +##### Option 1: Manual Control with Foreach Parallelization + +Use this approach when you need fine-grained control and want to parallelize across multiple Metaflow workers: + +```python +from metaflow import FlowSpec, step +from ds_platform_utils.metaflow import BatchInferencePipeline + +class MyPredictionFlow(FlowSpec): + + @step + def start(self): + # Initialize pipeline and export data to S3 + self.pipeline = BatchInferencePipeline() + self.worker_ids = self.pipeline.query_and_batch( + input_query="SELECT * FROM my_table WHERE date >= '2024-01-01'", + parallel_workers=10, # Split into 10 parallel workers + ) + self.next(self.predict, foreach='worker_ids') + + @step + def predict(self): + # Process single batch (runs in parallel via foreach) + worker_id = self.input + self.pipeline.process_batch( + worker_id=worker_id, + predict_fn=my_model.predict, + batch_size_in_mb=256, + ) + self.next(self.join) + + @step + def join(self, inputs): + # Merge and write results to Snowflake + self.pipeline = inputs[0].pipeline + self.pipeline.publish_results( + output_table_name="predictions_table", + auto_create_table=True, + ) + self.next(self.end) + + @step + def end(self): + print("✅ Pipeline complete!") +``` + +##### Option 2: Convenience Method + +Use this for simpler workflows without foreach parallelization: + +```python +from ds_platform_utils.metaflow import BatchInferencePipeline + +def my_predict_function(df): + # Your prediction logic here + df['prediction'] = model.predict(df[feature_columns]) + return df[['id', 'prediction']] + +# Run the complete pipeline +pipeline = BatchInferencePipeline() +pipeline.run( + input_query="SELECT * FROM input_table", + output_table_name="predictions_table", + predict_fn=my_predict_function, + batch_size_in_mb=128, + auto_create_table=True, + overwrite=True, +) +``` + +#### API Reference + +##### `BatchInferencePipeline()` + +Initialize the pipeline. Automatically configures S3 paths based on Metaflow context. + +##### `query_and_batch()` + +**Step 1**: Export data from Snowflake to S3 and create worker batches. + +```python +worker_ids = pipeline.query_and_batch( + input_query: Union[str, Path], # SQL query or path to .sql file + ctx: Optional[dict] = None, # Template variables (e.g., {"schema": "dev"}) + warehouse: Optional[str] = None, # Snowflake warehouse + use_utc: bool = True, # Use UTC timezone + parallel_workers: int = 1, # Number of parallel workers +) +``` + +**Returns**: List of worker IDs for foreach parallelization + +##### `process_batch()` + +**Step 2**: Process a single batch with streaming pipeline. + +```python +s3_path = pipeline.process_batch( + worker_id: int, # Worker ID from foreach + predict_fn: Callable[[pd.DataFrame], pd.DataFrame], # Prediction function + batch_size_in_mb: int = 128, # Batch size in MB + timeout_per_batch: int = 300, # Timeout in seconds +) +``` + +**Your `predict_fn` signature**: +```python +def predict_fn(input_df: pd.DataFrame) -> pd.DataFrame: + # Process the input DataFrame and return predictions + return predictions_df +``` + +##### `publish_results()` + +**Step 3**: Write all predictions from S3 to Snowflake. + +```python +pipeline.publish_results( + output_table_name: str, # Snowflake table name + output_table_definition: Optional[List[Tuple]] = None, # Schema definition + auto_create_table: bool = True, # Auto-create if missing + overwrite: bool = True, # Overwrite existing data + warehouse: Optional[str] = None, # Snowflake warehouse + use_utc: bool = True, # Use UTC timezone +) +``` + +##### `run()` + +Convenience method that combines all three steps for simple workflows. + +```python +pipeline.run( + input_query: Union[str, Path], + output_table_name: str, + predict_fn: Callable[[pd.DataFrame], pd.DataFrame], + # ... plus all parameters from query_and_batch(), process_batch(), publish_results() +) +``` + +#### Advanced Usage + +##### Custom Table Schema + +```python +table_schema = [ + ("id", "VARCHAR(100)"), + ("prediction", "FLOAT"), + ("confidence", "FLOAT"), + ("predicted_at", "TIMESTAMP_NTZ"), +] + +pipeline.publish_results( + output_table_name="predictions", + output_table_definition=table_schema, + auto_create_table=True, +) +``` + +##### Using SQL Template Variables + +```python +worker_ids = pipeline.query_and_batch( + input_query=""" + SELECT * FROM {{schema}}.my_table + WHERE date >= '{{start_date}}' + """, + ctx={ + "schema": "production", + "start_date": "2024-01-01", + }, +) +``` + +##### External SQL Files + +```python +worker_ids = pipeline.query_and_batch( + input_query=Path("queries/input_query.sql"), + ctx={"schema": "production"}, +) +``` + +#### Error Handling & Validation + +The pipeline validates execution order and provides clear error messages: + +```python +pipeline = BatchInferencePipeline() + +# ❌ This will raise RuntimeError +pipeline.process_batch(worker_id=1, predict_fn=my_fn) +# Error: "Cannot process batch: query_and_batch() must be called first." + +# ❌ This will also raise RuntimeError +pipeline.publish_results(output_table_name="results") +# Error: "Cannot publish results: No batches have been processed." +``` + +Re-execution warnings: + +```python +# First execution +worker_ids = pipeline.query_and_batch(input_query="SELECT * FROM table") +pipeline.process_batch(worker_id=1, predict_fn=my_fn) + +# Second execution - warns about state reset +worker_ids = pipeline.query_and_batch(input_query="SELECT * FROM table") +# ⚠️ Warning: Re-executing query_and_batch() will reset batch processing state. + +# Publishing again - warns about duplicates +pipeline.publish_results(output_table_name="results") # First time - OK +pipeline.publish_results(output_table_name="results") # Second time +# ⚠️ Warning: Results have already been published. Publishing again may cause duplicate data. +``` + +#### Performance Tips + +1. **Batch Size**: Tune `batch_size_in_mb` based on your data and memory constraints + - Larger batches = fewer S3 operations but more memory usage + - Recommended: 128-512 MB per batch + +2. **Parallel Workers**: Balance parallelization with Metaflow cluster capacity + - More workers = faster processing but more resources + - Consider your data size and available compute + +3. **Timeouts**: Adjust `timeout_per_batch` for long-running inference + - Default: 300 seconds (5 minutes) + - Increase for complex models or large batches + +#### Troubleshooting + +##### "Worker X not found" +- The worker_id doesn't match any created worker +- Check that you're using worker_ids from `query_and_batch()` + +##### Timeout Errors +- Increase `timeout_per_batch` parameter +- Reduce `batch_size_in_mb` to process smaller chunks +- Check model inference performance + +##### Memory Issues +- Reduce `batch_size_in_mb` +- Ensure predict_fn doesn't accumulate data +- Monitor Metaflow task memory usage + +#### Architecture + +``` +┌──────────────┐ +│ Snowflake │ +│ (Query) │ +└──────┬───────┘ + │ COPY INTO + ▼ +┌──────────────┐ ┌─────────────────────────┐ +│ S3 │ │ Metaflow Workers │ +│ (Stage) │◄────►│ (Foreach Parallel) │ +│ Input Data │ │ │ +└──────────────┘ │ ┌───────────────────┐ │ + │ │ │ Queue Pipeline: │ │ + │ │ │ Download ──→ │ │ + │ │ │ Inference ──→ │ │ + │ │ │ Upload │ │ + │ │ └───────────────────┘ │ + │ └─────────┬───────────────┘ + ▼ │ +┌──────────────┐ │ +│ S3 │◄──────────────┘ +│ (Stage) │ +│ Output Data │ +└──────┬───────┘ + │ COPY INTO + ▼ +┌──────────────┐ +│ Snowflake │ +│ (Publish) │ +└──────────────┘ +``` From a654caa63feca1fcd586c57139608a472a16f96b Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:45:26 +0530 Subject: [PATCH 100/167] Add Metaflow utilities documentation and modules for batch inference, pandas integration, and configuration validation - Created README.md for Metaflow Utilities with an overview of modules and common workflows. - Added batch_inference_pipeline.md detailing the BatchInferencePipeline for large-scale ML predictions. - Introduced pandas.md for querying and publishing pandas DataFrames with Snowflake. - Developed validate_config.md for type-safe configuration using Pydantic, including examples and best practices. --- README.md | 302 +---------- docs/README.md | 201 ++++++++ docs/api/index.md | 519 +++++++++++++++++++ docs/examples/README.md | 579 +++++++++++++++++++++ docs/guides/best_practices.md | 452 ++++++++++++++++ docs/guides/common_patterns.md | 578 +++++++++++++++++++++ docs/guides/getting_started.md | 281 ++++++++++ docs/guides/performance_tuning.md | 461 +++++++++++++++++ docs/guides/troubleshooting.md | 598 ++++++++++++++++++++++ docs/metaflow/README.md | 299 +++++++++++ docs/metaflow/batch_inference_pipeline.md | 296 +++++++++++ docs/metaflow/pandas.md | 381 ++++++++++++++ docs/metaflow/validate_config.md | 479 +++++++++++++++++ 13 files changed, 5132 insertions(+), 294 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/api/index.md create mode 100644 docs/examples/README.md create mode 100644 docs/guides/best_practices.md create mode 100644 docs/guides/common_patterns.md create mode 100644 docs/guides/getting_started.md create mode 100644 docs/guides/performance_tuning.md create mode 100644 docs/guides/troubleshooting.md create mode 100644 docs/metaflow/README.md create mode 100644 docs/metaflow/batch_inference_pipeline.md create mode 100644 docs/metaflow/pandas.md create mode 100644 docs/metaflow/validate_config.md diff --git a/README.md b/README.md index 6dca519..9869a22 100644 --- a/README.md +++ b/README.md @@ -2,299 +2,13 @@ Utility library to support Pattern's [data-science-projects](https://github.com/patterninc/data-science-projects/). -## Features +## 📚 Documentation -### BatchInferencePipeline +For comprehensive documentation, guides, and examples: -A scalable batch inference pipeline for running ML predictions on large datasets using Metaflow, Snowflake, and S3. - -#### Key Features - -- **Snowflake Integration**: Query data directly from Snowflake and write results back -- **S3 Staging**: Efficient data transfer via S3 for large datasets -- **Parallel Processing**: Built-in support for Metaflow's foreach parallelization -- **Pipeline Orchestration**: Three-stage pipeline (query → process → publish) -- **Queue-based Processing**: Multi-threaded download→inference→upload pipeline for optimal throughput -- **Execution State Validation**: Prevents out-of-order execution with clear error messages - -#### Quick Start - -##### Option 1: Manual Control with Foreach Parallelization - -Use this approach when you need fine-grained control and want to parallelize across multiple Metaflow workers: - -```python -from metaflow import FlowSpec, step -from ds_platform_utils.metaflow import BatchInferencePipeline - -class MyPredictionFlow(FlowSpec): - - @step - def start(self): - # Initialize pipeline and export data to S3 - self.pipeline = BatchInferencePipeline() - self.worker_ids = self.pipeline.query_and_batch( - input_query="SELECT * FROM my_table WHERE date >= '2024-01-01'", - parallel_workers=10, # Split into 10 parallel workers - ) - self.next(self.predict, foreach='worker_ids') - - @step - def predict(self): - # Process single batch (runs in parallel via foreach) - worker_id = self.input - self.pipeline.process_batch( - worker_id=worker_id, - predict_fn=my_model.predict, - batch_size_in_mb=256, - ) - self.next(self.join) - - @step - def join(self, inputs): - # Merge and write results to Snowflake - self.pipeline = inputs[0].pipeline - self.pipeline.publish_results( - output_table_name="predictions_table", - auto_create_table=True, - ) - self.next(self.end) - - @step - def end(self): - print("✅ Pipeline complete!") -``` - -##### Option 2: Convenience Method - -Use this for simpler workflows without foreach parallelization: - -```python -from ds_platform_utils.metaflow import BatchInferencePipeline - -def my_predict_function(df): - # Your prediction logic here - df['prediction'] = model.predict(df[feature_columns]) - return df[['id', 'prediction']] - -# Run the complete pipeline -pipeline = BatchInferencePipeline() -pipeline.run( - input_query="SELECT * FROM input_table", - output_table_name="predictions_table", - predict_fn=my_predict_function, - batch_size_in_mb=128, - auto_create_table=True, - overwrite=True, -) -``` - -#### API Reference - -##### `BatchInferencePipeline()` - -Initialize the pipeline. Automatically configures S3 paths based on Metaflow context. - -##### `query_and_batch()` - -**Step 1**: Export data from Snowflake to S3 and create worker batches. - -```python -worker_ids = pipeline.query_and_batch( - input_query: Union[str, Path], # SQL query or path to .sql file - ctx: Optional[dict] = None, # Template variables (e.g., {"schema": "dev"}) - warehouse: Optional[str] = None, # Snowflake warehouse - use_utc: bool = True, # Use UTC timezone - parallel_workers: int = 1, # Number of parallel workers -) -``` - -**Returns**: List of worker IDs for foreach parallelization - -##### `process_batch()` - -**Step 2**: Process a single batch with streaming pipeline. - -```python -s3_path = pipeline.process_batch( - worker_id: int, # Worker ID from foreach - predict_fn: Callable[[pd.DataFrame], pd.DataFrame], # Prediction function - batch_size_in_mb: int = 128, # Batch size in MB - timeout_per_batch: int = 300, # Timeout in seconds -) -``` - -**Your `predict_fn` signature**: -```python -def predict_fn(input_df: pd.DataFrame) -> pd.DataFrame: - # Process the input DataFrame and return predictions - return predictions_df -``` - -##### `publish_results()` - -**Step 3**: Write all predictions from S3 to Snowflake. - -```python -pipeline.publish_results( - output_table_name: str, # Snowflake table name - output_table_definition: Optional[List[Tuple]] = None, # Schema definition - auto_create_table: bool = True, # Auto-create if missing - overwrite: bool = True, # Overwrite existing data - warehouse: Optional[str] = None, # Snowflake warehouse - use_utc: bool = True, # Use UTC timezone -) -``` - -##### `run()` - -Convenience method that combines all three steps for simple workflows. - -```python -pipeline.run( - input_query: Union[str, Path], - output_table_name: str, - predict_fn: Callable[[pd.DataFrame], pd.DataFrame], - # ... plus all parameters from query_and_batch(), process_batch(), publish_results() -) -``` - -#### Advanced Usage - -##### Custom Table Schema - -```python -table_schema = [ - ("id", "VARCHAR(100)"), - ("prediction", "FLOAT"), - ("confidence", "FLOAT"), - ("predicted_at", "TIMESTAMP_NTZ"), -] - -pipeline.publish_results( - output_table_name="predictions", - output_table_definition=table_schema, - auto_create_table=True, -) -``` - -##### Using SQL Template Variables - -```python -worker_ids = pipeline.query_and_batch( - input_query=""" - SELECT * FROM {{schema}}.my_table - WHERE date >= '{{start_date}}' - """, - ctx={ - "schema": "production", - "start_date": "2024-01-01", - }, -) -``` - -##### External SQL Files - -```python -worker_ids = pipeline.query_and_batch( - input_query=Path("queries/input_query.sql"), - ctx={"schema": "production"}, -) -``` - -#### Error Handling & Validation - -The pipeline validates execution order and provides clear error messages: - -```python -pipeline = BatchInferencePipeline() - -# ❌ This will raise RuntimeError -pipeline.process_batch(worker_id=1, predict_fn=my_fn) -# Error: "Cannot process batch: query_and_batch() must be called first." - -# ❌ This will also raise RuntimeError -pipeline.publish_results(output_table_name="results") -# Error: "Cannot publish results: No batches have been processed." -``` - -Re-execution warnings: - -```python -# First execution -worker_ids = pipeline.query_and_batch(input_query="SELECT * FROM table") -pipeline.process_batch(worker_id=1, predict_fn=my_fn) - -# Second execution - warns about state reset -worker_ids = pipeline.query_and_batch(input_query="SELECT * FROM table") -# ⚠️ Warning: Re-executing query_and_batch() will reset batch processing state. - -# Publishing again - warns about duplicates -pipeline.publish_results(output_table_name="results") # First time - OK -pipeline.publish_results(output_table_name="results") # Second time -# ⚠️ Warning: Results have already been published. Publishing again may cause duplicate data. -``` - -#### Performance Tips - -1. **Batch Size**: Tune `batch_size_in_mb` based on your data and memory constraints - - Larger batches = fewer S3 operations but more memory usage - - Recommended: 128-512 MB per batch - -2. **Parallel Workers**: Balance parallelization with Metaflow cluster capacity - - More workers = faster processing but more resources - - Consider your data size and available compute - -3. **Timeouts**: Adjust `timeout_per_batch` for long-running inference - - Default: 300 seconds (5 minutes) - - Increase for complex models or large batches - -#### Troubleshooting - -##### "Worker X not found" -- The worker_id doesn't match any created worker -- Check that you're using worker_ids from `query_and_batch()` - -##### Timeout Errors -- Increase `timeout_per_batch` parameter -- Reduce `batch_size_in_mb` to process smaller chunks -- Check model inference performance - -##### Memory Issues -- Reduce `batch_size_in_mb` -- Ensure predict_fn doesn't accumulate data -- Monitor Metaflow task memory usage - -#### Architecture - -``` -┌──────────────┐ -│ Snowflake │ -│ (Query) │ -└──────┬───────┘ - │ COPY INTO - ▼ -┌──────────────┐ ┌─────────────────────────┐ -│ S3 │ │ Metaflow Workers │ -│ (Stage) │◄────►│ (Foreach Parallel) │ -│ Input Data │ │ │ -└──────────────┘ │ ┌───────────────────┐ │ - │ │ │ Queue Pipeline: │ │ - │ │ │ Download ──→ │ │ - │ │ │ Inference ──→ │ │ - │ │ │ Upload │ │ - │ │ └───────────────────┘ │ - │ └─────────┬───────────────┘ - ▼ │ -┌──────────────┐ │ -│ S3 │◄──────────────┘ -│ (Stage) │ -│ Output Data │ -└──────┬───────┘ - │ COPY INTO - ▼ -┌──────────────┐ -│ Snowflake │ -│ (Publish) │ -└──────────────┘ -``` +- **[📖 Full Documentation](docs/README.md)** - Complete documentation hub +- **[🚀 Getting Started](docs/guides/getting_started.md)** - Quick start guide +- **[✨ Best Practices](docs/guides/best_practices.md)** - Production-ready patterns +- **[⚡ Performance Tuning](docs/guides/performance_tuning.md)** - Optimization guide +- **[🔧 API Reference](docs/api/index.md)** - Complete API documentation +- **[🛠️ Troubleshooting](docs/guides/troubleshooting.md)** - Common issues & solutions diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7febb82 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,201 @@ +# ds-platform-utils Documentation + +Comprehensive documentation for Pattern's data science platform utilities. + +## Overview + +`ds-platform-utils` is a utility library designed to streamline ML workflows on Pattern's data platform. It provides high-level abstractions for common operations involving Metaflow, Snowflake, and S3. + +## Table of Contents + +### Core Modules + +**[Metaflow Utilities](metaflow/README.md)** +- [BatchInferencePipeline](metaflow/batch_inference_pipeline.md) - Scalable batch inference orchestration +- [Pandas Integration](metaflow/pandas.md) - Query and publish functions for Snowflake +- [Config Validation](metaflow/validate_config.md) - Pydantic-based configuration validation + +### Guides + +- [Getting Started](guides/getting_started.md) +- [Best Practices](guides/best_practices.md) +- [Performance Tuning](guides/performance_tuning.md) +- [Common Patterns](guides/common_patterns.md) +- [Troubleshooting](guides/troubleshooting.md) + +### Examples + +- [Practical Examples](examples/README.md) - Complete working examples for common scenarios + - Simple Query and Publish + - Feature Engineering Pipeline + - Batch Inference at Scale + - Incremental Data Processing + - Multi-Table Join Pipeline + +### API Reference + +- [Complete API Reference](api/index.md) + +## Quick Links + +- [Getting Started →](guides/getting_started.md) +- [Best Practices →](guides/best_practices.md) +- [API Reference →](api/index.md) +- [Practical Examples →](examples/README.md) +- [Installation](#installation) +- [Quick Start](#quick-start) + +## Installation + +```bash +# Install from the repository +pip install git+https://github.com/patterninc/ds-platform-utils.git + +# For development +git clone https://github.com/patterninc/ds-platform-utils.git +cd ds-platform-utils +uv sync +``` + +## Configuration + +### Environment Variables + +```bash +# Enable debug logging +export DEBUG=1 + +# Snowflake configuration (usually handled by Metaflow integration) +export SNOWFLAKE_ACCOUNT=your_account +export SNOWFLAKE_USER=your_user +export SNOWFLAKE_WAREHOUSE=your_warehouse +``` + +### Metaflow Setup + +This library is designed to work seamlessly with Metaflow. Ensure your Metaflow configuration is properly set up: + +```bash +# Configure Metaflow with Outerbounds +metaflow configure aws +``` + +## Quick Start + +### Example 1: Query Data from Snowflake + +```python +from ds_platform_utils.metaflow import query_pandas_from_snowflake + +# Query data into a pandas DataFrame +df = query_pandas_from_snowflake( + query="SELECT * FROM my_schema.my_table LIMIT 1000", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", +) +``` + +### Example 2: Publish Results to Snowflake + +```python +from ds_platform_utils.metaflow import publish_pandas + +# Publish DataFrame to Snowflake +publish_pandas( + table_name="my_results_table", + df=results_df, + auto_create_table=True, + overwrite=True, +) +``` + +### Example 3: Batch Inference Pipeline + +```python +from ds_platform_utils.metaflow import BatchInferencePipeline + +pipeline = BatchInferencePipeline() +pipeline.run( + input_query="SELECT * FROM features_table", + output_table_name="predictions_table", + predict_fn=my_model.predict, +) +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ ds-platform-utils │ +│ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ Metaflow Integration │ │ +│ │ │ │ +│ │ • BatchInferencePipeline │ │ +│ │ • Pandas Integration (query/publish) │ │ +│ │ • Write, Audit, Publish │ │ +│ │ • State Management │ │ +│ │ • Config Validation │ │ +│ └────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ Data Operations │ │ +│ │ │ │ +│ │ • S3 File Operations │ │ +│ │ • S3 Stage Management │ │ +│ │ • Snowflake Connection │ │ +│ └────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ + ┌─────────────────┴─────────────────┐ + │ │ + ▼ ▼ +┌───────────────┐ ┌───────────────┐ +│ Snowflake │ │ S3 │ +│ Database │◄─────────────────► Storage │ +│ │ S3 Stage Copy │ │ +└───────────────┘ └───────────────┘ +``` + +## Key Features + +### 🚀 Scalable Batch Inference +- Automatic parallelization with Metaflow foreach +- Efficient S3 staging for large datasets +- Queue-based streaming pipeline +- Built-in error handling and validation + +### 📊 Snowflake Integration +- Direct pandas integration +- S3 stage operations for large datasets +- Production-ready write patterns +- Automatic schema management + +### 🔄 State Management +- Flow state restoration +- Artifact management +- Configuration validation + +### 🛡️ Production Ready +- Audit trail generation +- Dev/Prod schema separation +- Query tagging and tracking +- Safe publishing patterns + +## Contributing + +Contributions are welcome! Please ensure you: +- Follow the existing code style +- Add tests for new features +- Update documentation as needed + +## License + +Internal use only - Pattern Inc. + +## Support + +For questions or issues: +- Create an issue in the [GitHub repository](https://github.com/patterninc/ds-platform-utils) +- Check the [Troubleshooting Guide](guides/troubleshooting.md) +- Contact the Data Science Platform team +- Check the [Troubleshooting Guide](guides/troubleshooting.md) diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..d7141c0 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,519 @@ +# API Reference + +[← Back to Main Docs](../README.md) + +Complete API documentation for `ds-platform-utils`. + +## Table of Contents + +- [Metaflow Utilities](#metaflow-utilities) +- [Snowflake Utilities](#snowflake-utilities) + +## Metaflow Utilities + +Located in `ds_platform_utils.metaflow` + +### Query Functions + +#### `query_pandas_from_snowflake()` + +Query Snowflake and return a pandas DataFrame. + +**Signature:** +```python +def query_pandas_from_snowflake( + query: Optional[str] = None, + query_fpath: Optional[str] = None, + ctx: Optional[Dict[str, Any]] = None, + warehouse: Optional[str] = None, + use_s3_stage: bool = False, + timeout_seconds: Optional[int] = None, +) -> pd.DataFrame +``` + +**Parameters:** +- `query` (str, optional): SQL query string. Mutually exclusive with `query_fpath`. +- `query_fpath` (str, optional): Path to SQL file containing query. Mutually exclusive with `query`. +- `ctx` (dict, optional): Template variables for query substitution using `{{variable}}` syntax. +- `warehouse` (str, optional): Snowflake warehouse name. If not provided, uses default from connection. +- `use_s3_stage` (bool, default=False): Use S3 staging for large results (recommended for > 1GB). +- `timeout_seconds` (int, optional): Query timeout in seconds. + +**Returns:** +- `pd.DataFrame`: Query results as pandas DataFrame + +**Raises:** +- `ValueError`: If neither `query` nor `query_fpath` provided, or if both provided. +- `FileNotFoundError`: If `query_fpath` does not exist. +- `SnowflakeQueryError`: If query execution fails. +- `TimeoutError`: If query exceeds timeout. + +**Example:** +```python +# Direct query +df = query_pandas_from_snowflake( + query="SELECT * FROM my_table WHERE date >= '2024-01-01'", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", +) + +# From SQL file with template variables +df = query_pandas_from_snowflake( + query_fpath="sql/extract.sql", + ctx={"start_date": "2024-01-01", "end_date": "2024-12-31"}, +) + +# Large dataset via S3 +df = query_pandas_from_snowflake( + query="SELECT * FROM large_table", + use_s3_stage=True, + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", +) +``` + +**See Also:** +- [Pandas Integration Guide](../metaflow/pandas.md) +- [Performance Tuning](../guides/performance_tuning.md) + +--- + +### Publish Functions + +#### `publish_pandas()` + +Publish a pandas DataFrame to Snowflake. + +**Signature:** +```python +def publish_pandas( + table_name: str, + df: pd.DataFrame, + schema: Optional[str] = None, + mode: str = "replace", + warehouse: Optional[str] = None, + comment: Optional[str] = None, +) -> None +``` + +**Parameters:** +- `table_name` (str): Target table name (without schema). +- `df` (pd.DataFrame): DataFrame to publish. +- `schema` (str, optional): Target schema. If not provided, uses default dev schema. +- `mode` (str, default="replace"): Write mode: + - `"replace"`: Drop and recreate table + - `"append"`: Append to existing table + - `"fail"`: Fail if table exists +- `warehouse` (str, optional): Snowflake warehouse name. +- `comment` (str, optional): Table comment for documentation. + +**Returns:** None + +**Raises:** +- `ValueError`: If DataFrame is empty or invalid mode. +- `SnowflakeError`: If publish operation fails. +- `PermissionError`: If no write access to schema. + +**Example:** +```python +# Basic publish +publish_pandas( + table_name="my_results", + df=results_df, + schema="my_dev_schema", +) + +# Append mode +publish_pandas( + table_name="incremental_data", + df=new_data_df, + mode="append", +) + +# With comment +publish_pandas( + table_name="features", + df=features_df, + comment="Daily feature refresh - 2024-01-15", +) +``` + +**See Also:** +- [Pandas Integration Guide](../metaflow/pandas.md) +- [Common Patterns](../guides/common_patterns.md) + +--- + +#### `publish()` + +Query, optionally transform, and publish in one call. + +**Signature:** +```python +def publish( + query_fpath: str, + ctx: Dict[str, Any], + publish_query_fpath: str, + transform_fn: Optional[Callable[[pd.DataFrame], pd.DataFrame]] = None, + comment: Optional[str] = None, + warehouse: Optional[str] = None, +) -> None +``` + +**Parameters:** +- `query_fpath` (str): Path to SQL file for querying data. +- `ctx` (dict): Template variables for both query and publish SQL. +- `publish_query_fpath` (str): Path to SQL file for publishing. +- `transform_fn` (callable, optional): Function to transform DataFrame between query and publish. +- `comment` (str, optional): Table comment. +- `warehouse` (str, optional): Snowflake warehouse name. + +**Returns:** None + +**Example:** +```python +def add_features(df: pd.DataFrame) -> pd.DataFrame: + """Add engineered features.""" + df['new_feature'] = df['value'] * 2 + return df + +publish( + query_fpath="sql/extract.sql", + ctx={"start_date": "2024-01-01", "end_date": "2024-12-31"}, + transform_fn=add_features, + publish_query_fpath="sql/publish.sql", + comment="Daily feature engineering", +) +``` + +--- + +### BatchInferencePipeline + +Class for large-scale batch inference with parallel processing. + +**Signature:** +```python +class BatchInferencePipeline: + def __init__(self) -> None +``` + +#### Methods + +##### `query_and_batch()` + +Query input data and split into batches for parallel processing. + +**Signature:** +```python +def query_and_batch( + self, + input_query: str, + batch_size_in_mb: int = 256, + parallel_workers: int = 10, + warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", +) -> List[int] +``` + +**Parameters:** +- `input_query` (str): SQL query to fetch input data +- `batch_size_in_mb` (int, default=256): Target size per batch file in MB +- `parallel_workers` (int, default=10): Number of parallel workers +- `warehouse` (str): Snowflake warehouse name + +**Returns:** +- `List[int]`: Worker IDs for use in `process_batch()` + +**Example:** +```python +pipeline = BatchInferencePipeline() +worker_ids = pipeline.query_and_batch( + input_query="SELECT * FROM large_input", + batch_size_in_mb=256, + parallel_workers=20, +) +``` + +##### `process_batch()` + +Process a single batch with predictions. + +**Signature:** +```python +def process_batch( + self, + worker_id: int, + predict_fn: Callable[[pd.DataFrame], pd.DataFrame], + batch_size_in_mb: int = 64, + timeout_per_batch: int = 3600, +) -> None +``` + +**Parameters:** +- `worker_id` (int): Worker ID from `query_and_batch()` +- `predict_fn` (callable): Function that takes DataFrame and returns predictions +- `batch_size_in_mb` (int, default=64): Batch size for processing +- `timeout_per_batch` (int, default=3600): Timeout per batch in seconds + +**Returns:** None + +**Example:** +```python +def predict(df: pd.DataFrame) -> pd.DataFrame: + df['score'] = model.predict(df[['f1', 'f2']]) + return df[['id', 'score']] + +pipeline = BatchInferencePipeline() +pipeline.process_batch( + worker_id=worker_id, + predict_fn=predict, +) +``` + +##### `publish_results()` + +Publish all processed results to Snowflake. + +**Signature:** +```python +def publish_results( + self, + output_table: str, + output_schema: str, + warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", +) -> None +``` + +**Parameters:** +- `output_table` (str): Target table name +- `output_schema` (str): Target schema +- `warehouse` (str): Snowflake warehouse name + +**Returns:** None + +**Example:** +```python +pipeline = BatchInferencePipeline() +pipeline.publish_results( + output_table="predictions", + output_schema="my_dev_schema", +) +``` + +**See Also:** +- [BatchInferencePipeline Guide](../metaflow/batch_inference_pipeline.md) +- [Common Patterns - Batch Inference](../guides/common_patterns.md#batch-inference) + +--- + +### Configuration Validation + +#### `make_pydantic_parser_fn()` + +Create a parser function for Pydantic model validation in Metaflow Parameters. + +**Signature:** +```python +def make_pydantic_parser_fn( + model_class: Type[BaseModel] +) -> Callable[[str], BaseModel] +``` + +**Parameters:** +- `model_class` (Type[BaseModel]): Pydantic model class + +**Returns:** +- `Callable[[str], BaseModel]`: Parser function for Metaflow Parameter + +**Example:** +```python +from pydantic import BaseModel +from metaflow import FlowSpec, Parameter +from ds_platform_utils.metaflow import make_pydantic_parser_fn + +class Config(BaseModel): + start_date: str + end_date: str + threshold: float = 0.5 + +class MyFlow(FlowSpec): + config = Parameter( + 'config', + type=make_pydantic_parser_fn(Config), + default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}', + ) +``` + +**See Also:** +- [Configuration Validation Guide](../metaflow/validate_config.md) + +--- + +### Utility Functions + +#### `add_query_tags()` + +Add metadata tags to SQL queries for tracking. + +**Signature:** +```python +def add_query_tags( + query: str, + flow_name: str, + step_name: str, +) -> str +``` + +**Parameters:** +- `query` (str): SQL query +- `flow_name` (str): Metaflow flow name +- `step_name` (str): Metaflow step name + +**Returns:** +- `str`: Query with tags prepended + +**Example:** +```python +tagged_query = add_query_tags( + query="SELECT * FROM my_table", + flow_name="MyFlow", + step_name="query_data", +) +``` + +#### `restore_step_state()` + +Restore Metaflow step state for debugging. + +**Signature:** +```python +@contextmanager +def restore_step_state( + flow_name: str, + run_id: str, + step: str, +) -> Generator[None, None, None] +``` + +**Parameters:** +- `flow_name` (str): Flow name +- `run_id` (str): Run ID +- `step` (str): Step name + +**Yields:** Context with restored step state + +**Example:** +```python +from ds_platform_utils.metaflow import restore_step_state + +with restore_step_state("MyFlow", run_id="123", step="process"): + # Access self.df from that step + print(self.df.head()) +``` + +--- + +## Snowflake Utilities + +Located in `ds_platform_utils._snowflake` + +### Connection Management + +#### `get_snowflake_connection()` + +Get a Snowflake connection cursor. + +**Signature:** +```python +def get_snowflake_connection() -> snowflake.connector.cursor.SnowflakeCursor +``` + +**Returns:** +- `SnowflakeCursor`: Snowflake cursor for executing queries + +**Example:** +```python +from ds_platform_utils._snowflake import get_snowflake_connection + +cursor = get_snowflake_connection() +cursor.execute("SELECT * FROM my_table") +results = cursor.fetchall() +``` + +--- + +### Write Audit Publish + +#### `write_audit_publish()` + +Execute SQL with audit logging and publish to target table. + +**Signature:** +```python +def write_audit_publish( + sql: str, + warehouse: Optional[str] = None, + comment: Optional[str] = None, +) -> None +``` + +**Parameters:** +- `sql` (str): SQL statement to execute +- `warehouse` (str, optional): Snowflake warehouse +- `comment` (str, optional): Audit comment + +**Returns:** None + +**Example:** +```python +from ds_platform_utils._snowflake import write_audit_publish + +write_audit_publish( + sql="CREATE OR REPLACE TABLE my_table AS SELECT * FROM source", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", + comment="Daily refresh", +) +``` + +--- + +## Type Definitions + +### Common Types + +```python +from typing import Optional, Dict, List, Callable, Any +import pandas as pd + +# Query context +QueryContext = Dict[str, Any] + +# Prediction function signature +PredictFn = Callable[[pd.DataFrame], pd.DataFrame] + +# Transform function signature +TransformFn = Callable[[pd.DataFrame], pd.DataFrame] +``` + +--- + +## Error Classes + +### `SnowflakeQueryError` + +Raised when Snowflake query fails. + +### `BatchProcessingError` + +Raised when batch processing fails. + +### `ValidationError` + +Raised when configuration validation fails (from Pydantic). + +--- + +## Related Documentation + +- [Getting Started Guide](../guides/getting_started.md) +- [Best Practices](../guides/best_practices.md) +- [Common Patterns](../guides/common_patterns.md) +- [Module-Specific Docs](../metaflow/README.md) diff --git a/docs/examples/README.md b/docs/examples/README.md new file mode 100644 index 0000000..a279ddb --- /dev/null +++ b/docs/examples/README.md @@ -0,0 +1,579 @@ +# Examples + +[← Back to Main Docs](../README.md) + +Practical examples for common use cases. + +## Table of Contents + +- [Simple Query and Publish](#simple-query-and-publish) +- [Feature Engineering Pipeline](#feature-engineering-pipeline) +- [Batch Inference at Scale](#batch-inference-at-scale) +- [Incremental Data Processing](#incremental-data-processing) +- [Multi-Table Join Pipeline](#multi-table-join-pipeline) + +## Simple Query and Publish + +Basic workflow: query → transform → publish. + +### Code + +```python +# simple_pipeline.py +from metaflow import FlowSpec, step +from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas + +class SimplePipeline(FlowSpec): + """Query data, transform, and publish.""" + + @step + def start(self): + """Query input data.""" + print("Querying data...") + self.df = query_pandas_from_snowflake( + query=""" + SELECT + user_id, + transaction_date, + amount, + category + FROM transactions + WHERE transaction_date >= '2024-01-01' + """, + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", + ) + print(f"Retrieved {len(self.df):,} rows") + self.next(self.transform) + + @step + def transform(self): + """Transform data.""" + print("Transforming data...") + + # Add month column + self.df['month'] = self.df['transaction_date'].dt.to_period('M') + + # Calculate monthly spending per user + self.results = self.df.groupby(['user_id', 'month']).agg({ + 'amount': ['sum', 'mean', 'count'] + }).reset_index() + + # Flatten column names + self.results.columns = [ + '_'.join(col).strip('_') for col in self.results.columns + ] + + print(f"Created {len(self.results):,} aggregated rows") + self.next(self.publish) + + @step + def publish(self): + """Publish results.""" + print("Publishing results...") + publish_pandas( + table_name="user_monthly_spending", + df=self.results, + schema="my_dev_schema", + comment="Monthly user spending aggregates", + ) + print("✅ Done!") + self.next(self.end) + + @step + def end(self): + pass + +if __name__ == '__main__': + SimplePipeline() +``` + +### Run + +```bash +# Local execution +python simple_pipeline.py run + +# View results in Snowflake +snowsql -q "SELECT * FROM my_dev_schema.user_monthly_spending LIMIT 10;" +``` + +--- + +## Feature Engineering Pipeline + +Create ML features using SQL and Python. + +### Files + +**config.py** +```python +from pydantic import BaseModel + +class FeatureConfig(BaseModel): + """Feature generation configuration.""" + start_date: str + end_date: str + lookback_days: int = 30 +``` + +**sql/extract_raw_features.sql** +```sql +-- Extract raw features from events +CREATE OR REPLACE TEMPORARY TABLE temp_raw_features AS +SELECT + user_id, + COUNT(*) as event_count, + COUNT(DISTINCT date) as active_days, + MIN(timestamp) as first_seen, + MAX(timestamp) as last_seen, + AVG(value) as avg_value, + STDDEV(value) as std_value +FROM events +WHERE date >= '{{start_date}}' + AND date <= '{{end_date}}' +GROUP BY user_id; +``` + +**sql/publish_features.sql** +```sql +-- Publish to target table +CREATE OR REPLACE TABLE {{schema}}.ml_features AS +SELECT * FROM temp_engineered_features; +``` + +**feature_pipeline.py** +```python +from metaflow import FlowSpec, Parameter, step +from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas, make_pydantic_parser_fn +import pandas as pd +from datetime import datetime + +from config import FeatureConfig + +class FeaturePipeline(FlowSpec): + """ML feature engineering pipeline.""" + + config = Parameter( + 'config', + type=make_pydantic_parser_fn(FeatureConfig), + default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}', + ) + + @step + def start(self): + """Extract raw features from Snowflake.""" + print(f"Extracting features from {self.config.start_date} to {self.config.end_date}") + + self.df = query_pandas_from_snowflake( + query_fpath="sql/extract_raw_features.sql", + ctx={ + "start_date": self.config.start_date, + "end_date": self.config.end_date, + }, + ) + print(f"Extracted features for {len(self.df):,} users") + self.next(self.engineer_features) + + @step + def engineer_features(self): + """Engineer features in Python.""" + print("Engineering features...") + + # Time-based features + now = pd.Timestamp.now() + self.df['recency_days'] = (now - pd.to_datetime(self.df['last_seen'])).dt.days + self.df['account_age_days'] = (now - pd.to_datetime(self.df['first_seen'])).dt.days + + # Engagement features + self.df['events_per_day'] = self.df['event_count'] / self.df['active_days'] + self.df['engagement_ratio'] = self.df['active_days'] / self.df['account_age_days'] + + # Value features + self.df['value_volatility'] = self.df['std_value'] / (self.df['avg_value'] + 1) + + # Segments + self.df['user_segment'] = pd.cut( + self.df['event_count'], + bins=[0, 10, 50, 200, float('inf')], + labels=['low', 'medium', 'high', 'power_user'] + ) + + print(f"Engineered {len(self.df.columns)} features") + self.next(self.publish) + + @step + def publish(self): + """Publish features.""" + print("Publishing features...") + publish_pandas( + table_name="ml_features", + df=self.df, + schema="my_dev_schema", + comment=f"Features for {self.config.start_date} to {self.config.end_date}", + ) + print(f"✅ Published {len(self.df):,} rows with {len(self.df.columns)} columns") + self.next(self.end) + + @step + def end(self): + pass + +if __name__ == '__main__': + FeaturePipeline() +``` + +### Run + +```bash +# With default dates +python feature_pipeline.py run + +# With custom dates +python feature_pipeline.py run --config '{"start_date": "2024-06-01", "end_date": "2024-12-31"}' + +# Check results +snowsql -q "SELECT * FROM my_dev_schema.ml_features LIMIT 10;" +``` + +--- + +## Batch Inference at Scale + +Large-scale ML predictions with parallel processing. + +### Code + +**batch_inference.py** +```python +from metaflow import FlowSpec, step +from ds_platform_utils.metaflow import BatchInferencePipeline +import pandas as pd +import pickle + +class LargeScaleInference(FlowSpec): + """Batch inference for millions of rows.""" + + @step + def start(self): + """Query and split into batches.""" + print("Querying input data and splitting into batches...") + + pipeline = BatchInferencePipeline() + self.worker_ids = pipeline.query_and_batch( + input_query=""" + SELECT + user_id, + feature_1, + feature_2, + feature_3, + feature_4, + feature_5 + FROM ml_features + WHERE last_updated >= '2024-01-01' + """, + batch_size_in_mb=256, + parallel_workers=20, # 20 parallel workers + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", + ) + + print(f"Split into {len(self.worker_ids)} batches") + self.next(self.predict, foreach='worker_ids') + + @step + def predict(self): + """Predict for each batch (runs in parallel).""" + worker_id = self.input + print(f"Processing batch {worker_id}") + + # Load model (cached across batches on same worker) + with open('model.pkl', 'rb') as f: + model = pickle.load(f) + + def predict_fn(df: pd.DataFrame) -> pd.DataFrame: + """Generate predictions.""" + feature_cols = [ + 'feature_1', 'feature_2', 'feature_3', + 'feature_4', 'feature_5' + ] + + # Generate predictions + predictions = model.predict_proba(df[feature_cols])[:, 1] + + # Create output DataFrame + result = pd.DataFrame({ + 'user_id': df['user_id'], + 'score': predictions, + 'prediction': (predictions >= 0.5).astype(int), + }) + + return result + + # Process this batch + pipeline = BatchInferencePipeline() + pipeline.process_batch( + worker_id=worker_id, + predict_fn=predict_fn, + batch_size_in_mb=64, # Process in 64MB chunks + ) + + print(f"✅ Batch {worker_id} complete") + self.next(self.join) + + @step + def join(self, inputs): + """Collect results and publish.""" + print(f"All {len(inputs)} batches processed, publishing results...") + + pipeline = BatchInferencePipeline() + pipeline.publish_results( + output_table="user_predictions", + output_schema="my_dev_schema", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", + ) + + print("✅ All predictions published!") + self.next(self.end) + + @step + def end(self): + pass + +if __name__ == '__main__': + LargeScaleInference() +``` + +### Run + +```bash +# Local execution +python batch_inference.py run + +# Production execution +python batch_inference.py run --production + +# Check results +snowsql -q "SELECT COUNT(*), AVG(score) FROM my_dev_schema.user_predictions;" +``` + +--- + +## Incremental Data Processing + +Process new data daily and append to existing table. + +### Code + +**incremental_pipeline.py** +```python +from metaflow import FlowSpec, Parameter, step +from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas +from datetime import datetime, timedelta + +class IncrementalPipeline(FlowSpec): + """Process daily incremental data.""" + + date = Parameter( + 'date', + default=datetime.now().strftime('%Y-%m-%d'), + help='Date to process (YYYY-MM-DD)', + ) + + @step + def start(self): + """Query new data for specified date.""" + print(f"Processing data for {self.date}") + + self.df = query_pandas_from_snowflake( + query=f""" + SELECT * + FROM raw_events + WHERE date = '{self.date}' + """ + ) + + if len(self.df) == 0: + print(f"⚠️ No data found for {self.date}") + else: + print(f"Found {len(self.df):,} rows for {self.date}") + + self.next(self.transform) + + @step + def transform(self): + """Transform new data.""" + if len(self.df) > 0: + print("Transforming data...") + + # Your transformation logic + self.df['processed_date'] = datetime.now() + self.df['derived_field'] = self.df['value'] * 2 + + print(f"Transformed {len(self.df):,} rows") + + self.next(self.publish) + + @step + def publish(self): + """Append to existing table.""" + if len(self.df) > 0: + print(f"Appending {len(self.df):,} rows...") + + publish_pandas( + table_name="processed_events", + df=self.df, + schema="my_dev_schema", + mode="append", # ← Append instead of replace + comment=f"Incremental load for {self.date}", + ) + + print(f"✅ Appended {len(self.df):,} rows for {self.date}") + else: + print("⏭️ No data to publish") + + self.next(self.end) + + @step + def end(self): + pass + +if __name__ == '__main__': + IncrementalPipeline() +``` + +### Run + +```bash +# Process today +python incremental_pipeline.py run + +# Process specific date +python incremental_pipeline.py run --date 2024-01-15 + +# Schedule with cron (runs daily at 2 AM) +# 0 2 * * * cd /path/to/project && python incremental_pipeline.py run +``` + +--- + +## Multi-Table Join Pipeline + +Join data from multiple Snowflake tables. + +### Code + +**multi_table_pipeline.py** +```python +from metaflow import FlowSpec, step +from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas + +class MultiTableJoin(FlowSpec): + """Join multiple tables and create enriched dataset.""" + + @step + def start(self): + """Start parallel queries.""" + self.next(self.query_users, self.query_events, self.query_demographics) + + @step + def query_users(self): + """Query user data.""" + print("Querying users...") + self.users_df = query_pandas_from_snowflake( + query="SELECT user_id, signup_date, status FROM users WHERE status = 'active'" + ) + print(f"Retrieved {len(self.users_df):,} users") + self.next(self.join_data) + + @step + def query_events(self): + """Query event data.""" + print("Querying events...") + self.events_df = query_pandas_from_snowflake( + query=""" + SELECT + user_id, + COUNT(*) as event_count, + MAX(timestamp) as last_event + FROM events + WHERE date >= '2024-01-01' + GROUP BY user_id + """ + ) + print(f"Retrieved events for {len(self.events_df):,} users") + self.next(self.join_data) + + @step + def query_demographics(self): + """Query demographic data.""" + print("Querying demographics...") + self.demographics_df = query_pandas_from_snowflake( + query="SELECT user_id, age, country, segment FROM user_demographics" + ) + print(f"Retrieved demographics for {len(self.demographics_df):,} users") + self.next(self.join_data) + + @step + def join_data(self, inputs): + """Join all data sources.""" + print("Joining data from all sources...") + + # Merge users + events + result = inputs.query_users.users_df.merge( + inputs.query_events.events_df, + on='user_id', + how='left' + ) + + # Merge with demographics + result = result.merge( + inputs.query_demographics.demographics_df, + on='user_id', + how='left' + ) + + # Fill missing event counts with 0 + result['event_count'] = result['event_count'].fillna(0) + + self.enriched_df = result + print(f"Created enriched dataset with {len(self.enriched_df):,} rows") + self.next(self.publish) + + @step + def publish(self): + """Publish enriched dataset.""" + print("Publishing enriched dataset...") + publish_pandas( + table_name="enriched_user_data", + df=self.enriched_df, + schema="my_dev_schema", + comment="Enriched user data from multiple sources", + ) + print(f"✅ Published {len(self.enriched_df):,} rows") + self.next(self.end) + + @step + def end(self): + pass + +if __name__ == '__main__': + MultiTableJoin() +``` + +### Run + +```bash +# Run the pipeline +python multi_table_pipeline.py run + +# View results +snowsql -q "SELECT * FROM my_dev_schema.enriched_user_data LIMIT 10;" +``` + +--- + +## Additional Resources + +- [Getting Started Guide](../guides/getting_started.md) +- [Best Practices](../guides/best_practices.md) +- [Common Patterns](../guides/common_patterns.md) +- [API Reference](../api/index.md) diff --git a/docs/guides/best_practices.md b/docs/guides/best_practices.md new file mode 100644 index 0000000..5583610 --- /dev/null +++ b/docs/guides/best_practices.md @@ -0,0 +1,452 @@ +# Best Practices + +[← Back to Main Docs](../README.md) + +Guidelines for production-ready code using `ds-platform-utils`. + +## Table of Contents + +- [Code Organization](#code-organization) +- [Error Handling](#error-handling) +- [Performance](#performance) +- [Security](#security) +- [Testing](#testing) +- [Production Deployment](#production-deployment) + +## Code Organization + +### ✅ DO: Separate Configuration from Logic + +```python +# config.py +from pydantic import BaseModel + +class FlowConfig(BaseModel): + start_date: str + end_date: str + warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" + batch_size: int = 1000 + +# flow.py +from metaflow import FlowSpec, Parameter +from ds_platform_utils.metaflow import make_pydantic_parser_fn + +class MyFlow(FlowSpec): + config = Parameter( + 'config', + type=make_pydantic_parser_fn(FlowConfig), + default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}' + ) +``` + +### ✅ DO: Use External SQL Files + +```python +# ✅ Good - SQL in separate file +publish( + query_fpath="sql/create_features.sql", + ctx={"start_date": self.config.start_date}, + publish_query_fpath="sql/publish_features.sql", +) + +# ❌ Bad - SQL in Python strings +query = f""" + CREATE OR REPLACE TABLE my_features AS + SELECT * FROM raw_data + WHERE date >= '{self.config.start_date}' +""" +``` + +### ✅ DO: Use Type Hints + +```python +from typing import Optional +import pandas as pd + +def process_data( + df: pd.DataFrame, + threshold: float = 0.5, + warehouse: Optional[str] = None, +) -> pd.DataFrame: + """Process data with predictions.""" + # Your logic here + return result_df +``` + +##Error Handling + +### ✅ DO: Handle Expected Failures Gracefully + +```python +from metaflow import FlowSpec, step, retry + +class RobustFlow(FlowSpec): + + @retry(times=3) # Retry up to 3 times + @step + def query_data(self): + """Query data with retry logic.""" + try: + self.df = query_pandas_from_snowflake( + query="SELECT * FROM my_table", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", + ) + except Exception as e: + print(f"⚠️ Query failed: {e}") + # Log error, send alert, etc. + raise # Re-raise to trigger retry + + if len(self.df) == 0: + raise ValueError("No data returned from query") + + self.next(self.process) +``` + +### ✅ DO: Validate Data Early + +```python +@step +def validate_input(self): + """Validate input data before processing.""" + required_columns = ['id', 'feature_1', 'feature_2'] + + missing = set(required_columns) - set(self.df.columns) + if missing: + raise ValueError(f"Missing required columns: {missing}") + + if self.df.isnull().any().any(): + raise ValueError("Input data contains null values") + + print(f"✅ Validation passed: {len(self.df)} rows") + self.next(self.process) +``` + +### ❌ DON'T: Silently Catch All Exceptions + +```python +# ❌ Bad - swallows all errors +try: + result = process_data(df) +except: + result = None + +# ✅ Good - specific exception handling +try: + result = process_data(df) +except ValueError as e: + print(f"Invalid input: {e}") + raise +except Exception as e: + print(f"Unexpected error: {e}") + # Log for debugging + raise +``` + +## Performance + +### ✅ DO: Use S3 Staging for Large Datasets + +```python +# For datasets > 1GB +df = query_pandas_from_snowflake( + query="SELECT * FROM large_table", + use_s3_stage=True, # ← Enable S3 staging + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", +) +``` + +### ✅ DO: Choose Appropriate Warehouse Size + +```python +# Small queries (< 1M rows) +warehouse = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH" + +# Medium workloads (1M-10M rows) +warehouse = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" + +# Large batch jobs (> 10M rows) +warehouse = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH" +``` + +### ✅ DO: Use BatchInferencePipeline for Very Large Scale + +```python +# For > 10M rows with parallel processing +pipeline = BatchInferencePipeline() +worker_ids = pipeline.query_and_batch( + input_query="SELECT * FROM huge_table", + parallel_workers=10, # Adjust based on data size +) +``` + +### ✅ DO: Optimize Batch Sizes + +```python +# For memory-constrained environments +pipeline.process_batch( + worker_id=worker_id, + predict_fn=predict_fn, + batch_size_in_mb=64, # Smaller batches +) + +# For high-memory environments +pipeline.process_batch( + worker_id=worker_id, + predict_fn=predict_fn, + batch_size_in_mb=512, # Larger batches = fewer S3 ops +) +``` + +### ❌ DON'T: Query Everything When You Need Subset + +```python +# ❌ Bad - queries all data +df = query_pandas_from_snowflake( + query="SELECT * FROM huge_table" +) +df = df[df['date'] >= '2024-01-01'] # Filter in Python + +# ✅ Good - filter in SQL +df = query_pandas_from_snowflake( + query=""" + SELECT * + FROM huge_table + WHERE date >= '2024-01-01' + """ +) +``` + +## Security + +### ✅ DO: Use Template Variables for SQL + +```python +# ✅ Good - prevents SQL injection +ctx = { + "table_name": "my_table", + "start_date": user_input_date, +} +publish( + query_fpath="sql/query.sql", # Uses {{table_name}}, {{start_date}} + ctx=ctx, +) + +# ❌ Bad - SQL injection risk +query = f"SELECT * FROM {user_input_table} WHERE date >= '{user_input_date}'" +``` + +### ✅ DO: Validate User Inputs + +```python +from datetime import datetime + +def validate_date(date_str: str) -> str: + """Validate date format.""" + try: + datetime.strptime(date_str, '%Y-%m-%d') + return date_str + except ValueError: + raise ValueError(f"Invalid date format: {date_str}") + +# Use in flow +start_date = validate_date(self.config.start_date) +``` + +### ❌ DON'T: Hard-code Credentials + +```python +# ❌ Bad - credentials in code +SNOWFLAKE_PASSWORD = "my_password" + +# ✅ Good - use Metaflow's integration +# Credentials are automatically handled by Metaflow +``` + +## Testing + +### ✅ DO: Write Unit Tests for Business Logic + +```python +# tests/test_processing.py +import pandas as pd +import pytest + +def test_process_predictions(): + """Test prediction processing.""" + # Arrange + input_df = pd.DataFrame({ + 'id': [1, 2, 3], + 'score': [0.1, 0.6, 0.9] + }) + + # Act + result = process_predictions(input_df, threshold=0.5) + + # Assert + assert len(result) == 2 # Only scores >= 0.5 + assert all(result['score'] >= 0.5) +``` + +### ✅ DO: Use Fixtures for Test Data + +```python +# tests/conftest.py +import pytest +import pandas as pd + +@pytest.fixture +def sample_features(): + """Sample feature data.""" + return pd.DataFrame({ + 'id': range(100), + 'feature_1': range(100), + 'feature_2': range(100, 200), + }) + +# tests/test_flow.py +def test_feature_engineering(sample_features): + """Test feature engineering.""" + result = engineer_features(sample_features) + assert 'engineered_feature' in result.columns +``` + +### ✅ DO: Test Edge Cases + +```python +def test_empty_dataframe(): + """Test handling of empty input.""" + df = pd.DataFrame() + with pytest.raises(ValueError, match="Empty DataFrame"): + process_data(df) + +def test_missing_columns(): + """Test handling of missing columns.""" + df = pd.DataFrame({' id': [1, 2]}) # Missing required columns + with pytest.raises(ValueError, match="Missing required columns"): + process_data(df) +``` + +## Production Deployment + +### ✅ DO: Use Production Warehouses in Prod + +```python +# Use environment-aware warehouse selection +from metaflow import current + +def get_warehouse(): + """Get warehouse based on environment.""" + if hasattr(current, 'is_production') and current.is_production: + return "OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_MED_WH" + return "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" + +# Use in flow +warehouse = get_warehouse() +``` + +### ✅ DO: Enable Monitoring and Alerts + +```python +@step +def publish_with_monitoring(self): + """Publish results with monitoring.""" + start_time = time.time() + + try: + publish_pandas( + table_name="production_features", + df=self.features_df, + ) + + duration = time.time() - start_time + self.metrics = { + 'rows_published': len(self.features_df), + 'duration_seconds': duration, + 'timestamp': datetime.now().isoformat(), + } + + # Log metrics + print(f"📊 Published {self.metrics['rows_published']} rows in {duration:.2f}s") + + except Exception as e: + # Send alert + send_alert(f"Pipeline failed: {e}") + raise + + self.next(self.end) +``` + +### ✅ DO: Version Your Flows + +```python +from metaflow import FlowSpec, Parameter + +class ProductionFlow(FlowSpec): + """Production ML pipeline. + + Version: 2.1.0 + Last Updated: 2024-01-15 + Owner: data-science-team + """ + + version = Parameter( + 'version', + default='2.1.0', + help='Pipeline version' + ) +``` + +### ✅ DO: Document Your SQL + +```sql +-- sql/create_features.sql +-- Feature Engineering Pipeline +-- Owner: data-science-team +-- Description: Creates ML features from raw events +-- Dependencies: pattern_db.raw_data.events + +CREATE OR REPLACE TABLE {{schema}}.ml_features AS +SELECT + user_id, + COUNT(*) as event_count, + AVG(value) as avg_value, + MAX(timestamp) as last_seen +FROM pattern_db.raw_data.events +WHERE date >= '{{start_date}}' + AND date <= '{{end_date}}' +GROUP BY user_id; +``` + +### ❌ DON'T: Deploy Untested Code + +```python +# ✅ Good - run tests before deploying +$ pytest tests/ +$ python flow.py run # Test locally +$ python flow.py run --production # Deploy + +# ❌ Bad - deploy without testing +$ python flow.py run --production # YOLO +``` + +## Checklist for Production Code + +Before deploying to production: + +- [ ] All tests passing +- [ ] SQL queries optimized +- [ ] Appropriate warehouse selected +- [ ] Error handling implemented +- [ ] Monitoring/alerts configured +- [ ] Documentation updated +- [ ] Code reviewed +- [ ] Dev environment tested +- [ ] Staging environment tested (if available) +- [ ] Rollback plan documented + +## Additional Resources + +- [Getting Started Guide](getting_started.md) +- [Common Patterns](common_patterns.md) +- [Performance Tuning](performance_tuning.md) +- [Troubleshooting](troubleshooting.md) diff --git a/docs/guides/common_patterns.md b/docs/guides/common_patterns.md new file mode 100644 index 0000000..5d56416 --- /dev/null +++ b/docs/guides/common_patterns.md @@ -0,0 +1,578 @@ +# Common Patterns + +[← Back to Main Docs](../README.md) + +Proven patterns for common data science workflows. + +## Table of Contents + +- [Query Patterns](#query-patterns) +- [Feature Engineering](#feature-engineering) +- [Batch Inference](#batch-inference) +- [Incremental Processing](#incremental-processing) +- [Error Recovery](#error-recovery) +- [Testing Patterns](#testing-patterns) + +## Query Patterns + +### Simple Query and Publish + +The most basic pattern: query data, transform, publish results. + +```python +from metaflow import FlowSpec, step +from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas + +class SimpleFlow(FlowSpec): + + @step + def start(self): + """Query input data.""" + self.df = query_pandas_from_snowflake( + query="SELECT * FROM input_table", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", + ) + self.next(self.transform) + + @step + def transform(self): + """Transform data.""" + self.df['new_column'] = self.df['old_column'] * 2 + self.next(self.publish) + + @step + def publish(self): + """Publish results.""" + publish_pandas( + table_name="output_table", + df=self.df, + schema="my_dev_schema", + ) + self.next(self.end) + + @step + def end(self): + print(f"✅ Published {len(self.df)} rows") +``` + +### Parameterized Query with SQL File + +Use external SQL files with template variables: + +```python +# config.py +from pydantic import BaseModel + +class QueryConfig(BaseModel): + start_date: str + end_date: str + min_value: float + +# flow.py +from metaflow import FlowSpec, Parameter, step +from ds_platform_utils.metaflow import query_pandas_from_snowflake, make_pydantic_parser_fn + +class ParameterizedFlow(FlowSpec): + + config = Parameter( + 'config', + type=make_pydantic_parser_fn(QueryConfig), + default='{"start_date": "2024-01-01", "end_date": "2024-01-31", "min_value": 100}' + ) + + @step + def start(self): + """Query with parameters.""" + self.df = query_pandas_from_snowflake( + query_fpath="sql/extract_data.sql", + ctx={ + "start_date": self.config.start_date, + "end_date": self.config.end_date, + "min_value": self.config.min_value, + }, + ) + self.next(self.end) + + @step + def end(self): + print(f"Retrieved {len(self.df)} rows") +``` + +```sql +-- sql/extract_data.sql +SELECT * +FROM transactions +WHERE date >= '{{start_date}}' + AND date <= '{{end_date}}' + AND amount >= {{min_value}} +``` + +### Query with Large Results via S3 + +For datasets > 1GB: + +```python +@step +def query_large_dataset(self): + """Query large dataset via S3 staging.""" + self.df = query_pandas_from_snowflake( + query="SELECT * FROM large_table", + use_s3_stage=True, # ← Enable S3 for large results + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", + ) + print(f"Retrieved {len(self.df):,} rows via S3") + self.next(self.process) +``` + +## Feature Engineering + +### Multiclass Classification Features + +```python +from metaflow import FlowSpec, step +from ds_platform_utils.metaflow import publish + +class FeatureEngineeringFlow(FlowSpec): + + @step + def start(self): + """Create features and publish.""" + publish( + query_fpath="sql/create_features.sql", + ctx={"lookback_days": 30}, + publish_query_fpath="sql/publish_features.sql", + comment="Daily feature refresh", + ) + self.next(self.end) + + @step + def end(self): + print("✅ Features published") +``` + +```sql +-- sql/create_features.sql +CREATE OR REPLACE TEMPORARY TABLE temp_features AS +SELECT + user_id, + COUNT(*) as event_count_30d, + AVG(value) as avg_value_30d, + MAX(timestamp) as last_seen, + DATEDIFF(day, MAX(timestamp), CURRENT_DATE()) as recency, + COUNT(DISTINCT date) as active_days +FROM events +WHERE date >= DATEADD(day, -{{lookback_days}}, CURRENT_DATE()) +GROUP BY user_id; +``` + +```sql +-- sql/publish_features.sql +CREATE OR REPLACE TABLE my_dev_schema.user_features AS +SELECT * FROM temp_features; +``` + +### Time-based Features + +```python +@step +def create_time_features(self): + """Create time-based features.""" + self.df['day_of_week'] = pd.to_datetime(self.df['timestamp']).dt.dayofweek + self.df['hour'] = pd.to_datetime(self.df['timestamp']).dt.hour + self.df['is_weekend'] = self.df['day_of_week'].isin([5, 6]).astype(int) + self.df['is_business_hours'] = ( + (self.df['hour'] >= 9) & (self.df['hour'] < 17) + ).astype(int) + + self.next(self.publish) +``` + +### Aggregate Features + +```python +@step +def create_aggregate_features(self): + """Create user-level aggregates.""" + user_features = self.df.groupby('user_id').agg({ + 'transaction_amount': ['sum', 'mean', 'max', 'count'], + 'timestamp': ['min', 'max'], + }).reset_index() + + # Flatten column names + user_features.columns = [ + '_'.join(col).strip('_') for col in user_features.columns + ] + + self.features_df = user_features + self.next(self.publish) +``` + +## Batch Inference + +### Simple Batch Scoring + +For datasets that fit in memory: + +```python +import pandas as pd +from metaflow import FlowSpec, step + +class BatchScoringFlow(FlowSpec): + + @step + def start(self): + """Load model and data.""" + import pickle + with open('model.pkl', 'rb') as f: + self.model = pickle.load(f) + + self.df = query_pandas_from_snowflake( + query="SELECT * FROM inference_input" + ) + self.next(self.predict) + + @step + def predict(self): + """Generate predictions.""" + features = self.df[['feature_1', 'feature_2', 'feature_3']] + self.df['prediction'] = self.model.predict(features) + self.df['probability'] = self.model.predict_proba(features)[:, 1] + self.next(self.publish) + + @step + def publish(self): + """Publish predictions.""" + publish_pandas( + table_name="predictions", + df=self.df[['id', 'prediction', 'probability']], + ) + self.next(self.end) + + @step + def end(self): + print(f"✅ Scored {len(self.df)} rows") +``` + +### Large-Scale Batch Inference + +For datasets > 10M rows: + +```python +from metaflow import FlowSpec, step +from ds_platform_utils.metaflow import BatchInferencePipeline + +class LargeScaleScoringFlow(FlowSpec): + + @step + def start(self): + """Query and split into batches.""" + pipeline = BatchInferencePipeline() + self.worker_ids = pipeline.query_and_batch( + input_query="SELECT * FROM large_input_table", + batch_size_in_mb=256, + parallel_workers=20, + ) + self.next(self.predict, foreach='worker_ids') + + @step + def predict(self): + """Predict for each batch (parallel).""" + worker_id = self.input + + # Load model (cached across batches) + import pickle + with open('model.pkl', 'rb') as f: + model = pickle.load(f) + + def predict_fn(df: pd.DataFrame) -> pd.DataFrame: + """Prediction function.""" + df['score'] = model.predict_proba(df[['f1', 'f2', 'f3']])[:, 1] + return df[['id', 'score']] + + pipeline = BatchInferencePipeline() + pipeline.process_batch( + worker_id=worker_id, + predict_fn=predict_fn, + batch_size_in_mb=64, + ) + self.next(self.join) + + @step + def join(self, inputs): + """Collect all predictions.""" + pipeline = BatchInferencePipeline() + pipeline.publish_results( + output_table="predictions", + output_schema="my_dev_schema", + ) + self.next(self.end) + + @step + def end(self): + print("✅ Predictions published") +``` + +### Batch Inference with Post-processing + +```python +def predict_with_postprocessing(df: pd.DataFrame) -> pd.DataFrame: + """Predict and post-process.""" + # Generate predictions + scores = model.predict_proba(df[feature_cols])[:, 1] + + # Post-processing: apply business rules + df['raw_score'] = scores + df['final_score'] = scores + + # Rule: cap scores for new users + new_user_mask = df['account_age_days'] < 30 + df.loc[new_user_mask, 'final_score'] = df.loc[new_user_mask, 'final_score'] * 0.8 + + # Rule: boost scores for loyal customers + loyal_mask = df['total_purchases'] > 50 + df.loc[loyal_mask, 'final_score'] = df.loc[loyal_mask, 'final_score'] * 1.2 + + # Clip to [0, 1] + df['final_score'] = df['final_score'].clip(0, 1) + + return df[['id', 'raw_score', 'final_score']] +``` + +## Incremental Processing + +### Daily Incremental Load + +Process only new data each day: + +```python +from datetime import datetime, timedelta +from metaflow import FlowSpec, Parameter, step + +class IncrementalFlow(FlowSpec): + + date = Parameter( + 'date', + help='Date to process (YYYY-MM-DD)', + default=datetime.now().strftime('%Y-%m-%d') + ) + + @step + def start(self): + """Process single day of data.""" + self.df = query_pandas_from_snowflake( + query=f""" + SELECT * + FROM events + WHERE date = '{self.date}' + """ + ) + print(f"Processing {len(self.df)} rows for {self.date}") + self.next(self.transform) + + @step + def transform(self): + """Transform data.""" + # Your transformation logic + self.results = self.df # Placeholder + self.next(self.publish) + + @step + def publish(self): + """Append to existing table.""" + publish_pandas( + table_name="incremental_results", + df=self.results, + mode="append", # ← Append instead of replace + ) + self.next(self.end) + + @step + def end(self): + print(f"✅ Appended {len(self.results)} rows for {self.date}") +``` + +### Backfill Pattern + +Process historical data in parallel: + +```python +from datetime import datetime, timedelta +from metaflow import FlowSpec, Parameter, step + +class BackfillFlow(FlowSpec): + + start_date = Parameter('start_date', default='2024-01-01') + end_date = Parameter('end_date', default='2024-01-31') + + @step + def start(self): + """Generate list of dates to backfill.""" + start = datetime.strptime(self.start_date, '%Y-%m-%d') + end = datetime.strptime(self.end_date, '%Y-%m-%d') + + self.dates = [] + current = start + while current <= end: + self.dates.append(current.strftime('%Y-%m-%d')) + current += timedelta(days=1) + + print(f"Backfilling {len(self.dates)} days") + self.next(self.process_date, foreach='dates') + + @step + def process_date(self): + """Process each date in parallel.""" + date = self.input + + df = query_pandas_from_snowflake( + query=f"SELECT * FROM events WHERE date = '{date}'" + ) + + # Transform + result = transform(df) + + # Publish + publish_pandas( + table_name="backfill_results", + df=result, + mode="append", + ) + + self.rows_processed = len(result) + self.next(self.join) + + @step + def join(self, inputs): + """Collect statistics.""" + total_rows = sum(inp.rows_processed for inp in inputs) + print(f"✅ Backfilled {total_rows:,} rows across {len(inputs)} days") + self.next(self.end) + + @step + def end(self): + pass +``` + +## Error Recovery + +### Retry Failed Steps + +```python +from metaflow import FlowSpec, step, retry + +class ResilientFlow(FlowSpec): + + @retry(times=3) # Retry up to 3 times + @step + def query_data(self): + """Query with retry.""" + try: + self.df = query_pandas_from_snowflake( + query="SELECT * FROM flaky_table", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", + ) + except Exception as e: + print(f"⚠️ Query failed: {e}") + raise # Will trigger retry + + self.next(self.process) +``` + +### Checkpoint Pattern + +Save intermediate results to resume from failures: + +```python +@step +def process_with_checkpoints(self): + """Process with checkpoints.""" + results = [] + + for i, chunk in enumerate(self.chunks): + try: + result = process_chunk(chunk) + results.append(result) + + # Checkpoint every 10 chunks + if i % 10 == 0: + checkpoint_df = pd.concat(results) + publish_pandas( + table_name="checkpoint_results", + df=checkpoint_df, + mode="replace", + ) + print(f"✅ Checkpoint saved at chunk {i}") + + except Exception as e: + print(f"❌ Failed at chunk {i}: {e}") + # Can resume from last checkpoint + raise + + self.results = pd.concat(results) + self.next(self.publish) +``` + +## Testing Patterns + +### Test with Sampled Data + +```python +from metaflow import FlowSpec, Parameter, step + +class TestableFlow(FlowSpec): + + sample_size = Parameter( + 'sample_size', + type=int, + default=None, + help='Sample size for testing (None = all data)' + ) + + @step + def start(self): + """Query with optional sampling.""" + query = "SELECT * FROM large_table" + + if self.sample_size is not None: + query += f" LIMIT {self.sample_size}" + print(f"📊 Testing with {self.sample_size} rows") + + self.df = query_pandas_from_snowflake(query=query) + self.next(self.process) +``` + +Run with: `python flow.py run --sample_size 1000` + +### Dry Run Pattern + +```python +class ProductionFlow(FlowSpec): + + dry_run = Parameter( + 'dry_run', + type=bool, + default=False, + help='If True, skip publishing' + ) + + @step + def publish_results(self): + """Publish or dry-run.""" + if self.dry_run: + print(f"🔍 DRY RUN: Would publish {len(self.df)} rows") + print(self.df.head()) + else: + publish_pandas(table_name="results", df=self.df) + print(f"✅ Published {len(self.df)} rows") + + self.next(self.end) +``` + +Run with: `python flow.py run --dry_run True` + +## Additional Resources + +- [Best Practices](best_practices.md) +- [Performance Tuning](performance_tuning.md) +- [Troubleshooting](troubleshooting.md) +- [Getting Started](getting_started.md) diff --git a/docs/guides/getting_started.md b/docs/guides/getting_started.md new file mode 100644 index 0000000..a4c202c --- /dev/null +++ b/docs/guides/getting_started.md @@ -0,0 +1,281 @@ +# Getting Started with ds-platform-utils + +[← Back to Main Docs](../README.md) + +This guide will help you get started with `ds-platform-utils` for building ML workflows on Pattern's data platform. + +## Prerequisites + +Before you begin, ensure you have: + +1. **Metaflow installed and configured** + ```bash + pip install metaflow + metaflow configure aws + ``` + +2. **Access to Pattern's Snowflake account** + - You should be able to connect through Metaflow's Snowflake integration + +3. **AWS credentials configured** + - Metaflow will handle S3 access through your configured AWS profile + +## Installation + +### Production Use + +```bash +pip install git+https://github.com/patterninc/ds-platform-utils.git +``` + +### Development + +```bash +git clone https://github.com/patterninc/ds-platform-utils.git +cd ds-platform-utils +uv sync +``` + +## Your First Query + +Let's start with a simple example: querying data from Snowflake into a pandas DataFrame. + +```python +from ds_platform_utils.metaflow import query_pandas_from_snowflake + +# Query data from Snowflake +df = query_pandas_from_snowflake( + query=""" + SELECT * + FROM pattern_db.data_science.my_table + LIMIT 1000 + """, + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", +) + +print(f"Retrieved {len(df)} rows") +print(df.head()) +``` + +### What's Happening Here? + +1. The library connects to Snowflake using Metaflow's integration +2. Executes your SQL query +3. Handles timezone conversion (UTC by default) +4. Returns a pandas DataFrame + +## Your First Data Publication + +Now let's publish some results back to Snowflake: + +```python +from ds_platform_utils.metaflow import publish_pandas +import pandas as pd + +# Create some sample results +results_df = pd.DataFrame({ + 'id': [1, 2, 3], + 'prediction': [0.8, 0.6, 0.9], + 'confidence': [0.95, 0.82, 0.91] +}) + +# Publish to Snowflake +publish_pandas( + table_name="my_predictions", + df=results_df, + auto_create_table=True, # Creates table if it doesn't exist + overwrite=True, # Replaces existing data + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", +) + +print("✅ Results published successfully!") +``` + +## Your First Metaflow Flow + +Let's combine everything into a simple Metaflow flow: + +```python +from metaflow import FlowSpec, step +from ds_platform_utils.metaflow import ( + query_pandas_from_snowflake, + publish_pandas +) + +class SimpleMLFlow(FlowSpec): + """A simple ML workflow.""" + + @step + def start(self): + """Query training data.""" + print("📊 Querying training data...") + self.df = query_pandas_from_snowflake( + query=""" + SELECT * + FROM pattern_db.data_science_stage.training_features + WHERE date >= '2024-01-01' + LIMIT 10000 + """, + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", + ) + print(f" Retrieved {len(self.df)} rows") + self.next(self.train) + + @step + def train(self): + """Train a simple model.""" + print("🤖 Training model...") + # Your model training code here + # For demo, just create predictions + self.predictions = self.df[['id']].copy() + self.predictions['prediction'] = 0.5 + self.next(self.publish_results) + + @step + def publish_results(self): + """Publish predictions to Snowflake.""" + print("📤 Publishing results...") + publish_pandas( + table_name="simple_ml_predictions", + df=self.predictions, + auto_create_table=True, + overwrite=True, + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", + ) + print("✅ Flow complete!") + self.next(self.end) + + @step + def end(self): + """Flow end.""" + pass + +if __name__ == '__main__': + SimpleMLFlow() +``` + +Run the flow: + +```bash +python simple_ml_flow.py run +``` + +## Working with Large Datasets + +For datasets larger than a few GB, use S3 staging: + +```python +from ds_platform_utils.metaflow import query_pandas_from_snowflake + +# For large datasets, enable S3 staging +df = query_pandas_from_snowflake( + query="SELECT * FROM very_large_table", + use_s3_stage=True, # ← This enables S3 staging + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", +) +``` + +### When to Use S3 Staging? + +| Data Size | Method | Reason | +| --------- | --------------------------------- | -------------------------------- | +| < 1 GB | Direct | Simpler, faster for small data | +| 1-10 GB | S3 Stage | More reliable, prevents timeouts | +| > 10 GB | S3 Stage + BatchInferencePipeline | Parallel processing required | + +## Batch Inference + +For very large-scale predictions, use `BatchInferencePipeline`: + +```python +from metaflow import FlowSpec, step +from ds_platform_utils.metaflow import BatchInferencePipeline + +class BatchPredictionFlow(FlowSpec): + + @step + def start(self): + """Setup pipeline and create worker batches.""" + self.pipeline = BatchInferencePipeline() + self.worker_ids = self.pipeline.query_and_batch( + input_query="SELECT * FROM large_input_table", + parallel_workers=5, # Split work across 5 workers + ) + self.next(self.predict, foreach='worker_ids') + + @step + def predict(self): + """Process one batch in parallel.""" + def my_predict_fn(df): + # Your prediction logic + df['prediction'] = 0.5 # Replace with actual model + return df[['id', 'prediction']] + + self.pipeline.process_batch( + worker_id=self.input, + predict_fn=my_predict_fn, + ) + self.next(self.join) + + @step + def join(self, inputs): + """Merge results and publish.""" + self.pipeline = inputs[0].pipeline + self.pipeline.publish_results( + output_table_name="batch_predictions", + ) + self.next(self.end) + + @step + def end(self): + print("✅ Batch inference complete!") + +if __name__ == '__main__': + BatchPredictionFlow() +``` + +## Dev vs Prod + +The library automatically handles dev/prod schema separation: + +```python +# In development (default Metaflow perimeter) +publish_pandas( + table_name="my_table", # Goes to: pattern_db.data_science_stage.my_table + df=df, +) + +# In production (production Metaflow perimeter) +# Same code, but goes to: pattern_db.data_science.my_table +``` + +## Understanding Warehouses + +Pattern provides several Snowflake warehouses: + +| Warehouse | Size | Use Case | +| ---------- | ----------- | ---------------------------------- | +| `*_XS_WH` | Extra Small | Quick queries, small data | +| `*_MED_WH` | Medium | Medium workloads, ML training | +| `*_XL_WH` | Extra Large | Large batch jobs, heavy processing | + +Choose based on your workload: +- **Development**: Use `_DEV_` warehouses +- **Production**: Use `_PROD_` warehouses +- **Shared**: Use `_SHARED_` for general work +- **ADS**: Use `_ADS_` for ads-specific work + +## Next Steps + +Now that you've got the basics: + +1. 📖 Learn [Common Patterns](common_patterns.md) for typical workflows +2. 🎯 Review [Best Practices](best_practices.md) for production code +3. 🔧 Check out [Performance Tuning](performance_tuning.md) for optimization +4. 🔍 Explore the complete [API Reference](../api/index.md) + +## Need Help? + +- 📚 Check the [Troubleshooting Guide](troubleshooting.md) +- 💬 Ask in the #data-science-platform Slack channel +- 🐛 Report issues on GitHub diff --git a/docs/guides/performance_tuning.md b/docs/guides/performance_tuning.md new file mode 100644 index 0000000..1569f0f --- /dev/null +++ b/docs/guides/performance_tuning.md @@ -0,0 +1,461 @@ +# Performance Tuning Guide + +[← Back to Main Docs](../README.md) + +Optimize your workflows for speed, cost, and reliability. + +## Table of Contents + +- [Understanding Performance Bottlenecks](#understanding-performance-bottlenecks) +- [Snowflake Optimization](#snowflake-optimization) +- [S3 Staging Optimization](#s3-staging-optimization) +- [Metaflow Parallelization](#metaflow-parallelization) +- [Memory Management](#memory-management) +- [Cost Optimization](#cost-optimization) + +## Understanding Performance Bottlenecks + +Common bottlenecks in data pipelines: + +1. **Snowflake Query Time** - Complex queries, large scans, inefficient joins +2. **Data Transfer** - Moving large datasets between Snowflake and Python +3. **Python Processing** - CPU-intensive operations (ML inference, transformations) +4. **Memory Constraints** - Loading datasets larger than available RAM +5. **Sequential Processing** - Not leveraging parallelization + +## Snowflake Optimization + +### Query Performance + +#### ✅ Use Query Tags for Monitoring + +```python +from ds_platform_utils.metaflow import add_query_tags + +query = add_query_tags( + query="SELECT * FROM large_table", + flow_name="MyFlow", + step_name="query_data", +) +``` + +This adds metadata for tracking query performance in Snowflake. + +#### ✅ Filter Early and Aggressively + +```python +# ❌ Bad - returns 100M rows, filters in Python +df = query_pandas_from_snowflake( + query="SELECT * FROM events" +) +df = df[df['date'] >= '2024-01-01'] + +# ✅ Good - returns only needed rows +df = query_pandas_from_snowflake( + query=""" + SELECT * + FROM events + WHERE date >= '2024-01-01' + """ +) +``` + +#### ✅ Select Only Needed Columns + +```python +# ❌ Bad - returns all 50 columns +df = query_pandas_from_snowflake( + query="SELECT * FROM wide_table" +) + +# ✅ Good - returns only 5 needed columns +df = query_pandas_from_snowflake( + query=""" + SELECT user_id, feature_1, feature_2, feature_3, target + FROM wide_table + """ +) +``` + +#### ✅ Use Clustering Keys + +```sql +-- For tables with common filter patterns +ALTER TABLE events CLUSTER BY (date, user_id); + +-- Helps queries like: +SELECT * FROM events +WHERE date >= '2024-01-01' + AND user_id IN (1, 2, 3); +``` + +### Warehouse Sizing + +Choose the right warehouse for your workload: + +| Warehouse | Use Case | Query Time | Cost | +| --------- | ---------------------------- | ---------- | --------- | +| XS | Small queries (<100K rows) | Slower | Low | +| S | Development, ad-hoc queries | Moderate | Low-Med | +| M | Regular production workloads | Fast | Medium | +| L | Large batch jobs (>10M rows) | Fast | High | +| XL | Massive parallel processing | Fastest | Very High | + +```python +# Size based on your workload +def get_warehouse(row_count: int) -> str: + """Get optimal warehouse for row count.""" + if row_count < 100_000: + return "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH" + elif row_count < 1_000_000: + return "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_S_WH" + elif row_count < 10_000_000: + return "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" + else: + return "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH" +``` + +### Query Result Caching + +Snowflake automatically caches query results for 24 hours: + +```python +# First run - hits database +df1 = query_pandas_from_snowflake( + query="SELECT COUNT(*) FROM events WHERE date = '2024-01-01'" +) + +# Second run within 24h - returns from cache (instant!) +df2 = query_pandas_from_snowflake( + query="SELECT COUNT(*) FROM events WHERE date = '2024-01-01'" +) +``` + +## S3 Staging Optimization + +### When to Use S3 Staging + +**Enable S3 staging when:** +- Dataset > 1 GB +- Network bandwidth is limited +- Query returns many columns (wide tables) + +```python +# Automatically use S3 for large results +df = query_pandas_from_snowflake( + query="SELECT * FROM large_table", + use_s3_stage=True, # ← Enable for datasets > 1GB + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", +) +``` + +### Performance Comparison + +| Dataset Size | Without S3 | With S3 | Speedup | +| ------------ | ---------- | ------- | -------------- | +| 100 MB | 30s | 35s | 0.86x (slower) | +| 500 MB | 2.5min | 1.5min | 1.67x | +| 2 GB | 10min | 3min | 3.33x | +| 10 GB | 50min | 12min | 4.17x | + +### Optimize S3 File Size + +```python +# For BatchInferencePipeline +pipeline = BatchInferencePipeline() + +# Adjust batch size based on row width +# Wide tables (many columns) → smaller batches +pipeline.query_and_batch( + input_query="SELECT * FROM wide_table", # 100 columns + batch_size_in_mb=128, # Smaller batches for wide tables + parallel_workers=20, +) + +# Narrow tables (few columns) → larger batches +pipeline.query_and_batch( + input_query="SELECT id, value FROM narrow_table", # 2 columns + batch_size_in_mb=512, # Larger batches for narrow tables + parallel_workers=10, +) +``` + +## Metaflow Parallelization + +### Simple Parallelization with foreach + +```python +from metaflow import FlowSpec, step + +class ParallelFlow(FlowSpec): + + @step + def start(self): + """Split work into chunks.""" + self.chunks = list(range(10)) # 10 parallel tasks + self.next(self.process, foreach='chunks') + + @step + def process(self): + """Process each chunk in parallel.""" + chunk_id = self.input + # Process chunk... + self.result = f"Processed {chunk_id}" + self.next(self.join) + + @step + def join(self, inputs): + """Collect results.""" + self.results = [inp.result for inp in inputs] + self.next(self.end) + + @step + def end(self): + print(f"Processed {len(self.results)} chunks") +``` + +### Parallel Batch Inference + +```python +@step +def query_and_split(self): + """Query and split into batches.""" + pipeline = BatchInferencePipeline() + self.worker_ids = pipeline.query_and_batch( + input_query="SELECT * FROM input_data", + batch_size_in_mb=256, + parallel_workers=20, # 20 parallel tasks + ) + self.next(self.process_batch, foreach='worker_ids') + +@step +def process_batch(self): + """Process each batch in parallel.""" + worker_id = self.input + pipeline = BatchInferencePipeline() + + # This runs in parallel across 20 workers + pipeline.process_batch( + worker_id=worker_id, + predict_fn=my_prediction_function, + batch_size_in_mb=64, + ) + self.next(self.join_batches) +``` + +### Optimizing Parallel Workers + +Choose the number of workers based on: + +1. **Dataset Size**: Larger datasets → more workers +2. **Processing Time**: Longer processing → more workers +3. **Cost**: More workers = more compute cost + +```python +def calculate_optimal_workers(total_rows: int, processing_time_per_row: float) -> int: + """Calculate optimal number of parallel workers.""" + # Target: each worker processes ~30 minutes of work + target_time_minutes = 30 + rows_per_minute = 60 / processing_time_per_row + rows_per_worker = target_time_minutes * rows_per_minute + + workers = max(1, int(total_rows / rows_per_worker)) + return min(workers, 50) # Cap at 50 workers + +# Example +total_rows = 10_000_000 +time_per_row = 0.1 # seconds +workers = calculate_optimal_workers(total_rows, time_per_row) +print(f"Use {workers} workers") # → 28 workers +``` + +## Memory Management + +### Chunked Processing + +Process large datasets in chunks to avoid memory issues: + +```python +def process_in_chunks(df: pd.DataFrame, chunk_size: int = 10000): + """Process DataFrame in chunks.""" + results = [] + + for i in range(0, len(df), chunk_size): + chunk = df.iloc[i:i+chunk_size] + result = process_chunk(chunk) + results.append(result) + + # Free memory + del chunk + gc.collect() + + return pd.concat(results) +``` + +### Monitor Memory Usage + +```python +import psutil +import os + +def log_memory_usage(step_name: str): + """Log current memory usage.""" + process = psutil.Process(os.getpid()) + mem_mb = process.memory_info().rss / 1024 / 1024 + print(f"📊 {step_name}: Memory usage = {mem_mb:.1f} MB") + +@step +def process_data(self): + log_memory_usage("start") + + df = query_pandas_from_snowflake(query="...") + log_memory_usage("after_query") + + df = process(df) + log_memory_usage("after_process") + + publish_pandas(table_name="results", df=df) + log_memory_usage("after_publish") +``` + +### Use Appropriate Data Types + +```python +# ❌ Bad - uses default types +df = pd.DataFrame({ + 'id': [1, 2, 3], # int64 (8 bytes per value) + 'category': ['A', 'B', 'C'], # object (variable size) + 'value': [1.0, 2.0, 3.0], # float64 (8 bytes per value) +}) + +# ✅ Good - optimize types +df = df.astype({ + 'id': 'int32', # 4 bytes per value (50% memory reduction) + 'category': 'category', # Much smaller for repeated values + 'value': 'float32', # 4 bytes per value (50% memory reduction) +}) + +# For 10M rows, this saves ~150 MB memory! +``` + +## Cost Optimization + +### Warehouse Auto-suspend + +Warehouses auto-suspend after inactivity, but you can optimize timing: + +```sql +-- Set shorter auto-suspend for dev warehouses +ALTER WAREHOUSE OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH +SET AUTO_SUSPEND = 60; -- Suspend after 1 minute + +-- Longer auto-suspend for production (avoid cold starts) +ALTER WAREHOUSE OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_MED_WH +SET AUTO_SUSPEND = 600; -- Suspend after 10 minutes +``` + +### Query Result Caching + +Leverage Snowflake's result cache to avoid redundant queries: + +```python +# Development: run queries multiple times during debugging +# → Results are cached, subsequent runs are free! + +@step +def explore_data(self): + # First run: hits database (costs money) + df = query_pandas_from_snowflake( + query="SELECT * FROM my_table WHERE date = '2024-01-01'" + ) + print(df.head()) # Check data + + # Realize you need to adjust something... + + # Re-run flow: uses cached result (free!) + df = query_pandas_from_snowflake( + query="SELECT * FROM my_table WHERE date = '2024-01-01'" # Same query + ) +``` + +### Right-size Your Work + +```python +# Development: use smaller datasets +if not is_production(): + query += " LIMIT 10000" # Only 10K rows for testing + +# Production: use full dataset +df = query_pandas_from_snowflake(query=query) +``` + +### Monitor Costs + +```sql +-- Check warehouse usage +SELECT + warehouse_name, + SUM(credits_used) as total_credits, + SUM(credits_used) * 3 as estimated_cost_usd -- ~$3 per credit +FROM snowflake.account_usage.warehouse_metering_history +WHERE start_time >= DATEADD(day, -7, CURRENT_TIMESTAMP()) +GROUP BY warehouse_name +ORDER BY total_credits DESC; +``` + +## Performance Checklist + +Before running large workloads: + +- [ ] Query filters data as early as possible +- [ ] Only SELECT needed columns +- [ ] Using appropriate warehouse size +- [ ] S3 staging enabled for datasets > 1GB +- [ ] Parallel processing configured for long-running tasks +- [ ] Memory usage monitored and optimized +- [ ] Data types optimized (int32, float32, category) +- [ ] Result caching leveraged for development +- [ ] Chunk size tuned for workload +- [ ] Cost tracking enabled + +## Benchmarking + +Use this template to benchmark your optimizations: + +```python +import time + +def benchmark_query(query: str, use_s3: bool = False) -> dict: + """Benchmark a query.""" + start = time.time() + + df = query_pandas_from_snowflake( + query=query, + use_s3_stage=use_s3, + ) + + duration = time.time() - start + + return { + 'duration_seconds': duration, + 'rows': len(df), + 'columns': len(df.columns), + 'memory_mb': df.memory_usage(deep=True).sum() / 1024 / 1024, + 'rows_per_second': len(df) / duration, + } + +# Test both approaches +results_no_s3 = benchmark_query(my_query, use_s3=False) +results_with_s3 = benchmark_query(my_query, use_s3=True) + +print(f"Without S3: {results_no_s3['duration_seconds']:.1f}s") +print(f"With S3: {results_with_s3['duration_seconds']:.1f}s") +print(f"Speedup: {results_no_s3['duration_seconds'] / results_with_s3['duration_seconds']:.2f}x") +``` + +## Additional Resources + +- [Best Practices](best_practices.md) +- [Common Patterns](common_patterns.md) +- [Troubleshooting](troubleshooting.md) +- [Snowflake Query Performance Guide](https://docs.snowflake.com/en/user-guide/ui-snowsight-query-performance) diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md new file mode 100644 index 0000000..71bb1a8 --- /dev/null +++ b/docs/guides/troubleshooting.md @@ -0,0 +1,598 @@ +# Troubleshooting Guide + +[← Back to Main Docs](../README.md) + +Solutions to common issues and error messages. + +## Table of Contents + +- [Snowflake Connection Issues](#snowflake-connection-issues) +- [Query Errors](#query-errors) +- [Memory Errors](#memory-errors) +- [S3 Staging Issues](#s3-staging-issues) +- [Batch Inference Errors](#batch-inference-errors) +- [Metaflow Issues](#metaflow-issues) +- [Publishing Errors](#publishing-errors) + +## Snowflake Connection Issues + +### Error: "Snowflake connector not configured" + +**Cause**: Metaflow's Snowflake connector is not set up. + +**Solution**: Use Metaflow's built-in Snowflake integration: + +```python +from metaflow import FlowSpec, step +from ds_platform_utils.metaflow.get_snowflake_connection import get_snowflake_connection + +class MyFlow(FlowSpec): + + @step + def start(self): + # Use Metaflow's connection + cursor = get_snowflake_connection() + # Your code... +``` + +### Error: "Authentication failed" + +**Cause**: Snowflake credentials not available or expired. + +**Solution**: +```bash +# Check your Snowflake connection +snowsql -c my_connection + +# If using SSO, re-authenticate +snowsql -a -u --authenticator externalbrowser +``` + +### Error: "Warehouse does not exist" + +**Cause**: Wrong warehouse name or no access. + +**Solution**: Verify warehouse name: +```python +# Check available warehouses +cursor = get_snowflake_connection() +cursor.execute("SHOW WAREHOUSES") +print(cursor.fetchall()) + +# Use correct warehouse name (case-sensitive!) +query_pandas_from_snowflake( + query="SELECT * FROM table", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", # Correct name +) +``` + +## Query Errors + +### Error: "SQL compilation error: Object does not exist" + +**Cause**: Table/view not found or no access. + +**Solution**: + +```python +# Check table exists +cursor = get_snowflake_connection() +cursor.execute(""" + SHOW TABLES LIKE 'my_table' IN SCHEMA my_database.my_schema +""") +print(cursor.fetchall()) + +# Check you have access +cursor.execute(""" + SELECT * FROM my_database.my_schema.my_table + LIMIT 1 +""") +``` + +### Error: "Template variable not provided" + +**Cause**: Missing variable in `ctx` dictionary. + +**Solution**: +```python +# ❌ Bad - missing variable +query_pandas_from_snowflake( + query_fpath="sql/query.sql", # Uses {{start_date}} + ctx={"end_date": "2024-12-31"}, # Missing start_date! +) + +# ✅ Good - all variables provided +query_pandas_from_snowflake( + query_fpath="sql/query.sql", + ctx={ + "start_date": "2024-01-01", + "end_date": "2024-12-31", + }, +) +``` + +### Error: "Query timeout exceeded" + +**Cause**: Query takes too long. + +**Solutions**: + +1. **Optimize query** (filter early, select fewer columns) +2. **Use larger warehouse** +3. **Increase timeout** + +```python +query_pandas_from_snowflake( + query="SELECT * FROM huge_table", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", # Larger warehouse + timeout_seconds=1800, # 30 minutes +) +``` + +### Error: "Statement reached its statement or warehouse timeout" + +**Cause**: Query exceeded warehouse timeout. + +**Solution**: Break query into smaller chunks or use intermediate tables: + +```sql +-- Instead of one massive query +CREATE TEMPORARY TABLE temp_results AS +SELECT * FROM huge_table +WHERE date >= '2024-01-01'; + +-- Then query the smaller result +SELECT * FROM temp_results; +``` + +## Memory Errors + +### Error: "MemoryError" or "Killed" + +**Cause**: Dataset too large for available RAM. + +**Solutions**: + +#### Option 1: Use S3 Staging + +```python +df = query_pandas_from_snowflake( + query="SELECT * FROM large_table", + use_s3_stage=True, # ← Reduces memory pressure +) +``` + +#### Option 2: Process in Chunks + +```python +def process_in_chunks(query: str, chunk_size: int = 100000): + """Process large query in chunks.""" + offset = 0 + results = [] + + while True: + chunk_query = f"{query} LIMIT {chunk_size} OFFSET {offset}" + chunk = query_pandas_from_snowflake(query=chunk_query) + + if len(chunk) == 0: + break + + # Process chunk + result = process(chunk) + results.append(result) + + offset += chunk_size + + return pd.concat(results) +``` + +#### Option 3: Use BatchInferencePipeline + +```python +# For very large datasets (> 10M rows) +pipeline = BatchInferencePipeline() +worker_ids = pipeline.query_and_batch( + input_query="SELECT * FROM huge_table", + parallel_workers=20, # Split into 20 batches +) +``` + +#### Option 4: Optimize Data Types + +```python +# After loading data +df = df.astype({ + 'int_col': 'int32', # Instead of int64 + 'float_col': 'float32', # Instead of float64 + 'category_col': 'category', # For repeated values +}) + +# Can reduce memory by 50%+ +``` + +### Error: "DataFrame too large to serialize" + +**Cause**: Metaflow cannot serialize large DataFrames between steps. + +**Solution**: Don't pass large DataFrames, use temporary tables instead: + +```python +@step +def query_data(self): + """Query and store in temp table.""" + cursor = get_snowflake_connection() + cursor.execute(""" + CREATE TEMPORARY TABLE temp_my_data AS + SELECT * FROM large_table + WHERE date >= '2024-01-01' + """) + + # Just pass the table name, not the data + self.temp_table = "temp_my_data" + self.next(self.process) + +@step +def process(self): + """Query from temp table.""" + self.df = query_pandas_from_snowflake( + query=f"SELECT * FROM {self.temp_table}" + ) + # Process... +``` + +## S3 Staging Issues + +### Error: "S3 upload failed" + +**Cause**: No S3 access or wrong permissions. + +**Solution**: Check S3 configuration: + +```bash +# Check AWS credentials +aws sts get-caller-identity + +# Test S3 access +aws s3 ls s3://your-bucket/ +``` + +### Error: "Slow performance with S3 staging" + +**Cause**: Too many small files or inefficient batch size. + +**Solution**: Tune batch size: + +```python +# For wide tables (many columns) +pipeline.query_and_batch( + input_query="SELECT * FROM wide_table", + batch_size_in_mb=128, # Smaller batches + parallel_workers=30, +) + +# For narrow tables (few columns) +pipeline.query_and_batch( + input_query="SELECT id, value FROM narrow_table", + batch_size_in_mb=512, # Larger batches + parallel_workers=10, +) +``` + +## Batch Inference Errors + +### Error: "Cannot process batch before query_and_batch" + +**Cause**: Trying to call `process_batch()` before `query_and_batch()`. + +**Solution**: Follow the correct order: + +```python +# ✅ Correct order +pipeline = BatchInferencePipeline() + +# 1. Query and split +worker_ids = pipeline.query_and_batch(...) + +# 2. Process each batch +for worker_id in worker_ids: + pipeline.process_batch(worker_id, ...) + +# 3. Publish results +pipeline.publish_results(...) +``` + +### Error: "Cannot publish before processing" + +**Cause**: Trying to `publish_results()` before processing any batches. + +**Solution**: Ensure at least one batch is processed: + +```python +@step +def process_batches(self): + worker_id = self.input + pipeline = BatchInferencePipeline() + + # Process this batch + pipeline.process_batch( + worker_id=worker_id, + predict_fn=my_predict_fn, + ) + + self.next(self.join) + +@step +def join(self, inputs): + """Now safe to publish.""" + pipeline = BatchInferencePipeline() + pipeline.publish_results( + output_table="predictions", + output_schema="my_dev_schema", + ) + self.next(self.end) +``` + +### Error: "Worker ID not found" + +**Cause**: Invalid worker ID or not from current pipeline. + +**Solution**: Use worker IDs from `query_and_batch()`: + +```python +# ❌ Bad - made-up worker ID +pipeline.process_batch(worker_id=999, ...) + +# ✅ Good - use returned worker IDs +worker_ids = pipeline.query_and_batch(...) +for worker_id in worker_ids: # Use these IDs + pipeline.process_batch(worker_id=worker_id, ...) +``` + +### Error: "Prediction function failed" + +**Cause**: Exception in your `predict_fn`. + +**Solution**: Test your function separately: + +```python +# Test predict_fn with sample data +sample_df = pd.DataFrame({ + 'feature_1': [1, 2, 3], + 'feature_2': [4, 5, 6], +}) + +try: + result = my_predict_fn(sample_df) + print("✅ Predict function works") + print(result.head()) +except Exception as e: + print(f"❌ Predict function failed: {e}") + import traceback + traceback.print_exc() +``` + +## Metaflow Issues + +### Error: "Step failed with StepTimeout" + +**Cause**: Step exceeded time limit. + +**Solutions**: + +1. **Increase timeout**: +```python +@timeout(seconds=7200) # 2 hours +@step +def long_running_step(self): + # Your code... +``` + +2. **Optimize processing** (see [Performance Tuning](performance_tuning.md)) + +3. **Split into parallel tasks**: +```python +@step +def split_work(self): + self.chunks = range(10) + self.next(self.process_chunk, foreach='chunks') + +@step +def process_chunk(self): + # Process one chunk (faster) + chunk_id = self.input + # Your code... +``` + +### Error: "Resume failed" + +**Cause**: Metaflow cannot resume from checkpoint. + +**Solution**: Re-run from start or from different step: + +```bash +# Re-run entire flow +python flow.py run + +# Resume from specific step +python flow.py resume --origin-run-id + +# Re-run from specific step +python flow.py run --start-at process_data +``` + +### Error: "Parameter validation failed" + +**Cause**: Invalid parameter value. + +**Solution**: Check parameter constraints: + +```python +from pydantic import BaseModel, validator + +class Config(BaseModel): + date: str + + @validator('date') + def validate_date(cls, v): + """Validate date format.""" + try: + datetime.strptime(v, '%Y-%m-%d') + return v + except ValueError: + raise ValueError(f"Invalid date format: {v}. Use YYYY-MM-DD") +``` + +## Publishing Errors + +### Error: "Table already exists" + +**Cause**: Table exists and mode is not specified. + +**Solution**: Specify mode: + +```python +publish_pandas( + table_name="my_table", + df=df, + mode="replace", # or "append" or "fail" +) +``` + +### Error: "Permission denied" + +**Cause**: No write access to schema. + +**Solution**: Use your dev schema: + +```python +# ✅ Use your dev schema +publish_pandas( + table_name="my_table", + df=df, + schema="my_dev_schema", # You have access here +) + +# ❌ Don't write to production without permission +publish_pandas( + table_name="my_table", + df=df, + schema="production_schema", # No access! +) +``` + +### Error: "Column name mismatch" + +**Cause**: DataFrame columns don't match target table. + +**Solution**: + +```python +# Check current columns +print(df.columns.tolist()) + +# Rename to match target +df = df.rename(columns={ + 'old_name': 'new_name', +}) + +# Or select specific columns +df = df[['col1', 'col2', 'col3']] + +publish_pandas(table_name="my_table", df=df) +``` + +## General Debugging Tips + +### Enable Verbose Logging + +```python +import logging + +logging.basicConfig(level=logging.DEBUG) + +# Now you'll see more detailed output +df = query_pandas_from_snowflake(query="...") +``` + +### Check Snowflake Query History + +```sql +-- View recent queries +SELECT + query_id, + query_text, + execution_status, + error_message, + total_elapsed_time / 1000 as seconds +FROM table(information_schema.query_history()) +WHERE user_name = CURRENT_USER() +ORDER BY start_time DESC +LIMIT 10; +``` + +### Test SQL Separately + +Before running in Metaflow, test SQL in Snowflake UI: + +1. Copy your SQL query +2. Run in Snowflake console +3. Check results +4. Fix issues +5. Then use in flow + +### Isolate the Problem + +```python +# Instead of running full flow +python flow.py run + +# Run just one step +python flow.py step query_data +``` + +### Use Restore Step State + +For debugging Metaflow flows: + +```python +from ds_platform_utils.metaflow import restore_step_state + +# Restore state from previous run +with restore_step_state("MyFlow", run_id="123", step="process"): + # Access self.df and other artifacts + print(self.df.head()) + + # Debug your processing logic + result = process(self.df) + print(result.head()) +``` + +## Getting Help + +If you're still stuck: + +1. **Check the logs**: Full error messages often contain the solution +2. **Review the docs**: [Getting Started](getting_started.md), [Best Practices](best_practices.md) +3. **Search Snowflake docs**: [docs.snowflake.com](https://docs.snowflake.com) +4. **Search Metaflow docs**: [docs.metaflow.org](https://docs.metaflow.org) +5. **Ask your team**: Someone may have seen the issue before + +## Common Error Patterns + +| Error Message | Likely Cause | Solution | +| ----------------------------------- | ----------------------- | ---------------------------- | +| "Object does not exist" | Table/schema name wrong | Check table path | +| "Authentication failed" | Credentials expired | Re-authenticate | +| "MemoryError" | DataFrame too large | Use S3 staging or chunks | +| "Timeout exceeded" | Query too slow | Optimize query or warehouse | +| "Permission denied" | No write access | Use dev schema | +| "Template variable not provided" | Missing ctx variable | Add to ctx dict | +| "Cannot process batch before query" | Wrong order | Call query_and_batch() first | +| "Serialization failed" | Object too large | Use temp tables | + +## Additional Resources + +- [Best Practices](best_practices.md) +- [Performance Tuning](performance_tuning.md) +- [Common Patterns](common_patterns.md) +- [Getting Started](getting_started.md) diff --git a/docs/metaflow/README.md b/docs/metaflow/README.md new file mode 100644 index 0000000..13893a1 --- /dev/null +++ b/docs/metaflow/README.md @@ -0,0 +1,299 @@ +# Metaflow Utilities + +High-level utilities for building ML workflows with Metaflow, Snowflake, and S3. + +## Modules Overview + +### 🤖 [BatchInferencePipeline](batch_inference_pipeline.md) +**Purpose**: Orchestrate large-scale batch inference workflows + +**Key Features**: +- Automatic data export from Snowflake to S3 +- Parallel processing with Metaflow foreach +- Queue-based streaming pipeline (download → inference → upload) +- Automatic results publishing back to Snowflake +- Execution state validation + +**When to Use**: +- Running predictions on millions of rows +- Need for parallel processing +- Memory constraints require streaming +- Production batch scoring jobs + +**Example**: +```python +from ds_platform_utils.metaflow import BatchInferencePipeline + +pipeline = BatchInferencePipeline() +pipeline.run( + input_query="SELECT * FROM features", + output_table_name="predictions", + predict_fn=model.predict, +) +``` + +--- + +### 📊 [Pandas Integration](pandas.md) +**Purpose**: Seamless Pandas ↔ Snowflake operations + +**Key Functions**: +- `query_pandas_from_snowflake()` - Query Snowflake into DataFrame +- `publish_pandas()` - Write DataFrame to Snowflake + +**Key Features**: +- Automatic timezone handling (UTC) +- S3 staging for large datasets +- Schema auto-creation +- Compression options (snappy/gzip) +- Parallel uploads + +**When to Use**: +- Ad-hoc data analysis +- Feature engineering +- Model training data retrieval +- Publishing model outputs + +**Example**: +```python +from ds_platform_utils.metaflow import ( + query_pandas_from_snowflake, + publish_pandas +) + +# Query data +df = query_pandas_from_snowflake( + query="SELECT * FROM training_data", + use_s3_stage=True, # For large datasets +) + +# Publish results +publish_pandas( + table_name="model_outputs", + df=predictions_df, + auto_create_table=True, +) +``` + +--- + +### ✍️ [Write, Audit & Publish](write_audit_publish.md) +**Purpose**: Safe, auditable data publishing patterns + +**Key Features**: +- SQL file management +- Template variable substitution +- Query tagging for tracking +- Dev/Prod schema separation +- Audit trail generation +- Table URL generation + +**When to Use**: +- Publishing production models +- Executing parameterized SQL +- Audit requirements +- Schema-aware deployments + +**Example**: +```python +from ds_platform_utils.metaflow import publish + +publish( + query_fpath="queries/create_aggregates.sql", + ctx={"start_date": "2024-01-01"}, + publish_query_fpath="queries/publish_results.sql", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_MED_WH", +) +``` + +--- + +### 🔄 [State Management](restore_step_state.md) +**Purpose**: Restore Metaflow step state for debugging + +**Key Features**: +- Artifact restoration +- Namespace recreation +- Interactive debugging support + +**When to Use**: +- Debugging failed flows +- Reproducing specific step states +- Testing step logic interactively + +**Example**: +```python +from ds_platform_utils.metaflow import restore_step_state + +# Restore state from a previous run +namespace = restore_step_state( + flow_name="MyFlow", + run_id="123", + step_name="process_data", +) + +# Access restored artifacts +df = namespace.df +model = namespace.model +``` + +--- + +### ⚙️ [Config Validation](validate_config.md) +**Purpose**: Type-safe configuration with Pydantic + +**Key Features**: +- Pydantic model validation +- Metaflow parameter parsing +- Type checking and coercion +- Clear error messages + +**When to Use**: +- Complex flow configurations +- Type safety requirements +- Parameter validation +- Configuration schemas + +**Example**: +```python +from pydantic import BaseModel +from ds_platform_utils.metaflow import make_pydantic_parser_fn + +class FlowConfig(BaseModel): + start_date: str + end_date: str + batch_size: int = 1000 + +class MyFlow(FlowSpec): + config = Parameter( + 'config', + type=make_pydantic_parser_fn(FlowConfig), + default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}' + ) +``` + +--- + +## Module Comparison + +| Module | Data Size | Processing | Use Case | Complexity | +| -------------------------- | --------- | ---------- | -------------- | ---------- | +| **BatchInferencePipeline** | 100GB+ | Parallel | Batch scoring | Medium | +| **Pandas Integration** | <10GB | Sequential | Analysis, ETL | Low | +| **Write/Audit/Publish** | Any | Sequential | Production SQL | Low | +| **State Management** | N/A | N/A | Debugging | Low | +| **Config Validation** | N/A | N/A | Configuration | Low | + +## Common Workflows + +### Workflow 1: Model Training Pipeline +```python +from ds_platform_utils.metaflow import ( + query_pandas_from_snowflake, + publish_pandas +) + +class TrainingFlow(FlowSpec): + @step + def start(self): + # Query training data + self.df = query_pandas_from_snowflake( + query="SELECT * FROM features WHERE date >= '2024-01-01'", + use_s3_stage=True, + ) + self.next(self.train) + + @step + def train(self): + # Train model + self.model = train_model(self.df) + self.next(self.end) + + @step + def end(self): + # Publish metrics + publish_pandas( + table_name="model_metrics", + df=self.metrics_df, + ) +``` + +### Workflow 2: Batch Inference Pipeline +```python +from ds_platform_utils.metaflow import BatchInferencePipeline + +class PredictionFlow(FlowSpec): + @step + def start(self): + self.pipeline = BatchInferencePipeline() + self.worker_ids = self.pipeline.query_and_batch( + input_query="SELECT * FROM input_features", + parallel_workers=10, + ) + self.next(self.predict, foreach='worker_ids') + + @step + def predict(self): + self.pipeline.process_batch( + worker_id=self.input, + predict_fn=self.model.predict, + ) + self.next(self.join) + + @step + def join(self, inputs): + self.pipeline = inputs[0].pipeline + self.pipeline.publish_results( + output_table_name="predictions", + ) + self.next(self.end) +``` + +### Workflow 3: Audited Data Publication +```python +from ds_platform_utils.metaflow import publish + +class DataPipelineFlow(FlowSpec): + @step + def start(self): + publish( + query_fpath="sql/transform_data.sql", + ctx={ + "start_date": self.start_date, + "end_date": self.end_date, + }, + publish_query_fpath="sql/publish_results.sql", + ) + self.next(self.end) +``` + +## Design Principles + +### 1. **Simplicity First** +- High-level abstractions hide complexity +- Sensible defaults for common cases +- Progressive disclosure of advanced features + +### 2. **Production Ready** +- Built-in error handling +- Audit trails +- Dev/Prod separation +- Query tagging + +### 3. **Performance** +- S3 staging for large data +- Parallel processing where applicable +- Streaming pipelines to manage memory +- Efficient compression + +### 4. **Type Safety** +- Type hints throughout +- Pydantic validation +- Clear error messages + +## Next Steps + +- 📖 Read the [Getting Started Guide](../guides/getting_started.md) +- 🎯 Check out [Common Patterns](../guides/common_patterns.md) +- 🔧 Review [Best Practices](../guides/best_practices.md) +- 🐛 See [Troubleshooting](../guides/troubleshooting.md) diff --git a/docs/metaflow/batch_inference_pipeline.md b/docs/metaflow/batch_inference_pipeline.md new file mode 100644 index 0000000..c1e10f6 --- /dev/null +++ b/docs/metaflow/batch_inference_pipeline.md @@ -0,0 +1,296 @@ +# BatchInferencePipeline + +[← Back to Metaflow Utilities](README.md) | [← Back to Main Docs](../README.md) + +A scalable batch inference pipeline for running ML predictions on large datasets using Metaflow, Snowflake, and S3. + +## Key Features + +- **Snowflake Integration**: Query data directly from Snowflake and write results back +- **S3 Staging**: Efficient data transfer via S3 for large datasets +- **Parallel Processing**: Built-in support for Metaflow's foreach parallelization +- **Pipeline Orchestration**: Three-stage pipeline (query → process → publish) +- **Queue-based Processing**: Multi-threaded download→inference→upload pipeline for optimal throughput +- **Execution State Validation**: Prevents out-of-order execution with clear error messages + +#### Quick Start + +##### Option 1: Manual Control with Foreach Parallelization + +Use this approach when you need fine-grained control and want to parallelize across multiple Metaflow workers: + +```python +from metaflow import FlowSpec, step +from ds_platform_utils.metaflow import BatchInferencePipeline + +class MyPredictionFlow(FlowSpec): + + @step + def start(self): + # Initialize pipeline and export data to S3 + self.pipeline = BatchInferencePipeline() + self.worker_ids = self.pipeline.query_and_batch( + input_query="SELECT * FROM my_table WHERE date >= '2024-01-01'", + parallel_workers=10, # Split into 10 parallel workers + ) + self.next(self.predict, foreach='worker_ids') + + @step + def predict(self): + # Process single batch (runs in parallel via foreach) + worker_id = self.input + self.pipeline.process_batch( + worker_id=worker_id, + predict_fn=my_model.predict, + batch_size_in_mb=256, + ) + self.next(self.join) + + @step + def join(self, inputs): + # Merge and write results to Snowflake + self.pipeline = inputs[0].pipeline + self.pipeline.publish_results( + output_table_name="predictions_table", + auto_create_table=True, + ) + self.next(self.end) + + @step + def end(self): + print("✅ Pipeline complete!") +``` + +##### Option 2: Convenience Method + +Use this for simpler workflows without foreach parallelization: + +```python +from ds_platform_utils.metaflow import BatchInferencePipeline + +def my_predict_function(df): + # Your prediction logic here + df['prediction'] = model.predict(df[feature_columns]) + return df[['id', 'prediction']] + +# Run the complete pipeline +pipeline = BatchInferencePipeline() +pipeline.run( + input_query="SELECT * FROM input_table", + output_table_name="predictions_table", + predict_fn=my_predict_function, + batch_size_in_mb=128, + auto_create_table=True, + overwrite=True, +) +``` + +#### API Reference + +##### `BatchInferencePipeline()` + +Initialize the pipeline. Automatically configures S3 paths based on Metaflow context. + +##### `query_and_batch()` + +**Step 1**: Export data from Snowflake to S3 and create worker batches. + +```python +worker_ids = pipeline.query_and_batch( + input_query: Union[str, Path], # SQL query or path to .sql file + ctx: Optional[dict] = None, # Template variables (e.g., {"schema": "dev"}) + warehouse: Optional[str] = None, # Snowflake warehouse + use_utc: bool = True, # Use UTC timezone + parallel_workers: int = 1, # Number of parallel workers +) +``` + +**Returns**: List of worker IDs for foreach parallelization + +##### `process_batch()` + +**Step 2**: Process a single batch with streaming pipeline. + +```python +s3_path = pipeline.process_batch( + worker_id: int, # Worker ID from foreach + predict_fn: Callable[[pd.DataFrame], pd.DataFrame], # Prediction function + batch_size_in_mb: int = 128, # Batch size in MB + timeout_per_batch: int = 300, # Timeout in seconds +) +``` + +**Your `predict_fn` signature**: +```python +def predict_fn(input_df: pd.DataFrame) -> pd.DataFrame: + # Process the input DataFrame and return predictions + return predictions_df +``` + +##### `publish_results()` + +**Step 3**: Write all predictions from S3 to Snowflake. + +```python +pipeline.publish_results( + output_table_name: str, # Snowflake table name + output_table_definition: Optional[List[Tuple]] = None, # Schema definition + auto_create_table: bool = True, # Auto-create if missing + overwrite: bool = True, # Overwrite existing data + warehouse: Optional[str] = None, # Snowflake warehouse + use_utc: bool = True, # Use UTC timezone +) +``` + +##### `run()` + +Convenience method that combines all three steps for simple workflows. + +```python +pipeline.run( + input_query: Union[str, Path], + output_table_name: str, + predict_fn: Callable[[pd.DataFrame], pd.DataFrame], + # ... plus all parameters from query_and_batch(), process_batch(), publish_results() +) +``` + +#### Advanced Usage + +##### Custom Table Schema + +```python +table_schema = [ + ("id", "VARCHAR(100)"), + ("prediction", "FLOAT"), + ("confidence", "FLOAT"), + ("predicted_at", "TIMESTAMP_NTZ"), +] + +pipeline.publish_results( + output_table_name="predictions", + output_table_definition=table_schema, + auto_create_table=True, +) +``` + +##### Using SQL Template Variables + +```python +worker_ids = pipeline.query_and_batch( + input_query=""" + SELECT * FROM {{schema}}.my_table + WHERE date >= '{{start_date}}' + """, + ctx={ + "schema": "production", + "start_date": "2024-01-01", + }, +) +``` + +##### External SQL Files + +```python +worker_ids = pipeline.query_and_batch( + input_query=Path("queries/input_query.sql"), + ctx={"schema": "production"}, +) +``` + +#### Error Handling & Validation + +The pipeline validates execution order and provides clear error messages: + +```python +pipeline = BatchInferencePipeline() + +# ❌ This will raise RuntimeError +pipeline.process_batch(worker_id=1, predict_fn=my_fn) +# Error: "Cannot process batch: query_and_batch() must be called first." + +# ❌ This will also raise RuntimeError +pipeline.publish_results(output_table_name="results") +# Error: "Cannot publish results: No batches have been processed." +``` + +Re-execution warnings: + +```python +# First execution +worker_ids = pipeline.query_and_batch(input_query="SELECT * FROM table") +pipeline.process_batch(worker_id=1, predict_fn=my_fn) + +# Second execution - warns about state reset +worker_ids = pipeline.query_and_batch(input_query="SELECT * FROM table") +# ⚠️ Warning: Re-executing query_and_batch() will reset batch processing state. + +# Publishing again - warns about duplicates +pipeline.publish_results(output_table_name="results") # First time - OK +pipeline.publish_results(output_table_name="results") # Second time +# ⚠️ Warning: Results have already been published. Publishing again may cause duplicate data. +``` + +#### Performance Tips + +1. **Batch Size**: Tune `batch_size_in_mb` based on your data and memory constraints + - Larger batches = fewer S3 operations but more memory usage + - Recommended: 128-512 MB per batch + +2. **Parallel Workers**: Balance parallelization with Metaflow cluster capacity + - More workers = faster processing but more resources + - Consider your data size and available compute + +3. **Timeouts**: Adjust `timeout_per_batch` for long-running inference + - Default: 300 seconds (5 minutes) + - Increase for complex models or large batches + +#### Troubleshooting + +##### "Worker X not found" +- The worker_id doesn't match any created worker +- Check that you're using worker_ids from `query_and_batch()` + +##### Timeout Errors +- Increase `timeout_per_batch` parameter +- Reduce `batch_size_in_mb` to process smaller chunks +- Check model inference performance + +##### Memory Issues +- Reduce `batch_size_in_mb` +- Ensure predict_fn doesn't accumulate data +- Monitor Metaflow task memory usage + +#### Architecture + +``` +┌──────────────┐ +│ Snowflake │ +│ (Query) │ +└──────┬───────┘ + │ COPY INTO + ▼ +┌──────────────┐ ┌─────────────────────────┐ +│ S3 │ │ Metaflow Workers │ +│ (Stage) │◄────►│ (Foreach Parallel) │ +│ Input Data │ │ │ +└──────────────┘ │ ┌───────────────────┐ │ + │ │ │ Queue Pipeline: │ │ + │ │ │ Download ──→ │ │ + │ │ │ Inference ──→ │ │ + │ │ │ Upload │ │ + │ │ └───────────────────┘ │ + │ └─────────┬───────────────┘ + ▼ │ +┌──────────────┐ │ +│ S3 │◄──────────────┘ +│ (Stage) │ +│ Output Data │ +└──────┬───────┘ + │ COPY INTO + ▼ +┌──────────────┐ +│ Snowflake │ +│ (Publish) │ +└──────────────┘ +``` diff --git a/docs/metaflow/pandas.md b/docs/metaflow/pandas.md new file mode 100644 index 0000000..2fe09bf --- /dev/null +++ b/docs/metaflow/pandas.md @@ -0,0 +1,381 @@ +# Pandas Integration + +[← Back to Metaflow Docs](README.md) + +Query and publish pandas DataFrames with Snowflake. + +## Table of Contents + +- [Overview](#overview) +- [Querying Data](#querying-data) +- [Publishing Data](#publishing-data) +- [Using SQL Files](#using-sql-files) +- [Advanced Usage](#advanced-usage) + +## Overview + +The pandas integration provides simple functions to move data between Snowflake and pandas DataFrames: + +```python +from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas +``` + +## Querying Data + +### Basic Query + +```python +from ds_platform_utils.metaflow import query_pandas_from_snowflake + +df = query_pandas_from_snowflake( + query="SELECT * FROM my_table WHERE date >= '2024-01-01'", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", +) +``` + +### Query from SQL File + +```python +df = query_pandas_from_snowflake( + query_fpath="sql/extract_data.sql", + ctx={ + "start_date": "2024-01-01", + "end_date": "2024-12-31", + "min_value": 100, + }, + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", +) +``` + +```sql +-- sql/extract_data.sql +SELECT * +FROM transactions +WHERE date >= '{{start_date}}' + AND date <= '{{end_date}}' + AND amount >= {{min_value}} +``` + +### Large Datasets via S3 + +For datasets > 1GB, use S3 staging: + +```python +df = query_pandas_from_snowflake( + query="SELECT * FROM large_table", + use_s3_stage=True, # ← Enable S3 staging + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", +) +``` + +**Benefits:** +- Much faster for large datasets (3-5x speedup) +- Reduces memory pressure +- More reliable for very large results + +**When to use:** +- Dataset > 1 GB +- Many columns (wide tables) +- Network bandwidth limited + +### Custom Timeouts + +```python +df = query_pandas_from_snowflake( + query="SELECT * FROM huge_table", + timeout_seconds=1800, # 30 minutes + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", +) +``` + +## Publishing Data + +### Basic Publish + +```python +from ds_platform_utils.metaflow import publish_pandas + +publish_pandas( + table_name="my_results", + df=results_df, + schema="my_dev_schema", +) +``` + +### Replace vs. Append + +```python +# Replace existing table (default) +publish_pandas( + table_name="my_table", + df=df, + mode="replace", +) + +# Append to existing table +publish_pandas( + table_name="my_table", + df=df, + mode="append", +) + +# Fail if table exists +publish_pandas( + table_name="my_table", + df=df, + mode="fail", +) +``` + +### Add Comments + +```python +publish_pandas( + table_name="my_table", + df=df, + comment="Daily feature refresh - 2024-01-15", +) +``` + +### Specify Warehouse + +```python +publish_pandas( + table_name="large_table", + df=large_df, + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", # Use larger warehouse +) +``` + +## Using SQL Files + +### Query and Publish Pattern + +The most common pattern: query data with one SQL file, transform in Python, publish with another SQL file. + +```python +from ds_platform_utils.metaflow import publish + +publish( + query_fpath="sql/create_features.sql", + ctx={"start_date": "2024-01-01", "end_date": "2024-12-31"}, + publish_query_fpath="sql/publish_features.sql", + comment="Daily feature engineering", +) +``` + +```sql +-- sql/create_features.sql +CREATE OR REPLACE TEMPORARY TABLE temp_features AS +SELECT + user_id, + COUNT(*) as event_count, + AVG(value) as avg_value, + MAX(timestamp) as last_seen +FROM events +WHERE date >= '{{start_date}}' + AND date <= '{{end_date}}' +GROUP BY user_id; +``` + +```sql +-- sql/publish_features.sql +CREATE OR REPLACE TABLE my_dev_schema.user_features AS +SELECT * FROM temp_features; +``` + +### Transform Function + +Add Python transformation between query and publish: + +```python +def transform_features(df: pd.DataFrame) -> pd.DataFrame: + """Add engineered features.""" + df['recency_days'] = ( + datetime.now() - pd.to_datetime(df['last_seen']) + ).dt.days + df['frequency_per_day'] = df['event_count'] / 30 + return df + +publish( + query_fpath="sql/create_features.sql", + ctx={"start_date": "2024-01-01", "end_date": "2024-12-31"}, + transform_fn=transform_features, # ← Add transformation + publish_query_fpath="sql/publish_features.sql", +) +``` + +## Advanced Usage + +### Multiple Queries in Sequence + +```python +# Query 1: Get user data +users_df = query_pandas_from_snowflake( + query="SELECT * FROM users WHERE active = TRUE" +) + +# Query 2: Get events for these users +user_ids = tuple(users_df['user_id'].tolist()) +events_df = query_pandas_from_snowflake( + query=f"SELECT * FROM events WHERE user_id IN {user_ids}" +) + +# Join in pandas +result = users_df.merge(events_df, on='user_id') + +# Publish +publish_pandas(table_name="user_events", df=result) +``` + +### Chunked Publishing + +For very large DataFrames: + +```python +# Split into chunks +chunk_size = 100000 +for i in range(0, len(large_df), chunk_size): + chunk = large_df.iloc[i:i+chunk_size] + + publish_pandas( + table_name="large_table", + df=chunk, + mode="append" if i > 0 else "replace", # Replace first, append rest + ) + + print(f"Published chunk {i//chunk_size + 1}") +``` + +### Query with Date Range + +```python +from datetime import datetime, timedelta + +# Query last 7 days +end_date = datetime.now() +start_date = end_date - timedelta(days=7) + +df = query_pandas_from_snowflake( + query=f""" + SELECT * + FROM events + WHERE date >= '{start_date.strftime('%Y-%m-%d')}' + AND date < '{end_date.strftime('%Y-%m-%d')}' + """ +) +``` + +### Error Handling + +```python +from metaflow import retry + +@retry(times=3) +def query_with_retry(): + """Query with automatic retries.""" + try: + df = query_pandas_from_snowflake( + query="SELECT * FROM sometimes_flaky_table", + ) + return df + except Exception as e: + print(f"⚠️ Query failed: {e}") + raise # Will trigger retry + +df = query_with_retry() +``` + +## API Reference + +### query_pandas_from_snowflake() + +Query Snowflake and return a pandas DataFrame. + +**Parameters:** +- `query` (str, optional): SQL query string +- `query_fpath` (str, optional): Path to SQL file +- `ctx` (dict, optional): Template variables for query +- `warehouse` (str, optional): Snowflake warehouse name +- `use_s3_stage` (bool): Use S3 staging for large results (default: False) +- `timeout_seconds` (int, optional): Query timeout in seconds + +**Returns:** `pandas.DataFrame` + +**Example:** +```python +df = query_pandas_from_snowflake( + query="SELECT * FROM my_table", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", +) +``` + +### publish_pandas() + +Publish a pandas DataFrame to Snowflake. + +**Parameters:** +- `table_name` (str): Target table name +- `df` (pd.DataFrame): DataFrame to publish +- `schema` (str, optional): Target schema (default: dev schema) +- `mode` (str): "replace", "append", or "fail" (default: "replace") +- `warehouse` (str, optional): Snowflake warehouse name +- `comment` (str, optional): Table comment + +**Returns:** None + +**Example:** +```python +publish_pandas( + table_name="my_results", + df=results_df, + schema="my_dev_schema", + mode="replace", +) +``` + +### publish() + +Query, transform, and publish in one call. + +**Parameters:** +- `query_fpath` (str): Path to query SQL file +- `ctx` (dict): Template variables +- `publish_query_fpath` (str): Path to publish SQL file +- `transform_fn` (callable, optional): Transformation function +- `comment` (str, optional): Table comment +- `warehouse` (str, optional): Snowflake warehouse name + +**Returns:** None + +**Example:** +```python +publish( + query_fpath="sql/query.sql", + ctx={"date": "2024-01-01"}, + publish_query_fpath="sql/publish.sql", + comment="Daily update", +) +``` + +## Performance Tips + +1. **Filter early**: Apply WHERE clauses in SQL, not pandas +2. **Select only needed columns**: Avoid `SELECT *` when possible +3. **Use S3 staging**: For datasets > 1GB +4. **Choose right warehouse**: Larger warehouse for larger datasets +5. **Optimize data types**: Use `int32`, `float32`, `category` to reduce memory + +## Common Patterns + +See [Common Patterns Guide](../guides/common_patterns.md) for more examples. + +## Troubleshooting + +See [Troubleshooting Guide](../guides/troubleshooting.md) for solutions to common issues. + +## Related Documentation + +- [BatchInferencePipeline](batch_inference_pipeline.md) - For very large datasets +- [S3 Integration](s3.md) - Direct S3 operations +- [Configuration Validation](validate_config.md) - Pydantic integration diff --git a/docs/metaflow/validate_config.md b/docs/metaflow/validate_config.md new file mode 100644 index 0000000..0ebe7bf --- /dev/null +++ b/docs/metaflow/validate_config.md @@ -0,0 +1,479 @@ +# Configuration Validation + +[← Back to Metaflow Docs](README.md) + +Use Pydantic for type-safe flow configuration. + +## Table of Contents + +- [Overview](#overview) +- [Basic Usage](#basic-usage) +- [Advanced Validation](#advanced-validation) +- [Best Practices](#best-practices) + +## Overview + +`make_pydantic_parser_fn` integrates Pydantic models with Metaflow Parameters for type-safe configuration: + +```python +from pydantic import BaseModel +from metaflow import FlowSpec, Parameter +from ds_platform_utils.metaflow import make_pydantic_parser_fn + +class FlowConfig(BaseModel): + start_date: str + end_date: str + threshold: float = 0.5 + +class MyFlow(FlowSpec): + config = Parameter( + 'config', + type=make_pydantic_parser_fn(FlowConfig), + default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}', + ) +``` + +## Basic Usage + +### Simple Configuration + +```python +from pydantic import BaseModel +from metaflow import FlowSpec, Parameter, step +from ds_platform_utils.metaflow import make_pydantic_parser_fn + +class Config(BaseModel): + """Flow configuration.""" + table_name: str + warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" + limit: int = 1000 + +class SimpleFlow(FlowSpec): + """Flow with validated config.""" + + config = Parameter( + 'config', + type=make_pydantic_parser_fn(Config), + default='{"table_name": "my_table"}', + help='JSON configuration' + ) + + @step + def start(self): + # Access validated config + print(f"Table: {self.config.table_name}") + print(f"Warehouse: {self.config.warehouse}") + print(f"Limit: {self.config.limit}") + self.next(self.end) + + @step + def end(self): + pass +``` + +**Run:** +```bash +# Use default config +python flow.py run + +# Override config +python flow.py run --config '{"table_name": "other_table", "limit": 5000}' +``` + +### Date Range Configuration + +```python +from pydantic import BaseModel, validator +from datetime import datetime + +class DateRangeConfig(BaseModel): + """Configuration with date validation.""" + start_date: str + end_date: str + + @validator('start_date', 'end_date') + def validate_date_format(cls, v): + """Ensure dates are in YYYY-MM-DD format.""" + try: + datetime.strptime(v, '%Y-%m-%d') + return v + except ValueError: + raise ValueError(f"Date must be in YYYY-MM-DD format, got: {v}") + + @validator('end_date') + def end_after_start(cls, v, values): + """Ensure end_date is after start_date.""" + if 'start_date' in values and v < values['start_date']: + raise ValueError("end_date must be after start_date") + return v + +class DateRangeFlow(FlowSpec): + config = Parameter( + 'config', + type=make_pydantic_parser_fn(DateRangeConfig), + default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}', + ) + + @step + def start(self): + print(f"Processing {self.config.start_date} to {self.config.end_date}") + self.next(self.end) + + @step + def end(self): + pass +``` + +**Run:** +```bash +# Valid +python flow.py run --config '{"start_date": "2024-01-01", "end_date": "2024-12-31"}' + +# Invalid - will fail validation +python flow.py run --config '{"start_date": "2024-12-31", "end_date": "2024-01-01"}' +# Error: end_date must be after start_date +``` + +## Advanced Validation + +### Nested Configuration + +```python +from pydantic import BaseModel +from typing import List + +class SnowflakeConfig(BaseModel): + """Snowflake settings.""" + warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" + database: str = "PATTERN_DB" + schema: str = "my_dev_schema" + +class ModelConfig(BaseModel): + """Model settings.""" + model_path: str + threshold: float = 0.5 + features: List[str] + +class FullConfig(BaseModel): + """Complete flow configuration.""" + snowflake: SnowflakeConfig + model: ModelConfig + debug: bool = False + +class AdvancedFlow(FlowSpec): + config = Parameter( + 'config', + type=make_pydantic_parser_fn(FullConfig), + default=''' + { + "snowflake": { + "warehouse": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", + "database": "PATTERN_DB", + "schema": "my_dev_schema" + }, + "model": { + "model_path": "models/my_model.pkl", + "threshold": 0.5, + "features": ["feature_1", "feature_2", "feature_3"] + }, + "debug": false + } + ''', + ) + + @step + def start(self): + print(f"Warehouse: {self.config.snowflake.warehouse}") + print(f"Model: {self.config.model.model_path}") + print(f"Features: {self.config.model.features}") + self.next(self.end) + + @step + def end(self): + pass +``` + +### Custom Validators + +```python +from pydantic import BaseModel, validator, root_validator + +class MLConfig(BaseModel): + """ML pipeline configuration.""" + training_start: str + training_end: str + inference_date: str + min_samples: int = 1000 + max_samples: int = 1_000_000 + + @validator('inference_date') + def inference_after_training(cls, v, values): + """Inference date must be after training period.""" + if 'training_end' in values and v <= values['training_end']: + raise ValueError("inference_date must be after training_end") + return v + + @validator('min_samples', 'max_samples') + def positive_samples(cls, v): + """Sample counts must be positive.""" + if v <= 0: + raise ValueError("Sample count must be positive") + return v + + @root_validator + def check_sample_range(cls, values): + """min_samples must be less than max_samples.""" + min_s = values.get('min_samples') + max_s = values.get('max_samples') + + if min_s and max_s and min_s >= max_s: + raise ValueError("min_samples must be less than max_samples") + + return values +``` + +### Enum Validation + +```python +from enum import Enum +from pydantic import BaseModel + +class Warehouse(str, Enum): + """Valid warehouse names.""" + XS = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH" + SMALL = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_S_WH" + MEDIUM = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" + LARGE = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_L_WH" + XL = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH" + +class QueryConfig(BaseModel): + """Query configuration.""" + query: str + warehouse: Warehouse # Only accepts valid warehouses + limit: int = 10000 + +# Valid +config = QueryConfig( + query="SELECT * FROM table", + warehouse=Warehouse.MEDIUM, # or "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" +) + +# Invalid - will fail +config = QueryConfig( + query="SELECT * FROM table", + warehouse="INVALID_WAREHOUSE", # Error! +) +``` + +## Best Practices + +### ✅ DO: Use Type Hints + +```python +from typing import List, Optional + +class Config(BaseModel): + # Clear types + features: List[str] + threshold: float + warehouse: Optional[str] = None # Explicitly optional +``` + +### ✅ DO: Provide Defaults + +```python +class Config(BaseModel): + # Sensible defaults + warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" + batch_size: int = 1000 + timeout: int = 3600 +``` + +### ✅ DO: Add Docstrings + +```python +class Config(BaseModel): + """Flow configuration. + + Attributes: + start_date: Start date in YYYY-MM-DD format + end_date: End date in YYYY-MM-DD format + warehouse: Snowflake warehouse name + """ + start_date: str + end_date: str + warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" +``` + +### ✅ DO: Validate Early + +```python +@validator('threshold') +def threshold_in_range(cls, v): + """Threshold must be between 0 and 1.""" + if not 0 <= v <= 1: + raise ValueError(f"threshold must be in [0, 1], got {v}") + return v +``` + +### ❌ DON'T: Over-validate + +```python +# ❌ Bad - too restrictive +class Config(BaseModel): + table_name: str + + @validator('table_name') + def specific_table(cls, v): + if v != "exactly_this_table": # Too rigid! + raise ValueError("Only specific table allowed") + return v + +# ✅ Good - validate format, not content +class Config(BaseModel): + table_name: str + + @validator('table_name') + def valid_table_name(cls, v): + if not v.replace('_', '').isalnum(): # Allow alphanumeric + underscore + raise ValueError("Invalid table name format") + return v +``` + +## Example: Production Configuration + +```python +from pydantic import BaseModel, validator, Field +from typing import List, Optional +from datetime import datetime +from enum import Enum + +class Environment(str, Enum): + """Deployment environment.""" + DEV = "dev" + STAGING = "staging" + PROD = "prod" + +class Schedule(BaseModel): + """Schedule configuration.""" + enabled: bool = True + cron: str = "0 2 * * *" # Daily at 2 AM + timezone: str = "UTC" + +class ProductionConfig(BaseModel): + """Production-ready flow configuration.""" + + # Environment + env: Environment = Environment.DEV + + # Data + start_date: str = Field(..., description="Start date (YYYY-MM-DD)") + end_date: str = Field(..., description="End date (YYYY-MM-DD)") + table_name: str = Field(..., description="Input table name") + + # Model + model_path: str = Field(..., description="Path to model file") + features: List[str] = Field(..., description="Feature columns") + threshold: float = Field(0.5, ge=0, le=1, description="Prediction threshold") + + # Snowflake + warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" + schema_override: Optional[str] = None + + # Performance + use_s3_stage: bool = True + parallel_workers: int = Field(10, ge=1, le=50) + batch_size_mb: int = Field(256, ge=64, le=512) + + # Monitoring + enable_alerts: bool = True + alert_email: Optional[str] = None + + # Schedule + schedule: Optional[Schedule] = None + + @validator('start_date', 'end_date') + def validate_date(cls, v): + """Validate date format.""" + try: + datetime.strptime(v, '%Y-%m-%d') + return v + except ValueError: + raise ValueError(f"Invalid date format: {v}") + + @validator('warehouse') + def validate_warehouse(cls, v, values): + """Validate warehouse based on environment.""" + env = values.get('env') + if env == Environment.PROD and 'DEV' in v: + raise ValueError("Cannot use DEV warehouse in PROD environment") + return v + + @root_validator + def validate_alerts(cls, values): + """If alerts enabled, email is required.""" + if values.get('enable_alerts') and not values.get('alert_email'): + raise ValueError("alert_email required when enable_alerts=true") + return values + +class ProductionFlow(FlowSpec): + config = Parameter( + 'config', + type=make_pydantic_parser_fn(ProductionConfig), + default='{"start_date": "2024-01-01", "end_date": "2024-12-31", "table_name": "input_data", "model_path": "model.pkl", "features": ["f1", "f2"]}', + ) + + @step + def start(self): + print(f"Environment: {self.config.env.value}") + print(f"Date range: {self.config.start_date} to {self.config.end_date}") + self.next(self.end) + + @step + def end(self): + pass +``` + +## Troubleshooting + +### Validation Error + +```bash +# Error: ValidationError +python flow.py run --config '{"invalid": "config"}' + +# Error message shows which fields are missing/invalid +ValidationError: 2 validation errors for ProductionConfig +start_date + field required (type=value_error.missing) +end_date + field required (type=value_error.missing) +``` + +**Solution**: Provide all required fields. + +### JSON Parsing Error + +```bash +# Error: Invalid JSON +python flow.py run --config '{"start_date": 2024-01-01}' # Missing quotes! + +# Fix: properly quote values +python flow.py run --config '{"start_date": "2024-01-01"}' +``` + +### Type Mismatch + +```python +# Error: wrong type +config = Config(threshold="0.5") # String, not float + +# Fix: use correct type +config = Config(threshold=0.5) # Float +``` + +## Related Documentation + +- [Best Practices](../guides/best_practices.md) +- [Common Patterns](../guides/common_patterns.md) +- [Pydantic Documentation](https://docs.pydantic.dev/) From 23a5559f8c7992bd4309274d0e274f139a94253d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:54:34 +0530 Subject: [PATCH 101/167] docs: update README and guides to reflect Outerbounds integration for automatic configuration management --- docs/README.md | 120 ++++++++++++--------- docs/api/index.md | 191 ++++++++++++++------------------- docs/guides/best_practices.md | 10 +- docs/guides/getting_started.md | 20 ++-- docs/guides/troubleshooting.md | 85 ++++----------- 5 files changed, 176 insertions(+), 250 deletions(-) diff --git a/docs/README.md b/docs/README.md index 7febb82..26e5ba5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,26 +59,14 @@ uv sync ## Configuration -### Environment Variables +**No manual configuration required!** -```bash -# Enable debug logging -export DEBUG=1 - -# Snowflake configuration (usually handled by Metaflow integration) -export SNOWFLAKE_ACCOUNT=your_account -export SNOWFLAKE_USER=your_user -export SNOWFLAKE_WAREHOUSE=your_warehouse -``` +This library integrates seamlessly with Outerbounds, which automatically handles all Snowflake and AWS configuration. Simply use the functions in your Metaflow flows, and Outerbounds takes care of: -### Metaflow Setup - -This library is designed to work seamlessly with Metaflow. Ensure your Metaflow configuration is properly set up: - -```bash -# Configure Metaflow with Outerbounds -metaflow configure aws -``` +- ✅ Snowflake authentication and connection management +- ✅ AWS credentials and S3 access +- ✅ Warehouse selection and optimization +- ✅ Query tagging for cost tracking ## Quick Start @@ -103,57 +91,83 @@ from ds_platform_utils.metaflow import publish_pandas publish_pandas( table_name="my_results_table", df=results_df, - auto_create_table=True, - overwrite=True, + schema="my_dev_schema", + mode="replace", ) ``` ### Example 3: Batch Inference Pipeline ```python +from metaflow import FlowSpec, step from ds_platform_utils.metaflow import BatchInferencePipeline -pipeline = BatchInferencePipeline() -pipeline.run( - input_query="SELECT * FROM features_table", - output_table_name="predictions_table", - predict_fn=my_model.predict, -) +class PredictionFlow(FlowSpec): + @step + def start(self): + pipeline = BatchInferencePipeline() + self.worker_ids = pipeline.query_and_batch( + input_query="SELECT * FROM features_table", + parallel_workers=10, + ) + self.next(self.predict, foreach='worker_ids') + + @step + def predict(self): + worker_id = self.input + pipeline = BatchInferencePipeline() + pipeline.process_batch( + worker_id=worker_id, + predict_fn=my_model.predict, + ) + self.next(self.join) + + @step + def join(self, inputs): + pipeline = BatchInferencePipeline() + pipeline.publish_results( + output_table="predictions", + output_schema="my_dev_schema", + ) + self.next(self.end) + + @step + def end(self): + pass ``` ## Architecture ``` ┌─────────────────────────────────────────────────────────┐ -│ ds-platform-utils │ +│ ds-platform-utils Library │ │ │ -│ ┌────────────────────────────────────────────────┐ │ -│ │ Metaflow Integration │ │ -│ │ │ │ -│ │ • BatchInferencePipeline │ │ -│ │ • Pandas Integration (query/publish) │ │ -│ │ • Write, Audit, Publish │ │ -│ │ • State Management │ │ -│ │ • Config Validation │ │ -│ └────────────────────────────────────────────────┘ │ +│ Public API (ds_platform_utils.metaflow) │ +│ • BatchInferencePipeline │ +│ • query_pandas_from_snowflake / publish_pandas │ +│ • publish (query + transform + publish) │ +│ • make_pydantic_parser_fn │ +│ • restore_step_state │ +└─────────────────┬───────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Outerbounds Platform │ +│ (Handles all configuration automatically) │ │ │ -│ ┌────────────────────────────────────────────────┐ │ -│ │ Data Operations │ │ -│ │ │ │ -│ │ • S3 File Operations │ │ -│ │ • S3 Stage Management │ │ -│ │ • Snowflake Connection │ │ -│ └────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ - │ - ┌─────────────────┴─────────────────┐ - │ │ - ▼ ▼ -┌───────────────┐ ┌───────────────┐ -│ Snowflake │ │ S3 │ -│ Database │◄─────────────────► Storage │ -│ │ S3 Stage Copy │ │ -└───────────────┘ └───────────────┘ +│ • Snowflake Authentication & Connections │ +│ • AWS Credentials & S3 Access │ +│ • Metaflow Orchestration │ +│ • Query Tagging & Cost Tracking │ +└─────────────────┬───────────────────────────────────────┘ + │ + ┌─────────┴─────────┐ + │ │ + ▼ ▼ +┌───────────────┐ ┌───────────────┐ +│ Snowflake │ │ S3 │ +│ Database │◄──►│ Storage │ +└───────────────┘ └───────────────┘ ``` ## Key Features diff --git a/docs/api/index.md b/docs/api/index.md index d7141c0..3302159 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -4,18 +4,40 @@ Complete API documentation for `ds-platform-utils`. -## Table of Contents +## Public API + +All public functions are exported from `ds_platform_utils.metaflow`: + +```python +from ds_platform_utils.metaflow import ( + BatchInferencePipeline, # Scalable batch inference + make_pydantic_parser_fn, # Config validation + publish, # Query, transform, and publish + publish_pandas, # Publish DataFrame to Snowflake + query_pandas_from_snowflake, # Query from Snowflake to DataFrame + restore_step_state, # Restore flow state for debugging +) +``` -- [Metaflow Utilities](#metaflow-utilities) -- [Snowflake Utilities](#snowflake-utilities) +## Table of Contents -## Metaflow Utilities +- [Query Functions](#query-functions) + - [query_pandas_from_snowflake()](#query_pandas_from_snowflake) +- [Publish Functions](#publish-functions) + - [publish_pandas()](#publish_pandas) + - [publish()](#publish) +- [Batch Processing](#batch-processing) + - [BatchInferencePipeline](#batchinferencepipeline) +- [Configuration](#configuration) + - [make_pydantic_parser_fn()](#make_pydantic_parser_fn) +- [State Management](#state-management) + - [restore_step_state()](#restore_step_state) -Located in `ds_platform_utils.metaflow` +--- -### Query Functions +## Query Functions -#### `query_pandas_from_snowflake()` +### `query_pandas_from_snowflake()` Query Snowflake and return a pandas DataFrame. @@ -76,9 +98,9 @@ df = query_pandas_from_snowflake( --- -### Publish Functions +## Publish Functions -#### `publish_pandas()` +### `publish_pandas()` Publish a pandas DataFrame to Snowflake. @@ -142,7 +164,7 @@ publish_pandas( --- -#### `publish()` +### `publish()` Query, optionally transform, and publish in one call. @@ -186,6 +208,8 @@ publish( --- +## Batch Processing + ### BatchInferencePipeline Class for large-scale batch inference with parallel processing. @@ -304,9 +328,9 @@ pipeline.publish_results( --- -### Configuration Validation +## Configuration -#### `make_pydantic_parser_fn()` +### `make_pydantic_parser_fn()` Create a parser function for Pydantic model validation in Metaflow Parameters. @@ -347,131 +371,72 @@ class MyFlow(FlowSpec): --- -### Utility Functions - -#### `add_query_tags()` - -Add metadata tags to SQL queries for tracking. - -**Signature:** -```python -def add_query_tags( - query: str, - flow_name: str, - step_name: str, -) -> str -``` - -**Parameters:** -- `query` (str): SQL query -- `flow_name` (str): Metaflow flow name -- `step_name` (str): Metaflow step name - -**Returns:** -- `str`: Query with tags prepended - -**Example:** -```python -tagged_query = add_query_tags( - query="SELECT * FROM my_table", - flow_name="MyFlow", - step_name="query_data", -) -``` +## State Management -#### `restore_step_state()` +### `restore_step_state()` -Restore Metaflow step state for debugging. +Restore Metaflow step state for debugging and development. **Signature:** ```python -@contextmanager def restore_step_state( - flow_name: str, - run_id: str, - step: str, -) -> Generator[None, None, None] + flow_class: Optional[type[FlowSpec]] = None, + flow_name: Optional[str] = None, + step_name: str = "end", + flow_run_id: Union[Literal["latest_successful_run", "latest"], str] = "latest_successful_run", + secrets: Optional[list[str]] = None, + namespace: Optional[str] = None, +) -> FlowSpec ``` **Parameters:** -- `flow_name` (str): Flow name -- `run_id` (str): Run ID -- `step` (str): Step name +- `flow_class` (type[FlowSpec], optional): Flow class for type hints and autocompletion +- `flow_name` (str, optional): Flow name (defaults to flow_class name if provided) +- `step_name` (str, default="end"): Step to restore state from (restores from step before this) +- `flow_run_id` (str, default="latest_successful_run"): Run ID to restore: + - `"latest_successful_run"`: Latest successful run + - `"latest"`: Latest run (even if failed) + - Or specific run ID +- `secrets` (list[str], optional): Secrets to export as environment variables +- `namespace` (str, optional): Metaflow namespace to filter runs -**Yields:** Context with restored step state +**Returns:** +- `FlowSpec`: Restored flow state with access to all step artifacts **Example:** ```python from ds_platform_utils.metaflow import restore_step_state +from my_flows import MyPredictionFlow -with restore_step_state("MyFlow", run_id="123", step="process"): - # Access self.df from that step - print(self.df.head()) -``` - ---- - -## Snowflake Utilities - -Located in `ds_platform_utils._snowflake` - -### Connection Management - -#### `get_snowflake_connection()` +# Restore state from latest successful run +self = restore_step_state( + MyPredictionFlow, + step_name="process", + secrets=["outerbounds.my-secret"], +) -Get a Snowflake connection cursor. +# Now you can access step artifacts +print(self.df.head()) +print(self.config) -**Signature:** -```python -def get_snowflake_connection() -> snowflake.connector.cursor.SnowflakeCursor +# Debug or test step logic +result = process_data(self.df) ``` -**Returns:** -- `SnowflakeCursor`: Snowflake cursor for executing queries - -**Example:** -```python -from ds_platform_utils._snowflake import get_snowflake_connection +**Use Cases:** +- 🐛 **Debugging**: Inspect data and artifacts from failed runs +- 🧪 **Testing**: Test step logic without running entire flow +- 📊 **Analysis**: Explore intermediate results +- 🔄 **Development**: Iterate on step logic quickly -cursor = get_snowflake_connection() -cursor.execute("SELECT * FROM my_table") -results = cursor.fetchall() -``` +**See Also:** +- [Common Patterns](../guides/common_patterns.md) --- -### Write Audit Publish - -#### `write_audit_publish()` - -Execute SQL with audit logging and publish to target table. - -**Signature:** -```python -def write_audit_publish( - sql: str, - warehouse: Optional[str] = None, - comment: Optional[str] = None, -) -> None -``` - -**Parameters:** -- `sql` (str): SQL statement to execute -- `warehouse` (str, optional): Snowflake warehouse -- `comment` (str, optional): Audit comment - -**Returns:** None - -**Example:** -```python -from ds_platform_utils._snowflake import write_audit_publish +## Snowflake Utilities -write_audit_publish( - sql="CREATE OR REPLACE TABLE my_table AS SELECT * FROM source", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", - comment="Daily refresh", -) -``` +**Note:** The `ds_platform_utils._snowflake` module is private and not intended for direct use. All Snowflake operations should go through the public Metaflow utilities above. --- diff --git a/docs/guides/best_practices.md b/docs/guides/best_practices.md index 5583610..5d8831d 100644 --- a/docs/guides/best_practices.md +++ b/docs/guides/best_practices.md @@ -252,15 +252,7 @@ def validate_date(date_str: str) -> str: start_date = validate_date(self.config.start_date) ``` -### ❌ DON'T: Hard-code Credentials - -```python -# ❌ Bad - credentials in code -SNOWFLAKE_PASSWORD = "my_password" - -# ✅ Good - use Metaflow's integration -# Credentials are automatically handled by Metaflow -``` +**Note:** Outerbounds automatically handles all credentials and authentication, so you never need to manage Snowflake passwords or AWS keys. Just use the library functions directly. ## Testing diff --git a/docs/guides/getting_started.md b/docs/guides/getting_started.md index a4c202c..e2ea9fb 100644 --- a/docs/guides/getting_started.md +++ b/docs/guides/getting_started.md @@ -6,19 +6,13 @@ This guide will help you get started with `ds-platform-utils` for building ML wo ## Prerequisites -Before you begin, ensure you have: - -1. **Metaflow installed and configured** - ```bash - pip install metaflow - metaflow configure aws - ``` - -2. **Access to Pattern's Snowflake account** - - You should be able to connect through Metaflow's Snowflake integration - -3. **AWS credentials configured** - - Metaflow will handle S3 access through your configured AWS profile +**Metaflow with Outerbounds** - That's it! Outerbounds automatically handles: +- ✅ Snowflake authentication and connections +- ✅ AWS credentials and S3 access +- ✅ Warehouse management +- ✅ Query tagging for cost tracking + +No manual configuration required. ## Installation diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 71bb1a8..1202b35 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -16,53 +16,24 @@ Solutions to common issues and error messages. ## Snowflake Connection Issues -### Error: "Snowflake connector not configured" - -**Cause**: Metaflow's Snowflake connector is not set up. - -**Solution**: Use Metaflow's built-in Snowflake integration: - -```python -from metaflow import FlowSpec, step -from ds_platform_utils.metaflow.get_snowflake_connection import get_snowflake_connection - -class MyFlow(FlowSpec): - - @step - def start(self): - # Use Metaflow's connection - cursor = get_snowflake_connection() - # Your code... -``` - -### Error: "Authentication failed" - -**Cause**: Snowflake credentials not available or expired. - -**Solution**: -```bash -# Check your Snowflake connection -snowsql -c my_connection - -# If using SSO, re-authenticate -snowsql -a -u --authenticator externalbrowser -``` +**Note:** Outerbounds automatically handles all Snowflake authentication and connections. If you're seeing connection issues, contact your platform team. ### Error: "Warehouse does not exist" **Cause**: Wrong warehouse name or no access. -**Solution**: Verify warehouse name: +**Solution**: Use one of the standard warehouse names: + ```python -# Check available warehouses -cursor = get_snowflake_connection() -cursor.execute("SHOW WAREHOUSES") -print(cursor.fetchall()) +# Development warehouses +"OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH" # Extra small +"OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" # Medium (default) +"OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH" # Extra large -# Use correct warehouse name (case-sensitive!) +# Example usage query_pandas_from_snowflake( query="SELECT * FROM table", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", # Correct name + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", ) ``` @@ -72,21 +43,14 @@ query_pandas_from_snowflake( **Cause**: Table/view not found or no access. -**Solution**: +**Solution**: Verify table path and permissions: ```python -# Check table exists -cursor = get_snowflake_connection() -cursor.execute(""" - SHOW TABLES LIKE 'my_table' IN SCHEMA my_database.my_schema -""") -print(cursor.fetchall()) - -# Check you have access -cursor.execute(""" - SELECT * FROM my_database.my_schema.my_table - LIMIT 1 -""") +# Check table exists in Snowflake UI or verify the full path +# Format: database.schema.table_name +df = query_pandas_from_snowflake( + query="SELECT * FROM pattern_db.data_science.my_table LIMIT 10" +) ``` ### Error: "Template variable not provided" @@ -242,19 +206,16 @@ def process(self): ## S3 Staging Issues -### Error: "S3 upload failed" - -**Cause**: No S3 access or wrong permissions. +**Note:** Outerbounds automatically handles all S3 access and permissions. -**Solution**: Check S3 configuration: +### Error: "S3 upload failed" -```bash -# Check AWS credentials -aws sts get-caller-identity +**Cause**: Temporary S3 issue or permissions problem. -# Test S3 access -aws s3 ls s3://your-bucket/ -``` +**Solution**: +1. Retry the operation - transient S3 issues usually resolve +2. If persistent, contact your platform team +3. Check Metaflow logs for specific error details ### Error: "Slow performance with S3 staging" @@ -582,7 +543,7 @@ If you're still stuck: | Error Message | Likely Cause | Solution | | ----------------------------------- | ----------------------- | ---------------------------- | | "Object does not exist" | Table/schema name wrong | Check table path | -| "Authentication failed" | Credentials expired | Re-authenticate | +| "Warehouse does not exist" | Wrong warehouse name | Use standard warehouse names | | "MemoryError" | DataFrame too large | Use S3 staging or chunks | | "Timeout exceeded" | Query too slow | Optimize query or warehouse | | "Permission denied" | No write access | Use dev schema | From dbc27d471cf99f6c2d02cd6021bd08aacfb4470a Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 23 Feb 2026 12:17:24 +0530 Subject: [PATCH 102/167] Refactor Snowflake integration and documentation - Updated function signatures in the API documentation to use `query` as a unified parameter for SQL queries and file paths. - Enhanced `publish_pandas` and `publish` functions to support automatic table creation and overwriting options. - Revised examples and best practices to reflect the new function signatures and parameters. - Added detailed notes on the write-audit-publish pattern in the Snowflake utilities documentation. - Introduced a new Snowflake utilities documentation section for low-level operations and best practices. - Improved error handling and schema management explanations in the troubleshooting guide. --- docs/README.md | 45 +++-- docs/api/index.md | 265 ++++++++++++++++++------------ docs/examples/README.md | 26 ++- docs/guides/best_practices.md | 9 +- docs/guides/common_patterns.md | 31 ++-- docs/guides/troubleshooting.md | 35 ++-- docs/metaflow/README.md | 20 ++- docs/metaflow/pandas.md | 201 +++++++++++++++-------- docs/snowflake/README.md | 291 +++++++++++++++++++++++++++++++++ 9 files changed, 679 insertions(+), 244 deletions(-) create mode 100644 docs/snowflake/README.md diff --git a/docs/README.md b/docs/README.md index 26e5ba5..03e3eae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,12 @@ Comprehensive documentation for Pattern's data science platform utilities. ### Core Modules +**[Snowflake Utilities](snowflake/README.md)** +- Query execution and connection management +- Write-audit-publish pattern for data quality +- Schema management (dev/prod separation) +- Integrated with Outerbounds for automatic authentication + **[Metaflow Utilities](metaflow/README.md)** - [BatchInferencePipeline](metaflow/batch_inference_pipeline.md) - Scalable batch inference orchestration - [Pandas Integration](metaflow/pandas.md) - Query and publish functions for Snowflake @@ -91,8 +97,8 @@ from ds_platform_utils.metaflow import publish_pandas publish_pandas( table_name="my_results_table", df=results_df, - schema="my_dev_schema", - mode="replace", + auto_create_table=True, + overwrite=True, ) ``` @@ -105,8 +111,8 @@ from ds_platform_utils.metaflow import BatchInferencePipeline class PredictionFlow(FlowSpec): @step def start(self): - pipeline = BatchInferencePipeline() - self.worker_ids = pipeline.query_and_batch( + self.pipeline = BatchInferencePipeline() + self.worker_ids = self.pipeline.query_and_batch( input_query="SELECT * FROM features_table", parallel_workers=10, ) @@ -115,8 +121,7 @@ class PredictionFlow(FlowSpec): @step def predict(self): worker_id = self.input - pipeline = BatchInferencePipeline() - pipeline.process_batch( + self.pipeline.process_batch( worker_id=worker_id, predict_fn=my_model.predict, ) @@ -124,10 +129,9 @@ class PredictionFlow(FlowSpec): @step def join(self, inputs): - pipeline = BatchInferencePipeline() - pipeline.publish_results( - output_table="predictions", - output_schema="my_dev_schema", + self.pipeline = inputs[0].pipeline + self.pipeline.publish_results( + output_table_name="predictions", ) self.next(self.end) @@ -142,12 +146,21 @@ class PredictionFlow(FlowSpec): ┌─────────────────────────────────────────────────────────┐ │ ds-platform-utils Library │ │ │ -│ Public API (ds_platform_utils.metaflow) │ -│ • BatchInferencePipeline │ -│ • query_pandas_from_snowflake / publish_pandas │ -│ • publish (query + transform + publish) │ -│ • make_pydantic_parser_fn │ -│ • restore_step_state │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Public API (ds_platform_utils.metaflow) │ │ +│ │ • BatchInferencePipeline │ │ +│ │ • query_pandas_from_snowflake / publish_pandas │ │ +│ │ • publish (query + transform + publish) │ │ +│ │ • make_pydantic_parser_fn │ │ +│ │ • restore_step_state │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Snowflake Utilities (_snowflake) │ │ +│ │ • Query execution (_execute_sql) │ │ +│ │ • Write-audit-publish pattern │ │ +│ │ • Schema management (dev/prod) │ │ +│ └─────────────────────────────────────────────────┘ │ └─────────────────┬───────────────────────────────────────┘ │ ▼ diff --git a/docs/api/index.md b/docs/api/index.md index 3302159..375b84e 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -44,31 +44,27 @@ Query Snowflake and return a pandas DataFrame. **Signature:** ```python def query_pandas_from_snowflake( - query: Optional[str] = None, - query_fpath: Optional[str] = None, - ctx: Optional[Dict[str, Any]] = None, + query: Union[str, Path], warehouse: Optional[str] = None, + ctx: Optional[Dict[str, Any]] = None, + use_utc: bool = True, use_s3_stage: bool = False, - timeout_seconds: Optional[int] = None, ) -> pd.DataFrame ``` **Parameters:** -- `query` (str, optional): SQL query string. Mutually exclusive with `query_fpath`. -- `query_fpath` (str, optional): Path to SQL file containing query. Mutually exclusive with `query`. +- `query` (str | Path): SQL query string or path to a .sql file. +- `warehouse` (str, optional): Snowflake warehouse name. Defaults to `OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH` in dev or `OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_XS_WH` in production. - `ctx` (dict, optional): Template variables for query substitution using `{{variable}}` syntax. -- `warehouse` (str, optional): Snowflake warehouse name. If not provided, uses default from connection. -- `use_s3_stage` (bool, default=False): Use S3 staging for large results (recommended for > 1GB). -- `timeout_seconds` (int, optional): Query timeout in seconds. +- `use_utc` (bool, default=True): Whether to set Snowflake session to UTC timezone. +- `use_s3_stage` (bool, default=False): Use S3 staging for large results (more efficient for > 1GB). **Returns:** -- `pd.DataFrame`: Query results as pandas DataFrame +- `pd.DataFrame`: Query results as pandas DataFrame (column names lowercased) -**Raises:** -- `ValueError`: If neither `query` nor `query_fpath` provided, or if both provided. -- `FileNotFoundError`: If `query_fpath` does not exist. -- `SnowflakeQueryError`: If query execution fails. -- `TimeoutError`: If query exceeds timeout. +**Notes:** +- If the query contains `{{schema}}` placeholders, they will be replaced with the appropriate schema (prod or dev). +- Query tags are automatically added for cost tracking in select.dev. **Example:** ```python @@ -80,7 +76,7 @@ df = query_pandas_from_snowflake( # From SQL file with template variables df = query_pandas_from_snowflake( - query_fpath="sql/extract.sql", + query="sql/extract.sql", ctx={"start_date": "2024-01-01", "end_date": "2024-12-31"}, ) @@ -109,52 +105,71 @@ Publish a pandas DataFrame to Snowflake. def publish_pandas( table_name: str, df: pd.DataFrame, - schema: Optional[str] = None, - mode: str = "replace", + add_created_date: bool = False, + chunk_size: Optional[int] = None, + compression: Literal["snappy", "gzip"] = "snappy", warehouse: Optional[str] = None, - comment: Optional[str] = None, + parallel: int = 4, + quote_identifiers: bool = True, + auto_create_table: bool = False, + overwrite: bool = False, + use_logical_type: bool = True, + use_utc: bool = True, + use_s3_stage: bool = False, + table_definition: Optional[List[Tuple[str, str]]] = None, ) -> None ``` **Parameters:** -- `table_name` (str): Target table name (without schema). +- `table_name` (str): Name of the table to create (automatically uppercased). - `df` (pd.DataFrame): DataFrame to publish. -- `schema` (str, optional): Target schema. If not provided, uses default dev schema. -- `mode` (str, default="replace"): Write mode: - - `"replace"`: Drop and recreate table - - `"append"`: Append to existing table - - `"fail"`: Fail if table exists +- `add_created_date` (bool, default=False): Add a `created_date` column with current UTC timestamp. +- `chunk_size` (int, optional): Number of rows per insert batch. Default: all rows at once. +- `compression` (str, default="snappy"): Parquet compression: `"snappy"` or `"gzip"`. - `warehouse` (str, optional): Snowflake warehouse name. -- `comment` (str, optional): Table comment for documentation. +- `parallel` (int, default=4): Number of threads for uploading chunks. +- `quote_identifiers` (bool, default=True): Quote column/table names (preserve case). +- `auto_create_table` (bool, default=False): Auto-create table if it doesn't exist. +- `overwrite` (bool, default=False): Drop/truncate existing table before writing. +- `use_logical_type` (bool, default=True): Use Parquet logical types for timestamps. +- `use_utc` (bool, default=True): Set Snowflake session to UTC timezone. +- `use_s3_stage` (bool, default=False): Use S3 staging (more efficient for large DataFrames). +- `table_definition` (list, optional): Column schema as `[(col_name, col_type), ...]` for S3 staging. **Returns:** None -**Raises:** -- `ValueError`: If DataFrame is empty or invalid mode. -- `SnowflakeError`: If publish operation fails. -- `PermissionError`: If no write access to schema. +**Notes:** +- Schema is automatically selected: prod schema in production, dev schema otherwise. +- Table name is automatically uppercased for Snowflake standardization. **Example:** ```python -# Basic publish +# Basic publish with auto-create publish_pandas( table_name="my_results", df=results_df, - schema="my_dev_schema", + auto_create_table=True, + overwrite=True, ) -# Append mode +# Large DataFrame via S3 staging publish_pandas( - table_name="incremental_data", - df=new_data_df, - mode="append", + table_name="large_table", + df=large_df, + use_s3_stage=True, + table_definition=[ + ("id", "NUMBER"), + ("name", "STRING"), + ("score", "FLOAT"), + ], ) -# With comment +# With timestamp tracking publish_pandas( table_name="features", df=features_df, - comment="Daily feature refresh - 2024-01-15", + add_created_date=True, + auto_create_table=True, ) ``` @@ -166,43 +181,53 @@ publish_pandas( ### `publish()` -Query, optionally transform, and publish in one call. +Publish a Snowflake table using the write-audit-publish (WAP) pattern. **Signature:** ```python def publish( - query_fpath: str, - ctx: Dict[str, Any], - publish_query_fpath: str, - transform_fn: Optional[Callable[[pd.DataFrame], pd.DataFrame]] = None, - comment: Optional[str] = None, + table_name: str, + query: Union[str, Path], + audits: Optional[List[Union[str, Path]]] = None, + ctx: Optional[Dict[str, Any]] = None, warehouse: Optional[str] = None, + use_utc: bool = True, ) -> None ``` **Parameters:** -- `query_fpath` (str): Path to SQL file for querying data. -- `ctx` (dict): Template variables for both query and publish SQL. -- `publish_query_fpath` (str): Path to SQL file for publishing. -- `transform_fn` (callable, optional): Function to transform DataFrame between query and publish. -- `comment` (str, optional): Table comment. +- `table_name` (str): Name of the Snowflake table to publish (e.g., `"OUT_OF_STOCK_ADS"`). +- `query` (str | Path): SQL query string or path to .sql file that generates the table data. +- `audits` (list, optional): SQL audit scripts or file paths that validate data quality. Each script should return zero rows for success. +- `ctx` (dict, optional): Template variables for SQL substitution. - `warehouse` (str, optional): Snowflake warehouse name. +- `use_utc` (bool, default=True): Whether to use UTC timezone for the Snowflake connection. **Returns:** None +**Notes:** +- Uses the write-audit-publish pattern: write to temp table → run audits → promote to final table. +- Query tags are automatically added for cost tracking in select.dev. +- Schema is automatically selected based on production/dev environment. + **Example:** ```python -def add_features(df: pd.DataFrame) -> pd.DataFrame: - """Add engineered features.""" - df['new_feature'] = df['value'] * 2 - return df +# Simple publish +publish( + table_name="OUT_OF_STOCK_ADS", + query="sql/create_training_data.sql", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", +) +# With audits for data validation publish( - query_fpath="sql/extract.sql", - ctx={"start_date": "2024-01-01", "end_date": "2024-12-31"}, - transform_fn=add_features, - publish_query_fpath="sql/publish.sql", - comment="Daily feature engineering", + table_name="DAILY_FEATURES", + query="sql/create_features.sql", + audits=[ + "sql/audit_row_count.sql", + "sql/audit_null_check.sql", + ], + ctx={"date": "2024-01-01"}, ) ``` @@ -230,35 +255,36 @@ Query input data and split into batches for parallel processing. ```python def query_and_batch( self, - input_query: str, - batch_size_in_mb: int = 256, - parallel_workers: int = 10, - warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", + input_query: Union[str, Path], + ctx: Optional[dict] = None, + warehouse: Optional[str] = None, + use_utc: bool = True, + parallel_workers: int = 1, ) -> List[int] ``` **Parameters:** -- `input_query` (str): SQL query to fetch input data -- `batch_size_in_mb` (int, default=256): Target size per batch file in MB -- `parallel_workers` (int, default=10): Number of parallel workers -- `warehouse` (str): Snowflake warehouse name +- `input_query` (str | Path): SQL query string or file path to query +- `ctx` (dict, optional): Dict of variable substitutions for SQL template (e.g., `{{schema}}`) +- `warehouse` (str, optional): Snowflake warehouse name +- `use_utc` (bool, default=True): Whether to use UTC timezone for Snowflake +- `parallel_workers` (int, default=1): Number of parallel workers to use for processing **Returns:** -- `List[int]`: Worker IDs for use in `process_batch()` +- `List[int]`: Worker IDs to use with `foreach` in next step **Example:** ```python pipeline = BatchInferencePipeline() worker_ids = pipeline.query_and_batch( input_query="SELECT * FROM large_input", - batch_size_in_mb=256, - parallel_workers=20, + parallel_workers=10, ) ``` ##### `process_batch()` -Process a single batch with predictions. +Process a single batch with predictions using a queue-based 3-thread pipeline. **Signature:** ```python @@ -266,18 +292,19 @@ def process_batch( self, worker_id: int, predict_fn: Callable[[pd.DataFrame], pd.DataFrame], - batch_size_in_mb: int = 64, - timeout_per_batch: int = 3600, -) -> None + batch_size_in_mb: int = 128, + timeout_per_batch: int = 300, +) -> str ``` **Parameters:** - `worker_id` (int): Worker ID from `query_and_batch()` -- `predict_fn` (callable): Function that takes DataFrame and returns predictions -- `batch_size_in_mb` (int, default=64): Batch size for processing -- `timeout_per_batch` (int, default=3600): Timeout per batch in seconds +- `predict_fn` (callable): Function that takes DataFrame and returns predictions DataFrame +- `batch_size_in_mb` (int, default=128): Target size for each batch in MB +- `timeout_per_batch` (int, default=300): Timeout in seconds for each batch operation -**Returns:** None +**Returns:** +- `str`: S3 path where predictions were written **Example:** ```python @@ -294,22 +321,28 @@ pipeline.process_batch( ##### `publish_results()` -Publish all processed results to Snowflake. +Publish all processed results to Snowflake (call this in join step). **Signature:** ```python def publish_results( self, - output_table: str, - output_schema: str, - warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", + output_table_name: str, + output_table_definition: Optional[List[Tuple[str, str]]] = None, + auto_create_table: bool = True, + overwrite: bool = True, + warehouse: Optional[str] = None, + use_utc: bool = True, ) -> None ``` **Parameters:** -- `output_table` (str): Target table name -- `output_schema` (str): Target schema -- `warehouse` (str): Snowflake warehouse name +- `output_table_name` (str): Name of the Snowflake table +- `output_table_definition` (list, optional): Schema as list of `(column, type)` tuples +- `auto_create_table` (bool, default=True): Whether to auto-create table if not exists +- `overwrite` (bool, default=True): Whether to overwrite existing data +- `warehouse` (str, optional): Snowflake warehouse name +- `use_utc` (bool, default=True): Whether to use UTC timezone for Snowflake **Returns:** None @@ -317,8 +350,17 @@ def publish_results( ```python pipeline = BatchInferencePipeline() pipeline.publish_results( - output_table="predictions", - output_schema="my_dev_schema", + output_table_name="predictions", +) + +# With custom schema +pipeline.publish_results( + output_table_name="predictions", + output_table_definition=[ + ("id", "NUMBER"), + ("score", "FLOAT"), + ("prediction", "STRING"), + ], ) ``` @@ -332,38 +374,48 @@ pipeline.publish_results( ### `make_pydantic_parser_fn()` -Create a parser function for Pydantic model validation in Metaflow Parameters. +Create a parser function for Pydantic model validation in Metaflow Config. **Signature:** ```python def make_pydantic_parser_fn( - model_class: Type[BaseModel] -) -> Callable[[str], BaseModel] + pydantic_model: type[BaseModel] +) -> Callable[[str], dict] ``` **Parameters:** -- `model_class` (Type[BaseModel]): Pydantic model class +- `pydantic_model` (type[BaseModel]): Pydantic model class for validation **Returns:** -- `Callable[[str], BaseModel]`: Parser function for Metaflow Parameter +- `Callable[[str], dict]`: Parser function that validates and returns a dict + +**Notes:** +- Supports JSON, TOML, and YAML config formats +- YAML is preferred because it supports comments +- Returns a dict with default values applied after validation **Example:** ```python -from pydantic import BaseModel -from metaflow import FlowSpec, Parameter +from pydantic import BaseModel, Field +from metaflow import FlowSpec, step, Config from ds_platform_utils.metaflow import make_pydantic_parser_fn -class Config(BaseModel): - start_date: str - end_date: str +class PydanticFlowConfig(BaseModel): + \"\"\"Validate and provide autocompletion for config values.\"\"\" + n_rows: int = Field(ge=1) threshold: float = 0.5 class MyFlow(FlowSpec): - config = Parameter( - 'config', - type=make_pydantic_parser_fn(Config), - default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}', - ) + config: PydanticFlowConfig = Config( + name="config", + default="./configs/default.yaml", + parser=make_pydantic_parser_fn(PydanticFlowConfig) + ) # type: ignore[assignment] + + @step + def start(self): + print(f"{self.config.n_rows=}") + self.next(self.end) ``` **See Also:** @@ -436,7 +488,13 @@ result = process_data(self.df) ## Snowflake Utilities -**Note:** The `ds_platform_utils._snowflake` module is private and not intended for direct use. All Snowflake operations should go through the public Metaflow utilities above. +The `ds_platform_utils._snowflake` module contains lower-level utilities for Snowflake operations: +- `_execute_sql()` - Execute SQL statements with batch support +- `write_audit_publish()` - Implement write-audit-publish pattern + +**Note:** This module is marked as private (underscore prefix) because its APIs may change. Most users should use the high-level Metaflow utilities above. + +**For Advanced Users:** If you need direct access to these utilities for custom workflows, see the [Snowflake Utilities Documentation](../snowflake/README.md). --- @@ -481,4 +539,5 @@ Raised when configuration validation fails (from Pydantic). - [Getting Started Guide](../guides/getting_started.md) - [Best Practices](../guides/best_practices.md) - [Common Patterns](../guides/common_patterns.md) -- [Module-Specific Docs](../metaflow/README.md) +- [Metaflow Utilities](../metaflow/README.md) +- [Snowflake Utilities](../snowflake/README.md) diff --git a/docs/examples/README.md b/docs/examples/README.md index a279ddb..8b4acff 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -73,8 +73,8 @@ class SimplePipeline(FlowSpec): publish_pandas( table_name="user_monthly_spending", df=self.results, - schema="my_dev_schema", - comment="Monthly user spending aggregates", + auto_create_table=True, + overwrite=True, ) print("✅ Done!") self.next(self.end) @@ -165,7 +165,7 @@ class FeaturePipeline(FlowSpec): print(f"Extracting features from {self.config.start_date} to {self.config.end_date}") self.df = query_pandas_from_snowflake( - query_fpath="sql/extract_raw_features.sql", + query="sql/extract_raw_features.sql", ctx={ "start_date": self.config.start_date, "end_date": self.config.end_date, @@ -208,8 +208,8 @@ class FeaturePipeline(FlowSpec): publish_pandas( table_name="ml_features", df=self.df, - schema="my_dev_schema", - comment=f"Features for {self.config.start_date} to {self.config.end_date}", + auto_create_table=True, + overwrite=True, ) print(f"✅ Published {len(self.df):,} rows with {len(self.df.columns)} columns") self.next(self.end) @@ -324,10 +324,9 @@ class LargeScaleInference(FlowSpec): """Collect results and publish.""" print(f"All {len(inputs)} batches processed, publishing results...") - pipeline = BatchInferencePipeline() - pipeline.publish_results( - output_table="user_predictions", - output_schema="my_dev_schema", + self.pipeline = inputs[0].pipeline + self.pipeline.publish_results( + output_table_name="user_predictions", warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", ) @@ -421,9 +420,8 @@ class IncrementalPipeline(FlowSpec): publish_pandas( table_name="processed_events", df=self.df, - schema="my_dev_schema", - mode="append", # ← Append instead of replace - comment=f"Incremental load for {self.date}", + auto_create_table=False, # Table must exist + overwrite=False, # Append instead of replace ) print(f"✅ Appended {len(self.df):,} rows for {self.date}") @@ -545,8 +543,8 @@ class MultiTableJoin(FlowSpec): publish_pandas( table_name="enriched_user_data", df=self.enriched_df, - schema="my_dev_schema", - comment="Enriched user data from multiple sources", + auto_create_table=True, + overwrite=True, ) print(f"✅ Published {len(self.enriched_df):,} rows") self.next(self.end) diff --git a/docs/guides/best_practices.md b/docs/guides/best_practices.md index 5d8831d..fababe4 100644 --- a/docs/guides/best_practices.md +++ b/docs/guides/best_practices.md @@ -44,9 +44,10 @@ class MyFlow(FlowSpec): ```python # ✅ Good - SQL in separate file publish( - query_fpath="sql/create_features.sql", + table_name="ml_features", + query="sql/create_features.sql", + audits=["sql/audit_row_count.sql"], ctx={"start_date": self.config.start_date}, - publish_query_fpath="sql/publish_features.sql", ) # ❌ Bad - SQL in Python strings @@ -223,11 +224,11 @@ df = query_pandas_from_snowflake( ```python # ✅ Good - prevents SQL injection ctx = { - "table_name": "my_table", "start_date": user_input_date, } publish( - query_fpath="sql/query.sql", # Uses {{table_name}}, {{start_date}} + table_name="my_results", + query="sql/query.sql", # Uses {{start_date}} ctx=ctx, ) diff --git a/docs/guides/common_patterns.md b/docs/guides/common_patterns.md index 5d56416..92e1978 100644 --- a/docs/guides/common_patterns.md +++ b/docs/guides/common_patterns.md @@ -46,7 +46,8 @@ class SimpleFlow(FlowSpec): publish_pandas( table_name="output_table", df=self.df, - schema="my_dev_schema", + auto_create_table=True, + overwrite=True, ) self.next(self.end) @@ -84,7 +85,7 @@ class ParameterizedFlow(FlowSpec): def start(self): """Query with parameters.""" self.df = query_pandas_from_snowflake( - query_fpath="sql/extract_data.sql", + query="sql/extract_data.sql", ctx={ "start_date": self.config.start_date, "end_date": self.config.end_date, @@ -138,10 +139,10 @@ class FeatureEngineeringFlow(FlowSpec): def start(self): """Create features and publish.""" publish( - query_fpath="sql/create_features.sql", + table_name="user_features", + query="sql/create_features.sql", + audits=["sql/audit_feature_count.sql"], ctx={"lookback_days": 30}, - publish_query_fpath="sql/publish_features.sql", - comment="Daily feature refresh", ) self.next(self.end) @@ -152,7 +153,7 @@ class FeatureEngineeringFlow(FlowSpec): ```sql -- sql/create_features.sql -CREATE OR REPLACE TEMPORARY TABLE temp_features AS +CREATE OR REPLACE TABLE {{schema}}.{{table_name}} AS SELECT user_id, COUNT(*) as event_count_30d, @@ -166,7 +167,7 @@ GROUP BY user_id; ``` ```sql --- sql/publish_features.sql +-- sql/audit_feature_count.sql CREATE OR REPLACE TABLE my_dev_schema.user_features AS SELECT * FROM temp_features; ``` @@ -300,10 +301,9 @@ class LargeScaleScoringFlow(FlowSpec): @step def join(self, inputs): """Collect all predictions.""" - pipeline = BatchInferencePipeline() - pipeline.publish_results( - output_table="predictions", - output_schema="my_dev_schema", + self.pipeline = inputs[0].pipeline + self.pipeline.publish_results( + output_table_name="predictions", ) self.next(self.end) @@ -382,7 +382,8 @@ class IncrementalFlow(FlowSpec): publish_pandas( table_name="incremental_results", df=self.results, - mode="append", # ← Append instead of replace + auto_create_table=False, # Table must exist + overwrite=False, # Append instead of replace ) self.next(self.end) @@ -435,7 +436,8 @@ class BackfillFlow(FlowSpec): publish_pandas( table_name="backfill_results", df=result, - mode="append", + auto_create_table=False, + overwrite=False, # Append mode ) self.rows_processed = len(result) @@ -499,7 +501,8 @@ def process_with_checkpoints(self): publish_pandas( table_name="checkpoint_results", df=checkpoint_df, - mode="replace", + auto_create_table=True, + overwrite=True, # Replace mode ) print(f"✅ Checkpoint saved at chunk {i}") diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 1202b35..24e5df3 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -61,13 +61,13 @@ df = query_pandas_from_snowflake( ```python # ❌ Bad - missing variable query_pandas_from_snowflake( - query_fpath="sql/query.sql", # Uses {{start_date}} + query="sql/query.sql", # Uses {{start_date}} ctx={"end_date": "2024-12-31"}, # Missing start_date! ) # ✅ Good - all variables provided query_pandas_from_snowflake( - query_fpath="sql/query.sql", + query="sql/query.sql", ctx={ "start_date": "2024-01-01", "end_date": "2024-12-31", @@ -285,10 +285,9 @@ def process_batches(self): @step def join(self, inputs): """Now safe to publish.""" - pipeline = BatchInferencePipeline() - pipeline.publish_results( - output_table="predictions", - output_schema="my_dev_schema", + self.pipeline = inputs[0].pipeline + self.pipeline.publish_results( + output_table_name="predictions", ) self.next(self.end) ``` @@ -407,15 +406,16 @@ class Config(BaseModel): ### Error: "Table already exists" -**Cause**: Table exists and mode is not specified. +**Cause**: Table exists and overwrite is not enabled. -**Solution**: Specify mode: +**Solution**: Enable overwrite or auto_create_table: ```python publish_pandas( table_name="my_table", df=df, - mode="replace", # or "append" or "fail" + auto_create_table=True, + overwrite=True, # Drops table first then creates new ) ``` @@ -423,21 +423,18 @@ publish_pandas( **Cause**: No write access to schema. -**Solution**: Use your dev schema: +**Note**: The schema is automatically selected based on production/dev environment: +- In production: writes to PROD_SCHEMA +- In dev: writes to DEV_SCHEMA -```python -# ✅ Use your dev schema -publish_pandas( - table_name="my_table", - df=df, - schema="my_dev_schema", # You have access here -) +**Solution**: Ensure you're running in the correct environment: -# ❌ Don't write to production without permission +```python +# Schema is automatically selected publish_pandas( table_name="my_table", df=df, - schema="production_schema", # No access! + auto_create_table=True, ) ``` diff --git a/docs/metaflow/README.md b/docs/metaflow/README.md index 13893a1..141155b 100644 --- a/docs/metaflow/README.md +++ b/docs/metaflow/README.md @@ -99,9 +99,10 @@ publish_pandas( from ds_platform_utils.metaflow import publish publish( - query_fpath="queries/create_aggregates.sql", + table_name="aggregates", + query="queries/create_aggregates.sql", + audits=["queries/audit_row_count.sql"], ctx={"start_date": "2024-01-01"}, - publish_query_fpath="queries/publish_results.sql", warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_MED_WH", ) ``` @@ -257,12 +258,13 @@ class DataPipelineFlow(FlowSpec): @step def start(self): publish( - query_fpath="sql/transform_data.sql", + table_name="processed_results", + query="sql/transform_data.sql", + audits=["sql/audit_row_count.sql"], ctx={ "start_date": self.start_date, "end_date": self.end_date, }, - publish_query_fpath="sql/publish_results.sql", ) self.next(self.end) ``` @@ -297,3 +299,13 @@ class DataPipelineFlow(FlowSpec): - 🎯 Check out [Common Patterns](../guides/common_patterns.md) - 🔧 Review [Best Practices](../guides/best_practices.md) - 🐛 See [Troubleshooting](../guides/troubleshooting.md) + +## Related Modules + +### [Snowflake Utilities](../snowflake/README.md) +Lower-level utilities for direct Snowflake operations: +- SQL query execution +- Write-audit-publish pattern +- Schema management + +The Metaflow utilities build on top of these Snowflake utilities to provide higher-level abstractions. Most users should use the Metaflow API, but the Snowflake utilities are available for custom use cases. diff --git a/docs/metaflow/pandas.md b/docs/metaflow/pandas.md index 2fe09bf..72d7f38 100644 --- a/docs/metaflow/pandas.md +++ b/docs/metaflow/pandas.md @@ -37,7 +37,7 @@ df = query_pandas_from_snowflake( ```python df = query_pandas_from_snowflake( - query_fpath="sql/extract_data.sql", + query="sql/extract_data.sql", # Pass file path as query parameter ctx={ "start_date": "2024-01-01", "end_date": "2024-12-31", @@ -80,12 +80,20 @@ df = query_pandas_from_snowflake( ### Custom Timeouts +Note: Timeouts are managed by Metaflow decorators and Outerbounds, not the query function directly. + ```python -df = query_pandas_from_snowflake( - query="SELECT * FROM huge_table", - timeout_seconds=1800, # 30 minutes - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", -) +from metaflow import FlowSpec, step, timeout + +class MyFlow(FlowSpec): + @timeout(seconds=1800) # 30 minutes + @step + def query_large_data(self): + self.df = query_pandas_from_snowflake( + query="SELECT * FROM huge_table", + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", + ) + self.next(self.end) ``` ## Publishing Data @@ -98,75 +106,94 @@ from ds_platform_utils.metaflow import publish_pandas publish_pandas( table_name="my_results", df=results_df, - schema="my_dev_schema", + auto_create_table=True, + overwrite=True, ) ``` ### Replace vs. Append ```python -# Replace existing table (default) +# Replace existing table (overwrite=True) publish_pandas( table_name="my_table", df=df, - mode="replace", + auto_create_table=True, + overwrite=True, # Drops table first ) -# Append to existing table +# Append to existing table (overwrite=False) publish_pandas( table_name="my_table", df=df, - mode="append", + auto_create_table=False, # Table must already exist + overwrite=False, # Appends data ) +``` -# Fail if table exists +### Add Created Date + +```python publish_pandas( table_name="my_table", df=df, - mode="fail", + add_created_date=True, # Adds 'created_date' column with UTC timestamp + auto_create_table=True, ) ``` -### Add Comments +### Specify Warehouse ```python publish_pandas( - table_name="my_table", - df=df, - comment="Daily feature refresh - 2024-01-15", + table_name="large_table", + df=large_df, + warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", # Use larger warehouse + auto_create_table=True, + overwrite=True, ) ``` -### Specify Warehouse +### Large DataFrame via S3 Staging + +For very large DataFrames, use S3 staging for better performance: ```python publish_pandas( table_name="large_table", df=large_df, - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", # Use larger warehouse + use_s3_stage=True, + table_definition=[ + ("id", "NUMBER"), + ("name", "STRING"), + ("score", "FLOAT"), + ], ) ``` ## Using SQL Files -### Query and Publish Pattern +### Write-Audit-Publish Pattern -The most common pattern: query data with one SQL file, transform in Python, publish with another SQL file. +The `publish()` function implements the write-audit-publish pattern for data quality: ```python from ds_platform_utils.metaflow import publish publish( - query_fpath="sql/create_features.sql", + table_name="DAILY_FEATURES", + query="sql/create_features.sql", + audits=[ + "sql/audit_row_count.sql", + "sql/audit_null_check.sql", + ], ctx={"start_date": "2024-01-01", "end_date": "2024-12-31"}, - publish_query_fpath="sql/publish_features.sql", - comment="Daily feature engineering", ) ``` ```sql -- sql/create_features.sql -CREATE OR REPLACE TEMPORARY TABLE temp_features AS +CREATE OR REPLACE TABLE {{schema}}.{{table_name}} AS SELECT user_id, COUNT(*) as event_count, @@ -179,29 +206,47 @@ GROUP BY user_id; ``` ```sql --- sql/publish_features.sql -CREATE OR REPLACE TABLE my_dev_schema.user_features AS -SELECT * FROM temp_features; +-- sql/audit_row_count.sql (should return 0 rows if passing) +SELECT 1 WHERE (SELECT COUNT(*) FROM {{schema}}.{{table_name}}) < 100; ``` -### Transform Function +```sql +-- sql/audit_null_check.sql (should return 0 rows if passing) +SELECT 1 WHERE EXISTS ( + SELECT 1 FROM {{schema}}.{{table_name}} WHERE user_id IS NULL +); +``` + +### Feature Engineering in Python -Add Python transformation between query and publish: +For Python transformations, combine query and publish_pandas: ```python -def transform_features(df: pd.DataFrame) -> pd.DataFrame: - """Add engineered features.""" +from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas +from datetime import datetime + +# Query raw data +df = query_pandas_from_snowflake( + query="sql/create_features.sql", + ctx={"start_date": "2024-01-01", "end_date": "2024-12-31"}, +) + +# Transform in Python +def transform_features(df): df['recency_days'] = ( datetime.now() - pd.to_datetime(df['last_seen']) ).dt.days df['frequency_per_day'] = df['event_count'] / 30 return df -publish( - query_fpath="sql/create_features.sql", - ctx={"start_date": "2024-01-01", "end_date": "2024-12-31"}, - transform_fn=transform_features, # ← Add transformation - publish_query_fpath="sql/publish_features.sql", +df = transform_features(df) + +# Publish +publish_pandas( + table_name="user_features", + df=df, + auto_create_table=True, + overwrite=True, ) ``` @@ -230,21 +275,30 @@ publish_pandas(table_name="user_events", df=result) ### Chunked Publishing -For very large DataFrames: +For very large DataFrames, use chunk_size parameter: + +```python +publish_pandas( + table_name="large_table", + df=large_df, + chunk_size=100000, # Insert 100k rows at a time + auto_create_table=True, + overwrite=True, +) +``` + +Or use S3 staging for even better performance: ```python -# Split into chunks -chunk_size = 100000 -for i in range(0, len(large_df), chunk_size): - chunk = large_df.iloc[i:i+chunk_size] - - publish_pandas( - table_name="large_table", - df=chunk, - mode="append" if i > 0 else "replace", # Replace first, append rest - ) - - print(f"Published chunk {i//chunk_size + 1}") +publish_pandas( + table_name="large_table", + df=large_df, + use_s3_stage=True, + table_definition=[ + ("id", "NUMBER"), + ("value", "FLOAT"), + ], +) ``` ### Query with Date Range @@ -293,14 +347,13 @@ df = query_with_retry() Query Snowflake and return a pandas DataFrame. **Parameters:** -- `query` (str, optional): SQL query string -- `query_fpath` (str, optional): Path to SQL file -- `ctx` (dict, optional): Template variables for query +- `query` (str | Path): SQL query string or path to .sql file - `warehouse` (str, optional): Snowflake warehouse name +- `ctx` (dict, optional): Template variables for query substitution +- `use_utc` (bool): Use UTC timezone (default: True) - `use_s3_stage` (bool): Use S3 staging for large results (default: False) -- `timeout_seconds` (int, optional): Query timeout in seconds -**Returns:** `pandas.DataFrame` +**Returns:** `pandas.DataFrame` (column names lowercased) **Example:** ```python @@ -315,12 +368,20 @@ df = query_pandas_from_snowflake( Publish a pandas DataFrame to Snowflake. **Parameters:** -- `table_name` (str): Target table name +- `table_name` (str): Target table name (auto-uppercased) - `df` (pd.DataFrame): DataFrame to publish -- `schema` (str, optional): Target schema (default: dev schema) -- `mode` (str): "replace", "append", or "fail" (default: "replace") +- `add_created_date` (bool): Add created_date column (default: False) +- `chunk_size` (int, optional): Rows per insert batch +- `compression` (str): Parquet compression "snappy" or "gzip" (default: "snappy") - `warehouse` (str, optional): Snowflake warehouse name -- `comment` (str, optional): Table comment +- `parallel` (int): Upload threads (default: 4) +- `quote_identifiers` (bool): Quote column names (default: True) +- `auto_create_table` (bool): Create table if missing (default: False) +- `overwrite` (bool): Drop/truncate before write (default: False) +- `use_logical_type` (bool): Parquet logical types for timestamps (default: True) +- `use_utc` (bool): Use UTC timezone (default: True) +- `use_s3_stage` (bool): Use S3 staging (default: False) +- `table_definition` (list, optional): Schema as [(col, type), ...] for S3 staging **Returns:** None @@ -329,32 +390,32 @@ Publish a pandas DataFrame to Snowflake. publish_pandas( table_name="my_results", df=results_df, - schema="my_dev_schema", - mode="replace", + auto_create_table=True, + overwrite=True, ) ``` ### publish() -Query, transform, and publish in one call. +Publish a Snowflake table using the write-audit-publish pattern. **Parameters:** -- `query_fpath` (str): Path to query SQL file -- `ctx` (dict): Template variables -- `publish_query_fpath` (str): Path to publish SQL file -- `transform_fn` (callable, optional): Transformation function -- `comment` (str, optional): Table comment +- `table_name` (str): Name of the Snowflake table to publish +- `query` (str | Path): SQL query string or path to .sql file +- `audits` (list, optional): SQL audit scripts or file paths for validation +- `ctx` (dict, optional): Template variables for SQL substitution - `warehouse` (str, optional): Snowflake warehouse name +- `use_utc` (bool): Use UTC timezone (default: True) **Returns:** None **Example:** ```python publish( - query_fpath="sql/query.sql", + table_name="DAILY_FEATURES", + query="sql/create_features.sql", + audits=["sql/audit_row_count.sql"], ctx={"date": "2024-01-01"}, - publish_query_fpath="sql/publish.sql", - comment="Daily update", ) ``` diff --git a/docs/snowflake/README.md b/docs/snowflake/README.md new file mode 100644 index 0000000..0255ff0 --- /dev/null +++ b/docs/snowflake/README.md @@ -0,0 +1,291 @@ +# Snowflake Utilities + +Core utilities for interacting with Snowflake in Pattern's data platform. + +## Overview + +The Snowflake utilities module provides low-level functions for executing queries and implementing the write-audit-publish pattern. These utilities are integrated with Outerbounds, which automatically handles Snowflake authentication and connection management. + +> **Note:** Most users should use the higher-level [Metaflow Pandas Integration](../metaflow/pandas.md) functions (`query_pandas_from_snowflake`, `publish_pandas`, `publish`) rather than calling these utilities directly. + +## Key Features + +### 1. Query Execution + +Execute SQL statements against Snowflake with automatic connection handling via Outerbounds: + +```python +from ds_platform_utils._snowflake.run_query import _execute_sql + +# Execute returns the cursor for the last statement +cursor = _execute_sql(conn, """ + SELECT * FROM my_table LIMIT 10; + SELECT COUNT(*) FROM my_table; +""") +``` + +**Features:** +- Supports batch execution (multiple statements separated by semicolons) +- Returns cursor from the last executed statement +- Handles empty SQL statements gracefully +- Provides clear error messages for SQL syntax errors + +### 2. Write-Audit-Publish Pattern + +Implement data quality checks during table writes: + +```python +from ds_platform_utils._snowflake.write_audit_publish import write_audit_publish + +# Write table with audit checks +operations = write_audit_publish( + table_name="my_feature_table", + query="SELECT * FROM source_table WHERE date > '2024-01-01'", + audits=[ + "SELECT COUNT(*) > 0 AS has_rows FROM {{schema}}.{{table_name}}", + "SELECT COUNT(DISTINCT user_id) > 100 FROM {{schema}}.{{table_name}}" + ], + cursor=cursor, + is_production=False +) + +# Execute each operation +for op in operations: + print(f"Executing: {op.description}") + op.execute() +``` + +**The Pattern:** +1. **Write**: Create/replace table in dev schema +2. **Audit**: Run validation queries to check data quality +3. **Publish**: If audits pass, promote table to production schema + +**Benefits:** +- Catch data quality issues before production +- Atomic operations (all-or-nothing) +- Automatic schema management (dev vs prod) +- Query templating with Jinja2 + +## Authentication + +All Snowflake operations automatically use credentials managed by Outerbounds. No manual configuration required! + +Outerbounds handles: +- ✅ Snowflake authentication +- ✅ Warehouse selection +- ✅ Database/schema configuration +- ✅ Connection pooling +- ✅ Query tagging for audit trails + +## Common Patterns + +### Pattern 1: Simple Query Execution + +```python +from ds_platform_utils.metaflow import query_pandas_from_snowflake + +# High-level API (recommended) +df = query_pandas_from_snowflake("SELECT * FROM my_table") +``` + +### Pattern 2: Table Creation with Validation + +```python +from ds_platform_utils.metaflow import publish + +# High-level API with audits +publish( + table_name="features", + query="CREATE TABLE {{schema}}.{{table_name}} AS SELECT ...", + audits=["SELECT COUNT(*) > 1000 FROM {{schema}}.{{table_name}}"] +) +``` + +### Pattern 3: pandas DataFrame Publishing + +```python +from ds_platform_utils.metaflow import publish_pandas +import pandas as pd + +df = pd.DataFrame({"col1": [1, 2, 3]}) + +# Publish with automatic schema inference +publish_pandas( + table_name="my_data", + df=df, + auto_create_table=True, + overwrite=True, +) +``` + +## Query Templating + +The write-audit-publish functions use Jinja2 templating for dynamic table names: + +```sql +-- Use template variables in your queries +CREATE OR REPLACE TABLE {{schema}}.{{table_name}} AS +SELECT * FROM source_table; + +-- In audits +SELECT + COUNT(*) > 0 AS has_data, + MAX(created_at) > CURRENT_DATE - 7 AS is_recent +FROM {{schema}}.{{table_name}}; +``` + +**Template Variables:** +- `{{schema}}` - Automatically set to dev or prod schema +- `{{table_name}}` - The table name you specify +- Custom context via `ctx` parameter + +## Schema Management + +### Development vs Production + +```python +# Development (default) +publish(..., is_production=False) # Writes to DEV_SCHEMA + +# Production +publish(..., is_production=True) # Writes to PROD_SCHEMA +``` + +The library automatically manages schema selection: +- **Dev**: Fast iteration, no data quality gates +- **Prod**: Requires audit checks to pass + +### Branch-based Development + +```python +# Tables can be scoped to git branches +publish( + ..., + branch_name="feature-xyz" # Creates feature-xyz_table_name +) +``` + +## Error Handling + +The Snowflake utilities provide detailed error messages: + +```python +try: + cursor = _execute_sql(conn, bad_sql) +except Exception as e: + # Detailed error includes: + # - SQL syntax errors with line numbers + # - Table/column not found errors + # - Permission errors + print(f"Query failed: {e}") +``` + +**Common Errors:** +- `Empty SQL statement` - Query contains only whitespace/comments +- `SQL compilation error` - Syntax or schema errors +- `Insufficient privileges` - Permission issues (contact DevOps) + +## Best Practices + +### 1. Use High-Level APIs + +Prefer `query_pandas_from_snowflake` and `publish_pandas` over low-level utilities: + +```python +# ✅ Recommended +from ds_platform_utils.metaflow import query_pandas_from_snowflake +df = query_pandas_from_snowflake("SELECT ...") + +# ❌ Avoid (unless you need low-level control) +from ds_platform_utils._snowflake.run_query import _execute_sql +cursor = _execute_sql(conn, "SELECT ...") +``` + +### 2. Always Add Audit Checks + +Data quality checks prevent bad data from reaching production: + +```python +audits = [ + "SELECT COUNT(*) > 0 FROM {{schema}}.{{table_name}}", # Non-empty + "SELECT COUNT(*) = COUNT(DISTINCT id) FROM {{schema}}.{{table_name}}", # Unique IDs + "SELECT MAX(updated_at) > CURRENT_DATE - 1 FROM {{schema}}.{{table_name}}" # Fresh data +] +``` + +### 3. Use Template Variables + +Always use `{{schema}}.{{table_name}}` in queries for write-audit-publish: + +```sql +-- ✅ Correct +CREATE TABLE {{schema}}.{{table_name}} AS SELECT ... + +-- ❌ Wrong (hardcoded schema) +CREATE TABLE production.my_table AS SELECT ... +``` + +### 4. Handle Cursors Properly + +Cursors should be closed after use: + +```python +cursor = _execute_sql(conn, sql) +try: + results = cursor.fetchall() +finally: + cursor.close() +``` + +## Integration with Metaflow + +The Snowflake utilities are designed to work seamlessly with Metaflow flows: + +```python +from metaflow import FlowSpec, step +from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish + +class MyFlow(FlowSpec): + @step + def start(self): + # Query data + self.df = query_pandas_from_snowflake(""" + SELECT * FROM raw_data + WHERE date = CURRENT_DATE + """) + self.next(self.transform) + + @step + def transform(self): + # Transform data + self.features = self.df.groupby('user_id').size() + self.next(self.publish) + + @step + def publish(self): + # Publish DataFrame + publish_pandas( + table_name="daily_features", + df=self.features.reset_index(), + auto_create_table=True, + overwrite=True, + ) + self.next(self.end) + + @step + def end(self): + print("Pipeline complete!") +``` + +## Related Documentation + +- [Metaflow Pandas Integration](../metaflow/pandas.md) - High-level query/publish functions +- [API Reference](../api/index.md) - Complete function signatures +- [Troubleshooting](../guides/troubleshooting.md) - Common Snowflake issues +- [Best Practices](../guides/best_practices.md) - Production patterns + +## See Also + +- [Getting Started Guide](../guides/getting_started.md) +- [Write-Audit-Publish Examples](../examples/README.md) +- [Snowflake Official Docs](https://docs.snowflake.com/) From 5489a1626d30985979bfdae6d39093bcf0205390 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:45:53 +0530 Subject: [PATCH 103/167] refactor: remove batch inference module from Metaflow integration --- .../metaflow/batch_inference.py | 230 ------------------ 1 file changed, 230 deletions(-) delete mode 100644 src/ds_platform_utils/metaflow/batch_inference.py diff --git a/src/ds_platform_utils/metaflow/batch_inference.py b/src/ds_platform_utils/metaflow/batch_inference.py deleted file mode 100644 index d576bdc..0000000 --- a/src/ds_platform_utils/metaflow/batch_inference.py +++ /dev/null @@ -1,230 +0,0 @@ -import os -import queue -import time -from concurrent.futures import ThreadPoolExecutor -from contextlib import contextmanager -from pathlib import Path -from typing import Callable, List, Optional, Tuple, Union - -import pandas as pd -from metaflow import current - -from ds_platform_utils._snowflake.write_audit_publish import get_query_from_string_or_fpath, substitute_map_into_string -from ds_platform_utils.metaflow import s3 -from ds_platform_utils.metaflow._consts import ( - DEV_SCHEMA, - PROD_SCHEMA, - S3_DATA_FOLDER, -) -from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query -from ds_platform_utils.metaflow.s3_stage import ( - _get_s3_config, - copy_s3_to_snowflake, - copy_snowflake_to_s3, -) - -default_file_size_in_mb = 10 - - -def _debug(*args, **kwargs): - if os.getenv("DEBUG"): - print("DEBUG: ", end="") - print(*args, **kwargs) - - -@contextmanager -def timer(message: str): - t0 = time.time() - yield - t1 = time.time() - _debug(f"{message}: Completed in {t1 - t0:.2f} seconds") - - -def make_batches_of_files(file_paths, batch_size_in_mb): - with s3._get_metaflow_s3_client() as s3_client: - file_sizes = [(file.key, file.size) for file in s3_client.info_many(file_paths)] - - batches = [] - current_batch = [] - current_batch_size = 0 - warnings = False - - batch_size_in_bytes = batch_size_in_mb * 1024 * 1024 - for file_key, file_size in file_sizes: - current_batch.append(file_key) - current_batch_size += file_size - if current_batch_size > batch_size_in_bytes: - if len(current_batch) == 1: - warnings = True - batches.append(current_batch) - current_batch = [] - current_batch_size = 0 - - if current_batch: - batches.append(current_batch) - if warnings: - print("⚠️ Files larger than batch size detected. Increase batch size to avoid this warning.") - - return batches - - -def batch_inference_from_snowflake( # noqa: PLR0913 - input_query: Union[str, Path], - output_table_name: str, - predict_fn: Callable[[pd.DataFrame], pd.DataFrame], - output_table_definition: Optional[List[Tuple[str, str]]] = None, - use_utc: bool = True, - batch_size_in_mb: int = 128, - warehouse: Optional[str] = None, - ctx: Optional[dict] = None, - timeout_per_batch: int = 300, - auto_create_table: bool = True, - overwrite: bool = True, -): - """Execute batch inference on data from Snowflake, process it through a model, and upload results back to Snowflake. - - This function orchestrates a multi-threaded pipeline that: - 1. Exports data from Snowflake to S3 using COPY INTO - 2. Downloads data from S3 in batches - 3. Runs model predictions on each batch - 4. Uploads predictions back to S3 - 5. Imports results into a Snowflake table using COPY INTO - - Args: - input_query (Union[str, Path]): SQL query string or file path to query that defines the data to process. - output_table_name (str): Name of the Snowflake table where predictions will be written. - model_predictor_function (Callable[[pd.DataFrame], pd.DataFrame]): Function that takes a DataFrame and returns predictions DataFrame. - output_table_schema (Optional[List[Tuple[str, str]]], optional): Snowflake table schema as list of (column_name, column_type) tuples. - If None, schema is inferred from the first predictions file. Defaults to None. - use_utc (bool, optional): Whether to use UTC timezone for Snowflake connection. Defaults to True. - batch_size_in_mb (int, optional): Target batch size in megabytes for processing. Defaults to 128. - warehouse (Optional[str], optional): Snowflake warehouse to use for queries. If None, uses default warehouse. Defaults to None. - ctx (Optional[dict], optional): Dictionary of variable substitutions for the input query template. Defaults to None. - timeout_per_batch (int, optional): Timeout in seconds for processing each batch. Defaults to 300. - auto_create_table (bool, optional): Whether to automatically create the output table if it doesn't exist. Defaults to True. - overwrite (bool, optional): Whether to overwrite existing data in the output table. Defaults to True. - - Raises: - Exceptions from Snowflake connection, S3 operations, or model prediction function may propagate. - - Notes: - - Uses production or non-production schema based on execution context (Metaflow). - - Creates temporary S3 and Snowflake stage folders with timestamps for isolation. - - Implements a three-threaded pipeline: download -> inference -> upload. - - Column names are normalized to lowercase for consistent processing. - - Displays input query, sample data, and progress messages via Metaflow cards. - - """ - ## Define S3 paths and Snowflake schema based on environment - is_production = current.is_production if hasattr(current, "is_production") else False - s3_bucket, _ = _get_s3_config(is_production) - schema = PROD_SCHEMA if is_production else DEV_SCHEMA - - ## Create unique S3 paths for this batch inference run using timestamp - timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") - upload_folder = f"publish_{timestamp}" - output_s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{upload_folder}" - - # Step 1: Build COPY INTO query to export data from Snowflake to S3 - input_query = get_query_from_string_or_fpath(input_query) - input_query = substitute_map_into_string(input_query, {"schema": schema} | (ctx or {})) - - _debug_print_query(input_query) - - input_s3_path = copy_snowflake_to_s3( - query=input_query, - warehouse=warehouse, - use_utc=use_utc, - ) - - batch_inference_from_s3( - input_s3_path=input_s3_path, - output_s3_path=output_s3_path, - predict_fn=predict_fn, - timeout_per_batch=timeout_per_batch, - batch_size_in_mb=batch_size_in_mb, - ) - ## Step 2: Build COPY INTO query to import predictions from S3 back to Snowflake - - copy_s3_to_snowflake( - s3_path=output_s3_path, - table_name=output_table_name, - table_definition=output_table_definition, - warehouse=warehouse, - use_utc=use_utc, - auto_create_table=auto_create_table, - overwrite=overwrite, - ) - - print("✅ Batch inference completed successfully!") - - -def batch_inference_from_s3( - input_s3_path: str | List[str], - output_s3_path: str, - predict_fn: Callable[[pd.DataFrame], pd.DataFrame], - timeout_per_batch: int = 300, - batch_size_in_mb: int = 100, -): - if isinstance(input_s3_path, str): - if str.endswith(input_s3_path, ".parquet"): - input_s3_files = [input_s3_path] - else: - input_s3_files = s3._list_files_in_s3_folder(input_s3_path) - - elif not isinstance(input_s3_path, list): - raise ValueError("input_s3_path must be a string or list of strings.") - - else: - input_s3_files = input_s3_path - - ## Check if all paths are valid S3 URIs - if any(not (path.startswith("s3://") and path.endswith(".parquet")) for path in input_s3_files): - raise ValueError("Invalid S3 URI. All paths or folder files must start with 's3://' and end with '.parquet'.") - - input_s3_batches = make_batches_of_files(input_s3_files, batch_size_in_mb) - - print(f"📊 Total Batches to process: {len(input_s3_batches)}") - - download_queue = queue.Queue(maxsize=1) # Adjust maxsize per memory limits - inference_queue = queue.Queue(maxsize=1) - - def download_worker(file_keys): - for batch_id, key in enumerate(file_keys): - with timer(f"Downloading batch {batch_id} from S3"): - df = s3._get_df_from_s3_files(key) - df.columns = [ - col.lower() for col in df.columns - ] # Ensure columns are lowercase for consistent processing - download_queue.put((batch_id, df), timeout=timeout_per_batch) - download_queue.put(None, timeout=timeout_per_batch) - - def inference_worker(): - while True: - item = download_queue.get(timeout=timeout_per_batch) - if item is None: - inference_queue.put(None, timeout=timeout_per_batch) - break - batch_id, df = item - _debug(f"Generating predictions for batch {batch_id}...") - with timer(f"Generating predictions for batch {batch_id}"): - predictions_df = predict_fn(df) - inference_queue.put((batch_id, predictions_df), timeout=timeout_per_batch) - - def upload_worker(): - while True: - item = inference_queue.get(timeout=timeout_per_batch) - if item is None: - break - batch_id, predictions_df = item - s3_output_file = f"{output_s3_path}/predictions_{batch_id}.parquet" - with timer(f"Uploading predictions for batch {batch_id} to S3"): - s3._put_df_to_s3_file(predictions_df, s3_output_file) - - with ThreadPoolExecutor(max_workers=3) as executor: - # Use .submit() for different functions with varying arguments - executor.submit(download_worker, input_s3_batches) - executor.submit(inference_worker) - executor.submit(upload_worker) - - print("✅ All batches processed successfully!") From a5575f24891664fe90fcd4e39db8b6b0ac91171b Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:46:54 +0530 Subject: [PATCH 104/167] fix: prevent re-execution of query_and_batch in BatchInferencePipeline to maintain processing state --- src/ds_platform_utils/metaflow/batch_inference_pipeline.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 33c6133..84b7ce7 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -159,7 +159,11 @@ def query_and_batch( """ # Warn if re-executing query_and_batch after processing if self._query_executed and self._batch_processed: - print("⚠️ Warning: Re-executing query_and_batch() will reset batch processing state.") + raise RuntimeError( + "Cannot re-execute query_and_batch(): Batches have already been processed. " + "This would reset the state of the pipeline. " + "If you need to re-run the query, create a new instance of BatchInferencePipeline." + ) print("🚀 Starting batch inference pipeline...") # Process input query From 84ffb52f3c699468e45841179f7b89215b65ab4b Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:55:45 +0530 Subject: [PATCH 105/167] Remove outdated guides and documentation files: - Deleted the "Getting Started" guide, "Performance Tuning" guide, and "Troubleshooting" guide to streamline documentation. - Updated references in the Metaflow README and other documentation files to remove links to the deleted guides and point to relevant sections instead. - Cleaned up the "pandas" and "validate_config" documentation to remove references to the deleted guides. --- docs/README.md | 12 - docs/api/index.md | 8 +- docs/examples/README.md | 3 - docs/guides/best_practices.md | 445 ----------------------- docs/guides/common_patterns.md | 581 ------------------------------ docs/guides/getting_started.md | 275 -------------- docs/guides/performance_tuning.md | 461 ------------------------ docs/guides/troubleshooting.md | 556 ---------------------------- docs/metaflow/README.md | 6 +- docs/metaflow/pandas.md | 8 - docs/metaflow/validate_config.md | 2 - docs/snowflake/README.md | 3 - 12 files changed, 3 insertions(+), 2357 deletions(-) delete mode 100644 docs/guides/best_practices.md delete mode 100644 docs/guides/common_patterns.md delete mode 100644 docs/guides/getting_started.md delete mode 100644 docs/guides/performance_tuning.md delete mode 100644 docs/guides/troubleshooting.md diff --git a/docs/README.md b/docs/README.md index 03e3eae..77d775b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,14 +21,6 @@ Comprehensive documentation for Pattern's data science platform utilities. - [Pandas Integration](metaflow/pandas.md) - Query and publish functions for Snowflake - [Config Validation](metaflow/validate_config.md) - Pydantic-based configuration validation -### Guides - -- [Getting Started](guides/getting_started.md) -- [Best Practices](guides/best_practices.md) -- [Performance Tuning](guides/performance_tuning.md) -- [Common Patterns](guides/common_patterns.md) -- [Troubleshooting](guides/troubleshooting.md) - ### Examples - [Practical Examples](examples/README.md) - Complete working examples for common scenarios @@ -44,8 +36,6 @@ Comprehensive documentation for Pattern's data science platform utilities. ## Quick Links -- [Getting Started →](guides/getting_started.md) -- [Best Practices →](guides/best_practices.md) - [API Reference →](api/index.md) - [Practical Examples →](examples/README.md) - [Installation](#installation) @@ -223,6 +213,4 @@ Internal use only - Pattern Inc. For questions or issues: - Create an issue in the [GitHub repository](https://github.com/patterninc/ds-platform-utils) -- Check the [Troubleshooting Guide](guides/troubleshooting.md) - Contact the Data Science Platform team -- Check the [Troubleshooting Guide](guides/troubleshooting.md) diff --git a/docs/api/index.md b/docs/api/index.md index 375b84e..57781e5 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -90,7 +90,6 @@ df = query_pandas_from_snowflake( **See Also:** - [Pandas Integration Guide](../metaflow/pandas.md) -- [Performance Tuning](../guides/performance_tuning.md) --- @@ -175,7 +174,6 @@ publish_pandas( **See Also:** - [Pandas Integration Guide](../metaflow/pandas.md) -- [Common Patterns](../guides/common_patterns.md) --- @@ -366,7 +364,6 @@ pipeline.publish_results( **See Also:** - [BatchInferencePipeline Guide](../metaflow/batch_inference_pipeline.md) -- [Common Patterns - Batch Inference](../guides/common_patterns.md#batch-inference) --- @@ -482,7 +479,7 @@ result = process_data(self.df) - 🔄 **Development**: Iterate on step logic quickly **See Also:** -- [Common Patterns](../guides/common_patterns.md) +- [Examples](../examples/README.md) --- @@ -536,8 +533,5 @@ Raised when configuration validation fails (from Pydantic). ## Related Documentation -- [Getting Started Guide](../guides/getting_started.md) -- [Best Practices](../guides/best_practices.md) -- [Common Patterns](../guides/common_patterns.md) - [Metaflow Utilities](../metaflow/README.md) - [Snowflake Utilities](../snowflake/README.md) diff --git a/docs/examples/README.md b/docs/examples/README.md index 8b4acff..0048d2e 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -571,7 +571,4 @@ snowsql -q "SELECT * FROM my_dev_schema.enriched_user_data LIMIT 10;" ## Additional Resources -- [Getting Started Guide](../guides/getting_started.md) -- [Best Practices](../guides/best_practices.md) -- [Common Patterns](../guides/common_patterns.md) - [API Reference](../api/index.md) diff --git a/docs/guides/best_practices.md b/docs/guides/best_practices.md deleted file mode 100644 index fababe4..0000000 --- a/docs/guides/best_practices.md +++ /dev/null @@ -1,445 +0,0 @@ -# Best Practices - -[← Back to Main Docs](../README.md) - -Guidelines for production-ready code using `ds-platform-utils`. - -## Table of Contents - -- [Code Organization](#code-organization) -- [Error Handling](#error-handling) -- [Performance](#performance) -- [Security](#security) -- [Testing](#testing) -- [Production Deployment](#production-deployment) - -## Code Organization - -### ✅ DO: Separate Configuration from Logic - -```python -# config.py -from pydantic import BaseModel - -class FlowConfig(BaseModel): - start_date: str - end_date: str - warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" - batch_size: int = 1000 - -# flow.py -from metaflow import FlowSpec, Parameter -from ds_platform_utils.metaflow import make_pydantic_parser_fn - -class MyFlow(FlowSpec): - config = Parameter( - 'config', - type=make_pydantic_parser_fn(FlowConfig), - default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}' - ) -``` - -### ✅ DO: Use External SQL Files - -```python -# ✅ Good - SQL in separate file -publish( - table_name="ml_features", - query="sql/create_features.sql", - audits=["sql/audit_row_count.sql"], - ctx={"start_date": self.config.start_date}, -) - -# ❌ Bad - SQL in Python strings -query = f""" - CREATE OR REPLACE TABLE my_features AS - SELECT * FROM raw_data - WHERE date >= '{self.config.start_date}' -""" -``` - -### ✅ DO: Use Type Hints - -```python -from typing import Optional -import pandas as pd - -def process_data( - df: pd.DataFrame, - threshold: float = 0.5, - warehouse: Optional[str] = None, -) -> pd.DataFrame: - """Process data with predictions.""" - # Your logic here - return result_df -``` - -##Error Handling - -### ✅ DO: Handle Expected Failures Gracefully - -```python -from metaflow import FlowSpec, step, retry - -class RobustFlow(FlowSpec): - - @retry(times=3) # Retry up to 3 times - @step - def query_data(self): - """Query data with retry logic.""" - try: - self.df = query_pandas_from_snowflake( - query="SELECT * FROM my_table", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", - ) - except Exception as e: - print(f"⚠️ Query failed: {e}") - # Log error, send alert, etc. - raise # Re-raise to trigger retry - - if len(self.df) == 0: - raise ValueError("No data returned from query") - - self.next(self.process) -``` - -### ✅ DO: Validate Data Early - -```python -@step -def validate_input(self): - """Validate input data before processing.""" - required_columns = ['id', 'feature_1', 'feature_2'] - - missing = set(required_columns) - set(self.df.columns) - if missing: - raise ValueError(f"Missing required columns: {missing}") - - if self.df.isnull().any().any(): - raise ValueError("Input data contains null values") - - print(f"✅ Validation passed: {len(self.df)} rows") - self.next(self.process) -``` - -### ❌ DON'T: Silently Catch All Exceptions - -```python -# ❌ Bad - swallows all errors -try: - result = process_data(df) -except: - result = None - -# ✅ Good - specific exception handling -try: - result = process_data(df) -except ValueError as e: - print(f"Invalid input: {e}") - raise -except Exception as e: - print(f"Unexpected error: {e}") - # Log for debugging - raise -``` - -## Performance - -### ✅ DO: Use S3 Staging for Large Datasets - -```python -# For datasets > 1GB -df = query_pandas_from_snowflake( - query="SELECT * FROM large_table", - use_s3_stage=True, # ← Enable S3 staging - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", -) -``` - -### ✅ DO: Choose Appropriate Warehouse Size - -```python -# Small queries (< 1M rows) -warehouse = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH" - -# Medium workloads (1M-10M rows) -warehouse = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" - -# Large batch jobs (> 10M rows) -warehouse = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH" -``` - -### ✅ DO: Use BatchInferencePipeline for Very Large Scale - -```python -# For > 10M rows with parallel processing -pipeline = BatchInferencePipeline() -worker_ids = pipeline.query_and_batch( - input_query="SELECT * FROM huge_table", - parallel_workers=10, # Adjust based on data size -) -``` - -### ✅ DO: Optimize Batch Sizes - -```python -# For memory-constrained environments -pipeline.process_batch( - worker_id=worker_id, - predict_fn=predict_fn, - batch_size_in_mb=64, # Smaller batches -) - -# For high-memory environments -pipeline.process_batch( - worker_id=worker_id, - predict_fn=predict_fn, - batch_size_in_mb=512, # Larger batches = fewer S3 ops -) -``` - -### ❌ DON'T: Query Everything When You Need Subset - -```python -# ❌ Bad - queries all data -df = query_pandas_from_snowflake( - query="SELECT * FROM huge_table" -) -df = df[df['date'] >= '2024-01-01'] # Filter in Python - -# ✅ Good - filter in SQL -df = query_pandas_from_snowflake( - query=""" - SELECT * - FROM huge_table - WHERE date >= '2024-01-01' - """ -) -``` - -## Security - -### ✅ DO: Use Template Variables for SQL - -```python -# ✅ Good - prevents SQL injection -ctx = { - "start_date": user_input_date, -} -publish( - table_name="my_results", - query="sql/query.sql", # Uses {{start_date}} - ctx=ctx, -) - -# ❌ Bad - SQL injection risk -query = f"SELECT * FROM {user_input_table} WHERE date >= '{user_input_date}'" -``` - -### ✅ DO: Validate User Inputs - -```python -from datetime import datetime - -def validate_date(date_str: str) -> str: - """Validate date format.""" - try: - datetime.strptime(date_str, '%Y-%m-%d') - return date_str - except ValueError: - raise ValueError(f"Invalid date format: {date_str}") - -# Use in flow -start_date = validate_date(self.config.start_date) -``` - -**Note:** Outerbounds automatically handles all credentials and authentication, so you never need to manage Snowflake passwords or AWS keys. Just use the library functions directly. - -## Testing - -### ✅ DO: Write Unit Tests for Business Logic - -```python -# tests/test_processing.py -import pandas as pd -import pytest - -def test_process_predictions(): - """Test prediction processing.""" - # Arrange - input_df = pd.DataFrame({ - 'id': [1, 2, 3], - 'score': [0.1, 0.6, 0.9] - }) - - # Act - result = process_predictions(input_df, threshold=0.5) - - # Assert - assert len(result) == 2 # Only scores >= 0.5 - assert all(result['score'] >= 0.5) -``` - -### ✅ DO: Use Fixtures for Test Data - -```python -# tests/conftest.py -import pytest -import pandas as pd - -@pytest.fixture -def sample_features(): - """Sample feature data.""" - return pd.DataFrame({ - 'id': range(100), - 'feature_1': range(100), - 'feature_2': range(100, 200), - }) - -# tests/test_flow.py -def test_feature_engineering(sample_features): - """Test feature engineering.""" - result = engineer_features(sample_features) - assert 'engineered_feature' in result.columns -``` - -### ✅ DO: Test Edge Cases - -```python -def test_empty_dataframe(): - """Test handling of empty input.""" - df = pd.DataFrame() - with pytest.raises(ValueError, match="Empty DataFrame"): - process_data(df) - -def test_missing_columns(): - """Test handling of missing columns.""" - df = pd.DataFrame({' id': [1, 2]}) # Missing required columns - with pytest.raises(ValueError, match="Missing required columns"): - process_data(df) -``` - -## Production Deployment - -### ✅ DO: Use Production Warehouses in Prod - -```python -# Use environment-aware warehouse selection -from metaflow import current - -def get_warehouse(): - """Get warehouse based on environment.""" - if hasattr(current, 'is_production') and current.is_production: - return "OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_MED_WH" - return "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" - -# Use in flow -warehouse = get_warehouse() -``` - -### ✅ DO: Enable Monitoring and Alerts - -```python -@step -def publish_with_monitoring(self): - """Publish results with monitoring.""" - start_time = time.time() - - try: - publish_pandas( - table_name="production_features", - df=self.features_df, - ) - - duration = time.time() - start_time - self.metrics = { - 'rows_published': len(self.features_df), - 'duration_seconds': duration, - 'timestamp': datetime.now().isoformat(), - } - - # Log metrics - print(f"📊 Published {self.metrics['rows_published']} rows in {duration:.2f}s") - - except Exception as e: - # Send alert - send_alert(f"Pipeline failed: {e}") - raise - - self.next(self.end) -``` - -### ✅ DO: Version Your Flows - -```python -from metaflow import FlowSpec, Parameter - -class ProductionFlow(FlowSpec): - """Production ML pipeline. - - Version: 2.1.0 - Last Updated: 2024-01-15 - Owner: data-science-team - """ - - version = Parameter( - 'version', - default='2.1.0', - help='Pipeline version' - ) -``` - -### ✅ DO: Document Your SQL - -```sql --- sql/create_features.sql --- Feature Engineering Pipeline --- Owner: data-science-team --- Description: Creates ML features from raw events --- Dependencies: pattern_db.raw_data.events - -CREATE OR REPLACE TABLE {{schema}}.ml_features AS -SELECT - user_id, - COUNT(*) as event_count, - AVG(value) as avg_value, - MAX(timestamp) as last_seen -FROM pattern_db.raw_data.events -WHERE date >= '{{start_date}}' - AND date <= '{{end_date}}' -GROUP BY user_id; -``` - -### ❌ DON'T: Deploy Untested Code - -```python -# ✅ Good - run tests before deploying -$ pytest tests/ -$ python flow.py run # Test locally -$ python flow.py run --production # Deploy - -# ❌ Bad - deploy without testing -$ python flow.py run --production # YOLO -``` - -## Checklist for Production Code - -Before deploying to production: - -- [ ] All tests passing -- [ ] SQL queries optimized -- [ ] Appropriate warehouse selected -- [ ] Error handling implemented -- [ ] Monitoring/alerts configured -- [ ] Documentation updated -- [ ] Code reviewed -- [ ] Dev environment tested -- [ ] Staging environment tested (if available) -- [ ] Rollback plan documented - -## Additional Resources - -- [Getting Started Guide](getting_started.md) -- [Common Patterns](common_patterns.md) -- [Performance Tuning](performance_tuning.md) -- [Troubleshooting](troubleshooting.md) diff --git a/docs/guides/common_patterns.md b/docs/guides/common_patterns.md deleted file mode 100644 index 92e1978..0000000 --- a/docs/guides/common_patterns.md +++ /dev/null @@ -1,581 +0,0 @@ -# Common Patterns - -[← Back to Main Docs](../README.md) - -Proven patterns for common data science workflows. - -## Table of Contents - -- [Query Patterns](#query-patterns) -- [Feature Engineering](#feature-engineering) -- [Batch Inference](#batch-inference) -- [Incremental Processing](#incremental-processing) -- [Error Recovery](#error-recovery) -- [Testing Patterns](#testing-patterns) - -## Query Patterns - -### Simple Query and Publish - -The most basic pattern: query data, transform, publish results. - -```python -from metaflow import FlowSpec, step -from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas - -class SimpleFlow(FlowSpec): - - @step - def start(self): - """Query input data.""" - self.df = query_pandas_from_snowflake( - query="SELECT * FROM input_table", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", - ) - self.next(self.transform) - - @step - def transform(self): - """Transform data.""" - self.df['new_column'] = self.df['old_column'] * 2 - self.next(self.publish) - - @step - def publish(self): - """Publish results.""" - publish_pandas( - table_name="output_table", - df=self.df, - auto_create_table=True, - overwrite=True, - ) - self.next(self.end) - - @step - def end(self): - print(f"✅ Published {len(self.df)} rows") -``` - -### Parameterized Query with SQL File - -Use external SQL files with template variables: - -```python -# config.py -from pydantic import BaseModel - -class QueryConfig(BaseModel): - start_date: str - end_date: str - min_value: float - -# flow.py -from metaflow import FlowSpec, Parameter, step -from ds_platform_utils.metaflow import query_pandas_from_snowflake, make_pydantic_parser_fn - -class ParameterizedFlow(FlowSpec): - - config = Parameter( - 'config', - type=make_pydantic_parser_fn(QueryConfig), - default='{"start_date": "2024-01-01", "end_date": "2024-01-31", "min_value": 100}' - ) - - @step - def start(self): - """Query with parameters.""" - self.df = query_pandas_from_snowflake( - query="sql/extract_data.sql", - ctx={ - "start_date": self.config.start_date, - "end_date": self.config.end_date, - "min_value": self.config.min_value, - }, - ) - self.next(self.end) - - @step - def end(self): - print(f"Retrieved {len(self.df)} rows") -``` - -```sql --- sql/extract_data.sql -SELECT * -FROM transactions -WHERE date >= '{{start_date}}' - AND date <= '{{end_date}}' - AND amount >= {{min_value}} -``` - -### Query with Large Results via S3 - -For datasets > 1GB: - -```python -@step -def query_large_dataset(self): - """Query large dataset via S3 staging.""" - self.df = query_pandas_from_snowflake( - query="SELECT * FROM large_table", - use_s3_stage=True, # ← Enable S3 for large results - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", - ) - print(f"Retrieved {len(self.df):,} rows via S3") - self.next(self.process) -``` - -## Feature Engineering - -### Multiclass Classification Features - -```python -from metaflow import FlowSpec, step -from ds_platform_utils.metaflow import publish - -class FeatureEngineeringFlow(FlowSpec): - - @step - def start(self): - """Create features and publish.""" - publish( - table_name="user_features", - query="sql/create_features.sql", - audits=["sql/audit_feature_count.sql"], - ctx={"lookback_days": 30}, - ) - self.next(self.end) - - @step - def end(self): - print("✅ Features published") -``` - -```sql --- sql/create_features.sql -CREATE OR REPLACE TABLE {{schema}}.{{table_name}} AS -SELECT - user_id, - COUNT(*) as event_count_30d, - AVG(value) as avg_value_30d, - MAX(timestamp) as last_seen, - DATEDIFF(day, MAX(timestamp), CURRENT_DATE()) as recency, - COUNT(DISTINCT date) as active_days -FROM events -WHERE date >= DATEADD(day, -{{lookback_days}}, CURRENT_DATE()) -GROUP BY user_id; -``` - -```sql --- sql/audit_feature_count.sql -CREATE OR REPLACE TABLE my_dev_schema.user_features AS -SELECT * FROM temp_features; -``` - -### Time-based Features - -```python -@step -def create_time_features(self): - """Create time-based features.""" - self.df['day_of_week'] = pd.to_datetime(self.df['timestamp']).dt.dayofweek - self.df['hour'] = pd.to_datetime(self.df['timestamp']).dt.hour - self.df['is_weekend'] = self.df['day_of_week'].isin([5, 6]).astype(int) - self.df['is_business_hours'] = ( - (self.df['hour'] >= 9) & (self.df['hour'] < 17) - ).astype(int) - - self.next(self.publish) -``` - -### Aggregate Features - -```python -@step -def create_aggregate_features(self): - """Create user-level aggregates.""" - user_features = self.df.groupby('user_id').agg({ - 'transaction_amount': ['sum', 'mean', 'max', 'count'], - 'timestamp': ['min', 'max'], - }).reset_index() - - # Flatten column names - user_features.columns = [ - '_'.join(col).strip('_') for col in user_features.columns - ] - - self.features_df = user_features - self.next(self.publish) -``` - -## Batch Inference - -### Simple Batch Scoring - -For datasets that fit in memory: - -```python -import pandas as pd -from metaflow import FlowSpec, step - -class BatchScoringFlow(FlowSpec): - - @step - def start(self): - """Load model and data.""" - import pickle - with open('model.pkl', 'rb') as f: - self.model = pickle.load(f) - - self.df = query_pandas_from_snowflake( - query="SELECT * FROM inference_input" - ) - self.next(self.predict) - - @step - def predict(self): - """Generate predictions.""" - features = self.df[['feature_1', 'feature_2', 'feature_3']] - self.df['prediction'] = self.model.predict(features) - self.df['probability'] = self.model.predict_proba(features)[:, 1] - self.next(self.publish) - - @step - def publish(self): - """Publish predictions.""" - publish_pandas( - table_name="predictions", - df=self.df[['id', 'prediction', 'probability']], - ) - self.next(self.end) - - @step - def end(self): - print(f"✅ Scored {len(self.df)} rows") -``` - -### Large-Scale Batch Inference - -For datasets > 10M rows: - -```python -from metaflow import FlowSpec, step -from ds_platform_utils.metaflow import BatchInferencePipeline - -class LargeScaleScoringFlow(FlowSpec): - - @step - def start(self): - """Query and split into batches.""" - pipeline = BatchInferencePipeline() - self.worker_ids = pipeline.query_and_batch( - input_query="SELECT * FROM large_input_table", - batch_size_in_mb=256, - parallel_workers=20, - ) - self.next(self.predict, foreach='worker_ids') - - @step - def predict(self): - """Predict for each batch (parallel).""" - worker_id = self.input - - # Load model (cached across batches) - import pickle - with open('model.pkl', 'rb') as f: - model = pickle.load(f) - - def predict_fn(df: pd.DataFrame) -> pd.DataFrame: - """Prediction function.""" - df['score'] = model.predict_proba(df[['f1', 'f2', 'f3']])[:, 1] - return df[['id', 'score']] - - pipeline = BatchInferencePipeline() - pipeline.process_batch( - worker_id=worker_id, - predict_fn=predict_fn, - batch_size_in_mb=64, - ) - self.next(self.join) - - @step - def join(self, inputs): - """Collect all predictions.""" - self.pipeline = inputs[0].pipeline - self.pipeline.publish_results( - output_table_name="predictions", - ) - self.next(self.end) - - @step - def end(self): - print("✅ Predictions published") -``` - -### Batch Inference with Post-processing - -```python -def predict_with_postprocessing(df: pd.DataFrame) -> pd.DataFrame: - """Predict and post-process.""" - # Generate predictions - scores = model.predict_proba(df[feature_cols])[:, 1] - - # Post-processing: apply business rules - df['raw_score'] = scores - df['final_score'] = scores - - # Rule: cap scores for new users - new_user_mask = df['account_age_days'] < 30 - df.loc[new_user_mask, 'final_score'] = df.loc[new_user_mask, 'final_score'] * 0.8 - - # Rule: boost scores for loyal customers - loyal_mask = df['total_purchases'] > 50 - df.loc[loyal_mask, 'final_score'] = df.loc[loyal_mask, 'final_score'] * 1.2 - - # Clip to [0, 1] - df['final_score'] = df['final_score'].clip(0, 1) - - return df[['id', 'raw_score', 'final_score']] -``` - -## Incremental Processing - -### Daily Incremental Load - -Process only new data each day: - -```python -from datetime import datetime, timedelta -from metaflow import FlowSpec, Parameter, step - -class IncrementalFlow(FlowSpec): - - date = Parameter( - 'date', - help='Date to process (YYYY-MM-DD)', - default=datetime.now().strftime('%Y-%m-%d') - ) - - @step - def start(self): - """Process single day of data.""" - self.df = query_pandas_from_snowflake( - query=f""" - SELECT * - FROM events - WHERE date = '{self.date}' - """ - ) - print(f"Processing {len(self.df)} rows for {self.date}") - self.next(self.transform) - - @step - def transform(self): - """Transform data.""" - # Your transformation logic - self.results = self.df # Placeholder - self.next(self.publish) - - @step - def publish(self): - """Append to existing table.""" - publish_pandas( - table_name="incremental_results", - df=self.results, - auto_create_table=False, # Table must exist - overwrite=False, # Append instead of replace - ) - self.next(self.end) - - @step - def end(self): - print(f"✅ Appended {len(self.results)} rows for {self.date}") -``` - -### Backfill Pattern - -Process historical data in parallel: - -```python -from datetime import datetime, timedelta -from metaflow import FlowSpec, Parameter, step - -class BackfillFlow(FlowSpec): - - start_date = Parameter('start_date', default='2024-01-01') - end_date = Parameter('end_date', default='2024-01-31') - - @step - def start(self): - """Generate list of dates to backfill.""" - start = datetime.strptime(self.start_date, '%Y-%m-%d') - end = datetime.strptime(self.end_date, '%Y-%m-%d') - - self.dates = [] - current = start - while current <= end: - self.dates.append(current.strftime('%Y-%m-%d')) - current += timedelta(days=1) - - print(f"Backfilling {len(self.dates)} days") - self.next(self.process_date, foreach='dates') - - @step - def process_date(self): - """Process each date in parallel.""" - date = self.input - - df = query_pandas_from_snowflake( - query=f"SELECT * FROM events WHERE date = '{date}'" - ) - - # Transform - result = transform(df) - - # Publish - publish_pandas( - table_name="backfill_results", - df=result, - auto_create_table=False, - overwrite=False, # Append mode - ) - - self.rows_processed = len(result) - self.next(self.join) - - @step - def join(self, inputs): - """Collect statistics.""" - total_rows = sum(inp.rows_processed for inp in inputs) - print(f"✅ Backfilled {total_rows:,} rows across {len(inputs)} days") - self.next(self.end) - - @step - def end(self): - pass -``` - -## Error Recovery - -### Retry Failed Steps - -```python -from metaflow import FlowSpec, step, retry - -class ResilientFlow(FlowSpec): - - @retry(times=3) # Retry up to 3 times - @step - def query_data(self): - """Query with retry.""" - try: - self.df = query_pandas_from_snowflake( - query="SELECT * FROM flaky_table", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", - ) - except Exception as e: - print(f"⚠️ Query failed: {e}") - raise # Will trigger retry - - self.next(self.process) -``` - -### Checkpoint Pattern - -Save intermediate results to resume from failures: - -```python -@step -def process_with_checkpoints(self): - """Process with checkpoints.""" - results = [] - - for i, chunk in enumerate(self.chunks): - try: - result = process_chunk(chunk) - results.append(result) - - # Checkpoint every 10 chunks - if i % 10 == 0: - checkpoint_df = pd.concat(results) - publish_pandas( - table_name="checkpoint_results", - df=checkpoint_df, - auto_create_table=True, - overwrite=True, # Replace mode - ) - print(f"✅ Checkpoint saved at chunk {i}") - - except Exception as e: - print(f"❌ Failed at chunk {i}: {e}") - # Can resume from last checkpoint - raise - - self.results = pd.concat(results) - self.next(self.publish) -``` - -## Testing Patterns - -### Test with Sampled Data - -```python -from metaflow import FlowSpec, Parameter, step - -class TestableFlow(FlowSpec): - - sample_size = Parameter( - 'sample_size', - type=int, - default=None, - help='Sample size for testing (None = all data)' - ) - - @step - def start(self): - """Query with optional sampling.""" - query = "SELECT * FROM large_table" - - if self.sample_size is not None: - query += f" LIMIT {self.sample_size}" - print(f"📊 Testing with {self.sample_size} rows") - - self.df = query_pandas_from_snowflake(query=query) - self.next(self.process) -``` - -Run with: `python flow.py run --sample_size 1000` - -### Dry Run Pattern - -```python -class ProductionFlow(FlowSpec): - - dry_run = Parameter( - 'dry_run', - type=bool, - default=False, - help='If True, skip publishing' - ) - - @step - def publish_results(self): - """Publish or dry-run.""" - if self.dry_run: - print(f"🔍 DRY RUN: Would publish {len(self.df)} rows") - print(self.df.head()) - else: - publish_pandas(table_name="results", df=self.df) - print(f"✅ Published {len(self.df)} rows") - - self.next(self.end) -``` - -Run with: `python flow.py run --dry_run True` - -## Additional Resources - -- [Best Practices](best_practices.md) -- [Performance Tuning](performance_tuning.md) -- [Troubleshooting](troubleshooting.md) -- [Getting Started](getting_started.md) diff --git a/docs/guides/getting_started.md b/docs/guides/getting_started.md deleted file mode 100644 index e2ea9fb..0000000 --- a/docs/guides/getting_started.md +++ /dev/null @@ -1,275 +0,0 @@ -# Getting Started with ds-platform-utils - -[← Back to Main Docs](../README.md) - -This guide will help you get started with `ds-platform-utils` for building ML workflows on Pattern's data platform. - -## Prerequisites - -**Metaflow with Outerbounds** - That's it! Outerbounds automatically handles: -- ✅ Snowflake authentication and connections -- ✅ AWS credentials and S3 access -- ✅ Warehouse management -- ✅ Query tagging for cost tracking - -No manual configuration required. - -## Installation - -### Production Use - -```bash -pip install git+https://github.com/patterninc/ds-platform-utils.git -``` - -### Development - -```bash -git clone https://github.com/patterninc/ds-platform-utils.git -cd ds-platform-utils -uv sync -``` - -## Your First Query - -Let's start with a simple example: querying data from Snowflake into a pandas DataFrame. - -```python -from ds_platform_utils.metaflow import query_pandas_from_snowflake - -# Query data from Snowflake -df = query_pandas_from_snowflake( - query=""" - SELECT * - FROM pattern_db.data_science.my_table - LIMIT 1000 - """, - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", -) - -print(f"Retrieved {len(df)} rows") -print(df.head()) -``` - -### What's Happening Here? - -1. The library connects to Snowflake using Metaflow's integration -2. Executes your SQL query -3. Handles timezone conversion (UTC by default) -4. Returns a pandas DataFrame - -## Your First Data Publication - -Now let's publish some results back to Snowflake: - -```python -from ds_platform_utils.metaflow import publish_pandas -import pandas as pd - -# Create some sample results -results_df = pd.DataFrame({ - 'id': [1, 2, 3], - 'prediction': [0.8, 0.6, 0.9], - 'confidence': [0.95, 0.82, 0.91] -}) - -# Publish to Snowflake -publish_pandas( - table_name="my_predictions", - df=results_df, - auto_create_table=True, # Creates table if it doesn't exist - overwrite=True, # Replaces existing data - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", -) - -print("✅ Results published successfully!") -``` - -## Your First Metaflow Flow - -Let's combine everything into a simple Metaflow flow: - -```python -from metaflow import FlowSpec, step -from ds_platform_utils.metaflow import ( - query_pandas_from_snowflake, - publish_pandas -) - -class SimpleMLFlow(FlowSpec): - """A simple ML workflow.""" - - @step - def start(self): - """Query training data.""" - print("📊 Querying training data...") - self.df = query_pandas_from_snowflake( - query=""" - SELECT * - FROM pattern_db.data_science_stage.training_features - WHERE date >= '2024-01-01' - LIMIT 10000 - """, - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", - ) - print(f" Retrieved {len(self.df)} rows") - self.next(self.train) - - @step - def train(self): - """Train a simple model.""" - print("🤖 Training model...") - # Your model training code here - # For demo, just create predictions - self.predictions = self.df[['id']].copy() - self.predictions['prediction'] = 0.5 - self.next(self.publish_results) - - @step - def publish_results(self): - """Publish predictions to Snowflake.""" - print("📤 Publishing results...") - publish_pandas( - table_name="simple_ml_predictions", - df=self.predictions, - auto_create_table=True, - overwrite=True, - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", - ) - print("✅ Flow complete!") - self.next(self.end) - - @step - def end(self): - """Flow end.""" - pass - -if __name__ == '__main__': - SimpleMLFlow() -``` - -Run the flow: - -```bash -python simple_ml_flow.py run -``` - -## Working with Large Datasets - -For datasets larger than a few GB, use S3 staging: - -```python -from ds_platform_utils.metaflow import query_pandas_from_snowflake - -# For large datasets, enable S3 staging -df = query_pandas_from_snowflake( - query="SELECT * FROM very_large_table", - use_s3_stage=True, # ← This enables S3 staging - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", -) -``` - -### When to Use S3 Staging? - -| Data Size | Method | Reason | -| --------- | --------------------------------- | -------------------------------- | -| < 1 GB | Direct | Simpler, faster for small data | -| 1-10 GB | S3 Stage | More reliable, prevents timeouts | -| > 10 GB | S3 Stage + BatchInferencePipeline | Parallel processing required | - -## Batch Inference - -For very large-scale predictions, use `BatchInferencePipeline`: - -```python -from metaflow import FlowSpec, step -from ds_platform_utils.metaflow import BatchInferencePipeline - -class BatchPredictionFlow(FlowSpec): - - @step - def start(self): - """Setup pipeline and create worker batches.""" - self.pipeline = BatchInferencePipeline() - self.worker_ids = self.pipeline.query_and_batch( - input_query="SELECT * FROM large_input_table", - parallel_workers=5, # Split work across 5 workers - ) - self.next(self.predict, foreach='worker_ids') - - @step - def predict(self): - """Process one batch in parallel.""" - def my_predict_fn(df): - # Your prediction logic - df['prediction'] = 0.5 # Replace with actual model - return df[['id', 'prediction']] - - self.pipeline.process_batch( - worker_id=self.input, - predict_fn=my_predict_fn, - ) - self.next(self.join) - - @step - def join(self, inputs): - """Merge results and publish.""" - self.pipeline = inputs[0].pipeline - self.pipeline.publish_results( - output_table_name="batch_predictions", - ) - self.next(self.end) - - @step - def end(self): - print("✅ Batch inference complete!") - -if __name__ == '__main__': - BatchPredictionFlow() -``` - -## Dev vs Prod - -The library automatically handles dev/prod schema separation: - -```python -# In development (default Metaflow perimeter) -publish_pandas( - table_name="my_table", # Goes to: pattern_db.data_science_stage.my_table - df=df, -) - -# In production (production Metaflow perimeter) -# Same code, but goes to: pattern_db.data_science.my_table -``` - -## Understanding Warehouses - -Pattern provides several Snowflake warehouses: - -| Warehouse | Size | Use Case | -| ---------- | ----------- | ---------------------------------- | -| `*_XS_WH` | Extra Small | Quick queries, small data | -| `*_MED_WH` | Medium | Medium workloads, ML training | -| `*_XL_WH` | Extra Large | Large batch jobs, heavy processing | - -Choose based on your workload: -- **Development**: Use `_DEV_` warehouses -- **Production**: Use `_PROD_` warehouses -- **Shared**: Use `_SHARED_` for general work -- **ADS**: Use `_ADS_` for ads-specific work - -## Next Steps - -Now that you've got the basics: - -1. 📖 Learn [Common Patterns](common_patterns.md) for typical workflows -2. 🎯 Review [Best Practices](best_practices.md) for production code -3. 🔧 Check out [Performance Tuning](performance_tuning.md) for optimization -4. 🔍 Explore the complete [API Reference](../api/index.md) - -## Need Help? - -- 📚 Check the [Troubleshooting Guide](troubleshooting.md) -- 💬 Ask in the #data-science-platform Slack channel -- 🐛 Report issues on GitHub diff --git a/docs/guides/performance_tuning.md b/docs/guides/performance_tuning.md deleted file mode 100644 index 1569f0f..0000000 --- a/docs/guides/performance_tuning.md +++ /dev/null @@ -1,461 +0,0 @@ -# Performance Tuning Guide - -[← Back to Main Docs](../README.md) - -Optimize your workflows for speed, cost, and reliability. - -## Table of Contents - -- [Understanding Performance Bottlenecks](#understanding-performance-bottlenecks) -- [Snowflake Optimization](#snowflake-optimization) -- [S3 Staging Optimization](#s3-staging-optimization) -- [Metaflow Parallelization](#metaflow-parallelization) -- [Memory Management](#memory-management) -- [Cost Optimization](#cost-optimization) - -## Understanding Performance Bottlenecks - -Common bottlenecks in data pipelines: - -1. **Snowflake Query Time** - Complex queries, large scans, inefficient joins -2. **Data Transfer** - Moving large datasets between Snowflake and Python -3. **Python Processing** - CPU-intensive operations (ML inference, transformations) -4. **Memory Constraints** - Loading datasets larger than available RAM -5. **Sequential Processing** - Not leveraging parallelization - -## Snowflake Optimization - -### Query Performance - -#### ✅ Use Query Tags for Monitoring - -```python -from ds_platform_utils.metaflow import add_query_tags - -query = add_query_tags( - query="SELECT * FROM large_table", - flow_name="MyFlow", - step_name="query_data", -) -``` - -This adds metadata for tracking query performance in Snowflake. - -#### ✅ Filter Early and Aggressively - -```python -# ❌ Bad - returns 100M rows, filters in Python -df = query_pandas_from_snowflake( - query="SELECT * FROM events" -) -df = df[df['date'] >= '2024-01-01'] - -# ✅ Good - returns only needed rows -df = query_pandas_from_snowflake( - query=""" - SELECT * - FROM events - WHERE date >= '2024-01-01' - """ -) -``` - -#### ✅ Select Only Needed Columns - -```python -# ❌ Bad - returns all 50 columns -df = query_pandas_from_snowflake( - query="SELECT * FROM wide_table" -) - -# ✅ Good - returns only 5 needed columns -df = query_pandas_from_snowflake( - query=""" - SELECT user_id, feature_1, feature_2, feature_3, target - FROM wide_table - """ -) -``` - -#### ✅ Use Clustering Keys - -```sql --- For tables with common filter patterns -ALTER TABLE events CLUSTER BY (date, user_id); - --- Helps queries like: -SELECT * FROM events -WHERE date >= '2024-01-01' - AND user_id IN (1, 2, 3); -``` - -### Warehouse Sizing - -Choose the right warehouse for your workload: - -| Warehouse | Use Case | Query Time | Cost | -| --------- | ---------------------------- | ---------- | --------- | -| XS | Small queries (<100K rows) | Slower | Low | -| S | Development, ad-hoc queries | Moderate | Low-Med | -| M | Regular production workloads | Fast | Medium | -| L | Large batch jobs (>10M rows) | Fast | High | -| XL | Massive parallel processing | Fastest | Very High | - -```python -# Size based on your workload -def get_warehouse(row_count: int) -> str: - """Get optimal warehouse for row count.""" - if row_count < 100_000: - return "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH" - elif row_count < 1_000_000: - return "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_S_WH" - elif row_count < 10_000_000: - return "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" - else: - return "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH" -``` - -### Query Result Caching - -Snowflake automatically caches query results for 24 hours: - -```python -# First run - hits database -df1 = query_pandas_from_snowflake( - query="SELECT COUNT(*) FROM events WHERE date = '2024-01-01'" -) - -# Second run within 24h - returns from cache (instant!) -df2 = query_pandas_from_snowflake( - query="SELECT COUNT(*) FROM events WHERE date = '2024-01-01'" -) -``` - -## S3 Staging Optimization - -### When to Use S3 Staging - -**Enable S3 staging when:** -- Dataset > 1 GB -- Network bandwidth is limited -- Query returns many columns (wide tables) - -```python -# Automatically use S3 for large results -df = query_pandas_from_snowflake( - query="SELECT * FROM large_table", - use_s3_stage=True, # ← Enable for datasets > 1GB - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", -) -``` - -### Performance Comparison - -| Dataset Size | Without S3 | With S3 | Speedup | -| ------------ | ---------- | ------- | -------------- | -| 100 MB | 30s | 35s | 0.86x (slower) | -| 500 MB | 2.5min | 1.5min | 1.67x | -| 2 GB | 10min | 3min | 3.33x | -| 10 GB | 50min | 12min | 4.17x | - -### Optimize S3 File Size - -```python -# For BatchInferencePipeline -pipeline = BatchInferencePipeline() - -# Adjust batch size based on row width -# Wide tables (many columns) → smaller batches -pipeline.query_and_batch( - input_query="SELECT * FROM wide_table", # 100 columns - batch_size_in_mb=128, # Smaller batches for wide tables - parallel_workers=20, -) - -# Narrow tables (few columns) → larger batches -pipeline.query_and_batch( - input_query="SELECT id, value FROM narrow_table", # 2 columns - batch_size_in_mb=512, # Larger batches for narrow tables - parallel_workers=10, -) -``` - -## Metaflow Parallelization - -### Simple Parallelization with foreach - -```python -from metaflow import FlowSpec, step - -class ParallelFlow(FlowSpec): - - @step - def start(self): - """Split work into chunks.""" - self.chunks = list(range(10)) # 10 parallel tasks - self.next(self.process, foreach='chunks') - - @step - def process(self): - """Process each chunk in parallel.""" - chunk_id = self.input - # Process chunk... - self.result = f"Processed {chunk_id}" - self.next(self.join) - - @step - def join(self, inputs): - """Collect results.""" - self.results = [inp.result for inp in inputs] - self.next(self.end) - - @step - def end(self): - print(f"Processed {len(self.results)} chunks") -``` - -### Parallel Batch Inference - -```python -@step -def query_and_split(self): - """Query and split into batches.""" - pipeline = BatchInferencePipeline() - self.worker_ids = pipeline.query_and_batch( - input_query="SELECT * FROM input_data", - batch_size_in_mb=256, - parallel_workers=20, # 20 parallel tasks - ) - self.next(self.process_batch, foreach='worker_ids') - -@step -def process_batch(self): - """Process each batch in parallel.""" - worker_id = self.input - pipeline = BatchInferencePipeline() - - # This runs in parallel across 20 workers - pipeline.process_batch( - worker_id=worker_id, - predict_fn=my_prediction_function, - batch_size_in_mb=64, - ) - self.next(self.join_batches) -``` - -### Optimizing Parallel Workers - -Choose the number of workers based on: - -1. **Dataset Size**: Larger datasets → more workers -2. **Processing Time**: Longer processing → more workers -3. **Cost**: More workers = more compute cost - -```python -def calculate_optimal_workers(total_rows: int, processing_time_per_row: float) -> int: - """Calculate optimal number of parallel workers.""" - # Target: each worker processes ~30 minutes of work - target_time_minutes = 30 - rows_per_minute = 60 / processing_time_per_row - rows_per_worker = target_time_minutes * rows_per_minute - - workers = max(1, int(total_rows / rows_per_worker)) - return min(workers, 50) # Cap at 50 workers - -# Example -total_rows = 10_000_000 -time_per_row = 0.1 # seconds -workers = calculate_optimal_workers(total_rows, time_per_row) -print(f"Use {workers} workers") # → 28 workers -``` - -## Memory Management - -### Chunked Processing - -Process large datasets in chunks to avoid memory issues: - -```python -def process_in_chunks(df: pd.DataFrame, chunk_size: int = 10000): - """Process DataFrame in chunks.""" - results = [] - - for i in range(0, len(df), chunk_size): - chunk = df.iloc[i:i+chunk_size] - result = process_chunk(chunk) - results.append(result) - - # Free memory - del chunk - gc.collect() - - return pd.concat(results) -``` - -### Monitor Memory Usage - -```python -import psutil -import os - -def log_memory_usage(step_name: str): - """Log current memory usage.""" - process = psutil.Process(os.getpid()) - mem_mb = process.memory_info().rss / 1024 / 1024 - print(f"📊 {step_name}: Memory usage = {mem_mb:.1f} MB") - -@step -def process_data(self): - log_memory_usage("start") - - df = query_pandas_from_snowflake(query="...") - log_memory_usage("after_query") - - df = process(df) - log_memory_usage("after_process") - - publish_pandas(table_name="results", df=df) - log_memory_usage("after_publish") -``` - -### Use Appropriate Data Types - -```python -# ❌ Bad - uses default types -df = pd.DataFrame({ - 'id': [1, 2, 3], # int64 (8 bytes per value) - 'category': ['A', 'B', 'C'], # object (variable size) - 'value': [1.0, 2.0, 3.0], # float64 (8 bytes per value) -}) - -# ✅ Good - optimize types -df = df.astype({ - 'id': 'int32', # 4 bytes per value (50% memory reduction) - 'category': 'category', # Much smaller for repeated values - 'value': 'float32', # 4 bytes per value (50% memory reduction) -}) - -# For 10M rows, this saves ~150 MB memory! -``` - -## Cost Optimization - -### Warehouse Auto-suspend - -Warehouses auto-suspend after inactivity, but you can optimize timing: - -```sql --- Set shorter auto-suspend for dev warehouses -ALTER WAREHOUSE OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH -SET AUTO_SUSPEND = 60; -- Suspend after 1 minute - --- Longer auto-suspend for production (avoid cold starts) -ALTER WAREHOUSE OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_MED_WH -SET AUTO_SUSPEND = 600; -- Suspend after 10 minutes -``` - -### Query Result Caching - -Leverage Snowflake's result cache to avoid redundant queries: - -```python -# Development: run queries multiple times during debugging -# → Results are cached, subsequent runs are free! - -@step -def explore_data(self): - # First run: hits database (costs money) - df = query_pandas_from_snowflake( - query="SELECT * FROM my_table WHERE date = '2024-01-01'" - ) - print(df.head()) # Check data - - # Realize you need to adjust something... - - # Re-run flow: uses cached result (free!) - df = query_pandas_from_snowflake( - query="SELECT * FROM my_table WHERE date = '2024-01-01'" # Same query - ) -``` - -### Right-size Your Work - -```python -# Development: use smaller datasets -if not is_production(): - query += " LIMIT 10000" # Only 10K rows for testing - -# Production: use full dataset -df = query_pandas_from_snowflake(query=query) -``` - -### Monitor Costs - -```sql --- Check warehouse usage -SELECT - warehouse_name, - SUM(credits_used) as total_credits, - SUM(credits_used) * 3 as estimated_cost_usd -- ~$3 per credit -FROM snowflake.account_usage.warehouse_metering_history -WHERE start_time >= DATEADD(day, -7, CURRENT_TIMESTAMP()) -GROUP BY warehouse_name -ORDER BY total_credits DESC; -``` - -## Performance Checklist - -Before running large workloads: - -- [ ] Query filters data as early as possible -- [ ] Only SELECT needed columns -- [ ] Using appropriate warehouse size -- [ ] S3 staging enabled for datasets > 1GB -- [ ] Parallel processing configured for long-running tasks -- [ ] Memory usage monitored and optimized -- [ ] Data types optimized (int32, float32, category) -- [ ] Result caching leveraged for development -- [ ] Chunk size tuned for workload -- [ ] Cost tracking enabled - -## Benchmarking - -Use this template to benchmark your optimizations: - -```python -import time - -def benchmark_query(query: str, use_s3: bool = False) -> dict: - """Benchmark a query.""" - start = time.time() - - df = query_pandas_from_snowflake( - query=query, - use_s3_stage=use_s3, - ) - - duration = time.time() - start - - return { - 'duration_seconds': duration, - 'rows': len(df), - 'columns': len(df.columns), - 'memory_mb': df.memory_usage(deep=True).sum() / 1024 / 1024, - 'rows_per_second': len(df) / duration, - } - -# Test both approaches -results_no_s3 = benchmark_query(my_query, use_s3=False) -results_with_s3 = benchmark_query(my_query, use_s3=True) - -print(f"Without S3: {results_no_s3['duration_seconds']:.1f}s") -print(f"With S3: {results_with_s3['duration_seconds']:.1f}s") -print(f"Speedup: {results_no_s3['duration_seconds'] / results_with_s3['duration_seconds']:.2f}x") -``` - -## Additional Resources - -- [Best Practices](best_practices.md) -- [Common Patterns](common_patterns.md) -- [Troubleshooting](troubleshooting.md) -- [Snowflake Query Performance Guide](https://docs.snowflake.com/en/user-guide/ui-snowsight-query-performance) diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md deleted file mode 100644 index 24e5df3..0000000 --- a/docs/guides/troubleshooting.md +++ /dev/null @@ -1,556 +0,0 @@ -# Troubleshooting Guide - -[← Back to Main Docs](../README.md) - -Solutions to common issues and error messages. - -## Table of Contents - -- [Snowflake Connection Issues](#snowflake-connection-issues) -- [Query Errors](#query-errors) -- [Memory Errors](#memory-errors) -- [S3 Staging Issues](#s3-staging-issues) -- [Batch Inference Errors](#batch-inference-errors) -- [Metaflow Issues](#metaflow-issues) -- [Publishing Errors](#publishing-errors) - -## Snowflake Connection Issues - -**Note:** Outerbounds automatically handles all Snowflake authentication and connections. If you're seeing connection issues, contact your platform team. - -### Error: "Warehouse does not exist" - -**Cause**: Wrong warehouse name or no access. - -**Solution**: Use one of the standard warehouse names: - -```python -# Development warehouses -"OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH" # Extra small -"OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" # Medium (default) -"OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH" # Extra large - -# Example usage -query_pandas_from_snowflake( - query="SELECT * FROM table", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", -) -``` - -## Query Errors - -### Error: "SQL compilation error: Object does not exist" - -**Cause**: Table/view not found or no access. - -**Solution**: Verify table path and permissions: - -```python -# Check table exists in Snowflake UI or verify the full path -# Format: database.schema.table_name -df = query_pandas_from_snowflake( - query="SELECT * FROM pattern_db.data_science.my_table LIMIT 10" -) -``` - -### Error: "Template variable not provided" - -**Cause**: Missing variable in `ctx` dictionary. - -**Solution**: -```python -# ❌ Bad - missing variable -query_pandas_from_snowflake( - query="sql/query.sql", # Uses {{start_date}} - ctx={"end_date": "2024-12-31"}, # Missing start_date! -) - -# ✅ Good - all variables provided -query_pandas_from_snowflake( - query="sql/query.sql", - ctx={ - "start_date": "2024-01-01", - "end_date": "2024-12-31", - }, -) -``` - -### Error: "Query timeout exceeded" - -**Cause**: Query takes too long. - -**Solutions**: - -1. **Optimize query** (filter early, select fewer columns) -2. **Use larger warehouse** -3. **Increase timeout** - -```python -query_pandas_from_snowflake( - query="SELECT * FROM huge_table", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", # Larger warehouse - timeout_seconds=1800, # 30 minutes -) -``` - -### Error: "Statement reached its statement or warehouse timeout" - -**Cause**: Query exceeded warehouse timeout. - -**Solution**: Break query into smaller chunks or use intermediate tables: - -```sql --- Instead of one massive query -CREATE TEMPORARY TABLE temp_results AS -SELECT * FROM huge_table -WHERE date >= '2024-01-01'; - --- Then query the smaller result -SELECT * FROM temp_results; -``` - -## Memory Errors - -### Error: "MemoryError" or "Killed" - -**Cause**: Dataset too large for available RAM. - -**Solutions**: - -#### Option 1: Use S3 Staging - -```python -df = query_pandas_from_snowflake( - query="SELECT * FROM large_table", - use_s3_stage=True, # ← Reduces memory pressure -) -``` - -#### Option 2: Process in Chunks - -```python -def process_in_chunks(query: str, chunk_size: int = 100000): - """Process large query in chunks.""" - offset = 0 - results = [] - - while True: - chunk_query = f"{query} LIMIT {chunk_size} OFFSET {offset}" - chunk = query_pandas_from_snowflake(query=chunk_query) - - if len(chunk) == 0: - break - - # Process chunk - result = process(chunk) - results.append(result) - - offset += chunk_size - - return pd.concat(results) -``` - -#### Option 3: Use BatchInferencePipeline - -```python -# For very large datasets (> 10M rows) -pipeline = BatchInferencePipeline() -worker_ids = pipeline.query_and_batch( - input_query="SELECT * FROM huge_table", - parallel_workers=20, # Split into 20 batches -) -``` - -#### Option 4: Optimize Data Types - -```python -# After loading data -df = df.astype({ - 'int_col': 'int32', # Instead of int64 - 'float_col': 'float32', # Instead of float64 - 'category_col': 'category', # For repeated values -}) - -# Can reduce memory by 50%+ -``` - -### Error: "DataFrame too large to serialize" - -**Cause**: Metaflow cannot serialize large DataFrames between steps. - -**Solution**: Don't pass large DataFrames, use temporary tables instead: - -```python -@step -def query_data(self): - """Query and store in temp table.""" - cursor = get_snowflake_connection() - cursor.execute(""" - CREATE TEMPORARY TABLE temp_my_data AS - SELECT * FROM large_table - WHERE date >= '2024-01-01' - """) - - # Just pass the table name, not the data - self.temp_table = "temp_my_data" - self.next(self.process) - -@step -def process(self): - """Query from temp table.""" - self.df = query_pandas_from_snowflake( - query=f"SELECT * FROM {self.temp_table}" - ) - # Process... -``` - -## S3 Staging Issues - -**Note:** Outerbounds automatically handles all S3 access and permissions. - -### Error: "S3 upload failed" - -**Cause**: Temporary S3 issue or permissions problem. - -**Solution**: -1. Retry the operation - transient S3 issues usually resolve -2. If persistent, contact your platform team -3. Check Metaflow logs for specific error details - -### Error: "Slow performance with S3 staging" - -**Cause**: Too many small files or inefficient batch size. - -**Solution**: Tune batch size: - -```python -# For wide tables (many columns) -pipeline.query_and_batch( - input_query="SELECT * FROM wide_table", - batch_size_in_mb=128, # Smaller batches - parallel_workers=30, -) - -# For narrow tables (few columns) -pipeline.query_and_batch( - input_query="SELECT id, value FROM narrow_table", - batch_size_in_mb=512, # Larger batches - parallel_workers=10, -) -``` - -## Batch Inference Errors - -### Error: "Cannot process batch before query_and_batch" - -**Cause**: Trying to call `process_batch()` before `query_and_batch()`. - -**Solution**: Follow the correct order: - -```python -# ✅ Correct order -pipeline = BatchInferencePipeline() - -# 1. Query and split -worker_ids = pipeline.query_and_batch(...) - -# 2. Process each batch -for worker_id in worker_ids: - pipeline.process_batch(worker_id, ...) - -# 3. Publish results -pipeline.publish_results(...) -``` - -### Error: "Cannot publish before processing" - -**Cause**: Trying to `publish_results()` before processing any batches. - -**Solution**: Ensure at least one batch is processed: - -```python -@step -def process_batches(self): - worker_id = self.input - pipeline = BatchInferencePipeline() - - # Process this batch - pipeline.process_batch( - worker_id=worker_id, - predict_fn=my_predict_fn, - ) - - self.next(self.join) - -@step -def join(self, inputs): - """Now safe to publish.""" - self.pipeline = inputs[0].pipeline - self.pipeline.publish_results( - output_table_name="predictions", - ) - self.next(self.end) -``` - -### Error: "Worker ID not found" - -**Cause**: Invalid worker ID or not from current pipeline. - -**Solution**: Use worker IDs from `query_and_batch()`: - -```python -# ❌ Bad - made-up worker ID -pipeline.process_batch(worker_id=999, ...) - -# ✅ Good - use returned worker IDs -worker_ids = pipeline.query_and_batch(...) -for worker_id in worker_ids: # Use these IDs - pipeline.process_batch(worker_id=worker_id, ...) -``` - -### Error: "Prediction function failed" - -**Cause**: Exception in your `predict_fn`. - -**Solution**: Test your function separately: - -```python -# Test predict_fn with sample data -sample_df = pd.DataFrame({ - 'feature_1': [1, 2, 3], - 'feature_2': [4, 5, 6], -}) - -try: - result = my_predict_fn(sample_df) - print("✅ Predict function works") - print(result.head()) -except Exception as e: - print(f"❌ Predict function failed: {e}") - import traceback - traceback.print_exc() -``` - -## Metaflow Issues - -### Error: "Step failed with StepTimeout" - -**Cause**: Step exceeded time limit. - -**Solutions**: - -1. **Increase timeout**: -```python -@timeout(seconds=7200) # 2 hours -@step -def long_running_step(self): - # Your code... -``` - -2. **Optimize processing** (see [Performance Tuning](performance_tuning.md)) - -3. **Split into parallel tasks**: -```python -@step -def split_work(self): - self.chunks = range(10) - self.next(self.process_chunk, foreach='chunks') - -@step -def process_chunk(self): - # Process one chunk (faster) - chunk_id = self.input - # Your code... -``` - -### Error: "Resume failed" - -**Cause**: Metaflow cannot resume from checkpoint. - -**Solution**: Re-run from start or from different step: - -```bash -# Re-run entire flow -python flow.py run - -# Resume from specific step -python flow.py resume --origin-run-id - -# Re-run from specific step -python flow.py run --start-at process_data -``` - -### Error: "Parameter validation failed" - -**Cause**: Invalid parameter value. - -**Solution**: Check parameter constraints: - -```python -from pydantic import BaseModel, validator - -class Config(BaseModel): - date: str - - @validator('date') - def validate_date(cls, v): - """Validate date format.""" - try: - datetime.strptime(v, '%Y-%m-%d') - return v - except ValueError: - raise ValueError(f"Invalid date format: {v}. Use YYYY-MM-DD") -``` - -## Publishing Errors - -### Error: "Table already exists" - -**Cause**: Table exists and overwrite is not enabled. - -**Solution**: Enable overwrite or auto_create_table: - -```python -publish_pandas( - table_name="my_table", - df=df, - auto_create_table=True, - overwrite=True, # Drops table first then creates new -) -``` - -### Error: "Permission denied" - -**Cause**: No write access to schema. - -**Note**: The schema is automatically selected based on production/dev environment: -- In production: writes to PROD_SCHEMA -- In dev: writes to DEV_SCHEMA - -**Solution**: Ensure you're running in the correct environment: - -```python -# Schema is automatically selected -publish_pandas( - table_name="my_table", - df=df, - auto_create_table=True, -) -``` - -### Error: "Column name mismatch" - -**Cause**: DataFrame columns don't match target table. - -**Solution**: - -```python -# Check current columns -print(df.columns.tolist()) - -# Rename to match target -df = df.rename(columns={ - 'old_name': 'new_name', -}) - -# Or select specific columns -df = df[['col1', 'col2', 'col3']] - -publish_pandas(table_name="my_table", df=df) -``` - -## General Debugging Tips - -### Enable Verbose Logging - -```python -import logging - -logging.basicConfig(level=logging.DEBUG) - -# Now you'll see more detailed output -df = query_pandas_from_snowflake(query="...") -``` - -### Check Snowflake Query History - -```sql --- View recent queries -SELECT - query_id, - query_text, - execution_status, - error_message, - total_elapsed_time / 1000 as seconds -FROM table(information_schema.query_history()) -WHERE user_name = CURRENT_USER() -ORDER BY start_time DESC -LIMIT 10; -``` - -### Test SQL Separately - -Before running in Metaflow, test SQL in Snowflake UI: - -1. Copy your SQL query -2. Run in Snowflake console -3. Check results -4. Fix issues -5. Then use in flow - -### Isolate the Problem - -```python -# Instead of running full flow -python flow.py run - -# Run just one step -python flow.py step query_data -``` - -### Use Restore Step State - -For debugging Metaflow flows: - -```python -from ds_platform_utils.metaflow import restore_step_state - -# Restore state from previous run -with restore_step_state("MyFlow", run_id="123", step="process"): - # Access self.df and other artifacts - print(self.df.head()) - - # Debug your processing logic - result = process(self.df) - print(result.head()) -``` - -## Getting Help - -If you're still stuck: - -1. **Check the logs**: Full error messages often contain the solution -2. **Review the docs**: [Getting Started](getting_started.md), [Best Practices](best_practices.md) -3. **Search Snowflake docs**: [docs.snowflake.com](https://docs.snowflake.com) -4. **Search Metaflow docs**: [docs.metaflow.org](https://docs.metaflow.org) -5. **Ask your team**: Someone may have seen the issue before - -## Common Error Patterns - -| Error Message | Likely Cause | Solution | -| ----------------------------------- | ----------------------- | ---------------------------- | -| "Object does not exist" | Table/schema name wrong | Check table path | -| "Warehouse does not exist" | Wrong warehouse name | Use standard warehouse names | -| "MemoryError" | DataFrame too large | Use S3 staging or chunks | -| "Timeout exceeded" | Query too slow | Optimize query or warehouse | -| "Permission denied" | No write access | Use dev schema | -| "Template variable not provided" | Missing ctx variable | Add to ctx dict | -| "Cannot process batch before query" | Wrong order | Call query_and_batch() first | -| "Serialization failed" | Object too large | Use temp tables | - -## Additional Resources - -- [Best Practices](best_practices.md) -- [Performance Tuning](performance_tuning.md) -- [Common Patterns](common_patterns.md) -- [Getting Started](getting_started.md) diff --git a/docs/metaflow/README.md b/docs/metaflow/README.md index 141155b..c89f4dd 100644 --- a/docs/metaflow/README.md +++ b/docs/metaflow/README.md @@ -295,10 +295,8 @@ class DataPipelineFlow(FlowSpec): ## Next Steps -- 📖 Read the [Getting Started Guide](../guides/getting_started.md) -- 🎯 Check out [Common Patterns](../guides/common_patterns.md) -- 🔧 Review [Best Practices](../guides/best_practices.md) -- 🐛 See [Troubleshooting](../guides/troubleshooting.md) +- 📖 Check the [API Reference](../api/index.md) +- 🎯 See the [Examples](../examples/README.md) ## Related Modules diff --git a/docs/metaflow/pandas.md b/docs/metaflow/pandas.md index 72d7f38..98653aa 100644 --- a/docs/metaflow/pandas.md +++ b/docs/metaflow/pandas.md @@ -427,14 +427,6 @@ publish( 4. **Choose right warehouse**: Larger warehouse for larger datasets 5. **Optimize data types**: Use `int32`, `float32`, `category` to reduce memory -## Common Patterns - -See [Common Patterns Guide](../guides/common_patterns.md) for more examples. - -## Troubleshooting - -See [Troubleshooting Guide](../guides/troubleshooting.md) for solutions to common issues. - ## Related Documentation - [BatchInferencePipeline](batch_inference_pipeline.md) - For very large datasets diff --git a/docs/metaflow/validate_config.md b/docs/metaflow/validate_config.md index 0ebe7bf..895abc1 100644 --- a/docs/metaflow/validate_config.md +++ b/docs/metaflow/validate_config.md @@ -474,6 +474,4 @@ config = Config(threshold=0.5) # Float ## Related Documentation -- [Best Practices](../guides/best_practices.md) -- [Common Patterns](../guides/common_patterns.md) - [Pydantic Documentation](https://docs.pydantic.dev/) diff --git a/docs/snowflake/README.md b/docs/snowflake/README.md index 0255ff0..1b3fb0a 100644 --- a/docs/snowflake/README.md +++ b/docs/snowflake/README.md @@ -281,11 +281,8 @@ class MyFlow(FlowSpec): - [Metaflow Pandas Integration](../metaflow/pandas.md) - High-level query/publish functions - [API Reference](../api/index.md) - Complete function signatures -- [Troubleshooting](../guides/troubleshooting.md) - Common Snowflake issues -- [Best Practices](../guides/best_practices.md) - Production patterns ## See Also -- [Getting Started Guide](../guides/getting_started.md) - [Write-Audit-Publish Examples](../examples/README.md) - [Snowflake Official Docs](https://docs.snowflake.com/) From 0f79a589af02b4a6be5c653d24516ed601383c87 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:30:44 +0530 Subject: [PATCH 106/167] feat: add polars dependency and update S3 data retrieval to use polars for parquet files --- pyproject.toml | 1 + src/ds_platform_utils/metaflow/s3.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index eff2544..f79845f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "pandas", "jinja2", "sqlparse>=0.5.3", + "polars>=1.36.1", ] [dependency-groups] diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 587337e..85edcef 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -2,6 +2,7 @@ from pathlib import Path import pandas as pd +import polars as pl from metaflow import S3, current @@ -22,7 +23,7 @@ def _get_df_from_s3_file(path: str) -> pd.DataFrame: raise ValueError("Invalid S3 URI. Must start with 's3://'.") with _get_metaflow_s3_client() as s3: - return pd.read_parquet(s3.get(path).path) + return pl.read_parquet(s3.get(path).path).to_pandas() def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: From 61c4acccf4b6d075b16ae64cc7965fdd17bf1ffd Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:32:42 +0530 Subject: [PATCH 107/167] fix: update S3 file retrieval to use polars for DataFrame conversion --- src/ds_platform_utils/metaflow/s3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 85edcef..9b741b3 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -32,7 +32,7 @@ def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: with _get_metaflow_s3_client() as s3: df_paths = [obj.path for obj in s3.get_many(paths)] - return pd.read_parquet(df_paths) + return pl.read_parquet(df_paths).to_pandas() def _get_df_from_s3_folder(path: str) -> pd.DataFrame: From 0ef141febec7e7e2ca6be8c4e9d5558d57f8443f Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:38:52 +0530 Subject: [PATCH 108/167] fix: replace polars with pandas for S3 parquet file retrieval --- src/ds_platform_utils/metaflow/s3.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 9b741b3..5d6e7c7 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -2,7 +2,6 @@ from pathlib import Path import pandas as pd -import polars as pl from metaflow import S3, current @@ -23,7 +22,7 @@ def _get_df_from_s3_file(path: str) -> pd.DataFrame: raise ValueError("Invalid S3 URI. Must start with 's3://'.") with _get_metaflow_s3_client() as s3: - return pl.read_parquet(s3.get(path).path).to_pandas() + return pd.read_parquet(s3.get(path).path, engine="pyarrow") def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: @@ -32,7 +31,7 @@ def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: with _get_metaflow_s3_client() as s3: df_paths = [obj.path for obj in s3.get_many(paths)] - return pl.read_parquet(df_paths).to_pandas() + return pd.read_parquet(df_paths, engine="pyarrow") def _get_df_from_s3_folder(path: str) -> pd.DataFrame: From 374d53980f95d4f0baabb92a860998be7fbb50e7 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:41:30 +0530 Subject: [PATCH 109/167] fix: update parquet engine from pyarrow to fastparquet for S3 file retrieval --- pyproject.toml | 1 + src/ds_platform_utils/metaflow/s3.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f79845f..67e8a6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "jinja2", "sqlparse>=0.5.3", "polars>=1.36.1", + "fastparquet>=2024.11.0", ] [dependency-groups] diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 5d6e7c7..fc0fef0 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -22,7 +22,7 @@ def _get_df_from_s3_file(path: str) -> pd.DataFrame: raise ValueError("Invalid S3 URI. Must start with 's3://'.") with _get_metaflow_s3_client() as s3: - return pd.read_parquet(s3.get(path).path, engine="pyarrow") + return pd.read_parquet(s3.get(path).path, engine="fastparquet") def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: @@ -31,7 +31,7 @@ def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: with _get_metaflow_s3_client() as s3: df_paths = [obj.path for obj in s3.get_many(paths)] - return pd.read_parquet(df_paths, engine="pyarrow") + return pd.read_parquet(df_paths, engine="fastparquet") def _get_df_from_s3_folder(path: str) -> pd.DataFrame: From a7a360534ac718d0b43c6e33cb29d15d2330b00d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:48:49 +0530 Subject: [PATCH 110/167] fix: update S3 DataFrame retrieval to use dtype_backend="pyarrow" --- src/ds_platform_utils/metaflow/s3.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index fc0fef0..24af6f6 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -22,7 +22,7 @@ def _get_df_from_s3_file(path: str) -> pd.DataFrame: raise ValueError("Invalid S3 URI. Must start with 's3://'.") with _get_metaflow_s3_client() as s3: - return pd.read_parquet(s3.get(path).path, engine="fastparquet") + return pd.read_parquet(s3.get(path).path, dtype_backend="pyarrow") def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: @@ -31,7 +31,7 @@ def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: with _get_metaflow_s3_client() as s3: df_paths = [obj.path for obj in s3.get_many(paths)] - return pd.read_parquet(df_paths, engine="fastparquet") + return pd.read_parquet(df_paths, dtype_backend="pyarrow") def _get_df_from_s3_folder(path: str) -> pd.DataFrame: From a9793b4c02c54d15c8350d9cc414f46fda0ccc1e Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Tue, 24 Feb 2026 13:26:25 +0530 Subject: [PATCH 111/167] fix: update S3 DataFrame retrieval to remove dtype_backend and improve file handling --- src/ds_platform_utils/metaflow/s3.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 24af6f6..a92595e 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -22,16 +22,18 @@ def _get_df_from_s3_file(path: str) -> pd.DataFrame: raise ValueError("Invalid S3 URI. Must start with 's3://'.") with _get_metaflow_s3_client() as s3: - return pd.read_parquet(s3.get(path).path, dtype_backend="pyarrow") + return pd.read_parquet(s3.get(path).path) def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: if any(not path.startswith("s3://") for path in paths): raise ValueError("Invalid S3 URI. All paths must start with 's3://'.") + dfs = [] with _get_metaflow_s3_client() as s3: - df_paths = [obj.path for obj in s3.get_many(paths)] - return pd.read_parquet(df_paths, dtype_backend="pyarrow") + for obj in s3.get_many(paths): + dfs.append(pd.read_parquet(obj.path)) + return pd.concat(dfs, ignore_index=True) def _get_df_from_s3_folder(path: str) -> pd.DataFrame: From c1af0b1aeb53f33bef5af61a2b803800473ee5cd Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:49:32 +0530 Subject: [PATCH 112/167] fix: optimize S3 DataFrame retrieval by consolidating file reading into a single call --- src/ds_platform_utils/metaflow/s3.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index a92595e..587337e 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -29,11 +29,9 @@ def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: if any(not path.startswith("s3://") for path in paths): raise ValueError("Invalid S3 URI. All paths must start with 's3://'.") - dfs = [] with _get_metaflow_s3_client() as s3: - for obj in s3.get_many(paths): - dfs.append(pd.read_parquet(obj.path)) - return pd.concat(dfs, ignore_index=True) + df_paths = [obj.path for obj in s3.get_many(paths)] + return pd.read_parquet(df_paths) def _get_df_from_s3_folder(path: str) -> pd.DataFrame: From 75a94a209c0f5cf2d3c3c67aee0eb4cb54b660dc Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:23:13 +0530 Subject: [PATCH 113/167] fix: update S3 DataFrame retrieval to use Polars for parquet files and cast decimal columns to float --- src/ds_platform_utils/metaflow/s3.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 587337e..9c6ca7d 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -2,6 +2,7 @@ from pathlib import Path import pandas as pd +import polars as pl from metaflow import S3, current @@ -31,7 +32,12 @@ def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: with _get_metaflow_s3_client() as s3: df_paths = [obj.path for obj in s3.get_many(paths)] - return pd.read_parquet(df_paths) + df = pl.read_parquet(df_paths) + # Cast decimal columns to float before converting to pandas + + decimal_cols = [col for col in df.columns if df[col].dtype == pl.Decimal] + df_casted = df.with_columns([pl.col(col).cast(pl.Float64) for col in decimal_cols]) + return df_casted.to_pandas(use_pyarrow_extension_array=False) def _get_df_from_s3_folder(path: str) -> pd.DataFrame: From c91426819a7a623ae408062f8c2e2af91898aa29 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:48:45 +0530 Subject: [PATCH 114/167] fix: cast decimal columns to float64 for pandas compatibility in S3 DataFrame retrieval --- src/ds_platform_utils/metaflow/s3.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 9c6ca7d..8a48138 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -34,9 +34,13 @@ def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: df_paths = [obj.path for obj in s3.get_many(paths)] df = pl.read_parquet(df_paths) # Cast decimal columns to float before converting to pandas - + print("Casting decimal columns to float64 for pandas compatibility...") + print(df.dtypes) decimal_cols = [col for col in df.columns if df[col].dtype == pl.Decimal] + print(f"Found decimal columns: {decimal_cols}") df_casted = df.with_columns([pl.col(col).cast(pl.Float64) for col in decimal_cols]) + print("Casting complete. Converting to pandas DataFrame...") + print(df_casted.dtypes) return df_casted.to_pandas(use_pyarrow_extension_array=False) From 8cf7b71c6d8769d6d7c7303f6d75a4d58259943e Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:58:02 +0530 Subject: [PATCH 115/167] fix: enhance S3 DataFrame retrieval by adding type mapping for decimal columns to ensure pandas compatibility --- src/ds_platform_utils/metaflow/s3.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 8a48138..2505c0b 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -6,6 +6,18 @@ from metaflow import S3, current +def correct_type_mapping(dtype): + try: + if dtype == pl.Decimal: + if dtype.scale == 0: + return pl.Int64 + else: + return pl.Float64 + return dtype + except Exception: + return dtype + + def _get_metaflow_s3_client(): return S3(role="arn:aws:iam::209479263910:role/outerbounds_iam_role") @@ -23,7 +35,8 @@ def _get_df_from_s3_file(path: str) -> pd.DataFrame: raise ValueError("Invalid S3 URI. Must start with 's3://'.") with _get_metaflow_s3_client() as s3: - return pd.read_parquet(s3.get(path).path) + dl = pl.read_parquet(s3.get(path).path) + return dl.with_columns([pl.col(i).cast(correct_type_mapping(dl[i].dtype)) for i in dl.columns]).to_pandas() def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: @@ -32,16 +45,8 @@ def _get_df_from_s3_files(paths: list[str]) -> pd.DataFrame: with _get_metaflow_s3_client() as s3: df_paths = [obj.path for obj in s3.get_many(paths)] - df = pl.read_parquet(df_paths) - # Cast decimal columns to float before converting to pandas - print("Casting decimal columns to float64 for pandas compatibility...") - print(df.dtypes) - decimal_cols = [col for col in df.columns if df[col].dtype == pl.Decimal] - print(f"Found decimal columns: {decimal_cols}") - df_casted = df.with_columns([pl.col(col).cast(pl.Float64) for col in decimal_cols]) - print("Casting complete. Converting to pandas DataFrame...") - print(df_casted.dtypes) - return df_casted.to_pandas(use_pyarrow_extension_array=False) + dl = pl.read_parquet(df_paths) + return dl.with_columns([pl.col(i).cast(correct_type_mapping(dl[i].dtype)) for i in dl.columns]).to_pandas() def _get_df_from_s3_folder(path: str) -> pd.DataFrame: From b2e202fdd580a4026b14c7e70d006826a737f11c Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:43:25 +0530 Subject: [PATCH 116/167] feat: enhance Snowflake integration by adding query tagging for cost tracking and refactoring connection handling --- src/ds_platform_utils/_snowflake/run_query.py | 137 ++++++++++++++- .../metaflow/batch_inference_pipeline.py | 2 +- src/ds_platform_utils/metaflow/pandas.py | 38 +---- src/ds_platform_utils/metaflow/s3_stage.py | 11 +- ..._connection.py => snowflake_connection.py} | 37 ++-- .../metaflow/write_audit_publish.py | 159 +----------------- .../test__get_select_dev_query_tags.py | 14 +- .../unit_tests/snowflake/test__execute_sql.py | 2 +- 8 files changed, 178 insertions(+), 222 deletions(-) rename src/ds_platform_utils/metaflow/{get_snowflake_connection.py => snowflake_connection.py} (76%) diff --git a/src/ds_platform_utils/_snowflake/run_query.py b/src/ds_platform_utils/_snowflake/run_query.py index 1b6b517..27a2c1c 100644 --- a/src/ds_platform_utils/_snowflake/run_query.py +++ b/src/ds_platform_utils/_snowflake/run_query.py @@ -1,12 +1,141 @@ """Shared Snowflake utility functions.""" +import json +import os import warnings -from typing import Iterable, Optional +from textwrap import dedent +from typing import Dict, Iterable, Optional +import sqlparse +from metaflow import current from snowflake.connector import SnowflakeConnection from snowflake.connector.cursor import SnowflakeCursor from snowflake.connector.errors import ProgrammingError +from ds_platform_utils._snowflake.run_query import _execute_sql + + +def get_select_dev_query_tags() -> Dict[str, Optional[str]]: + """Return tags for the current Metaflow flow run. + + These tags are used for cost tracking in select.dev. + See the select.dev docs on custom workload tags: + https://select.dev/docs/reference/integrations/custom-workloads#example-query-tag + + What the main tags mean and why we set them this way: + + "app": a broad category that groups queries by domain. We set app to the value of ds.domain + that we get from current tags of the flow, so queries are attributed to the right domain (for example, "Operations"). + + "workload_id": identifies the specific project or sub-unit inside that domain. + We set workload_id to the value of ds.project that we get from current tags of + the flow so select.dev can attribute costs to the exact project (for example, "out-of-stock"). + + For more granular attribution we have other tags: + + "flow_name": the flow name + + "step_name": the step within the flow + + "run_id": the unique id of the flow run + + "user": the username of the user who triggered the flow run (or argo-workflows if it's a deployed flow) + + "namespace": the namespace of the flow run + + "team": the team name, hardcoded as "data-science" for all flows + + **Note: all other tags are arbitrary. Add any extra key/value pairs that help you trace and group queries for cost reporting.** + """ + fetched_tags = current.tags + required_tags_are_present = any(tag.startswith("ds.project") for tag in fetched_tags) and any( + tag.startswith("ds.domain") for tag in fetched_tags + ) # Checking presence of both required Metaflow user tags in current tags of the flow + if not required_tags_are_present: + warnings.warn( + dedent(""" + Warning: ds-platform-utils attempted to add query tags to a Snowflake query + for cost tracking in select.dev, but one or both required Metaflow user tags + ('ds.domain' and 'ds.project') were not found on this flow. + + These tags are used to correctly attribute query costs by domain and project. + Please ensure both tags are included when running the flow, for example: + + uv run _flow.py \\ + --environment=fast-bakery \\ + --package-suffixes='.csv,.sql,.json,.toml,.yaml,.yml,.txt' \\ + --with card \\ + argo-workflows create \\ + --tag "ds.domain:operations" \\ + --tag "ds.project:regional-forecast" + + Note: in the monorepo, these tags are applied automatically in CI and when using + the standard poe commands for running flows. + """), + stacklevel=2, + ) + + def _extract(prefix: str, default: str = "unknown") -> str: + for tag in fetched_tags: + if tag.startswith(prefix + ":"): + return tag.split(":", 1)[1] + return default + + # most of these will be unknown if no tags are set on the flow + # (most likely for the flow runs which are triggered manually locally) + return { + "app": _extract( + "ds.domain" + ), # first tag after 'app:', is the domain of the flow, fetched from current tags of the flow + "workload_id": _extract( + "ds.project" + ), # second tag after 'workload_id:', is the project of the flow which it belongs to + "flow_name": current.flow_name, + "project": current.project_name, # Project name from the @project decorator, lets us + # identify the flow’s project without relying on user tags (added via --tag). + "step_name": current.step_name, # name of the current step + "run_id": current.run_id, # run_id: unique id of the current run + "user": current.username, # username of user who triggered the run (argo-workflows if its a deployed flow) + "domain": _extract("ds.domain"), # business unit (domain) of the flow, same as app + "namespace": current.namespace, # namespace of the flow + "perimeter": str(os.environ.get("OB_CURRENT_PERIMETER") or os.environ.get("OBP_PERIMETER")), + "is_production": str( + current.is_production + ), # True, if the flow is deployed with the --production flag else false + "team": "data-science", # team name, hardcoded as data-science + } + + +def add_comment_to_each_sql_statement(sql_text: str, comment: str) -> str: + """Append `comment` (e.g., /* {...} */) to every SQL statement in `sql_text`. + + Purpose: + Some SQL files contain multiple statements separated by semicolons. + Snowflake only associates query-level metadata (like select.dev cost-tracking tags) + with individual statements, not entire batches. This helper ensures that the + JSON-style comment containing query tags is added to each statement separately, + so every query executed can be properly attributed and tracked. + + The comment is inserted immediately before the terminating semicolon of each statement, + preserving whether the original statement had one. + """ + statements = [s.strip() for s in sqlparse.split(sql_text) if s.strip()] + if not statements: + return sql_text + + annotated = [] + for stmt in statements: + has_semicolon = stmt.rstrip().endswith(";") + trimmed = stmt.rstrip() + if has_semicolon: + trimmed = trimmed[:-1].rstrip() + annotated.append(f"{trimmed} {comment};") + else: + annotated.append(f"{trimmed} {comment}") + + # Separate statements with a blank line for readability + return "\n".join(annotated) + def _execute_sql(conn: SnowflakeConnection, sql: str) -> Optional[SnowflakeCursor]: """Execute SQL statement(s) using Snowflake's ``connection.execute_string()`` and return the *last* resulting cursor. @@ -20,12 +149,16 @@ def _execute_sql(conn: SnowflakeConnection, sql: str) -> Optional[SnowflakeCurso :param conn: Snowflake connection object :param sql: SQL query or batch of semicolon-delimited SQL statements :return: The cursor corresponding to the last executed statement, or None if no - statements were executed or if the SQL contains only whitespace/comments + statements were executed or if the SQL contains only whitespace/comments """ if not sql.strip(): return None try: + # adding query tags comment in query for cost tracking in select.dev + tags = get_select_dev_query_tags() + tag_str = json.dumps(tags, indent=2) + sql = add_comment_to_each_sql_statement(sql, tag_str) cursors: Iterable[SnowflakeCursor] = conn.execute_string(sql.strip()) if cursors is None: diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 84b7ce7..383475a 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -18,12 +18,12 @@ PROD_SCHEMA, S3_DATA_FOLDER, ) -from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query from ds_platform_utils.metaflow.s3_stage import ( _get_s3_config, copy_s3_to_snowflake, copy_snowflake_to_s3, ) +from ds_platform_utils.metaflow.snowflake_connection import _debug_print_query def _debug(*args, **kwargs): diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 8002d2d..945702f 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -1,4 +1,3 @@ -import json from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Literal, Optional, Tuple, Union @@ -17,34 +16,17 @@ PROD_SCHEMA, S3_DATA_FOLDER, ) -from ds_platform_utils.metaflow.get_snowflake_connection import _debug_print_query, get_snowflake_connection from ds_platform_utils.metaflow.s3 import _get_df_from_s3_files, _put_df_to_s3_folder from ds_platform_utils.metaflow.s3_stage import ( _get_s3_config, copy_s3_to_snowflake, copy_snowflake_to_s3, ) +from ds_platform_utils.metaflow.snowflake_connection import _debug_print_query, get_snowflake_connection from ds_platform_utils.metaflow.write_audit_publish import ( _make_snowflake_table_url, - add_comment_to_each_sql_statement, - get_select_dev_query_tags, ) -TWarehouse = Literal[ - "OUTERBOUNDS_DATA_SCIENCE_ADS_PROD_XS_WH", - "OUTERBOUNDS_DATA_SCIENCE_ADS_PROD_MED_WH", - "OUTERBOUNDS_DATA_SCIENCE_ADS_PROD_XL_WH", - "OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_XS_WH", - "OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_MED_WH", - "OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_XL_WH", - "OUTERBOUNDS_DATA_SCIENCE_ADS_DEV_XS_WH", - "OUTERBOUNDS_DATA_SCIENCE_ADS_DEV_MED_WH", - "OUTERBOUNDS_DATA_SCIENCE_ADS_DEV_XL_WH", - "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", - "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", - "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", -] - def publish_pandas( # noqa: PLR0913 (too many arguments) table_name: str, @@ -52,7 +34,7 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) add_created_date: bool = False, chunk_size: Optional[int] = None, compression: Literal["snappy", "gzip"] = "snappy", - warehouse: Optional[TWarehouse] = None, + warehouse: Optional[Literal["XS", "MED", "XL"]] = None, parallel: int = 4, quote_identifiers: bool = True, auto_create_table: bool = False, @@ -125,9 +107,7 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) current.card.append(Markdown(f"## Publishing DataFrame to Snowflake table: `{table_name}`")) current.card.append(Table.from_dataframe(df.head())) - conn: SnowflakeConnection = get_snowflake_connection(use_utc) - if warehouse is not None: - _execute_sql(conn, f"USE WAREHOUSE {warehouse};") + conn: SnowflakeConnection = get_snowflake_connection(warehouse=warehouse, use_utc=use_utc) _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") if use_s3_stage: @@ -182,7 +162,7 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) def query_pandas_from_snowflake( query: Union[str, Path], - warehouse: Optional[TWarehouse] = None, + warehouse: Optional[Literal["XS", "MED", "XL"]] = None, ctx: Optional[Dict[str, Any]] = None, use_utc: bool = True, use_s3_stage: bool = False, @@ -216,16 +196,10 @@ def query_pandas_from_snowflake( schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA # adding query tags comment in query for cost tracking in select.dev - tags = get_select_dev_query_tags() - query_comment_str = f"\n\n/* {json.dumps(tags)} */" - query = get_query_from_string_or_fpath(query) - query = add_comment_to_each_sql_statement(query, query_comment_str) - if "{{schema}}" in query or "{{ schema }}" in query: - query = substitute_map_into_string(query, {"schema": schema}) + query = get_query_from_string_or_fpath(query) - if ctx: - query = substitute_map_into_string(query, ctx) + query = substitute_map_into_string(query, {"schema": schema} | (ctx or {})) # print query if DEBUG_QUERY env var is set _debug_print_query(query) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index c3b2ad4..60b9761 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -14,7 +14,7 @@ PROD_SNOWFLAKE_STAGE, S3_DATA_FOLDER, ) -from ds_platform_utils.metaflow.get_snowflake_connection import get_snowflake_connection +from ds_platform_utils.metaflow.snowflake_connection import get_snowflake_connection def _get_s3_config(is_production: bool) -> Tuple[str, str]: @@ -173,11 +173,8 @@ def copy_snowflake_to_s3( query=query, snowflake_stage_path=sf_stage_path, ) - conn = get_snowflake_connection(use_utc) - if warehouse is not None: - _execute_sql(conn, f"USE WAREHOUSE {warehouse};") + conn = get_snowflake_connection(warehouse=warehouse, use_utc=use_utc) _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") - _execute_sql(conn, query) print(f"✅ Data exported to S3 path: {s3_path}") @@ -221,9 +218,7 @@ def copy_s3_to_snowflake( # noqa: PLR0913 s3_bucket, snowflake_stage = _get_s3_config(current.is_production) sf_stage_path = s3_path.replace(s3_bucket, snowflake_stage) - conn = get_snowflake_connection(use_utc) - if warehouse is not None: - _execute_sql(conn, f"USE WAREHOUSE {warehouse};") + conn = get_snowflake_connection(warehouse=warehouse, use_utc=use_utc) _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") if table_definition is None: diff --git a/src/ds_platform_utils/metaflow/get_snowflake_connection.py b/src/ds_platform_utils/metaflow/snowflake_connection.py similarity index 76% rename from src/ds_platform_utils/metaflow/get_snowflake_connection.py rename to src/ds_platform_utils/metaflow/snowflake_connection.py index 926393f..e12cc2b 100644 --- a/src/ds_platform_utils/metaflow/get_snowflake_connection.py +++ b/src/ds_platform_utils/metaflow/snowflake_connection.py @@ -4,8 +4,6 @@ from metaflow import Snowflake, current from snowflake.connector import SnowflakeConnection -from ds_platform_utils._snowflake.run_query import _execute_sql - #################### # --- Metaflow --- # #################### @@ -14,8 +12,20 @@ SNOWFLAKE_INTEGRATION = "snowflake-default" +def get_snowflake_warehouse(warehouse: Optional[str] = None) -> Optional[str]: + if not warehouse: + warehouse = "XS" + + if warehouse.upper() in ["XS", "MED", "XL"]: + domain = "ADS" if current.is_running_flow and "ds.domain:advertising" in current.tags else "SHARED" + env = "PROD" if current.is_production else "DEV" + warehouse = f"OUTERBOUNDS_DATA_SCIENCE_{domain}_{env}_{warehouse}_WH" + return warehouse.upper() + + # @lru_cache def get_snowflake_connection( + warehouse: Optional[str] = None, use_utc: bool = True, ) -> SnowflakeConnection: """Return a singleton Snowflake cursor. @@ -48,7 +58,11 @@ def get_snowflake_connection( else: query_tag = None - return _create_snowflake_connection(use_utc=use_utc, query_tag=query_tag) + return _create_snowflake_connection( + warehouse=get_snowflake_warehouse(warehouse), + use_utc=use_utc, + query_tag=query_tag, + ) ##################### @@ -57,27 +71,18 @@ def get_snowflake_connection( def _create_snowflake_connection( + warehouse: Optional[str], use_utc: bool, query_tag: Optional[str] = None, ) -> SnowflakeConnection: conn: SnowflakeConnection = Snowflake( integration=SNOWFLAKE_INTEGRATION, client_session_keep_alive=True, + warehouse=warehouse, + timezone="UTC" if use_utc else None, + session_parameters={"QUERY_TAG": query_tag}, ).cn # type: ignore[attr-defined] - queries = [] - - if use_utc: - queries.append("ALTER SESSION SET TIMEZONE = 'UTC';") - - if query_tag: - queries.append(f"ALTER SESSION SET QUERY_TAG = '{query_tag}';") - - # Merge into single SQL batch - sql = "\n".join(queries) - _debug_print_query(sql) - _execute_sql(conn, sql) - return conn diff --git a/src/ds_platform_utils/metaflow/write_audit_publish.py b/src/ds_platform_utils/metaflow/write_audit_publish.py index 4672a64..52fb795 100644 --- a/src/ds_platform_utils/metaflow/write_audit_publish.py +++ b/src/ds_platform_utils/metaflow/write_audit_publish.py @@ -1,17 +1,13 @@ -import json -import os -import warnings from pathlib import Path from textwrap import dedent -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union -import sqlparse from metaflow import current from metaflow.cards import Artifact, Markdown, Table from snowflake.connector.cursor import SnowflakeCursor from ds_platform_utils._snowflake.run_query import _execute_sql -from ds_platform_utils.metaflow.get_snowflake_connection import get_snowflake_connection +from ds_platform_utils.metaflow.snowflake_connection import get_snowflake_connection if TYPE_CHECKING: from ds_platform_utils._snowflake.write_audit_publish import ( @@ -20,152 +16,13 @@ # write_audit_publish, ) -from typing import Literal - -TWarehouse = Literal[ - "OUTERBOUNDS_DATA_SCIENCE_ADS_PROD_XS_WH", - "OUTERBOUNDS_DATA_SCIENCE_ADS_PROD_MED_WH", - "OUTERBOUNDS_DATA_SCIENCE_ADS_PROD_XL_WH", - "OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_XS_WH", - "OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_MED_WH", - "OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_XL_WH", - "OUTERBOUNDS_DATA_SCIENCE_ADS_DEV_XS_WH", - "OUTERBOUNDS_DATA_SCIENCE_ADS_DEV_MED_WH", - "OUTERBOUNDS_DATA_SCIENCE_ADS_DEV_XL_WH", - "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", - "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", - "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", -] - - -def get_select_dev_query_tags() -> Dict[str, str]: - """Return tags for the current Metaflow flow run. - - These tags are used for cost tracking in select.dev. - See the select.dev docs on custom workload tags: - https://select.dev/docs/reference/integrations/custom-workloads#example-query-tag - - What the main tags mean and why we set them this way: - - "app": a broad category that groups queries by domain. We set app to the value of ds.domain - that we get from current tags of the flow, so queries are attributed to the right domain (for example, "Operations"). - - "workload_id": identifies the specific project or sub-unit inside that domain. - We set workload_id to the value of ds.project that we get from current tags of - the flow so select.dev can attribute costs to the exact project (for example, "out-of-stock"). - - For more granular attribution we have other tags: - - "flow_name": the flow name - - "step_name": the step within the flow - - "run_id": the unique id of the flow run - - "user": the username of the user who triggered the flow run (or argo-workflows if it's a deployed flow) - - "namespace": the namespace of the flow run - - "team": the team name, hardcoded as "data-science" for all flows - - **Note: all other tags are arbitrary. Add any extra key/value pairs that help you trace and group queries for cost reporting.** - """ - fetched_tags = current.tags - required_tags_are_present = any(tag.startswith("ds.project") for tag in fetched_tags) and any( - tag.startswith("ds.domain") for tag in fetched_tags - ) # Checking presence of both required Metaflow user tags in current tags of the flow - if not required_tags_are_present: - warnings.warn( - dedent(""" - Warning: ds-platform-utils attempted to add query tags to a Snowflake query - for cost tracking in select.dev, but one or both required Metaflow user tags - ('ds.domain' and 'ds.project') were not found on this flow. - - These tags are used to correctly attribute query costs by domain and project. - Please ensure both tags are included when running the flow, for example: - - uv run _flow.py \\ - --environment=fast-bakery \\ - --package-suffixes='.csv,.sql,.json,.toml,.yaml,.yml,.txt' \\ - --with card \\ - argo-workflows create \\ - --tag "ds.domain:operations" \\ - --tag "ds.project:regional-forecast" - - Note: in the monorepo, these tags are applied automatically in CI and when using - the standard poe commands for running flows. - """), - stacklevel=2, - ) - - def _extract(prefix: str, default: str = "unknown") -> str: - for tag in fetched_tags: - if tag.startswith(prefix + ":"): - return tag.split(":", 1)[1] - return default - - # most of these will be unknown if no tags are set on the flow - # (most likely for the flow runs which are triggered manually locally) - return { - "app": _extract( - "ds.domain" - ), # first tag after 'app:', is the domain of the flow, fetched from current tags of the flow - "workload_id": _extract( - "ds.project" - ), # second tag after 'workload_id:', is the project of the flow which it belongs to - "flow_name": current.flow_name, - "project": current.project_name, # Project name from the @project decorator, lets us - # identify the flow’s project without relying on user tags (added via --tag). - "step_name": current.step_name, # name of the current step - "run_id": current.run_id, # run_id: unique id of the current run - "user": current.username, # username of user who triggered the run (argo-workflows if its a deployed flow) - "domain": _extract("ds.domain"), # business unit (domain) of the flow, same as app - "namespace": current.namespace, # namespace of the flow - "perimeter": str(os.environ.get("OB_CURRENT_PERIMETER") or os.environ.get("OBP_PERIMETER")), - "is_production": str( - current.is_production - ), # True, if the flow is deployed with the --production flag else false - "team": "data-science", # team name, hardcoded as data-science - } - - -def add_comment_to_each_sql_statement(sql_text: str, comment: str) -> str: - """Append `comment` (e.g., /* {...} */) to every SQL statement in `sql_text`. - - Purpose: - Some SQL files contain multiple statements separated by semicolons. - Snowflake only associates query-level metadata (like select.dev cost-tracking tags) - with individual statements, not entire batches. This helper ensures that the - JSON-style comment containing query tags is added to each statement separately, - so every query executed can be properly attributed and tracked. - - The comment is inserted immediately before the terminating semicolon of each statement, - preserving whether the original statement had one. - """ - statements = [s.strip() for s in sqlparse.split(sql_text) if s.strip()] - if not statements: - return sql_text - - annotated = [] - for stmt in statements: - has_semicolon = stmt.rstrip().endswith(";") - trimmed = stmt.rstrip() - if has_semicolon: - trimmed = trimmed[:-1].rstrip() - annotated.append(f"{trimmed} {comment};") - else: - annotated.append(f"{trimmed} {comment}") - - # Separate statements with a blank line for readability - return "\n\n".join(annotated) + "\n" - def publish( # noqa: PLR0913, D417 table_name: str, query: Union[str, Path], audits: Optional[List[Union[str, Path]]] = None, ctx: Optional[Dict[str, Any]] = None, - warehouse: Optional[TWarehouse] = None, + warehouse: Optional[Literal["XS", "MED", "XL"]] = None, use_utc: bool = True, ) -> None: """Publish a Snowflake table using the write-audit-publish (WAP) pattern via Metaflow's Snowflake connection. @@ -207,18 +64,10 @@ def publish( # noqa: PLR0913, D417 write_audit_publish, ) - conn = get_snowflake_connection(use_utc=use_utc) - - # adding query tags comment in query for cost tracking in select.dev - tags = get_select_dev_query_tags() - query_comment_str = f"\n\n/* {json.dumps(tags)} */" + conn = get_snowflake_connection(warehouse=warehouse, use_utc=use_utc) query = get_query_from_string_or_fpath(query) - query = add_comment_to_each_sql_statement(query, query_comment_str) with conn.cursor() as cur: - if warehouse is not None: - _execute_sql(conn, f"USE WAREHOUSE {warehouse}") - last_op_was_write = False for operation in write_audit_publish( table_name=table_name, diff --git a/tests/functional_tests/metaflow/test__get_select_dev_query_tags.py b/tests/functional_tests/metaflow/test__get_select_dev_query_tags.py index 5aa802b..1c73be8 100644 --- a/tests/functional_tests/metaflow/test__get_select_dev_query_tags.py +++ b/tests/functional_tests/metaflow/test__get_select_dev_query_tags.py @@ -3,7 +3,7 @@ import pytest -from src.ds_platform_utils.metaflow import write_audit_publish +from src.ds_platform_utils._snowflake import run_query def _make_dummy_current(*, tags): @@ -24,27 +24,27 @@ def _make_dummy_current(*, tags): def test_warns_when_either_required_tag_missing(monkeypatch): """Raise warning if either `'ds.domain'` or `'ds.project'` is missing.""" dummy_current = _make_dummy_current(tags={"ds.project:foo"}) - monkeypatch.setattr(write_audit_publish, "current", dummy_current) + monkeypatch.setattr(run_query, "current", dummy_current) with pytest.warns(UserWarning, match=r"one or both required Metaflow user tags"): - write_audit_publish.get_select_dev_query_tags() + run_query.get_select_dev_query_tags() def test_warns_when_no_tags(monkeypatch): """Raise warning if no tags are present.""" dummy_current = _make_dummy_current(tags=set()) - monkeypatch.setattr(write_audit_publish, "current", dummy_current) + monkeypatch.setattr(run_query, "current", dummy_current) with pytest.warns(UserWarning, match=r"one or both required Metaflow user tags"): - write_audit_publish.get_select_dev_query_tags() + run_query.get_select_dev_query_tags() def test_no_warning_when_both_required_tags_present(monkeypatch): """No warning when both required tags are present.""" dummy_current = _make_dummy_current(tags={"ds.domain:operations", "ds.project:myproj"}) - monkeypatch.setattr(write_audit_publish, "current", dummy_current) + monkeypatch.setattr(run_query, "current", dummy_current) with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") - write_audit_publish.get_select_dev_query_tags() + run_query.get_select_dev_query_tags() assert not w # check no warnings captured diff --git a/tests/unit_tests/snowflake/test__execute_sql.py b/tests/unit_tests/snowflake/test__execute_sql.py index 4816841..c52e1ff 100644 --- a/tests/unit_tests/snowflake/test__execute_sql.py +++ b/tests/unit_tests/snowflake/test__execute_sql.py @@ -6,7 +6,7 @@ from snowflake.connector import SnowflakeConnection from ds_platform_utils._snowflake.run_query import _execute_sql -from ds_platform_utils.metaflow.get_snowflake_connection import get_snowflake_connection +from ds_platform_utils.metaflow.snowflake_connection import get_snowflake_connection @pytest.fixture(scope="module") From 503e8ef8c8f38789b4d6a23674f0a9d00a90314f Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:50:08 +0530 Subject: [PATCH 117/167] fix: remove fastparquet dependency and update polars versioning for compatibility --- pyproject.toml | 1 - uv.lock | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 67e8a6e..f79845f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ dependencies = [ "jinja2", "sqlparse>=0.5.3", "polars>=1.36.1", - "fastparquet>=2024.11.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 2bdc757..39d38f1 100644 --- a/uv.lock +++ b/uv.lock @@ -485,6 +485,8 @@ dependencies = [ { name = "jinja2" }, { name = "outerbounds" }, { name = "pandas" }, + { name = "polars", version = "1.36.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "polars", version = "1.38.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pyarrow" }, { name = "pydantic" }, { name = "pyyaml" }, @@ -508,6 +510,7 @@ requires-dist = [ { name = "jinja2" }, { name = "outerbounds", specifier = ">=0.3.159" }, { name = "pandas" }, + { name = "polars", specifier = ">=1.36.1" }, { name = "pyarrow" }, { name = "pydantic", specifier = ">=2" }, { name = "pyyaml" }, @@ -1362,6 +1365,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/1b/5337af1a6a478d25a3e3c56b9b4b42b0a160314e02f4a0498d5322c8dac4/poethepoet-0.37.0-py3-none-any.whl", hash = "sha256:861790276315abcc8df1b4bd60e28c3d48a06db273edd3092f3c94e1a46e5e22", size = 90062, upload-time = "2025-08-11T18:00:27.595Z" }, ] +[[package]] +name = "polars" +version = "1.36.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "polars-runtime-32", version = "1.36.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/dc/56f2a90c79a2cb13f9e956eab6385effe54216ae7a2068b3a6406bae4345/polars-1.36.1.tar.gz", hash = "sha256:12c7616a2305559144711ab73eaa18814f7aa898c522e7645014b68f1432d54c", size = 711993, upload-time = "2025-12-10T01:14:53.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/c6/36a1b874036b49893ecae0ac44a2f63d1a76e6212631a5b2f50a86e0e8af/polars-1.36.1-py3-none-any.whl", hash = "sha256:853c1bbb237add6a5f6d133c15094a9b727d66dd6a4eb91dbb07cdb056b2b8ef", size = 802429, upload-time = "2025-12-10T01:13:53.838Z" }, +] + +[[package]] +name = "polars" +version = "1.38.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "polars-runtime-32", version = "1.38.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/5e/208a24471a433bcd0e9a6889ac49025fd4daad2815c8220c5bd2576e5f1b/polars-1.38.1.tar.gz", hash = "sha256:803a2be5344ef880ad625addfb8f641995cfd777413b08a10de0897345778239", size = 717667, upload-time = "2026-02-06T18:13:23.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/737c1a6273c585719858261753da0b688454d1b634438ccba8a9c4eb5aab/polars-1.38.1-py3-none-any.whl", hash = "sha256:a29479c48fed4984d88b656486d221f638cba45d3e961631a50ee5fdde38cb2c", size = 810368, upload-time = "2026-02-06T18:11:55.819Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.36.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +sdist = { url = "https://files.pythonhosted.org/packages/31/df/597c0ef5eb8d761a16d72327846599b57c5d40d7f9e74306fc154aba8c37/polars_runtime_32-1.36.1.tar.gz", hash = "sha256:201c2cfd80ceb5d5cd7b63085b5fd08d6ae6554f922bcb941035e39638528a09", size = 2788751, upload-time = "2025-12-10T01:14:54.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/ea/871129a2d296966c0925b078a9a93c6c5e7facb1c5eebfcd3d5811aeddc1/polars_runtime_32-1.36.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:327b621ca82594f277751f7e23d4b939ebd1be18d54b4cdf7a2f8406cecc18b2", size = 43494311, upload-time = "2025-12-10T01:13:56.096Z" }, + { url = "https://files.pythonhosted.org/packages/d8/76/0038210ad1e526ce5bb2933b13760d6b986b3045eccc1338e661bd656f77/polars_runtime_32-1.36.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ab0d1f23084afee2b97de8c37aa3e02ec3569749ae39571bd89e7a8b11ae9e83", size = 39300602, upload-time = "2025-12-10T01:13:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/54/1e/2707bee75a780a953a77a2c59829ee90ef55708f02fc4add761c579bf76e/polars_runtime_32-1.36.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:899b9ad2e47ceb31eb157f27a09dbc2047efbf4969a923a6b1ba7f0412c3e64c", size = 44511780, upload-time = "2025-12-10T01:14:02.285Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/3fede95feee441be64b4bcb32444679a8fbb7a453a10251583053f6efe52/polars_runtime_32-1.36.1-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:d9d077bb9df711bc635a86540df48242bb91975b353e53ef261c6fae6cb0948f", size = 40688448, upload-time = "2025-12-10T01:14:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/05/0f/e629713a72999939b7b4bfdbf030a32794db588b04fdf3dc977dd8ea6c53/polars_runtime_32-1.36.1-cp39-abi3-win_amd64.whl", hash = "sha256:cc17101f28c9a169ff8b5b8d4977a3683cd403621841623825525f440b564cf0", size = 44464898, upload-time = "2025-12-10T01:14:08.296Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d8/a12e6aa14f63784cead437083319ec7cece0d5bb9a5bfe7678cc6578b52a/polars_runtime_32-1.36.1-cp39-abi3-win_arm64.whl", hash = "sha256:809e73857be71250141225ddd5d2b30c97e6340aeaa0d445f930e01bef6888dc", size = 39798896, upload-time = "2025-12-10T01:14:11.568Z" }, +] + +[[package]] +name = "polars-runtime-32" +version = "1.38.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/07/4b/04d6b3fb7cf336fbe12fbc4b43f36d1783e11bb0f2b1e3980ec44878df06/polars_runtime_32-1.38.1.tar.gz", hash = "sha256:04f20ed1f5c58771f34296a27029dc755a9e4b1390caeaef8f317e06fdfce2ec", size = 2812631, upload-time = "2026-02-06T18:13:25.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/a2/a00defbddadd8cf1042f52380dcba6b6592b03bac8e3b34c436b62d12d3b/polars_runtime_32-1.38.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:18154e96044724a0ac38ce155cf63aa03c02dd70500efbbf1a61b08cadd269ef", size = 44108001, upload-time = "2026-02-06T18:11:58.127Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/599ff3709e6a303024efd7edfd08cf8de55c6ac39527d8f41cbc4399385f/polars_runtime_32-1.38.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c49acac34cc4049ed188f1eb67d6ff3971a39b4af7f7b734b367119970f313ac", size = 40230140, upload-time = "2026-02-06T18:12:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8c/3ac18d6f89dc05fe2c7c0ee1dc5b81f77a5c85ad59898232c2500fe2ebbf/polars_runtime_32-1.38.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fef2ef2626a954e010e006cc8e4de467ecf32d08008f130cea1c78911f545323", size = 41994039, upload-time = "2026-02-06T18:12:04.332Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5a/61d60ec5cc0ab37cbd5a699edb2f9af2875b7fdfdfb2a4608ca3cc5f0448/polars_runtime_32-1.38.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8a5f7a8125e2d50e2e060296551c929aec09be23a9edcb2b12ca923f555a5ba", size = 45755804, upload-time = "2026-02-06T18:12:07.846Z" }, + { url = "https://files.pythonhosted.org/packages/91/54/02cd4074c98c361ccd3fec3bcb0bd68dbc639c0550c42a4436b0ff0f3ccf/polars_runtime_32-1.38.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:10d19cd9863e129273b18b7fcaab625b5c8143c2d22b3e549067b78efa32e4fa", size = 42159605, upload-time = "2026-02-06T18:12:10.919Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f3/b2a5e720cc56eaa38b4518e63aa577b4bbd60e8b05a00fe43ca051be5879/polars_runtime_32-1.38.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61e8d73c614b46a00d2f853625a7569a2e4a0999333e876354ac81d1bf1bb5e2", size = 45336615, upload-time = "2026-02-06T18:12:14.074Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/ee2e4b7de948090cfb3df37d401c521233daf97bfc54ddec5d61d1d31618/polars_runtime_32-1.38.1-cp310-abi3-win_amd64.whl", hash = "sha256:08c2b3b93509c1141ac97891294ff5c5b0c548a373f583eaaea873a4bf506437", size = 45680732, upload-time = "2026-02-06T18:12:19.097Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/72c216f4ab0c82b907009668f79183ae029116ff0dd245d56ef58aac48e7/polars_runtime_32-1.38.1-cp310-abi3-win_arm64.whl", hash = "sha256:6d07d0cc832bfe4fb54b6e04218c2c27afcfa6b9498f9f6bbf262a00d58cc7c4", size = 41639413, upload-time = "2026-02-06T18:12:22.044Z" }, +] + [[package]] name = "pre-commit" version = "4.3.0" From aed6cab41bb0c859299d31630c2652deb87cfa43 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:52:51 +0530 Subject: [PATCH 118/167] fix: remove unused import of _execute_sql from run_query.py --- src/ds_platform_utils/_snowflake/run_query.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ds_platform_utils/_snowflake/run_query.py b/src/ds_platform_utils/_snowflake/run_query.py index 27a2c1c..a2386f1 100644 --- a/src/ds_platform_utils/_snowflake/run_query.py +++ b/src/ds_platform_utils/_snowflake/run_query.py @@ -12,8 +12,6 @@ from snowflake.connector.cursor import SnowflakeCursor from snowflake.connector.errors import ProgrammingError -from ds_platform_utils._snowflake.run_query import _execute_sql - def get_select_dev_query_tags() -> Dict[str, Optional[str]]: """Return tags for the current Metaflow flow run. From f937cd4a40e74fce5725a6705dab3f2986347df9 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:53:22 +0530 Subject: [PATCH 119/167] fix: remove unnecessary whitespace in documentation and code examples --- docs/README.md | 8 +- docs/examples/README.md | 120 +++++++++++----------- docs/metaflow/README.md | 8 +- docs/metaflow/batch_inference_pipeline.md | 8 +- docs/metaflow/validate_config.md | 58 +++++------ docs/snowflake/README.md | 10 +- 6 files changed, 106 insertions(+), 106 deletions(-) diff --git a/docs/README.md b/docs/README.md index 77d775b..b5c2c7f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -55,7 +55,7 @@ uv sync ## Configuration -**No manual configuration required!** +**No manual configuration required!** This library integrates seamlessly with Outerbounds, which automatically handles all Snowflake and AWS configuration. Simply use the functions in your Metaflow flows, and Outerbounds takes care of: @@ -107,7 +107,7 @@ class PredictionFlow(FlowSpec): parallel_workers=10, ) self.next(self.predict, foreach='worker_ids') - + @step def predict(self): worker_id = self.input @@ -116,7 +116,7 @@ class PredictionFlow(FlowSpec): predict_fn=my_model.predict, ) self.next(self.join) - + @step def join(self, inputs): self.pipeline = inputs[0].pipeline @@ -124,7 +124,7 @@ class PredictionFlow(FlowSpec): output_table_name="predictions", ) self.next(self.end) - + @step def end(self): pass diff --git a/docs/examples/README.md b/docs/examples/README.md index 0048d2e..35c09b4 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -25,7 +25,7 @@ from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pand class SimplePipeline(FlowSpec): """Query data, transform, and publish.""" - + @step def start(self): """Query input data.""" @@ -44,28 +44,28 @@ class SimplePipeline(FlowSpec): ) print(f"Retrieved {len(self.df):,} rows") self.next(self.transform) - + @step def transform(self): """Transform data.""" print("Transforming data...") - + # Add month column self.df['month'] = self.df['transaction_date'].dt.to_period('M') - + # Calculate monthly spending per user self.results = self.df.groupby(['user_id', 'month']).agg({ 'amount': ['sum', 'mean', 'count'] }).reset_index() - + # Flatten column names self.results.columns = [ '_'.join(col).strip('_') for col in self.results.columns ] - + print(f"Created {len(self.results):,} aggregated rows") self.next(self.publish) - + @step def publish(self): """Publish results.""" @@ -78,7 +78,7 @@ class SimplePipeline(FlowSpec): ) print("✅ Done!") self.next(self.end) - + @step def end(self): pass @@ -152,18 +152,18 @@ from config import FeatureConfig class FeaturePipeline(FlowSpec): """ML feature engineering pipeline.""" - + config = Parameter( 'config', type=make_pydantic_parser_fn(FeatureConfig), default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}', ) - + @step def start(self): """Extract raw features from Snowflake.""" print(f"Extracting features from {self.config.start_date} to {self.config.end_date}") - + self.df = query_pandas_from_snowflake( query="sql/extract_raw_features.sql", ctx={ @@ -173,34 +173,34 @@ class FeaturePipeline(FlowSpec): ) print(f"Extracted features for {len(self.df):,} users") self.next(self.engineer_features) - + @step def engineer_features(self): """Engineer features in Python.""" print("Engineering features...") - + # Time-based features now = pd.Timestamp.now() self.df['recency_days'] = (now - pd.to_datetime(self.df['last_seen'])).dt.days self.df['account_age_days'] = (now - pd.to_datetime(self.df['first_seen'])).dt.days - + # Engagement features self.df['events_per_day'] = self.df['event_count'] / self.df['active_days'] self.df['engagement_ratio'] = self.df['active_days'] / self.df['account_age_days'] - + # Value features self.df['value_volatility'] = self.df['std_value'] / (self.df['avg_value'] + 1) - + # Segments self.df['user_segment'] = pd.cut( self.df['event_count'], bins=[0, 10, 50, 200, float('inf')], labels=['low', 'medium', 'high', 'power_user'] ) - + print(f"Engineered {len(self.df.columns)} features") self.next(self.publish) - + @step def publish(self): """Publish features.""" @@ -213,7 +213,7 @@ class FeaturePipeline(FlowSpec): ) print(f"✅ Published {len(self.df):,} rows with {len(self.df.columns)} columns") self.next(self.end) - + @step def end(self): pass @@ -252,12 +252,12 @@ import pickle class LargeScaleInference(FlowSpec): """Batch inference for millions of rows.""" - + @step def start(self): """Query and split into batches.""" print("Querying input data and splitting into batches...") - + pipeline = BatchInferencePipeline() self.worker_ids = pipeline.query_and_batch( input_query=""" @@ -275,39 +275,39 @@ class LargeScaleInference(FlowSpec): parallel_workers=20, # 20 parallel workers warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", ) - + print(f"Split into {len(self.worker_ids)} batches") self.next(self.predict, foreach='worker_ids') - + @step def predict(self): """Predict for each batch (runs in parallel).""" worker_id = self.input print(f"Processing batch {worker_id}") - + # Load model (cached across batches on same worker) with open('model.pkl', 'rb') as f: model = pickle.load(f) - + def predict_fn(df: pd.DataFrame) -> pd.DataFrame: """Generate predictions.""" feature_cols = [ - 'feature_1', 'feature_2', 'feature_3', + 'feature_1', 'feature_2', 'feature_3', 'feature_4', 'feature_5' ] - + # Generate predictions predictions = model.predict_proba(df[feature_cols])[:, 1] - + # Create output DataFrame result = pd.DataFrame({ 'user_id': df['user_id'], 'score': predictions, 'prediction': (predictions >= 0.5).astype(int), }) - + return result - + # Process this batch pipeline = BatchInferencePipeline() pipeline.process_batch( @@ -315,24 +315,24 @@ class LargeScaleInference(FlowSpec): predict_fn=predict_fn, batch_size_in_mb=64, # Process in 64MB chunks ) - + print(f"✅ Batch {worker_id} complete") self.next(self.join) - + @step def join(self, inputs): """Collect results and publish.""" print(f"All {len(inputs)} batches processed, publishing results...") - + self.pipeline = inputs[0].pipeline self.pipeline.publish_results( output_table_name="user_predictions", warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", ) - + print("✅ All predictions published!") self.next(self.end) - + @step def end(self): pass @@ -370,18 +370,18 @@ from datetime import datetime, timedelta class IncrementalPipeline(FlowSpec): """Process daily incremental data.""" - + date = Parameter( 'date', default=datetime.now().strftime('%Y-%m-%d'), help='Date to process (YYYY-MM-DD)', ) - + @step def start(self): """Query new data for specified date.""" print(f"Processing data for {self.date}") - + self.df = query_pandas_from_snowflake( query=f""" SELECT * @@ -389,47 +389,47 @@ class IncrementalPipeline(FlowSpec): WHERE date = '{self.date}' """ ) - + if len(self.df) == 0: print(f"⚠️ No data found for {self.date}") else: print(f"Found {len(self.df):,} rows for {self.date}") - + self.next(self.transform) - + @step def transform(self): """Transform new data.""" if len(self.df) > 0: print("Transforming data...") - + # Your transformation logic self.df['processed_date'] = datetime.now() self.df['derived_field'] = self.df['value'] * 2 - + print(f"Transformed {len(self.df):,} rows") - + self.next(self.publish) - + @step def publish(self): """Append to existing table.""" if len(self.df) > 0: print(f"Appending {len(self.df):,} rows...") - + publish_pandas( table_name="processed_events", df=self.df, auto_create_table=False, # Table must exist overwrite=False, # Append instead of replace ) - + print(f"✅ Appended {len(self.df):,} rows for {self.date}") else: print("⏭️ No data to publish") - + self.next(self.end) - + @step def end(self): pass @@ -466,12 +466,12 @@ from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pand class MultiTableJoin(FlowSpec): """Join multiple tables and create enriched dataset.""" - + @step def start(self): """Start parallel queries.""" self.next(self.query_users, self.query_events, self.query_demographics) - + @step def query_users(self): """Query user data.""" @@ -481,7 +481,7 @@ class MultiTableJoin(FlowSpec): ) print(f"Retrieved {len(self.users_df):,} users") self.next(self.join_data) - + @step def query_events(self): """Query event data.""" @@ -499,7 +499,7 @@ class MultiTableJoin(FlowSpec): ) print(f"Retrieved events for {len(self.events_df):,} users") self.next(self.join_data) - + @step def query_demographics(self): """Query demographic data.""" @@ -509,33 +509,33 @@ class MultiTableJoin(FlowSpec): ) print(f"Retrieved demographics for {len(self.demographics_df):,} users") self.next(self.join_data) - + @step def join_data(self, inputs): """Join all data sources.""" print("Joining data from all sources...") - + # Merge users + events result = inputs.query_users.users_df.merge( inputs.query_events.events_df, on='user_id', how='left' ) - + # Merge with demographics result = result.merge( inputs.query_demographics.demographics_df, on='user_id', how='left' ) - + # Fill missing event counts with 0 result['event_count'] = result['event_count'].fillna(0) - + self.enriched_df = result print(f"Created enriched dataset with {len(self.enriched_df):,} rows") self.next(self.publish) - + @step def publish(self): """Publish enriched dataset.""" @@ -548,7 +548,7 @@ class MultiTableJoin(FlowSpec): ) print(f"✅ Published {len(self.enriched_df):,} rows") self.next(self.end) - + @step def end(self): pass diff --git a/docs/metaflow/README.md b/docs/metaflow/README.md index c89f4dd..33e65dc 100644 --- a/docs/metaflow/README.md +++ b/docs/metaflow/README.md @@ -203,13 +203,13 @@ class TrainingFlow(FlowSpec): use_s3_stage=True, ) self.next(self.train) - + @step def train(self): # Train model self.model = train_model(self.df) self.next(self.end) - + @step def end(self): # Publish metrics @@ -232,7 +232,7 @@ class PredictionFlow(FlowSpec): parallel_workers=10, ) self.next(self.predict, foreach='worker_ids') - + @step def predict(self): self.pipeline.process_batch( @@ -240,7 +240,7 @@ class PredictionFlow(FlowSpec): predict_fn=self.model.predict, ) self.next(self.join) - + @step def join(self, inputs): self.pipeline = inputs[0].pipeline diff --git a/docs/metaflow/batch_inference_pipeline.md b/docs/metaflow/batch_inference_pipeline.md index c1e10f6..e78a84f 100644 --- a/docs/metaflow/batch_inference_pipeline.md +++ b/docs/metaflow/batch_inference_pipeline.md @@ -24,7 +24,7 @@ from metaflow import FlowSpec, step from ds_platform_utils.metaflow import BatchInferencePipeline class MyPredictionFlow(FlowSpec): - + @step def start(self): # Initialize pipeline and export data to S3 @@ -34,7 +34,7 @@ class MyPredictionFlow(FlowSpec): parallel_workers=10, # Split into 10 parallel workers ) self.next(self.predict, foreach='worker_ids') - + @step def predict(self): # Process single batch (runs in parallel via foreach) @@ -45,7 +45,7 @@ class MyPredictionFlow(FlowSpec): batch_size_in_mb=256, ) self.next(self.join) - + @step def join(self, inputs): # Merge and write results to Snowflake @@ -55,7 +55,7 @@ class MyPredictionFlow(FlowSpec): auto_create_table=True, ) self.next(self.end) - + @step def end(self): print("✅ Pipeline complete!") diff --git a/docs/metaflow/validate_config.md b/docs/metaflow/validate_config.md index 895abc1..f8572c9 100644 --- a/docs/metaflow/validate_config.md +++ b/docs/metaflow/validate_config.md @@ -50,14 +50,14 @@ class Config(BaseModel): class SimpleFlow(FlowSpec): """Flow with validated config.""" - + config = Parameter( 'config', type=make_pydantic_parser_fn(Config), default='{"table_name": "my_table"}', help='JSON configuration' ) - + @step def start(self): # Access validated config @@ -65,7 +65,7 @@ class SimpleFlow(FlowSpec): print(f"Warehouse: {self.config.warehouse}") print(f"Limit: {self.config.limit}") self.next(self.end) - + @step def end(self): pass @@ -90,7 +90,7 @@ class DateRangeConfig(BaseModel): """Configuration with date validation.""" start_date: str end_date: str - + @validator('start_date', 'end_date') def validate_date_format(cls, v): """Ensure dates are in YYYY-MM-DD format.""" @@ -99,7 +99,7 @@ class DateRangeConfig(BaseModel): return v except ValueError: raise ValueError(f"Date must be in YYYY-MM-DD format, got: {v}") - + @validator('end_date') def end_after_start(cls, v, values): """Ensure end_date is after start_date.""" @@ -113,12 +113,12 @@ class DateRangeFlow(FlowSpec): type=make_pydantic_parser_fn(DateRangeConfig), default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}', ) - + @step def start(self): print(f"Processing {self.config.start_date} to {self.config.end_date}") self.next(self.end) - + @step def end(self): pass @@ -180,14 +180,14 @@ class AdvancedFlow(FlowSpec): } ''', ) - + @step def start(self): print(f"Warehouse: {self.config.snowflake.warehouse}") print(f"Model: {self.config.model.model_path}") print(f"Features: {self.config.model.features}") self.next(self.end) - + @step def end(self): pass @@ -205,30 +205,30 @@ class MLConfig(BaseModel): inference_date: str min_samples: int = 1000 max_samples: int = 1_000_000 - + @validator('inference_date') def inference_after_training(cls, v, values): """Inference date must be after training period.""" if 'training_end' in values and v <= values['training_end']: raise ValueError("inference_date must be after training_end") return v - + @validator('min_samples', 'max_samples') def positive_samples(cls, v): """Sample counts must be positive.""" if v <= 0: raise ValueError("Sample count must be positive") return v - + @root_validator def check_sample_range(cls, values): """min_samples must be less than max_samples.""" min_s = values.get('min_samples') max_s = values.get('max_samples') - + if min_s and max_s and min_s >= max_s: raise ValueError("min_samples must be less than max_samples") - + return values ``` @@ -294,7 +294,7 @@ class Config(BaseModel): ```python class Config(BaseModel): """Flow configuration. - + Attributes: start_date: Start date in YYYY-MM-DD format end_date: End date in YYYY-MM-DD format @@ -322,7 +322,7 @@ def threshold_in_range(cls, v): # ❌ Bad - too restrictive class Config(BaseModel): table_name: str - + @validator('table_name') def specific_table(cls, v): if v != "exactly_this_table": # Too rigid! @@ -332,7 +332,7 @@ class Config(BaseModel): # ✅ Good - validate format, not content class Config(BaseModel): table_name: str - + @validator('table_name') def valid_table_name(cls, v): if not v.replace('_', '').isalnum(): # Allow alphanumeric + underscore @@ -362,36 +362,36 @@ class Schedule(BaseModel): class ProductionConfig(BaseModel): """Production-ready flow configuration.""" - + # Environment env: Environment = Environment.DEV - + # Data start_date: str = Field(..., description="Start date (YYYY-MM-DD)") end_date: str = Field(..., description="End date (YYYY-MM-DD)") table_name: str = Field(..., description="Input table name") - + # Model model_path: str = Field(..., description="Path to model file") features: List[str] = Field(..., description="Feature columns") threshold: float = Field(0.5, ge=0, le=1, description="Prediction threshold") - + # Snowflake warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" schema_override: Optional[str] = None - + # Performance use_s3_stage: bool = True parallel_workers: int = Field(10, ge=1, le=50) batch_size_mb: int = Field(256, ge=64, le=512) - + # Monitoring enable_alerts: bool = True alert_email: Optional[str] = None - + # Schedule schedule: Optional[Schedule] = None - + @validator('start_date', 'end_date') def validate_date(cls, v): """Validate date format.""" @@ -400,7 +400,7 @@ class ProductionConfig(BaseModel): return v except ValueError: raise ValueError(f"Invalid date format: {v}") - + @validator('warehouse') def validate_warehouse(cls, v, values): """Validate warehouse based on environment.""" @@ -408,7 +408,7 @@ class ProductionConfig(BaseModel): if env == Environment.PROD and 'DEV' in v: raise ValueError("Cannot use DEV warehouse in PROD environment") return v - + @root_validator def validate_alerts(cls, values): """If alerts enabled, email is required.""" @@ -422,13 +422,13 @@ class ProductionFlow(FlowSpec): type=make_pydantic_parser_fn(ProductionConfig), default='{"start_date": "2024-01-01", "end_date": "2024-12-31", "table_name": "input_data", "model_path": "model.pkl", "features": ["f1", "f2"]}', ) - + @step def start(self): print(f"Environment: {self.config.env.value}") print(f"Date range: {self.config.start_date} to {self.config.end_date}") self.next(self.end) - + @step def end(self): pass diff --git a/docs/snowflake/README.md b/docs/snowflake/README.md index 1b3fb0a..439f5af 100644 --- a/docs/snowflake/README.md +++ b/docs/snowflake/README.md @@ -128,7 +128,7 @@ CREATE OR REPLACE TABLE {{schema}}.{{table_name}} AS SELECT * FROM source_table; -- In audits -SELECT +SELECT COUNT(*) > 0 AS has_data, MAX(created_at) > CURRENT_DATE - 7 AS is_recent FROM {{schema}}.{{table_name}}; @@ -250,17 +250,17 @@ class MyFlow(FlowSpec): def start(self): # Query data self.df = query_pandas_from_snowflake(""" - SELECT * FROM raw_data + SELECT * FROM raw_data WHERE date = CURRENT_DATE """) self.next(self.transform) - + @step def transform(self): # Transform data self.features = self.df.groupby('user_id').size() self.next(self.publish) - + @step def publish(self): # Publish DataFrame @@ -271,7 +271,7 @@ class MyFlow(FlowSpec): overwrite=True, ) self.next(self.end) - + @step def end(self): print("Pipeline complete!") From 7df02c1a7e360007f30f9359514ff3ba7d778a6e Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:09:45 +0530 Subject: [PATCH 120/167] fix: update Snowflake connection handling to include warehouse parameter and adjust import path for add_comment_to_each_sql_statement --- src/ds_platform_utils/metaflow/pandas.py | 2 +- .../metaflow/test__add_comment_to_each_sql_statement.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 945702f..79c304d 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -217,7 +217,7 @@ def query_pandas_from_snowflake( ) df = _get_df_from_s3_files(s3_files) else: - conn: SnowflakeConnection = get_snowflake_connection(use_utc) + conn: SnowflakeConnection = get_snowflake_connection(warehouse=warehouse, use_utc=use_utc) if warehouse is not None: _execute_sql(conn, f"USE WAREHOUSE {warehouse};") _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") diff --git a/tests/functional_tests/metaflow/test__add_comment_to_each_sql_statement.py b/tests/functional_tests/metaflow/test__add_comment_to_each_sql_statement.py index 8b0d609..9ed1fa0 100644 --- a/tests/functional_tests/metaflow/test__add_comment_to_each_sql_statement.py +++ b/tests/functional_tests/metaflow/test__add_comment_to_each_sql_statement.py @@ -1,4 +1,4 @@ -from src.ds_platform_utils.metaflow.write_audit_publish import add_comment_to_each_sql_statement +from src.ds_platform_utils._snowflake.run_query import add_comment_to_each_sql_statement def test_add_comment_to_each_sql_statement(): From fe523dd23f96adc259beb02697b596a7472ffb31 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:23:48 +0530 Subject: [PATCH 121/167] Implement structure for code changes with placeholders for future updates --- src/ds_platform_utils/metaflow/batch_inference_pipeline.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 383475a..e5c5e30 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -11,7 +11,6 @@ import pandas as pd from metaflow import current -from ds_platform_utils._snowflake.write_audit_publish import get_query_from_string_or_fpath, substitute_map_into_string from ds_platform_utils.metaflow import s3 from ds_platform_utils.metaflow._consts import ( DEV_SCHEMA, @@ -157,6 +156,11 @@ def query_and_batch( List of worker_ids to use with foreach in next step """ + from ds_platform_utils._snowflake.write_audit_publish import ( + get_query_from_string_or_fpath, + substitute_map_into_string, + ) + # Warn if re-executing query_and_batch after processing if self._query_executed and self._batch_processed: raise RuntimeError( From 3f73bcfb8bdd3530e102a892d3238236a0540a77 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:40:03 +0530 Subject: [PATCH 122/167] fix: enhance test coverage for _execute_sql by adding fixtures and mocking current object --- .../unit_tests/snowflake/test__execute_sql.py | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/snowflake/test__execute_sql.py b/tests/unit_tests/snowflake/test__execute_sql.py index c52e1ff..4089f63 100644 --- a/tests/unit_tests/snowflake/test__execute_sql.py +++ b/tests/unit_tests/snowflake/test__execute_sql.py @@ -1,16 +1,46 @@ """Functional test for _execute_sql.""" +import types from typing import Generator import pytest from snowflake.connector import SnowflakeConnection +import ds_platform_utils._snowflake.run_query as run_query_module +import ds_platform_utils.metaflow.snowflake_connection as snowflake_connection_module from ds_platform_utils._snowflake.run_query import _execute_sql from ds_platform_utils.metaflow.snowflake_connection import get_snowflake_connection +def _make_dummy_current(*, tags): + """Use SimpleNamespace so we can quickly mock a Metaflow's `current` object attributes used by the `get_select_dev_query_tags()` function.""" + cur = types.SimpleNamespace() + cur.tags = tags + cur.flow_name = "DummyFlow" + cur.project_name = "dummy-project" + cur.step_name = "dummy-step" + cur.run_id = "123" + cur.username = "tester" + cur.namespace = "user:tester" + cur.is_production = False + cur.is_running_flow = True + cur.card = None + return cur + + +@pytest.fixture(scope="module") +def patched_current() -> Generator[None, None, None]: + """Patch Metaflow `current` object for modules used in this test file.""" + dummy_current = _make_dummy_current(tags=["ds.domain:operations", "ds.project:test-project"]) + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr(run_query_module, "current", dummy_current) + monkeypatch.setattr(snowflake_connection_module, "current", dummy_current) + yield + monkeypatch.undo() + + @pytest.fixture(scope="module") -def snowflake_conn() -> Generator[SnowflakeConnection, None, None]: +def snowflake_conn(patched_current) -> Generator[SnowflakeConnection, None, None]: """Get a Snowflake connection for testing.""" yield get_snowflake_connection(use_utc=True) From 72ff942b53cc284554a6e003a6c8e9e867283664 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:27:15 +0530 Subject: [PATCH 123/167] feat: add SQL utility functions for query handling and tagging in Snowflake and Metaflow --- src/ds_platform_utils/sql_utils.py | 116 +++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/ds_platform_utils/sql_utils.py diff --git a/src/ds_platform_utils/sql_utils.py b/src/ds_platform_utils/sql_utils.py new file mode 100644 index 0000000..1190f1b --- /dev/null +++ b/src/ds_platform_utils/sql_utils.py @@ -0,0 +1,116 @@ +"""Shared SQL utility functions used across Snowflake and Metaflow modules.""" + +import json +import os +import warnings +from pathlib import Path +from textwrap import dedent +from typing import Any, Optional, Union + +import sqlparse +from jinja2 import DebugUndefined, Template + + +def substitute_map_into_string(string: str, values: dict[str, Any]) -> str: + """Format a string using a dictionary with Jinja2 templating.""" + template = Template(string, undefined=DebugUndefined) + return template.render(values) + + +def get_query_from_string_or_fpath(query_str_or_fpath: Union[str, Path]) -> str: + """Get the SQL query from a string or file path.""" + stripped_query = str(query_str_or_fpath).strip() + query_is_file_path = isinstance(query_str_or_fpath, Path) or stripped_query.endswith(".sql") + if query_is_file_path: + return Path(query_str_or_fpath).read_text() + return stripped_query + + +def get_select_dev_query_tags(current_obj: Optional[Any] = None) -> dict[str, Optional[str]]: + """Return tags for the current Metaflow flow run for select.dev tracking. + + If ``current_obj`` is not provided, this function attempts to read from + ``metaflow.current``. Missing attributes are handled gracefully with defaults. + """ + if current_obj is None: + from metaflow import current as metaflow_current + + current_obj = metaflow_current + + fetched_tags = getattr(current_obj, "tags", []) + required_tags_are_present = any(tag.startswith("ds.project") for tag in fetched_tags) and any( + tag.startswith("ds.domain") for tag in fetched_tags + ) + if not required_tags_are_present: + warnings.warn( + dedent(""" + Warning: ds-platform-utils attempted to add query tags to a Snowflake query + for cost tracking in select.dev, but one or both required Metaflow user tags + ('ds.domain' and 'ds.project') were not found on this flow. + + These tags are used to correctly attribute query costs by domain and project. + Please ensure both tags are included when running the flow, for example: + + uv run _flow.py \\ + --environment=fast-bakery \\ + --package-suffixes='.csv,.sql,.json,.toml,.yaml,.yml,.txt' \\ + --with card \\ + argo-workflows create \\ + --tag "ds.domain:operations" \\ + --tag "ds.project:regional-forecast" + + Note: in the monorepo, these tags are applied automatically in CI and when using + the standard poe commands for running flows. + """), + stacklevel=2, + ) + + def _extract(prefix: str, default: str = "unknown") -> str: + for tag in fetched_tags: + if tag.startswith(prefix + ":"): + return tag.split(":", 1)[1] + return default + + def _attr(name: str, default: str = "unknown") -> str: + return str(getattr(current_obj, name, default)) + + return { + "app": _extract("ds.domain"), + "workload_id": _extract("ds.project"), + "flow_name": _attr("flow_name"), + "project": _attr("project_name"), + "step_name": _attr("step_name"), + "run_id": _attr("run_id"), + "user": _attr("username"), + "domain": _extract("ds.domain"), + "namespace": _attr("namespace"), + "perimeter": str(os.environ.get("OB_CURRENT_PERIMETER") or os.environ.get("OBP_PERIMETER")), + "is_production": _attr("is_production", "False"), + "team": "data-science", + } + + +def add_comment_to_each_sql_statement(sql_text: str, comment: str) -> str: + """Append a comment string to each SQL statement in a SQL script.""" + statements = [s.strip() for s in sqlparse.split(sql_text) if s.strip()] + if not statements: + return sql_text + + annotated = [] + for stmt in statements: + has_semicolon = stmt.rstrip().endswith(";") + trimmed = stmt.rstrip() + if has_semicolon: + trimmed = trimmed[:-1].rstrip() + annotated.append(f"{trimmed} {comment};") + else: + annotated.append(f"{trimmed} {comment}") + + return "\n".join(annotated) + + +def add_select_dev_query_tags_to_sql(sql_text: str, current_obj: Optional[Any] = None) -> str: + """Attach select.dev query-tag JSON comment to each SQL statement.""" + tags = get_select_dev_query_tags(current_obj=current_obj) + tag_str = json.dumps(tags, indent=2) + return add_comment_to_each_sql_statement(sql_text, tag_str) From 200c3f9b77a02cac0308241681f35e4a6a04157d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:31:45 +0530 Subject: [PATCH 124/167] feat: add SQL utility functions for query handling and tagging in Snowflake and Metaflow --- src/ds_platform_utils/_snowflake/run_query.py | 136 ++---------------- .../_snowflake/write_audit_publish.py | 38 +---- .../metaflow/batch_inference_pipeline.py | 8 +- src/ds_platform_utils/metaflow/pandas.py | 11 +- .../metaflow/write_audit_publish.py | 6 +- 5 files changed, 17 insertions(+), 182 deletions(-) diff --git a/src/ds_platform_utils/_snowflake/run_query.py b/src/ds_platform_utils/_snowflake/run_query.py index a2386f1..822f114 100644 --- a/src/ds_platform_utils/_snowflake/run_query.py +++ b/src/ds_platform_utils/_snowflake/run_query.py @@ -1,138 +1,25 @@ """Shared Snowflake utility functions.""" -import json import os import warnings -from textwrap import dedent -from typing import Dict, Iterable, Optional +from typing import Iterable, Optional -import sqlparse -from metaflow import current from snowflake.connector import SnowflakeConnection from snowflake.connector.cursor import SnowflakeCursor from snowflake.connector.errors import ProgrammingError +from ds_platform_utils.sql_utils import add_select_dev_query_tags_to_sql -def get_select_dev_query_tags() -> Dict[str, Optional[str]]: - """Return tags for the current Metaflow flow run. - These tags are used for cost tracking in select.dev. - See the select.dev docs on custom workload tags: - https://select.dev/docs/reference/integrations/custom-workloads#example-query-tag +def _debug_print_query(query: str) -> None: + """Print query if DEBUG_QUERY env var is set. - What the main tags mean and why we set them this way: - - "app": a broad category that groups queries by domain. We set app to the value of ds.domain - that we get from current tags of the flow, so queries are attributed to the right domain (for example, "Operations"). - - "workload_id": identifies the specific project or sub-unit inside that domain. - We set workload_id to the value of ds.project that we get from current tags of - the flow so select.dev can attribute costs to the exact project (for example, "out-of-stock"). - - For more granular attribution we have other tags: - - "flow_name": the flow name - - "step_name": the step within the flow - - "run_id": the unique id of the flow run - - "user": the username of the user who triggered the flow run (or argo-workflows if it's a deployed flow) - - "namespace": the namespace of the flow run - - "team": the team name, hardcoded as "data-science" for all flows - - **Note: all other tags are arbitrary. Add any extra key/value pairs that help you trace and group queries for cost reporting.** - """ - fetched_tags = current.tags - required_tags_are_present = any(tag.startswith("ds.project") for tag in fetched_tags) and any( - tag.startswith("ds.domain") for tag in fetched_tags - ) # Checking presence of both required Metaflow user tags in current tags of the flow - if not required_tags_are_present: - warnings.warn( - dedent(""" - Warning: ds-platform-utils attempted to add query tags to a Snowflake query - for cost tracking in select.dev, but one or both required Metaflow user tags - ('ds.domain' and 'ds.project') were not found on this flow. - - These tags are used to correctly attribute query costs by domain and project. - Please ensure both tags are included when running the flow, for example: - - uv run _flow.py \\ - --environment=fast-bakery \\ - --package-suffixes='.csv,.sql,.json,.toml,.yaml,.yml,.txt' \\ - --with card \\ - argo-workflows create \\ - --tag "ds.domain:operations" \\ - --tag "ds.project:regional-forecast" - - Note: in the monorepo, these tags are applied automatically in CI and when using - the standard poe commands for running flows. - """), - stacklevel=2, - ) - - def _extract(prefix: str, default: str = "unknown") -> str: - for tag in fetched_tags: - if tag.startswith(prefix + ":"): - return tag.split(":", 1)[1] - return default - - # most of these will be unknown if no tags are set on the flow - # (most likely for the flow runs which are triggered manually locally) - return { - "app": _extract( - "ds.domain" - ), # first tag after 'app:', is the domain of the flow, fetched from current tags of the flow - "workload_id": _extract( - "ds.project" - ), # second tag after 'workload_id:', is the project of the flow which it belongs to - "flow_name": current.flow_name, - "project": current.project_name, # Project name from the @project decorator, lets us - # identify the flow’s project without relying on user tags (added via --tag). - "step_name": current.step_name, # name of the current step - "run_id": current.run_id, # run_id: unique id of the current run - "user": current.username, # username of user who triggered the run (argo-workflows if its a deployed flow) - "domain": _extract("ds.domain"), # business unit (domain) of the flow, same as app - "namespace": current.namespace, # namespace of the flow - "perimeter": str(os.environ.get("OB_CURRENT_PERIMETER") or os.environ.get("OBP_PERIMETER")), - "is_production": str( - current.is_production - ), # True, if the flow is deployed with the --production flag else false - "team": "data-science", # team name, hardcoded as data-science - } - - -def add_comment_to_each_sql_statement(sql_text: str, comment: str) -> str: - """Append `comment` (e.g., /* {...} */) to every SQL statement in `sql_text`. - - Purpose: - Some SQL files contain multiple statements separated by semicolons. - Snowflake only associates query-level metadata (like select.dev cost-tracking tags) - with individual statements, not entire batches. This helper ensures that the - JSON-style comment containing query tags is added to each statement separately, - so every query executed can be properly attributed and tracked. - - The comment is inserted immediately before the terminating semicolon of each statement, - preserving whether the original statement had one. + :param query: SQL query to print """ - statements = [s.strip() for s in sqlparse.split(sql_text) if s.strip()] - if not statements: - return sql_text - - annotated = [] - for stmt in statements: - has_semicolon = stmt.rstrip().endswith(";") - trimmed = stmt.rstrip() - if has_semicolon: - trimmed = trimmed[:-1].rstrip() - annotated.append(f"{trimmed} {comment};") - else: - annotated.append(f"{trimmed} {comment}") - - # Separate statements with a blank line for readability - return "\n".join(annotated) + if os.getenv("DEBUG_QUERY"): + print("\n=== DEBUG SQL QUERY ===") + print(query) + print("=====================\n") def _execute_sql(conn: SnowflakeConnection, sql: str) -> Optional[SnowflakeCursor]: @@ -154,9 +41,8 @@ def _execute_sql(conn: SnowflakeConnection, sql: str) -> Optional[SnowflakeCurso try: # adding query tags comment in query for cost tracking in select.dev - tags = get_select_dev_query_tags() - tag_str = json.dumps(tags, indent=2) - sql = add_comment_to_each_sql_statement(sql, tag_str) + sql = add_select_dev_query_tags_to_sql(sql) + _debug_print_query(sql) cursors: Iterable[SnowflakeCursor] = conn.execute_string(sql.strip()) if cursors is None: diff --git a/src/ds_platform_utils/_snowflake/write_audit_publish.py b/src/ds_platform_utils/_snowflake/write_audit_publish.py index ce62234..b08afc4 100644 --- a/src/ds_platform_utils/_snowflake/write_audit_publish.py +++ b/src/ds_platform_utils/_snowflake/write_audit_publish.py @@ -1,14 +1,13 @@ -import os import uuid from dataclasses import dataclass from pathlib import Path from typing import Any, Generator, Literal, Optional, Union -from jinja2 import DebugUndefined, Template from snowflake.connector.cursor import SnowflakeCursor from ds_platform_utils._snowflake.run_query import _execute_sql from ds_platform_utils.metaflow._consts import DEV_SCHEMA, PROD_SCHEMA +from ds_platform_utils.sql_utils import get_query_from_string_or_fpath, substitute_map_into_string def write_audit_publish( # noqa: PLR0913 (too-many-arguments) this fn is an exception @@ -181,22 +180,12 @@ class AuditSQLOperation(SQLOperation): results: dict[str, Any] -def _debug_print_query(query: str) -> None: - """Print query if DEBUG_QUERY env var is set.""" - if os.getenv("DEBUG_QUERY"): - print("\n=== DEBUG SQL QUERY ===") - print(query) - print("=====================\n") - - def run_query(query: str, cursor: Optional[SnowflakeCursor] = None) -> None: """Execute one or more SQL statements. :param query: SQL query or queries to execute. Multiple statements must be separated by semicolons. :param cursor: Snowflake cursor. If None, prints query instead of executing """ - _debug_print_query(query) - if cursor is None: print(f"Would execute query:\n{query}") return @@ -212,8 +201,6 @@ def run_audit_query(query: str, cursor: Optional[SnowflakeCursor] = None) -> dic :param query: SQL query that returns a single row of boolean values :param cursor: Snowflake cursor. If None, returns mock successful result """ - _debug_print_query(query) - if cursor is None: return {"mock_result": True} @@ -401,29 +388,6 @@ def cleanup( run_query(query=drop_query, cursor=cursor) -def substitute_map_into_string(string: str, values: dict[str, Any]) -> str: - """Format a string using a dictionary with Jinja2 templating. - - :param string: The template string containing placeholders - :param values: A dictionary of values to substitute into the template - """ - template = Template(string, undefined=DebugUndefined) - return template.render(values) - - -def get_query_from_string_or_fpath(query_str_or_fpath: Union[str, Path]) -> str: - """Get the SQL query from a string or file path. - - :param query_str_or_fpath: SQL query string or path to a .sql file - :return: The SQL query as a string - """ - stripped_query = str(query_str_or_fpath).strip() - query_is_file_path = isinstance(query_str_or_fpath, Path) or stripped_query.endswith(".sql") - if query_is_file_path: - return Path(query_str_or_fpath).read_text() - return stripped_query - - if __name__ == "__main__": # Example usage table_name = "pokemon_stats" diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index e5c5e30..86d191d 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -22,7 +22,7 @@ copy_s3_to_snowflake, copy_snowflake_to_s3, ) -from ds_platform_utils.metaflow.snowflake_connection import _debug_print_query +from ds_platform_utils.sql_utils import get_query_from_string_or_fpath, substitute_map_into_string def _debug(*args, **kwargs): @@ -156,11 +156,6 @@ def query_and_batch( List of worker_ids to use with foreach in next step """ - from ds_platform_utils._snowflake.write_audit_publish import ( - get_query_from_string_or_fpath, - substitute_map_into_string, - ) - # Warn if re-executing query_and_batch after processing if self._query_executed and self._batch_processed: raise RuntimeError( @@ -173,7 +168,6 @@ def query_and_batch( # Process input query input_query = get_query_from_string_or_fpath(input_query) input_query = substitute_map_into_string(input_query, {"schema": self._schema} | (ctx or {})) - _debug_print_query(input_query) _debug(f"⏳ Exporting data from Snowflake to S3 to {self._input_path}...") # Export from Snowflake to S3 diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 79c304d..4b55dd9 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -22,10 +22,11 @@ copy_s3_to_snowflake, copy_snowflake_to_s3, ) -from ds_platform_utils.metaflow.snowflake_connection import _debug_print_query, get_snowflake_connection +from ds_platform_utils.metaflow.snowflake_connection import get_snowflake_connection from ds_platform_utils.metaflow.write_audit_publish import ( _make_snowflake_table_url, ) +from ds_platform_utils.sql_utils import get_query_from_string_or_fpath, substitute_map_into_string def publish_pandas( # noqa: PLR0913 (too many arguments) @@ -188,11 +189,6 @@ def query_pandas_from_snowflake( If the `ctx` dictionary is provided, it will be used to substitute values into the query string. The keys in the `ctx` dictionary should match the placeholders in the query string. """ - from ds_platform_utils._snowflake.write_audit_publish import ( - get_query_from_string_or_fpath, - substitute_map_into_string, - ) - schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA # adding query tags comment in query for cost tracking in select.dev @@ -201,9 +197,6 @@ def query_pandas_from_snowflake( query = substitute_map_into_string(query, {"schema": schema} | (ctx or {})) - # print query if DEBUG_QUERY env var is set - _debug_print_query(query) - if warehouse is not None: current.card.append(Markdown(f"## Using Snowflake Warehouse: `{warehouse}`")) current.card.append(Markdown("## Querying Snowflake Table")) diff --git a/src/ds_platform_utils/metaflow/write_audit_publish.py b/src/ds_platform_utils/metaflow/write_audit_publish.py index 52fb795..442680a 100644 --- a/src/ds_platform_utils/metaflow/write_audit_publish.py +++ b/src/ds_platform_utils/metaflow/write_audit_publish.py @@ -8,6 +8,7 @@ from ds_platform_utils._snowflake.run_query import _execute_sql from ds_platform_utils.metaflow.snowflake_connection import get_snowflake_connection +from ds_platform_utils.sql_utils import get_query_from_string_or_fpath if TYPE_CHECKING: from ds_platform_utils._snowflake.write_audit_publish import ( @@ -59,10 +60,7 @@ def publish( # noqa: PLR0913, D417 ``` """ - from ds_platform_utils._snowflake.write_audit_publish import ( - get_query_from_string_or_fpath, - write_audit_publish, - ) + from ds_platform_utils._snowflake.write_audit_publish import write_audit_publish conn = get_snowflake_connection(warehouse=warehouse, use_utc=use_utc) query = get_query_from_string_or_fpath(query) From 1500e06b71c21da238f7ed13785968d77de63e70 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:45:20 +0530 Subject: [PATCH 125/167] refactor: streamline _execute_sql function and enhance error handling in tests --- src/ds_platform_utils/_snowflake/run_query.py | 28 +++------ .../unit_tests/snowflake/test__execute_sql.py | 62 +++++++------------ 2 files changed, 32 insertions(+), 58 deletions(-) diff --git a/src/ds_platform_utils/_snowflake/run_query.py b/src/ds_platform_utils/_snowflake/run_query.py index 822f114..a20c936 100644 --- a/src/ds_platform_utils/_snowflake/run_query.py +++ b/src/ds_platform_utils/_snowflake/run_query.py @@ -1,12 +1,10 @@ """Shared Snowflake utility functions.""" import os -import warnings from typing import Iterable, Optional from snowflake.connector import SnowflakeConnection from snowflake.connector.cursor import SnowflakeCursor -from snowflake.connector.errors import ProgrammingError from ds_platform_utils.sql_utils import add_select_dev_query_tags_to_sql @@ -36,23 +34,13 @@ def _execute_sql(conn: SnowflakeConnection, sql: str) -> Optional[SnowflakeCurso :return: The cursor corresponding to the last executed statement, or None if no statements were executed or if the SQL contains only whitespace/comments """ - if not sql.strip(): + # adding query tags comment in query for cost tracking in select.dev + sql = add_select_dev_query_tags_to_sql(sql) + _debug_print_query(sql) + cursors: Iterable[SnowflakeCursor] = conn.execute_string(sql.strip()) + + if cursors is None: return None - try: - # adding query tags comment in query for cost tracking in select.dev - sql = add_select_dev_query_tags_to_sql(sql) - _debug_print_query(sql) - cursors: Iterable[SnowflakeCursor] = conn.execute_string(sql.strip()) - - if cursors is None: - return None - - *_, last = cursors - return last - except ProgrammingError as e: - if "Empty SQL statement" in str(e): - # raise a warning and return None - warnings.warn("Empty SQL statement encountered; returning None.", category=UserWarning, stacklevel=2) - return None - raise + *_, last = cursors + return last diff --git a/tests/unit_tests/snowflake/test__execute_sql.py b/tests/unit_tests/snowflake/test__execute_sql.py index 4089f63..163b3cf 100644 --- a/tests/unit_tests/snowflake/test__execute_sql.py +++ b/tests/unit_tests/snowflake/test__execute_sql.py @@ -1,74 +1,59 @@ """Functional test for _execute_sql.""" -import types from typing import Generator +from unittest.mock import MagicMock import pytest from snowflake.connector import SnowflakeConnection -import ds_platform_utils._snowflake.run_query as run_query_module -import ds_platform_utils.metaflow.snowflake_connection as snowflake_connection_module from ds_platform_utils._snowflake.run_query import _execute_sql -from ds_platform_utils.metaflow.snowflake_connection import get_snowflake_connection - - -def _make_dummy_current(*, tags): - """Use SimpleNamespace so we can quickly mock a Metaflow's `current` object attributes used by the `get_select_dev_query_tags()` function.""" - cur = types.SimpleNamespace() - cur.tags = tags - cur.flow_name = "DummyFlow" - cur.project_name = "dummy-project" - cur.step_name = "dummy-step" - cur.run_id = "123" - cur.username = "tester" - cur.namespace = "user:tester" - cur.is_production = False - cur.is_running_flow = True - cur.card = None - return cur +from ds_platform_utils.metaflow.snowflake_connection import _create_snowflake_connection @pytest.fixture(scope="module") -def patched_current() -> Generator[None, None, None]: +def patched_current() -> Generator[MagicMock, None, None]: """Patch Metaflow `current` object for modules used in this test file.""" - dummy_current = _make_dummy_current(tags=["ds.domain:operations", "ds.project:test-project"]) - monkeypatch = pytest.MonkeyPatch() - monkeypatch.setattr(run_query_module, "current", dummy_current) - monkeypatch.setattr(snowflake_connection_module, "current", dummy_current) - yield - monkeypatch.undo() + mock_current = MagicMock("metaflow.current") + mock_current.tags = ["ds.domain:testing", "ds.project:unit-tests"] + mock_current.flow_name = "DummyFlow" + mock_current.project_name = "dummy-project" + mock_current.step_name = "dummy-step" + mock_current.run_id = "123" + mock_current.username = "tester" + mock_current.is_production = False + mock_current.namespace = "user:tester" + mock_current.is_running_flow = True + mock_current.card = [] @pytest.fixture(scope="module") def snowflake_conn(patched_current) -> Generator[SnowflakeConnection, None, None]: """Get a Snowflake connection for testing.""" - yield get_snowflake_connection(use_utc=True) + yield _create_snowflake_connection(warehouse=None, use_utc=True) def test_execute_sql_empty_string(snowflake_conn): """Empty string returns None.""" - cursor = _execute_sql(snowflake_conn, "") - assert cursor is None + with pytest.raises(ValueError, match="No valid SQL statements found"): + _execute_sql(snowflake_conn, "") def test_execute_sql_whitespace_only(snowflake_conn): """Whitespace-only string returns None.""" - cursor = _execute_sql(snowflake_conn, " \n\t ") - assert cursor is None + with pytest.raises(ValueError, match="No valid SQL statements found"): + _execute_sql(snowflake_conn, " \n\t ") def test_execute_sql_only_semicolons(snowflake_conn): """String with only semicolons returns None and raises warning.""" - with pytest.warns(UserWarning, match="Empty SQL statement encountered"): - cursor = _execute_sql(snowflake_conn, " ; ;") - assert cursor is None + with pytest.raises(ValueError, match="No valid SQL statements found"): + _execute_sql(snowflake_conn, " ; ;") def test_execute_sql_only_comments(snowflake_conn): """String with only comments returns None and raises warning.""" - with pytest.warns(UserWarning, match="Empty SQL statement encountered"): - cursor = _execute_sql(snowflake_conn, "/* only comments */") - assert cursor is None + with pytest.raises(ValueError, match="No valid SQL statements found"): + _execute_sql(snowflake_conn, "/* only comments */") def test_execute_sql_single_statement(snowflake_conn): @@ -85,5 +70,6 @@ def test_execute_sql_multi_statement(snowflake_conn): cursor = _execute_sql(snowflake_conn, "SELECT 1 AS x; SELECT 2 AS x;") assert cursor is not None rows = cursor.fetchall() + print(rows) assert len(rows) == 1 assert rows[0][0] == 2 # Last statement result From fc223e763feb184bc1cd6a005a8eeb52a063aec9 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:45:46 +0530 Subject: [PATCH 126/167] refactor: replace warnings with print statements and enhance comment handling in SQL functions --- src/ds_platform_utils/sql_utils.py | 38 +++++++++++++++++------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/src/ds_platform_utils/sql_utils.py b/src/ds_platform_utils/sql_utils.py index 1190f1b..ed7ca56 100644 --- a/src/ds_platform_utils/sql_utils.py +++ b/src/ds_platform_utils/sql_utils.py @@ -2,7 +2,6 @@ import json import os -import warnings from pathlib import Path from textwrap import dedent from typing import Any, Optional, Union @@ -42,7 +41,7 @@ def get_select_dev_query_tags(current_obj: Optional[Any] = None) -> dict[str, Op tag.startswith("ds.domain") for tag in fetched_tags ) if not required_tags_are_present: - warnings.warn( + print( dedent(""" Warning: ds-platform-utils attempted to add query tags to a Snowflake query for cost tracking in select.dev, but one or both required Metaflow user tags @@ -61,8 +60,7 @@ def get_select_dev_query_tags(current_obj: Optional[Any] = None) -> dict[str, Op Note: in the monorepo, these tags are applied automatically in CI and when using the standard poe commands for running flows. - """), - stacklevel=2, + """) ) def _extract(prefix: str, default: str = "unknown") -> str: @@ -91,22 +89,30 @@ def _attr(name: str, default: str = "unknown") -> str: def add_comment_to_each_sql_statement(sql_text: str, comment: str) -> str: - """Append a comment string to each SQL statement in a SQL script.""" - statements = [s.strip() for s in sqlparse.split(sql_text) if s.strip()] - if not statements: - return sql_text + """Append `comment` (e.g., /* {...} */) to every SQL statement in `sql_text`. + Purpose: + Some SQL files contain multiple statements separated by semicolons. + Snowflake only associates query-level metadata (like select.dev cost-tracking tags) + with individual statements, not entire batches. This helper ensures that the + JSON-style comment containing query tags is added to each statement separately, + so every query executed can be properly attributed and tracked. + + The comment is inserted immediately before the terminating semicolon of each statement, + preserving whether the original statement had one. + """ + statements = [s.strip() for s in sqlparse.format(sql_text, strip_comments=True).split(";") if s.strip()] annotated = [] for stmt in statements: - has_semicolon = stmt.rstrip().endswith(";") - trimmed = stmt.rstrip() - if has_semicolon: - trimmed = trimmed[:-1].rstrip() - annotated.append(f"{trimmed} {comment};") + if stmt.strip(): + annotated.append(f"{stmt} \n/* {comment} */\n;") else: - annotated.append(f"{trimmed} {comment}") - - return "\n".join(annotated) + print( + "Warning: encountered empty SQL statement after parsing. This may indicate an issue with the SQL formatting." + ) + if not annotated: + raise ValueError("No valid SQL statements found in the provided SQL text.") + return "\n".join(annotated) + "\n" def add_select_dev_query_tags_to_sql(sql_text: str, current_obj: Optional[Any] = None) -> str: From 335562358cc2cadf6d8e907fcd7eda39f413c4ec Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:47:52 +0530 Subject: [PATCH 127/167] test: add test for multi-statement execution with comments in _execute_sql --- tests/unit_tests/snowflake/test__execute_sql.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/unit_tests/snowflake/test__execute_sql.py b/tests/unit_tests/snowflake/test__execute_sql.py index 163b3cf..75c13ae 100644 --- a/tests/unit_tests/snowflake/test__execute_sql.py +++ b/tests/unit_tests/snowflake/test__execute_sql.py @@ -73,3 +73,17 @@ def test_execute_sql_multi_statement(snowflake_conn): print(rows) assert len(rows) == 1 assert rows[0][0] == 2 # Last statement result + + +def test_execute_sql_multi_statement_with_comments(snowflake_conn): + """Multi-statement with comments returns cursor for last statement only.""" + cursor = _execute_sql( + snowflake_conn, + """SELECT 1 AS x; -- {comment} + SELECT 2 AS x;""", + ) + assert cursor is not None + rows = cursor.fetchall() + print(rows) + assert len(rows) == 1 + assert rows[0][0] == 2 # Last statement result From ea6ddb271734b6d61cf2c407243a4e12776297ed Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:17:12 +0530 Subject: [PATCH 128/167] feat: enhance SQL comment handling and add unit tests for comment insertion and tag validation --- src/ds_platform_utils/sql_utils.py | 9 +++++---- .../test__add_comment_to_each_sql_statement.py | 10 +++++----- .../snowflake}/test__get_select_dev_query_tags.py | 0 3 files changed, 10 insertions(+), 9 deletions(-) rename tests/{functional_tests/metaflow => unit_tests/snowflake}/test__add_comment_to_each_sql_statement.py (53%) rename tests/{functional_tests/metaflow => unit_tests/snowflake}/test__get_select_dev_query_tags.py (100%) diff --git a/src/ds_platform_utils/sql_utils.py b/src/ds_platform_utils/sql_utils.py index ed7ca56..bc224fe 100644 --- a/src/ds_platform_utils/sql_utils.py +++ b/src/ds_platform_utils/sql_utils.py @@ -101,18 +101,19 @@ def add_comment_to_each_sql_statement(sql_text: str, comment: str) -> str: The comment is inserted immediately before the terminating semicolon of each statement, preserving whether the original statement had one. """ - statements = [s.strip() for s in sqlparse.format(sql_text, strip_comments=True).split(";") if s.strip()] + statements = [s.strip() for s in sqlparse.split(sqlparse.format(sql_text, strip_comments=True)) if s.strip()] annotated = [] for stmt in statements: - if stmt.strip(): - annotated.append(f"{stmt} \n/* {comment} */\n;") + stmt = stmt.rstrip(";").strip() + if stmt: + annotated.append(f"{stmt}\n/* {comment} */\n;") else: print( "Warning: encountered empty SQL statement after parsing. This may indicate an issue with the SQL formatting." ) if not annotated: raise ValueError("No valid SQL statements found in the provided SQL text.") - return "\n".join(annotated) + "\n" + return "\n".join(annotated) def add_select_dev_query_tags_to_sql(sql_text: str, current_obj: Optional[Any] = None) -> str: diff --git a/tests/functional_tests/metaflow/test__add_comment_to_each_sql_statement.py b/tests/unit_tests/snowflake/test__add_comment_to_each_sql_statement.py similarity index 53% rename from tests/functional_tests/metaflow/test__add_comment_to_each_sql_statement.py rename to tests/unit_tests/snowflake/test__add_comment_to_each_sql_statement.py index 9ed1fa0..20f434c 100644 --- a/tests/functional_tests/metaflow/test__add_comment_to_each_sql_statement.py +++ b/tests/unit_tests/snowflake/test__add_comment_to_each_sql_statement.py @@ -1,15 +1,15 @@ -from src.ds_platform_utils._snowflake.run_query import add_comment_to_each_sql_statement +from src.ds_platform_utils.sql_utils import add_comment_to_each_sql_statement def test_add_comment_to_each_sql_statement(): """Test adding comments to each SQL statement.""" input_sql = "select * from foo; select * from bar; select 'abc;def' as col;" - comment = "/* {'app':'test'} */" + comment = "{'app':'test'}" expected_output = ( - "select * from foo /* {'app':'test'} */;\n\n" - "select * from bar /* {'app':'test'} */;\n\n" - "select 'abc;def' as col /* {'app':'test'} */;\n" + "select * from foo\n/* {'app':'test'} */\n;" + "\nselect * from bar\n/* {'app':'test'} */\n;" + "\nselect 'abc;def' as col\n/* {'app':'test'} */\n;" ) output = add_comment_to_each_sql_statement(input_sql, comment) diff --git a/tests/functional_tests/metaflow/test__get_select_dev_query_tags.py b/tests/unit_tests/snowflake/test__get_select_dev_query_tags.py similarity index 100% rename from tests/functional_tests/metaflow/test__get_select_dev_query_tags.py rename to tests/unit_tests/snowflake/test__get_select_dev_query_tags.py From 2a63370f901ee423cfdbda444dec128123f3c0d2 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:18:14 +0530 Subject: [PATCH 129/167] test: update multi-statement test to include comments in SQL execution --- tests/unit_tests/snowflake/test__execute_sql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/snowflake/test__execute_sql.py b/tests/unit_tests/snowflake/test__execute_sql.py index 75c13ae..f510a9c 100644 --- a/tests/unit_tests/snowflake/test__execute_sql.py +++ b/tests/unit_tests/snowflake/test__execute_sql.py @@ -79,7 +79,7 @@ def test_execute_sql_multi_statement_with_comments(snowflake_conn): """Multi-statement with comments returns cursor for last statement only.""" cursor = _execute_sql( snowflake_conn, - """SELECT 1 AS x; -- {comment} + """SELECT 1 AS x; -- {comment} SELECT 2 AS x;""", ) assert cursor is not None From 9aa83eecb33e93fb17456ee8c37da5c6600c775a Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:34:22 +0530 Subject: [PATCH 130/167] refactor: simplify test structure for get_select_dev_query_tags and enhance mock handling --- .../test__get_select_dev_query_tags.py | 85 +++++++++---------- 1 file changed, 39 insertions(+), 46 deletions(-) diff --git a/tests/unit_tests/snowflake/test__get_select_dev_query_tags.py b/tests/unit_tests/snowflake/test__get_select_dev_query_tags.py index 1c73be8..f26f066 100644 --- a/tests/unit_tests/snowflake/test__get_select_dev_query_tags.py +++ b/tests/unit_tests/snowflake/test__get_select_dev_query_tags.py @@ -1,50 +1,43 @@ -import types -import warnings +import os +from unittest.mock import MagicMock import pytest -from src.ds_platform_utils._snowflake import run_query - - -def _make_dummy_current(*, tags): - """Use SimpleNamespace so we can quickly mock a Metaflow's `current` object attributes used by the `get_select_dev_query_tags()` function.""" - cur = types.SimpleNamespace() - cur.tags = tags - cur.flow_name = "DummyFlow" - cur.project_name = "dummy-project" - cur.step_name = "dummy-step" - cur.run_id = "123" - cur.username = "tester" - cur.namespace = "user:tester" - cur.is_production = False - cur.card = None - return cur - - -def test_warns_when_either_required_tag_missing(monkeypatch): - """Raise warning if either `'ds.domain'` or `'ds.project'` is missing.""" - dummy_current = _make_dummy_current(tags={"ds.project:foo"}) - monkeypatch.setattr(run_query, "current", dummy_current) - - with pytest.warns(UserWarning, match=r"one or both required Metaflow user tags"): - run_query.get_select_dev_query_tags() - - -def test_warns_when_no_tags(monkeypatch): - """Raise warning if no tags are present.""" - dummy_current = _make_dummy_current(tags=set()) - monkeypatch.setattr(run_query, "current", dummy_current) - - with pytest.warns(UserWarning, match=r"one or both required Metaflow user tags"): - run_query.get_select_dev_query_tags() - - -def test_no_warning_when_both_required_tags_present(monkeypatch): +from ds_platform_utils.sql_utils import get_select_dev_query_tags + + +@pytest.fixture(scope="module") +def patched_current(): + """Patch Metaflow `current` object for modules used in this test file.""" + mock_current = MagicMock("metaflow.current") + mock_current.tags = ["ds.domain:testing", "ds.project:unit-tests"] + mock_current.flow_name = "DummyFlow" + mock_current.project_name = "dummy-project" + mock_current.step_name = "dummy-step" + mock_current.run_id = "123" + mock_current.username = "tester" + mock_current.is_production = False + mock_current.namespace = "user:tester" + mock_current.is_running_flow = True + mock_current.card = [] + os.environ["OB_CURRENT_PERIMETER"] = "default" + yield mock_current + os.environ.pop("OB_CURRENT_PERIMETER", None) + + +def test_get_select_dev_query_tags(patched_current): """No warning when both required tags are present.""" - dummy_current = _make_dummy_current(tags={"ds.domain:operations", "ds.project:myproj"}) - monkeypatch.setattr(run_query, "current", dummy_current) - - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - run_query.get_select_dev_query_tags() - assert not w # check no warnings captured + query_tags = get_select_dev_query_tags(current_obj=patched_current) + + assert query_tags["app"] == "testing" + assert query_tags["workload_id"] == "unit-tests" + assert query_tags["flow_name"] == "DummyFlow" + assert query_tags["project"] == "dummy-project" + assert query_tags["step_name"] == "dummy-step" + assert query_tags["run_id"] == "123" + assert query_tags["user"] == "tester" + assert query_tags["domain"] == "testing" + assert query_tags["namespace"] == "user:tester" + assert query_tags["perimeter"] == "default" + assert query_tags["is_production"] == "False" + assert query_tags["team"] == "data-science" From 0121904925fe90f0a31e1cea0e600ea2e6b56f31 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:09:47 +0530 Subject: [PATCH 131/167] refactor: restructure mock current object creation and enhance test coverage for query tags --- .../test__get_select_dev_query_tags.py | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/tests/unit_tests/snowflake/test__get_select_dev_query_tags.py b/tests/unit_tests/snowflake/test__get_select_dev_query_tags.py index f26f066..60f8a35 100644 --- a/tests/unit_tests/snowflake/test__get_select_dev_query_tags.py +++ b/tests/unit_tests/snowflake/test__get_select_dev_query_tags.py @@ -6,11 +6,10 @@ from ds_platform_utils.sql_utils import get_select_dev_query_tags -@pytest.fixture(scope="module") -def patched_current(): - """Patch Metaflow `current` object for modules used in this test file.""" +def _make_mock_current(tags=None): + """Create a mock Metaflow current object for query tag tests.""" mock_current = MagicMock("metaflow.current") - mock_current.tags = ["ds.domain:testing", "ds.project:unit-tests"] + mock_current.tags = tags if tags is not None else ["ds.domain:testing", "ds.project:unit-tests"] mock_current.flow_name = "DummyFlow" mock_current.project_name = "dummy-project" mock_current.step_name = "dummy-step" @@ -20,6 +19,13 @@ def patched_current(): mock_current.namespace = "user:tester" mock_current.is_running_flow = True mock_current.card = [] + return mock_current + + +@pytest.fixture +def patched_current(): + """Provide a mocked Metaflow current object for this test module.""" + mock_current = _make_mock_current() os.environ["OB_CURRENT_PERIMETER"] = "default" yield mock_current os.environ.pop("OB_CURRENT_PERIMETER", None) @@ -41,3 +47,32 @@ def test_get_select_dev_query_tags(patched_current): assert query_tags["perimeter"] == "default" assert query_tags["is_production"] == "False" assert query_tags["team"] == "data-science" + + +def test_get_select_dev_query_tags_missing_required_tags_prints_warning(capsys): + """Print warning and return defaults when ds.domain / ds.project tags are absent.""" + mock_current = _make_mock_current(tags=[]) + os.environ["OB_CURRENT_PERIMETER"] = "default" + + query_tags = get_select_dev_query_tags(current_obj=mock_current) + captured = capsys.readouterr() + + assert "Warning: ds-platform-utils attempted to add query tags" in captured.out + assert query_tags["app"] == "unknown" + assert query_tags["workload_id"] == "unknown" + assert query_tags["domain"] == "unknown" + + os.environ.pop("OB_CURRENT_PERIMETER", None) + + +def test_get_select_dev_query_tags_uses_obp_perimeter_fallback(): + """Use OBP_PERIMETER when OB_CURRENT_PERIMETER is not set.""" + mock_current = _make_mock_current() + os.environ.pop("OB_CURRENT_PERIMETER", None) + os.environ["OBP_PERIMETER"] = "fallback-perimeter" + + query_tags = get_select_dev_query_tags(current_obj=mock_current) + + assert query_tags["perimeter"] == "fallback-perimeter" + + os.environ.pop("OBP_PERIMETER", None) From be8cda7916201d4e246148c739b690b8bee7d32a Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:26:50 +0530 Subject: [PATCH 132/167] feat: add Metaflow flow for testing warehouse queries with dynamic parameters --- .../metaflow/test__warehouse.py | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/functional_tests/metaflow/test__warehouse.py diff --git a/tests/functional_tests/metaflow/test__warehouse.py b/tests/functional_tests/metaflow/test__warehouse.py new file mode 100644 index 0000000..130f8e4 --- /dev/null +++ b/tests/functional_tests/metaflow/test__warehouse.py @@ -0,0 +1,100 @@ +"""A Metaflow flow.""" + +import subprocess +import sys + +import pytest +from metaflow import FlowSpec, project, step + +from ds_platform_utils.metaflow import query_pandas_from_snowflake + + +@project(name="test_warehouse_flow") +class TestWarehouseFlow(FlowSpec): + """A sample flow.""" + + @step + def start(self): + """Start the flow.""" + self.next(self.test_publish_with_warehouse) + + @step + def test_publish_with_warehouse_xs(self): + """Test the publish function with warehouse parameter.""" + # Publish a simple query to Snowflake with a specific warehouse + warehouse_map = [ + { + "size": "XS", + "domain": "content", + "warehouse": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", + }, + { + "size": "MED", + "domain": "advertising", + "warehouse": "OUTERBOUNDS_DATA_SCIENCE_ADS_DEV_MED_WH", + }, + { + "size": "XL", + "domain": "reference", + "warehouse": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", + }, + { + "size": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", + "domain": "content", + "warehouse": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", + }, + ] + + for item in warehouse_map: + from metaflow import current + + current.tags.add(f"ds.domain:{item['domain']}") + df_warehouse = query_pandas_from_snowflake( + query="SELECT CURRENT_WAREHOUSE();", + warehouse=item["warehouse"], + ) + current.tags.pop() # Clean up tag after query + df_warehouse = df_warehouse.iloc[0, 0] + assert df_warehouse == item["warehouse"], f"Expected warehouse {item['warehouse']}, got {df_warehouse}" + + print(f"Successfully queried warehouse: {df_warehouse}") + + self.next(self.end) + + @step + def end(self): + """End the flow.""" + pass + + +if __name__ == "__main__": + TestWarehouseFlow() + + +@pytest.mark.slow +def test_warehouse_flow(): + """Test that the publish flow runs successfully.""" + cmd = [sys.executable, __file__, "--environment=local", "--with=card", "run"] + + print("\n=== Metaflow Output ===") + for line in execute_with_output(cmd): + print(line, end="") + + +def execute_with_output(cmd): + """Execute a command and yield output lines as they are produced.""" + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, # Merge stderr into stdout + universal_newlines=True, + bufsize=1, + ) + + for line in iter(process.stdout.readline, ""): + yield line + + process.stdout.close() + return_code = process.wait() + if return_code: + raise subprocess.CalledProcessError(return_code, cmd) From 355b2f5f7072d154aafb9187a656330b8ccd65ad Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:36:02 +0530 Subject: [PATCH 133/167] fix: rename test_publish_with_warehouse_xs to test_publish_with_warehouse for consistency --- tests/functional_tests/metaflow/test__warehouse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional_tests/metaflow/test__warehouse.py b/tests/functional_tests/metaflow/test__warehouse.py index 130f8e4..06a947c 100644 --- a/tests/functional_tests/metaflow/test__warehouse.py +++ b/tests/functional_tests/metaflow/test__warehouse.py @@ -19,7 +19,7 @@ def start(self): self.next(self.test_publish_with_warehouse) @step - def test_publish_with_warehouse_xs(self): + def test_publish_with_warehouse(self): """Test the publish function with warehouse parameter.""" # Publish a simple query to Snowflake with a specific warehouse warehouse_map = [ From c16fe77adb166a23f9c2b45fc564c06abe2da5fa Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:38:22 +0530 Subject: [PATCH 134/167] feat: add additional warehouse configuration to test_publish_with_warehouse --- tests/functional_tests/metaflow/test__warehouse.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/functional_tests/metaflow/test__warehouse.py b/tests/functional_tests/metaflow/test__warehouse.py index 06a947c..6f168cb 100644 --- a/tests/functional_tests/metaflow/test__warehouse.py +++ b/tests/functional_tests/metaflow/test__warehouse.py @@ -23,6 +23,11 @@ def test_publish_with_warehouse(self): """Test the publish function with warehouse parameter.""" # Publish a simple query to Snowflake with a specific warehouse warehouse_map = [ + { + "size": None, + "domain": "content", + "warehouse": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", + }, { "size": "XS", "domain": "content", From 33aaad4a22017072d976b3a7e4b4024217a1d00a Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:56:25 +0530 Subject: [PATCH 135/167] feat: implement Metaflow flow for batch inference processing and result publishing --- .../test__batch_inference_pipeline.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/functional_tests/metaflow/test__batch_inference_pipeline.py diff --git a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py new file mode 100644 index 0000000..1268a73 --- /dev/null +++ b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py @@ -0,0 +1,96 @@ +"""A Metaflow flow.""" + +import subprocess +import sys + +import pytest +from metaflow import FlowSpec, project, step + +from ds_platform_utils.metaflow import BatchInferencePipeline + + +@project(name="test_batch_inference_pipeline") +class TestBatchInferencePipeline(FlowSpec): + """A sample flow.""" + + @step + def start(self): + """Start the flow.""" + self.next(self.query_and_batch) + + @step + def query_and_batch(self): + """Run the query and batch step.""" + n = 1000000 + query = ( + f"SELECT ROW_NUMBER() AS ID ,f1 FROM (SELECT SEQ4() AS F1 AS id FROM TABLE(GENERATOR(ROWCOUNT => {n})))" + ) + self.pipeline = BatchInferencePipeline() + self.worker_ids = self.pipeline.query_and_batch( + input_query=query, + ctx={"extra": "value"}, + warehouse="XS", + ) + + self.next(self.process_batch, foreach="worker_ids") + + @step + def process_batch(self): + """Process the batch for each worker.""" + worker_id = self.input + + def predict_fn(df): + return df # Identity function for testing + + self.pipeline.process_batch(worker_id=worker_id, predict_fn=predict_fn, batch_size_in_mb=30) + print(f"Processing batch for worker {worker_id}...") + self.next(self.publish_results) + + @step + def publish_results(self, inputs): + """Join the parallel branches.""" + print("Joining results from all workers...") + self.pipeline.publish_results( + output_table_name="DS_PLATFORM_UTILS_TEST_BATCH_INFERENCE_OUTPUT", + overwrite=True, + auto_create_table=True, + ) + self.next(self.end) + + @step + def end(self): + """End the flow.""" + pass + + +if __name__ == "__main__": + TestBatchInferencePipeline() + + +@pytest.mark.slow +def test_warehouse_flow(): + """Test that the publish flow runs successfully.""" + cmd = [sys.executable, __file__, "--environment=local", "--with=card", "run"] + + print("\n=== Metaflow Output ===") + for line in execute_with_output(cmd): + print(line, end="") + + +def execute_with_output(cmd): + """Execute a command and yield output lines as they are produced.""" + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, # Merge stderr into stdout + universal_newlines=True, + bufsize=1, + ) + + for line in iter(process.stdout.readline, ""): + yield line + + process.stdout.close() + return_code = process.wait() + if return_code: + raise subprocess.CalledProcessError(return_code, cmd) From 289affc65a6aa0e2b878bf5361f5a6b2c4b3ca3b Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:02:56 +0530 Subject: [PATCH 136/167] fix: reduce query size in batch inference test for performance improvement --- .../metaflow/test__batch_inference_pipeline.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py index 1268a73..866debd 100644 --- a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py +++ b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py @@ -21,10 +21,8 @@ def start(self): @step def query_and_batch(self): """Run the query and batch step.""" - n = 1000000 - query = ( - f"SELECT ROW_NUMBER() AS ID ,f1 FROM (SELECT SEQ4() AS F1 AS id FROM TABLE(GENERATOR(ROWCOUNT => {n})))" - ) + n = 10000 + query = f"SELECT UNIFORM(0::FLOAT, 10::FLOAT, RANDOM()) , UNIFORM(0::INT, 1000::INT, RANDOM()) FROM TABLE(GENERATOR(ROWCOUNT => {n}));" self.pipeline = BatchInferencePipeline() self.worker_ids = self.pipeline.query_and_batch( input_query=query, From 206d83c2ef7d0cb6469b9b26989067962a05abd9 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:04:51 +0530 Subject: [PATCH 137/167] fix: set quote_identifiers to False in publish_pandas function for improved compatibility --- src/ds_platform_utils/metaflow/pandas.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 4b55dd9..ad4e13a 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -37,7 +37,7 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) compression: Literal["snappy", "gzip"] = "snappy", warehouse: Optional[Literal["XS", "MED", "XL"]] = None, parallel: int = 4, - quote_identifiers: bool = True, + quote_identifiers: bool = False, auto_create_table: bool = False, overwrite: bool = False, use_logical_type: bool = True, # prevent date times with timezone from being written incorrectly From 1f325ed12222994dba3c76558f3d150373851aee Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:06:07 +0530 Subject: [PATCH 138/167] fix: update publish_results method to use inputs from parallel branches --- .../functional_tests/metaflow/test__batch_inference_pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py index 866debd..f84a505 100644 --- a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py +++ b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py @@ -48,7 +48,7 @@ def predict_fn(df): def publish_results(self, inputs): """Join the parallel branches.""" print("Joining results from all workers...") - self.pipeline.publish_results( + inputs[0].pipeline.publish_results( output_table_name="DS_PLATFORM_UTILS_TEST_BATCH_INFERENCE_OUTPUT", overwrite=True, auto_create_table=True, From a2d4dd42c087bb73aed7c7e67e7a684eaa3a05a5 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:09:14 +0530 Subject: [PATCH 139/167] fix: increase row count in query for batch inference testing --- .../functional_tests/metaflow/test__batch_inference_pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py index f84a505..190304f 100644 --- a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py +++ b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py @@ -21,7 +21,7 @@ def start(self): @step def query_and_batch(self): """Run the query and batch step.""" - n = 10000 + n = 10000000 query = f"SELECT UNIFORM(0::FLOAT, 10::FLOAT, RANDOM()) , UNIFORM(0::INT, 1000::INT, RANDOM()) FROM TABLE(GENERATOR(ROWCOUNT => {n}));" self.pipeline = BatchInferencePipeline() self.worker_ids = self.pipeline.query_and_batch( From 83113f543c8bdcde9298c9e9a50479df94cdb5d6 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:12:20 +0530 Subject: [PATCH 140/167] fix: enable debug mode for query in publish_results step --- .../metaflow/test__batch_inference_pipeline.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py index 190304f..4c7bef3 100644 --- a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py +++ b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py @@ -1,5 +1,6 @@ """A Metaflow flow.""" +import os import subprocess import sys @@ -21,6 +22,7 @@ def start(self): @step def query_and_batch(self): """Run the query and batch step.""" + os.environ["DEBUG_QUERY"] = "1" n = 10000000 query = f"SELECT UNIFORM(0::FLOAT, 10::FLOAT, RANDOM()) , UNIFORM(0::INT, 1000::INT, RANDOM()) FROM TABLE(GENERATOR(ROWCOUNT => {n}));" self.pipeline = BatchInferencePipeline() @@ -47,6 +49,8 @@ def predict_fn(df): @step def publish_results(self, inputs): """Join the parallel branches.""" + os.environ["DEBUG_QUERY"] = "1" + print("Joining results from all workers...") inputs[0].pipeline.publish_results( output_table_name="DS_PLATFORM_UTILS_TEST_BATCH_INFERENCE_OUTPUT", From a88e6b58a845426713227a296cfae62a24b76130 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:17:06 +0530 Subject: [PATCH 141/167] fix: update schema substitution order in query processing for consistency --- src/ds_platform_utils/metaflow/batch_inference_pipeline.py | 2 +- src/ds_platform_utils/metaflow/pandas.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index 86d191d..d6c30f8 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -167,7 +167,7 @@ def query_and_batch( print("🚀 Starting batch inference pipeline...") # Process input query input_query = get_query_from_string_or_fpath(input_query) - input_query = substitute_map_into_string(input_query, {"schema": self._schema} | (ctx or {})) + input_query = substitute_map_into_string(input_query, (ctx or {}) | {"schema": self._schema}) _debug(f"⏳ Exporting data from Snowflake to S3 to {self._input_path}...") # Export from Snowflake to S3 diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index ad4e13a..193a7ec 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -195,7 +195,7 @@ def query_pandas_from_snowflake( query = get_query_from_string_or_fpath(query) - query = substitute_map_into_string(query, {"schema": schema} | (ctx or {})) + query = substitute_map_into_string(query, (ctx or {}) | {"schema": schema}) if warehouse is not None: current.card.append(Markdown(f"## Using Snowflake Warehouse: `{warehouse}`")) From 57a276cf6a4a8a5e2a769e4bc35702867449bb84 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:20:34 +0530 Subject: [PATCH 142/167] fix: add column aliases to query in query_and_batch step for clarity --- .../functional_tests/metaflow/test__batch_inference_pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py index 4c7bef3..101bc1f 100644 --- a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py +++ b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py @@ -24,7 +24,7 @@ def query_and_batch(self): """Run the query and batch step.""" os.environ["DEBUG_QUERY"] = "1" n = 10000000 - query = f"SELECT UNIFORM(0::FLOAT, 10::FLOAT, RANDOM()) , UNIFORM(0::INT, 1000::INT, RANDOM()) FROM TABLE(GENERATOR(ROWCOUNT => {n}));" + query = f"SELECT UNIFORM(0::FLOAT, 10::FLOAT, RANDOM()) F1 , UNIFORM(0::INT, 1000::INT, RANDOM()) F2 FROM TABLE(GENERATOR(ROWCOUNT => {n}));" self.pipeline = BatchInferencePipeline() self.worker_ids = self.pipeline.query_and_batch( input_query=query, From d5278766a1931c9c15c59cf16022e9380efdfe64 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:26:36 +0530 Subject: [PATCH 143/167] Add documentation for Metaflow utilities and remove outdated files - Introduced new documentation for `make_pydantic_parser_fn`, `publish`, `publish_pandas`, `query_pandas_from_snowflake`, and `restore_step_state` functions. - Removed the outdated `pandas.md` and `validate_config.md` documentation files. - Updated the Snowflake utilities README to reflect the integration with Metaflow and emphasize the use of high-level APIs. --- README.md | 19 +- docs/README.md | 216 ------- docs/api/index.md | 537 ----------------- docs/examples/README.md | 574 ------------------- docs/metaflow/README.md | 309 ---------- docs/metaflow/batch_inference_pipeline.md | 373 ++++-------- docs/metaflow/make_pydantic_parser_fn.md | 29 + docs/metaflow/pandas.md | 434 -------------- docs/metaflow/publish.md | 36 ++ docs/metaflow/publish_pandas.md | 32 ++ docs/metaflow/query_pandas_from_snowflake.md | 24 + docs/metaflow/restore_step_state.md | 25 + docs/metaflow/validate_config.md | 477 --------------- docs/snowflake/README.md | 288 ---------- 14 files changed, 258 insertions(+), 3115 deletions(-) delete mode 100644 docs/README.md delete mode 100644 docs/api/index.md delete mode 100644 docs/examples/README.md delete mode 100644 docs/metaflow/README.md create mode 100644 docs/metaflow/make_pydantic_parser_fn.md delete mode 100644 docs/metaflow/pandas.md create mode 100644 docs/metaflow/publish.md create mode 100644 docs/metaflow/publish_pandas.md create mode 100644 docs/metaflow/query_pandas_from_snowflake.md create mode 100644 docs/metaflow/restore_step_state.md delete mode 100644 docs/metaflow/validate_config.md delete mode 100644 docs/snowflake/README.md diff --git a/README.md b/README.md index 9869a22..9d4dad2 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,11 @@ -## ds-platform-utils +# ds-platform-utils -Utility library to support Pattern's [data-science-projects](https://github.com/patterninc/data-science-projects/). +## Metaflow API Docs -## 📚 Documentation +- [BatchInferencePipeline](docs/metaflow/batch_inference_pipeline.md) +- [make_pydantic_parser_fn](docs/metaflow/make_pydantic_parser_fn.md) +- [publish](docs/metaflow/publish.md) +- [publish_pandas](docs/metaflow/publish_pandas.md) +- [query_pandas_from_snowflake](docs/metaflow/query_pandas_from_snowflake.md) +- [restore_step_state](docs/metaflow/restore_step_state.md) -For comprehensive documentation, guides, and examples: - -- **[📖 Full Documentation](docs/README.md)** - Complete documentation hub -- **[🚀 Getting Started](docs/guides/getting_started.md)** - Quick start guide -- **[✨ Best Practices](docs/guides/best_practices.md)** - Production-ready patterns -- **[⚡ Performance Tuning](docs/guides/performance_tuning.md)** - Optimization guide -- **[🔧 API Reference](docs/api/index.md)** - Complete API documentation -- **[🛠️ Troubleshooting](docs/guides/troubleshooting.md)** - Common issues & solutions diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index b5c2c7f..0000000 --- a/docs/README.md +++ /dev/null @@ -1,216 +0,0 @@ -# ds-platform-utils Documentation - -Comprehensive documentation for Pattern's data science platform utilities. - -## Overview - -`ds-platform-utils` is a utility library designed to streamline ML workflows on Pattern's data platform. It provides high-level abstractions for common operations involving Metaflow, Snowflake, and S3. - -## Table of Contents - -### Core Modules - -**[Snowflake Utilities](snowflake/README.md)** -- Query execution and connection management -- Write-audit-publish pattern for data quality -- Schema management (dev/prod separation) -- Integrated with Outerbounds for automatic authentication - -**[Metaflow Utilities](metaflow/README.md)** -- [BatchInferencePipeline](metaflow/batch_inference_pipeline.md) - Scalable batch inference orchestration -- [Pandas Integration](metaflow/pandas.md) - Query and publish functions for Snowflake -- [Config Validation](metaflow/validate_config.md) - Pydantic-based configuration validation - -### Examples - -- [Practical Examples](examples/README.md) - Complete working examples for common scenarios - - Simple Query and Publish - - Feature Engineering Pipeline - - Batch Inference at Scale - - Incremental Data Processing - - Multi-Table Join Pipeline - -### API Reference - -- [Complete API Reference](api/index.md) - -## Quick Links - -- [API Reference →](api/index.md) -- [Practical Examples →](examples/README.md) -- [Installation](#installation) -- [Quick Start](#quick-start) - -## Installation - -```bash -# Install from the repository -pip install git+https://github.com/patterninc/ds-platform-utils.git - -# For development -git clone https://github.com/patterninc/ds-platform-utils.git -cd ds-platform-utils -uv sync -``` - -## Configuration - -**No manual configuration required!** - -This library integrates seamlessly with Outerbounds, which automatically handles all Snowflake and AWS configuration. Simply use the functions in your Metaflow flows, and Outerbounds takes care of: - -- ✅ Snowflake authentication and connection management -- ✅ AWS credentials and S3 access -- ✅ Warehouse selection and optimization -- ✅ Query tagging for cost tracking - -## Quick Start - -### Example 1: Query Data from Snowflake - -```python -from ds_platform_utils.metaflow import query_pandas_from_snowflake - -# Query data into a pandas DataFrame -df = query_pandas_from_snowflake( - query="SELECT * FROM my_schema.my_table LIMIT 1000", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", -) -``` - -### Example 2: Publish Results to Snowflake - -```python -from ds_platform_utils.metaflow import publish_pandas - -# Publish DataFrame to Snowflake -publish_pandas( - table_name="my_results_table", - df=results_df, - auto_create_table=True, - overwrite=True, -) -``` - -### Example 3: Batch Inference Pipeline - -```python -from metaflow import FlowSpec, step -from ds_platform_utils.metaflow import BatchInferencePipeline - -class PredictionFlow(FlowSpec): - @step - def start(self): - self.pipeline = BatchInferencePipeline() - self.worker_ids = self.pipeline.query_and_batch( - input_query="SELECT * FROM features_table", - parallel_workers=10, - ) - self.next(self.predict, foreach='worker_ids') - - @step - def predict(self): - worker_id = self.input - self.pipeline.process_batch( - worker_id=worker_id, - predict_fn=my_model.predict, - ) - self.next(self.join) - - @step - def join(self, inputs): - self.pipeline = inputs[0].pipeline - self.pipeline.publish_results( - output_table_name="predictions", - ) - self.next(self.end) - - @step - def end(self): - pass -``` - -## Architecture - -``` -┌─────────────────────────────────────────────────────────┐ -│ ds-platform-utils Library │ -│ │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ Public API (ds_platform_utils.metaflow) │ │ -│ │ • BatchInferencePipeline │ │ -│ │ • query_pandas_from_snowflake / publish_pandas │ │ -│ │ • publish (query + transform + publish) │ │ -│ │ • make_pydantic_parser_fn │ │ -│ │ • restore_step_state │ │ -│ └─────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────┐ │ -│ │ Snowflake Utilities (_snowflake) │ │ -│ │ • Query execution (_execute_sql) │ │ -│ │ • Write-audit-publish pattern │ │ -│ │ • Schema management (dev/prod) │ │ -│ └─────────────────────────────────────────────────┘ │ -└─────────────────┬───────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────┐ -│ Outerbounds Platform │ -│ (Handles all configuration automatically) │ -│ │ -│ • Snowflake Authentication & Connections │ -│ • AWS Credentials & S3 Access │ -│ • Metaflow Orchestration │ -│ • Query Tagging & Cost Tracking │ -└─────────────────┬───────────────────────────────────────┘ - │ - ┌─────────┴─────────┐ - │ │ - ▼ ▼ -┌───────────────┐ ┌───────────────┐ -│ Snowflake │ │ S3 │ -│ Database │◄──►│ Storage │ -└───────────────┘ └───────────────┘ -``` - -## Key Features - -### 🚀 Scalable Batch Inference -- Automatic parallelization with Metaflow foreach -- Efficient S3 staging for large datasets -- Queue-based streaming pipeline -- Built-in error handling and validation - -### 📊 Snowflake Integration -- Direct pandas integration -- S3 stage operations for large datasets -- Production-ready write patterns -- Automatic schema management - -### 🔄 State Management -- Flow state restoration -- Artifact management -- Configuration validation - -### 🛡️ Production Ready -- Audit trail generation -- Dev/Prod schema separation -- Query tagging and tracking -- Safe publishing patterns - -## Contributing - -Contributions are welcome! Please ensure you: -- Follow the existing code style -- Add tests for new features -- Update documentation as needed - -## License - -Internal use only - Pattern Inc. - -## Support - -For questions or issues: -- Create an issue in the [GitHub repository](https://github.com/patterninc/ds-platform-utils) -- Contact the Data Science Platform team diff --git a/docs/api/index.md b/docs/api/index.md deleted file mode 100644 index 57781e5..0000000 --- a/docs/api/index.md +++ /dev/null @@ -1,537 +0,0 @@ -# API Reference - -[← Back to Main Docs](../README.md) - -Complete API documentation for `ds-platform-utils`. - -## Public API - -All public functions are exported from `ds_platform_utils.metaflow`: - -```python -from ds_platform_utils.metaflow import ( - BatchInferencePipeline, # Scalable batch inference - make_pydantic_parser_fn, # Config validation - publish, # Query, transform, and publish - publish_pandas, # Publish DataFrame to Snowflake - query_pandas_from_snowflake, # Query from Snowflake to DataFrame - restore_step_state, # Restore flow state for debugging -) -``` - -## Table of Contents - -- [Query Functions](#query-functions) - - [query_pandas_from_snowflake()](#query_pandas_from_snowflake) -- [Publish Functions](#publish-functions) - - [publish_pandas()](#publish_pandas) - - [publish()](#publish) -- [Batch Processing](#batch-processing) - - [BatchInferencePipeline](#batchinferencepipeline) -- [Configuration](#configuration) - - [make_pydantic_parser_fn()](#make_pydantic_parser_fn) -- [State Management](#state-management) - - [restore_step_state()](#restore_step_state) - ---- - -## Query Functions - -### `query_pandas_from_snowflake()` - -Query Snowflake and return a pandas DataFrame. - -**Signature:** -```python -def query_pandas_from_snowflake( - query: Union[str, Path], - warehouse: Optional[str] = None, - ctx: Optional[Dict[str, Any]] = None, - use_utc: bool = True, - use_s3_stage: bool = False, -) -> pd.DataFrame -``` - -**Parameters:** -- `query` (str | Path): SQL query string or path to a .sql file. -- `warehouse` (str, optional): Snowflake warehouse name. Defaults to `OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH` in dev or `OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_XS_WH` in production. -- `ctx` (dict, optional): Template variables for query substitution using `{{variable}}` syntax. -- `use_utc` (bool, default=True): Whether to set Snowflake session to UTC timezone. -- `use_s3_stage` (bool, default=False): Use S3 staging for large results (more efficient for > 1GB). - -**Returns:** -- `pd.DataFrame`: Query results as pandas DataFrame (column names lowercased) - -**Notes:** -- If the query contains `{{schema}}` placeholders, they will be replaced with the appropriate schema (prod or dev). -- Query tags are automatically added for cost tracking in select.dev. - -**Example:** -```python -# Direct query -df = query_pandas_from_snowflake( - query="SELECT * FROM my_table WHERE date >= '2024-01-01'", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", -) - -# From SQL file with template variables -df = query_pandas_from_snowflake( - query="sql/extract.sql", - ctx={"start_date": "2024-01-01", "end_date": "2024-12-31"}, -) - -# Large dataset via S3 -df = query_pandas_from_snowflake( - query="SELECT * FROM large_table", - use_s3_stage=True, - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", -) -``` - -**See Also:** -- [Pandas Integration Guide](../metaflow/pandas.md) - ---- - -## Publish Functions - -### `publish_pandas()` - -Publish a pandas DataFrame to Snowflake. - -**Signature:** -```python -def publish_pandas( - table_name: str, - df: pd.DataFrame, - add_created_date: bool = False, - chunk_size: Optional[int] = None, - compression: Literal["snappy", "gzip"] = "snappy", - warehouse: Optional[str] = None, - parallel: int = 4, - quote_identifiers: bool = True, - auto_create_table: bool = False, - overwrite: bool = False, - use_logical_type: bool = True, - use_utc: bool = True, - use_s3_stage: bool = False, - table_definition: Optional[List[Tuple[str, str]]] = None, -) -> None -``` - -**Parameters:** -- `table_name` (str): Name of the table to create (automatically uppercased). -- `df` (pd.DataFrame): DataFrame to publish. -- `add_created_date` (bool, default=False): Add a `created_date` column with current UTC timestamp. -- `chunk_size` (int, optional): Number of rows per insert batch. Default: all rows at once. -- `compression` (str, default="snappy"): Parquet compression: `"snappy"` or `"gzip"`. -- `warehouse` (str, optional): Snowflake warehouse name. -- `parallel` (int, default=4): Number of threads for uploading chunks. -- `quote_identifiers` (bool, default=True): Quote column/table names (preserve case). -- `auto_create_table` (bool, default=False): Auto-create table if it doesn't exist. -- `overwrite` (bool, default=False): Drop/truncate existing table before writing. -- `use_logical_type` (bool, default=True): Use Parquet logical types for timestamps. -- `use_utc` (bool, default=True): Set Snowflake session to UTC timezone. -- `use_s3_stage` (bool, default=False): Use S3 staging (more efficient for large DataFrames). -- `table_definition` (list, optional): Column schema as `[(col_name, col_type), ...]` for S3 staging. - -**Returns:** None - -**Notes:** -- Schema is automatically selected: prod schema in production, dev schema otherwise. -- Table name is automatically uppercased for Snowflake standardization. - -**Example:** -```python -# Basic publish with auto-create -publish_pandas( - table_name="my_results", - df=results_df, - auto_create_table=True, - overwrite=True, -) - -# Large DataFrame via S3 staging -publish_pandas( - table_name="large_table", - df=large_df, - use_s3_stage=True, - table_definition=[ - ("id", "NUMBER"), - ("name", "STRING"), - ("score", "FLOAT"), - ], -) - -# With timestamp tracking -publish_pandas( - table_name="features", - df=features_df, - add_created_date=True, - auto_create_table=True, -) -``` - -**See Also:** -- [Pandas Integration Guide](../metaflow/pandas.md) - ---- - -### `publish()` - -Publish a Snowflake table using the write-audit-publish (WAP) pattern. - -**Signature:** -```python -def publish( - table_name: str, - query: Union[str, Path], - audits: Optional[List[Union[str, Path]]] = None, - ctx: Optional[Dict[str, Any]] = None, - warehouse: Optional[str] = None, - use_utc: bool = True, -) -> None -``` - -**Parameters:** -- `table_name` (str): Name of the Snowflake table to publish (e.g., `"OUT_OF_STOCK_ADS"`). -- `query` (str | Path): SQL query string or path to .sql file that generates the table data. -- `audits` (list, optional): SQL audit scripts or file paths that validate data quality. Each script should return zero rows for success. -- `ctx` (dict, optional): Template variables for SQL substitution. -- `warehouse` (str, optional): Snowflake warehouse name. -- `use_utc` (bool, default=True): Whether to use UTC timezone for the Snowflake connection. - -**Returns:** None - -**Notes:** -- Uses the write-audit-publish pattern: write to temp table → run audits → promote to final table. -- Query tags are automatically added for cost tracking in select.dev. -- Schema is automatically selected based on production/dev environment. - -**Example:** -```python -# Simple publish -publish( - table_name="OUT_OF_STOCK_ADS", - query="sql/create_training_data.sql", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", -) - -# With audits for data validation -publish( - table_name="DAILY_FEATURES", - query="sql/create_features.sql", - audits=[ - "sql/audit_row_count.sql", - "sql/audit_null_check.sql", - ], - ctx={"date": "2024-01-01"}, -) -``` - ---- - -## Batch Processing - -### BatchInferencePipeline - -Class for large-scale batch inference with parallel processing. - -**Signature:** -```python -class BatchInferencePipeline: - def __init__(self) -> None -``` - -#### Methods - -##### `query_and_batch()` - -Query input data and split into batches for parallel processing. - -**Signature:** -```python -def query_and_batch( - self, - input_query: Union[str, Path], - ctx: Optional[dict] = None, - warehouse: Optional[str] = None, - use_utc: bool = True, - parallel_workers: int = 1, -) -> List[int] -``` - -**Parameters:** -- `input_query` (str | Path): SQL query string or file path to query -- `ctx` (dict, optional): Dict of variable substitutions for SQL template (e.g., `{{schema}}`) -- `warehouse` (str, optional): Snowflake warehouse name -- `use_utc` (bool, default=True): Whether to use UTC timezone for Snowflake -- `parallel_workers` (int, default=1): Number of parallel workers to use for processing - -**Returns:** -- `List[int]`: Worker IDs to use with `foreach` in next step - -**Example:** -```python -pipeline = BatchInferencePipeline() -worker_ids = pipeline.query_and_batch( - input_query="SELECT * FROM large_input", - parallel_workers=10, -) -``` - -##### `process_batch()` - -Process a single batch with predictions using a queue-based 3-thread pipeline. - -**Signature:** -```python -def process_batch( - self, - worker_id: int, - predict_fn: Callable[[pd.DataFrame], pd.DataFrame], - batch_size_in_mb: int = 128, - timeout_per_batch: int = 300, -) -> str -``` - -**Parameters:** -- `worker_id` (int): Worker ID from `query_and_batch()` -- `predict_fn` (callable): Function that takes DataFrame and returns predictions DataFrame -- `batch_size_in_mb` (int, default=128): Target size for each batch in MB -- `timeout_per_batch` (int, default=300): Timeout in seconds for each batch operation - -**Returns:** -- `str`: S3 path where predictions were written - -**Example:** -```python -def predict(df: pd.DataFrame) -> pd.DataFrame: - df['score'] = model.predict(df[['f1', 'f2']]) - return df[['id', 'score']] - -pipeline = BatchInferencePipeline() -pipeline.process_batch( - worker_id=worker_id, - predict_fn=predict, -) -``` - -##### `publish_results()` - -Publish all processed results to Snowflake (call this in join step). - -**Signature:** -```python -def publish_results( - self, - output_table_name: str, - output_table_definition: Optional[List[Tuple[str, str]]] = None, - auto_create_table: bool = True, - overwrite: bool = True, - warehouse: Optional[str] = None, - use_utc: bool = True, -) -> None -``` - -**Parameters:** -- `output_table_name` (str): Name of the Snowflake table -- `output_table_definition` (list, optional): Schema as list of `(column, type)` tuples -- `auto_create_table` (bool, default=True): Whether to auto-create table if not exists -- `overwrite` (bool, default=True): Whether to overwrite existing data -- `warehouse` (str, optional): Snowflake warehouse name -- `use_utc` (bool, default=True): Whether to use UTC timezone for Snowflake - -**Returns:** None - -**Example:** -```python -pipeline = BatchInferencePipeline() -pipeline.publish_results( - output_table_name="predictions", -) - -# With custom schema -pipeline.publish_results( - output_table_name="predictions", - output_table_definition=[ - ("id", "NUMBER"), - ("score", "FLOAT"), - ("prediction", "STRING"), - ], -) -``` - -**See Also:** -- [BatchInferencePipeline Guide](../metaflow/batch_inference_pipeline.md) - ---- - -## Configuration - -### `make_pydantic_parser_fn()` - -Create a parser function for Pydantic model validation in Metaflow Config. - -**Signature:** -```python -def make_pydantic_parser_fn( - pydantic_model: type[BaseModel] -) -> Callable[[str], dict] -``` - -**Parameters:** -- `pydantic_model` (type[BaseModel]): Pydantic model class for validation - -**Returns:** -- `Callable[[str], dict]`: Parser function that validates and returns a dict - -**Notes:** -- Supports JSON, TOML, and YAML config formats -- YAML is preferred because it supports comments -- Returns a dict with default values applied after validation - -**Example:** -```python -from pydantic import BaseModel, Field -from metaflow import FlowSpec, step, Config -from ds_platform_utils.metaflow import make_pydantic_parser_fn - -class PydanticFlowConfig(BaseModel): - \"\"\"Validate and provide autocompletion for config values.\"\"\" - n_rows: int = Field(ge=1) - threshold: float = 0.5 - -class MyFlow(FlowSpec): - config: PydanticFlowConfig = Config( - name="config", - default="./configs/default.yaml", - parser=make_pydantic_parser_fn(PydanticFlowConfig) - ) # type: ignore[assignment] - - @step - def start(self): - print(f"{self.config.n_rows=}") - self.next(self.end) -``` - -**See Also:** -- [Configuration Validation Guide](../metaflow/validate_config.md) - ---- - -## State Management - -### `restore_step_state()` - -Restore Metaflow step state for debugging and development. - -**Signature:** -```python -def restore_step_state( - flow_class: Optional[type[FlowSpec]] = None, - flow_name: Optional[str] = None, - step_name: str = "end", - flow_run_id: Union[Literal["latest_successful_run", "latest"], str] = "latest_successful_run", - secrets: Optional[list[str]] = None, - namespace: Optional[str] = None, -) -> FlowSpec -``` - -**Parameters:** -- `flow_class` (type[FlowSpec], optional): Flow class for type hints and autocompletion -- `flow_name` (str, optional): Flow name (defaults to flow_class name if provided) -- `step_name` (str, default="end"): Step to restore state from (restores from step before this) -- `flow_run_id` (str, default="latest_successful_run"): Run ID to restore: - - `"latest_successful_run"`: Latest successful run - - `"latest"`: Latest run (even if failed) - - Or specific run ID -- `secrets` (list[str], optional): Secrets to export as environment variables -- `namespace` (str, optional): Metaflow namespace to filter runs - -**Returns:** -- `FlowSpec`: Restored flow state with access to all step artifacts - -**Example:** -```python -from ds_platform_utils.metaflow import restore_step_state -from my_flows import MyPredictionFlow - -# Restore state from latest successful run -self = restore_step_state( - MyPredictionFlow, - step_name="process", - secrets=["outerbounds.my-secret"], -) - -# Now you can access step artifacts -print(self.df.head()) -print(self.config) - -# Debug or test step logic -result = process_data(self.df) -``` - -**Use Cases:** -- 🐛 **Debugging**: Inspect data and artifacts from failed runs -- 🧪 **Testing**: Test step logic without running entire flow -- 📊 **Analysis**: Explore intermediate results -- 🔄 **Development**: Iterate on step logic quickly - -**See Also:** -- [Examples](../examples/README.md) - ---- - -## Snowflake Utilities - -The `ds_platform_utils._snowflake` module contains lower-level utilities for Snowflake operations: -- `_execute_sql()` - Execute SQL statements with batch support -- `write_audit_publish()` - Implement write-audit-publish pattern - -**Note:** This module is marked as private (underscore prefix) because its APIs may change. Most users should use the high-level Metaflow utilities above. - -**For Advanced Users:** If you need direct access to these utilities for custom workflows, see the [Snowflake Utilities Documentation](../snowflake/README.md). - ---- - -## Type Definitions - -### Common Types - -```python -from typing import Optional, Dict, List, Callable, Any -import pandas as pd - -# Query context -QueryContext = Dict[str, Any] - -# Prediction function signature -PredictFn = Callable[[pd.DataFrame], pd.DataFrame] - -# Transform function signature -TransformFn = Callable[[pd.DataFrame], pd.DataFrame] -``` - ---- - -## Error Classes - -### `SnowflakeQueryError` - -Raised when Snowflake query fails. - -### `BatchProcessingError` - -Raised when batch processing fails. - -### `ValidationError` - -Raised when configuration validation fails (from Pydantic). - ---- - -## Related Documentation - -- [Metaflow Utilities](../metaflow/README.md) -- [Snowflake Utilities](../snowflake/README.md) diff --git a/docs/examples/README.md b/docs/examples/README.md deleted file mode 100644 index 35c09b4..0000000 --- a/docs/examples/README.md +++ /dev/null @@ -1,574 +0,0 @@ -# Examples - -[← Back to Main Docs](../README.md) - -Practical examples for common use cases. - -## Table of Contents - -- [Simple Query and Publish](#simple-query-and-publish) -- [Feature Engineering Pipeline](#feature-engineering-pipeline) -- [Batch Inference at Scale](#batch-inference-at-scale) -- [Incremental Data Processing](#incremental-data-processing) -- [Multi-Table Join Pipeline](#multi-table-join-pipeline) - -## Simple Query and Publish - -Basic workflow: query → transform → publish. - -### Code - -```python -# simple_pipeline.py -from metaflow import FlowSpec, step -from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas - -class SimplePipeline(FlowSpec): - """Query data, transform, and publish.""" - - @step - def start(self): - """Query input data.""" - print("Querying data...") - self.df = query_pandas_from_snowflake( - query=""" - SELECT - user_id, - transaction_date, - amount, - category - FROM transactions - WHERE transaction_date >= '2024-01-01' - """, - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", - ) - print(f"Retrieved {len(self.df):,} rows") - self.next(self.transform) - - @step - def transform(self): - """Transform data.""" - print("Transforming data...") - - # Add month column - self.df['month'] = self.df['transaction_date'].dt.to_period('M') - - # Calculate monthly spending per user - self.results = self.df.groupby(['user_id', 'month']).agg({ - 'amount': ['sum', 'mean', 'count'] - }).reset_index() - - # Flatten column names - self.results.columns = [ - '_'.join(col).strip('_') for col in self.results.columns - ] - - print(f"Created {len(self.results):,} aggregated rows") - self.next(self.publish) - - @step - def publish(self): - """Publish results.""" - print("Publishing results...") - publish_pandas( - table_name="user_monthly_spending", - df=self.results, - auto_create_table=True, - overwrite=True, - ) - print("✅ Done!") - self.next(self.end) - - @step - def end(self): - pass - -if __name__ == '__main__': - SimplePipeline() -``` - -### Run - -```bash -# Local execution -python simple_pipeline.py run - -# View results in Snowflake -snowsql -q "SELECT * FROM my_dev_schema.user_monthly_spending LIMIT 10;" -``` - ---- - -## Feature Engineering Pipeline - -Create ML features using SQL and Python. - -### Files - -**config.py** -```python -from pydantic import BaseModel - -class FeatureConfig(BaseModel): - """Feature generation configuration.""" - start_date: str - end_date: str - lookback_days: int = 30 -``` - -**sql/extract_raw_features.sql** -```sql --- Extract raw features from events -CREATE OR REPLACE TEMPORARY TABLE temp_raw_features AS -SELECT - user_id, - COUNT(*) as event_count, - COUNT(DISTINCT date) as active_days, - MIN(timestamp) as first_seen, - MAX(timestamp) as last_seen, - AVG(value) as avg_value, - STDDEV(value) as std_value -FROM events -WHERE date >= '{{start_date}}' - AND date <= '{{end_date}}' -GROUP BY user_id; -``` - -**sql/publish_features.sql** -```sql --- Publish to target table -CREATE OR REPLACE TABLE {{schema}}.ml_features AS -SELECT * FROM temp_engineered_features; -``` - -**feature_pipeline.py** -```python -from metaflow import FlowSpec, Parameter, step -from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas, make_pydantic_parser_fn -import pandas as pd -from datetime import datetime - -from config import FeatureConfig - -class FeaturePipeline(FlowSpec): - """ML feature engineering pipeline.""" - - config = Parameter( - 'config', - type=make_pydantic_parser_fn(FeatureConfig), - default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}', - ) - - @step - def start(self): - """Extract raw features from Snowflake.""" - print(f"Extracting features from {self.config.start_date} to {self.config.end_date}") - - self.df = query_pandas_from_snowflake( - query="sql/extract_raw_features.sql", - ctx={ - "start_date": self.config.start_date, - "end_date": self.config.end_date, - }, - ) - print(f"Extracted features for {len(self.df):,} users") - self.next(self.engineer_features) - - @step - def engineer_features(self): - """Engineer features in Python.""" - print("Engineering features...") - - # Time-based features - now = pd.Timestamp.now() - self.df['recency_days'] = (now - pd.to_datetime(self.df['last_seen'])).dt.days - self.df['account_age_days'] = (now - pd.to_datetime(self.df['first_seen'])).dt.days - - # Engagement features - self.df['events_per_day'] = self.df['event_count'] / self.df['active_days'] - self.df['engagement_ratio'] = self.df['active_days'] / self.df['account_age_days'] - - # Value features - self.df['value_volatility'] = self.df['std_value'] / (self.df['avg_value'] + 1) - - # Segments - self.df['user_segment'] = pd.cut( - self.df['event_count'], - bins=[0, 10, 50, 200, float('inf')], - labels=['low', 'medium', 'high', 'power_user'] - ) - - print(f"Engineered {len(self.df.columns)} features") - self.next(self.publish) - - @step - def publish(self): - """Publish features.""" - print("Publishing features...") - publish_pandas( - table_name="ml_features", - df=self.df, - auto_create_table=True, - overwrite=True, - ) - print(f"✅ Published {len(self.df):,} rows with {len(self.df.columns)} columns") - self.next(self.end) - - @step - def end(self): - pass - -if __name__ == '__main__': - FeaturePipeline() -``` - -### Run - -```bash -# With default dates -python feature_pipeline.py run - -# With custom dates -python feature_pipeline.py run --config '{"start_date": "2024-06-01", "end_date": "2024-12-31"}' - -# Check results -snowsql -q "SELECT * FROM my_dev_schema.ml_features LIMIT 10;" -``` - ---- - -## Batch Inference at Scale - -Large-scale ML predictions with parallel processing. - -### Code - -**batch_inference.py** -```python -from metaflow import FlowSpec, step -from ds_platform_utils.metaflow import BatchInferencePipeline -import pandas as pd -import pickle - -class LargeScaleInference(FlowSpec): - """Batch inference for millions of rows.""" - - @step - def start(self): - """Query and split into batches.""" - print("Querying input data and splitting into batches...") - - pipeline = BatchInferencePipeline() - self.worker_ids = pipeline.query_and_batch( - input_query=""" - SELECT - user_id, - feature_1, - feature_2, - feature_3, - feature_4, - feature_5 - FROM ml_features - WHERE last_updated >= '2024-01-01' - """, - batch_size_in_mb=256, - parallel_workers=20, # 20 parallel workers - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", - ) - - print(f"Split into {len(self.worker_ids)} batches") - self.next(self.predict, foreach='worker_ids') - - @step - def predict(self): - """Predict for each batch (runs in parallel).""" - worker_id = self.input - print(f"Processing batch {worker_id}") - - # Load model (cached across batches on same worker) - with open('model.pkl', 'rb') as f: - model = pickle.load(f) - - def predict_fn(df: pd.DataFrame) -> pd.DataFrame: - """Generate predictions.""" - feature_cols = [ - 'feature_1', 'feature_2', 'feature_3', - 'feature_4', 'feature_5' - ] - - # Generate predictions - predictions = model.predict_proba(df[feature_cols])[:, 1] - - # Create output DataFrame - result = pd.DataFrame({ - 'user_id': df['user_id'], - 'score': predictions, - 'prediction': (predictions >= 0.5).astype(int), - }) - - return result - - # Process this batch - pipeline = BatchInferencePipeline() - pipeline.process_batch( - worker_id=worker_id, - predict_fn=predict_fn, - batch_size_in_mb=64, # Process in 64MB chunks - ) - - print(f"✅ Batch {worker_id} complete") - self.next(self.join) - - @step - def join(self, inputs): - """Collect results and publish.""" - print(f"All {len(inputs)} batches processed, publishing results...") - - self.pipeline = inputs[0].pipeline - self.pipeline.publish_results( - output_table_name="user_predictions", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", - ) - - print("✅ All predictions published!") - self.next(self.end) - - @step - def end(self): - pass - -if __name__ == '__main__': - LargeScaleInference() -``` - -### Run - -```bash -# Local execution -python batch_inference.py run - -# Production execution -python batch_inference.py run --production - -# Check results -snowsql -q "SELECT COUNT(*), AVG(score) FROM my_dev_schema.user_predictions;" -``` - ---- - -## Incremental Data Processing - -Process new data daily and append to existing table. - -### Code - -**incremental_pipeline.py** -```python -from metaflow import FlowSpec, Parameter, step -from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas -from datetime import datetime, timedelta - -class IncrementalPipeline(FlowSpec): - """Process daily incremental data.""" - - date = Parameter( - 'date', - default=datetime.now().strftime('%Y-%m-%d'), - help='Date to process (YYYY-MM-DD)', - ) - - @step - def start(self): - """Query new data for specified date.""" - print(f"Processing data for {self.date}") - - self.df = query_pandas_from_snowflake( - query=f""" - SELECT * - FROM raw_events - WHERE date = '{self.date}' - """ - ) - - if len(self.df) == 0: - print(f"⚠️ No data found for {self.date}") - else: - print(f"Found {len(self.df):,} rows for {self.date}") - - self.next(self.transform) - - @step - def transform(self): - """Transform new data.""" - if len(self.df) > 0: - print("Transforming data...") - - # Your transformation logic - self.df['processed_date'] = datetime.now() - self.df['derived_field'] = self.df['value'] * 2 - - print(f"Transformed {len(self.df):,} rows") - - self.next(self.publish) - - @step - def publish(self): - """Append to existing table.""" - if len(self.df) > 0: - print(f"Appending {len(self.df):,} rows...") - - publish_pandas( - table_name="processed_events", - df=self.df, - auto_create_table=False, # Table must exist - overwrite=False, # Append instead of replace - ) - - print(f"✅ Appended {len(self.df):,} rows for {self.date}") - else: - print("⏭️ No data to publish") - - self.next(self.end) - - @step - def end(self): - pass - -if __name__ == '__main__': - IncrementalPipeline() -``` - -### Run - -```bash -# Process today -python incremental_pipeline.py run - -# Process specific date -python incremental_pipeline.py run --date 2024-01-15 - -# Schedule with cron (runs daily at 2 AM) -# 0 2 * * * cd /path/to/project && python incremental_pipeline.py run -``` - ---- - -## Multi-Table Join Pipeline - -Join data from multiple Snowflake tables. - -### Code - -**multi_table_pipeline.py** -```python -from metaflow import FlowSpec, step -from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas - -class MultiTableJoin(FlowSpec): - """Join multiple tables and create enriched dataset.""" - - @step - def start(self): - """Start parallel queries.""" - self.next(self.query_users, self.query_events, self.query_demographics) - - @step - def query_users(self): - """Query user data.""" - print("Querying users...") - self.users_df = query_pandas_from_snowflake( - query="SELECT user_id, signup_date, status FROM users WHERE status = 'active'" - ) - print(f"Retrieved {len(self.users_df):,} users") - self.next(self.join_data) - - @step - def query_events(self): - """Query event data.""" - print("Querying events...") - self.events_df = query_pandas_from_snowflake( - query=""" - SELECT - user_id, - COUNT(*) as event_count, - MAX(timestamp) as last_event - FROM events - WHERE date >= '2024-01-01' - GROUP BY user_id - """ - ) - print(f"Retrieved events for {len(self.events_df):,} users") - self.next(self.join_data) - - @step - def query_demographics(self): - """Query demographic data.""" - print("Querying demographics...") - self.demographics_df = query_pandas_from_snowflake( - query="SELECT user_id, age, country, segment FROM user_demographics" - ) - print(f"Retrieved demographics for {len(self.demographics_df):,} users") - self.next(self.join_data) - - @step - def join_data(self, inputs): - """Join all data sources.""" - print("Joining data from all sources...") - - # Merge users + events - result = inputs.query_users.users_df.merge( - inputs.query_events.events_df, - on='user_id', - how='left' - ) - - # Merge with demographics - result = result.merge( - inputs.query_demographics.demographics_df, - on='user_id', - how='left' - ) - - # Fill missing event counts with 0 - result['event_count'] = result['event_count'].fillna(0) - - self.enriched_df = result - print(f"Created enriched dataset with {len(self.enriched_df):,} rows") - self.next(self.publish) - - @step - def publish(self): - """Publish enriched dataset.""" - print("Publishing enriched dataset...") - publish_pandas( - table_name="enriched_user_data", - df=self.enriched_df, - auto_create_table=True, - overwrite=True, - ) - print(f"✅ Published {len(self.enriched_df):,} rows") - self.next(self.end) - - @step - def end(self): - pass - -if __name__ == '__main__': - MultiTableJoin() -``` - -### Run - -```bash -# Run the pipeline -python multi_table_pipeline.py run - -# View results -snowsql -q "SELECT * FROM my_dev_schema.enriched_user_data LIMIT 10;" -``` - ---- - -## Additional Resources - -- [API Reference](../api/index.md) diff --git a/docs/metaflow/README.md b/docs/metaflow/README.md deleted file mode 100644 index 33e65dc..0000000 --- a/docs/metaflow/README.md +++ /dev/null @@ -1,309 +0,0 @@ -# Metaflow Utilities - -High-level utilities for building ML workflows with Metaflow, Snowflake, and S3. - -## Modules Overview - -### 🤖 [BatchInferencePipeline](batch_inference_pipeline.md) -**Purpose**: Orchestrate large-scale batch inference workflows - -**Key Features**: -- Automatic data export from Snowflake to S3 -- Parallel processing with Metaflow foreach -- Queue-based streaming pipeline (download → inference → upload) -- Automatic results publishing back to Snowflake -- Execution state validation - -**When to Use**: -- Running predictions on millions of rows -- Need for parallel processing -- Memory constraints require streaming -- Production batch scoring jobs - -**Example**: -```python -from ds_platform_utils.metaflow import BatchInferencePipeline - -pipeline = BatchInferencePipeline() -pipeline.run( - input_query="SELECT * FROM features", - output_table_name="predictions", - predict_fn=model.predict, -) -``` - ---- - -### 📊 [Pandas Integration](pandas.md) -**Purpose**: Seamless Pandas ↔ Snowflake operations - -**Key Functions**: -- `query_pandas_from_snowflake()` - Query Snowflake into DataFrame -- `publish_pandas()` - Write DataFrame to Snowflake - -**Key Features**: -- Automatic timezone handling (UTC) -- S3 staging for large datasets -- Schema auto-creation -- Compression options (snappy/gzip) -- Parallel uploads - -**When to Use**: -- Ad-hoc data analysis -- Feature engineering -- Model training data retrieval -- Publishing model outputs - -**Example**: -```python -from ds_platform_utils.metaflow import ( - query_pandas_from_snowflake, - publish_pandas -) - -# Query data -df = query_pandas_from_snowflake( - query="SELECT * FROM training_data", - use_s3_stage=True, # For large datasets -) - -# Publish results -publish_pandas( - table_name="model_outputs", - df=predictions_df, - auto_create_table=True, -) -``` - ---- - -### ✍️ [Write, Audit & Publish](write_audit_publish.md) -**Purpose**: Safe, auditable data publishing patterns - -**Key Features**: -- SQL file management -- Template variable substitution -- Query tagging for tracking -- Dev/Prod schema separation -- Audit trail generation -- Table URL generation - -**When to Use**: -- Publishing production models -- Executing parameterized SQL -- Audit requirements -- Schema-aware deployments - -**Example**: -```python -from ds_platform_utils.metaflow import publish - -publish( - table_name="aggregates", - query="queries/create_aggregates.sql", - audits=["queries/audit_row_count.sql"], - ctx={"start_date": "2024-01-01"}, - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_PROD_MED_WH", -) -``` - ---- - -### 🔄 [State Management](restore_step_state.md) -**Purpose**: Restore Metaflow step state for debugging - -**Key Features**: -- Artifact restoration -- Namespace recreation -- Interactive debugging support - -**When to Use**: -- Debugging failed flows -- Reproducing specific step states -- Testing step logic interactively - -**Example**: -```python -from ds_platform_utils.metaflow import restore_step_state - -# Restore state from a previous run -namespace = restore_step_state( - flow_name="MyFlow", - run_id="123", - step_name="process_data", -) - -# Access restored artifacts -df = namespace.df -model = namespace.model -``` - ---- - -### ⚙️ [Config Validation](validate_config.md) -**Purpose**: Type-safe configuration with Pydantic - -**Key Features**: -- Pydantic model validation -- Metaflow parameter parsing -- Type checking and coercion -- Clear error messages - -**When to Use**: -- Complex flow configurations -- Type safety requirements -- Parameter validation -- Configuration schemas - -**Example**: -```python -from pydantic import BaseModel -from ds_platform_utils.metaflow import make_pydantic_parser_fn - -class FlowConfig(BaseModel): - start_date: str - end_date: str - batch_size: int = 1000 - -class MyFlow(FlowSpec): - config = Parameter( - 'config', - type=make_pydantic_parser_fn(FlowConfig), - default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}' - ) -``` - ---- - -## Module Comparison - -| Module | Data Size | Processing | Use Case | Complexity | -| -------------------------- | --------- | ---------- | -------------- | ---------- | -| **BatchInferencePipeline** | 100GB+ | Parallel | Batch scoring | Medium | -| **Pandas Integration** | <10GB | Sequential | Analysis, ETL | Low | -| **Write/Audit/Publish** | Any | Sequential | Production SQL | Low | -| **State Management** | N/A | N/A | Debugging | Low | -| **Config Validation** | N/A | N/A | Configuration | Low | - -## Common Workflows - -### Workflow 1: Model Training Pipeline -```python -from ds_platform_utils.metaflow import ( - query_pandas_from_snowflake, - publish_pandas -) - -class TrainingFlow(FlowSpec): - @step - def start(self): - # Query training data - self.df = query_pandas_from_snowflake( - query="SELECT * FROM features WHERE date >= '2024-01-01'", - use_s3_stage=True, - ) - self.next(self.train) - - @step - def train(self): - # Train model - self.model = train_model(self.df) - self.next(self.end) - - @step - def end(self): - # Publish metrics - publish_pandas( - table_name="model_metrics", - df=self.metrics_df, - ) -``` - -### Workflow 2: Batch Inference Pipeline -```python -from ds_platform_utils.metaflow import BatchInferencePipeline - -class PredictionFlow(FlowSpec): - @step - def start(self): - self.pipeline = BatchInferencePipeline() - self.worker_ids = self.pipeline.query_and_batch( - input_query="SELECT * FROM input_features", - parallel_workers=10, - ) - self.next(self.predict, foreach='worker_ids') - - @step - def predict(self): - self.pipeline.process_batch( - worker_id=self.input, - predict_fn=self.model.predict, - ) - self.next(self.join) - - @step - def join(self, inputs): - self.pipeline = inputs[0].pipeline - self.pipeline.publish_results( - output_table_name="predictions", - ) - self.next(self.end) -``` - -### Workflow 3: Audited Data Publication -```python -from ds_platform_utils.metaflow import publish - -class DataPipelineFlow(FlowSpec): - @step - def start(self): - publish( - table_name="processed_results", - query="sql/transform_data.sql", - audits=["sql/audit_row_count.sql"], - ctx={ - "start_date": self.start_date, - "end_date": self.end_date, - }, - ) - self.next(self.end) -``` - -## Design Principles - -### 1. **Simplicity First** -- High-level abstractions hide complexity -- Sensible defaults for common cases -- Progressive disclosure of advanced features - -### 2. **Production Ready** -- Built-in error handling -- Audit trails -- Dev/Prod separation -- Query tagging - -### 3. **Performance** -- S3 staging for large data -- Parallel processing where applicable -- Streaming pipelines to manage memory -- Efficient compression - -### 4. **Type Safety** -- Type hints throughout -- Pydantic validation -- Clear error messages - -## Next Steps - -- 📖 Check the [API Reference](../api/index.md) -- 🎯 See the [Examples](../examples/README.md) - -## Related Modules - -### [Snowflake Utilities](../snowflake/README.md) -Lower-level utilities for direct Snowflake operations: -- SQL query execution -- Write-audit-publish pattern -- Schema management - -The Metaflow utilities build on top of these Snowflake utilities to provide higher-level abstractions. Most users should use the Metaflow API, but the Snowflake utilities are available for custom use cases. diff --git a/docs/metaflow/batch_inference_pipeline.md b/docs/metaflow/batch_inference_pipeline.md index e78a84f..a09d461 100644 --- a/docs/metaflow/batch_inference_pipeline.md +++ b/docs/metaflow/batch_inference_pipeline.md @@ -1,296 +1,131 @@ -# BatchInferencePipeline +# `BatchInferencePipeline` -[← Back to Metaflow Utilities](README.md) | [← Back to Main Docs](../README.md) +Source: `ds_platform_utils.metaflow.batch_inference_pipeline.BatchInferencePipeline` -A scalable batch inference pipeline for running ML predictions on large datasets using Metaflow, Snowflake, and S3. +Utility class to orchestrate batch inference with Snowflake + S3 in Metaflow steps. -## Key Features +## Main methods -- **Snowflake Integration**: Query data directly from Snowflake and write results back -- **S3 Staging**: Efficient data transfer via S3 for large datasets -- **Parallel Processing**: Built-in support for Metaflow's foreach parallelization -- **Pipeline Orchestration**: Three-stage pipeline (query → process → publish) -- **Queue-based Processing**: Multi-threaded download→inference→upload pipeline for optimal throughput -- **Execution State Validation**: Prevents out-of-order execution with clear error messages +- `query_and_batch(...)`: export source data to S3 and create worker batches. +- `process_batch(...)`: run download → inference → upload for one worker. +- `publish_results(...)`: copy prediction outputs from S3 to Snowflake. +- `run(...)`: convenience method to execute full flow sequentially. -#### Quick Start +## Detailed example (Metaflow foreach) -##### Option 1: Manual Control with Foreach Parallelization +This example shows the intended 3-step pattern in a Metaflow `FlowSpec`: -Use this approach when you need fine-grained control and want to parallelize across multiple Metaflow workers: +1. `query_and_batch()` in `start` +2. `process_batch()` in `foreach` +3. `publish_results()` in `join` ```python from metaflow import FlowSpec, step -from ds_platform_utils.metaflow import BatchInferencePipeline - -class MyPredictionFlow(FlowSpec): - - @step - def start(self): - # Initialize pipeline and export data to S3 - self.pipeline = BatchInferencePipeline() - self.worker_ids = self.pipeline.query_and_batch( - input_query="SELECT * FROM my_table WHERE date >= '2024-01-01'", - parallel_workers=10, # Split into 10 parallel workers - ) - self.next(self.predict, foreach='worker_ids') - - @step - def predict(self): - # Process single batch (runs in parallel via foreach) - worker_id = self.input - self.pipeline.process_batch( - worker_id=worker_id, - predict_fn=my_model.predict, - batch_size_in_mb=256, - ) - self.next(self.join) - - @step - def join(self, inputs): - # Merge and write results to Snowflake - self.pipeline = inputs[0].pipeline - self.pipeline.publish_results( - output_table_name="predictions_table", - auto_create_table=True, - ) - self.next(self.end) +import pandas as pd - @step - def end(self): - print("✅ Pipeline complete!") -``` - -##### Option 2: Convenience Method - -Use this for simpler workflows without foreach parallelization: - -```python from ds_platform_utils.metaflow import BatchInferencePipeline -def my_predict_function(df): - # Your prediction logic here - df['prediction'] = model.predict(df[feature_columns]) - return df[['id', 'prediction']] -# Run the complete pipeline -pipeline = BatchInferencePipeline() -pipeline.run( - input_query="SELECT * FROM input_table", - output_table_name="predictions_table", - predict_fn=my_predict_function, - batch_size_in_mb=128, - auto_create_table=True, - overwrite=True, -) +def predict_fn(df: pd.DataFrame) -> pd.DataFrame: + # Example model logic + out = pd.DataFrame() + out["id"] = df["id"] + out["score"] = (df["feature_1"].fillna(0) * 0.7 + df["feature_2"].fillna(0) * 0.3).round(6) + out["label"] = (out["score"] >= 0.5).astype(int) + return out + + +class BatchPredictFlow(FlowSpec): + @step + def start(self): + self.pipeline = BatchInferencePipeline() + + # Query can be inline SQL or a file path. + # {schema} is provided by ds_platform_utils (DEV/PROD selection). + self.worker_ids = self.pipeline.query_and_batch( + input_query=""" + SELECT + id, + feature_1, + feature_2 + FROM {schema}.model_features + WHERE ds = '2026-02-26' + """, + parallel_workers=8, + warehouse="ANALYTICS_WH", + use_utc=True, + ) + + self.next(self.predict, foreach="worker_ids") + + @step + def predict(self): + # In a foreach step, self.input contains one worker_id. + self.pipeline.process_batch( + worker_id=self.input, + predict_fn=predict_fn, + batch_size_in_mb=256, + timeout_per_batch=600, + ) + self.next(self.join) + + @step + def join(self, inputs): + # Reuse one pipeline object from foreach branches. + self.pipeline = inputs[0].pipeline + + self.pipeline.publish_results( + output_table_name="MODEL_PREDICTIONS_DAILY", + output_table_definition=[ + ("id", "NUMBER"), + ("score", "FLOAT"), + ("label", "NUMBER"), + ], + auto_create_table=True, + overwrite=True, + warehouse="ANALYTICS_WH", + use_utc=True, + ) + self.next(self.end) + + @step + def end(self): + print("Batch inference complete") ``` -#### API Reference - -##### `BatchInferencePipeline()` - -Initialize the pipeline. Automatically configures S3 paths based on Metaflow context. +## Detailed example (single-step convenience) -##### `query_and_batch()` - -**Step 1**: Export data from Snowflake to S3 and create worker batches. +Use `run()` when you do not need Metaflow foreach parallelization: ```python -worker_ids = pipeline.query_and_batch( - input_query: Union[str, Path], # SQL query or path to .sql file - ctx: Optional[dict] = None, # Template variables (e.g., {"schema": "dev"}) - warehouse: Optional[str] = None, # Snowflake warehouse - use_utc: bool = True, # Use UTC timezone - parallel_workers: int = 1, # Number of parallel workers -) -``` - -**Returns**: List of worker IDs for foreach parallelization - -##### `process_batch()` - -**Step 2**: Process a single batch with streaming pipeline. - -```python -s3_path = pipeline.process_batch( - worker_id: int, # Worker ID from foreach - predict_fn: Callable[[pd.DataFrame], pd.DataFrame], # Prediction function - batch_size_in_mb: int = 128, # Batch size in MB - timeout_per_batch: int = 300, # Timeout in seconds -) -``` - -**Your `predict_fn` signature**: -```python -def predict_fn(input_df: pd.DataFrame) -> pd.DataFrame: - # Process the input DataFrame and return predictions - return predictions_df -``` - -##### `publish_results()` - -**Step 3**: Write all predictions from S3 to Snowflake. - -```python -pipeline.publish_results( - output_table_name: str, # Snowflake table name - output_table_definition: Optional[List[Tuple]] = None, # Schema definition - auto_create_table: bool = True, # Auto-create if missing - overwrite: bool = True, # Overwrite existing data - warehouse: Optional[str] = None, # Snowflake warehouse - use_utc: bool = True, # Use UTC timezone -) -``` +from ds_platform_utils.metaflow import BatchInferencePipeline +import pandas as pd -##### `run()` -Convenience method that combines all three steps for simple workflows. +def predict_fn(df: pd.DataFrame) -> pd.DataFrame: + return pd.DataFrame( + { + "id": df["id"], + "score": (df["feature_1"] * 0.9).fillna(0), + } + ) -```python +pipeline = BatchInferencePipeline() pipeline.run( - input_query: Union[str, Path], - output_table_name: str, - predict_fn: Callable[[pd.DataFrame], pd.DataFrame], - # ... plus all parameters from query_and_batch(), process_batch(), publish_results() -) -``` - -#### Advanced Usage - -##### Custom Table Schema - -```python -table_schema = [ - ("id", "VARCHAR(100)"), - ("prediction", "FLOAT"), - ("confidence", "FLOAT"), - ("predicted_at", "TIMESTAMP_NTZ"), -] - -pipeline.publish_results( - output_table_name="predictions", - output_table_definition=table_schema, - auto_create_table=True, -) -``` - -##### Using SQL Template Variables - -```python -worker_ids = pipeline.query_and_batch( - input_query=""" - SELECT * FROM {{schema}}.my_table - WHERE date >= '{{start_date}}' - """, - ctx={ - "schema": "production", - "start_date": "2024-01-01", - }, + input_query=""" + SELECT id, feature_1 + FROM {schema}.model_features + WHERE ds = '2026-02-26' + """, + output_table_name="MODEL_PREDICTIONS_DAILY", + predict_fn=predict_fn, + output_table_definition=[("id", "NUMBER"), ("score", "FLOAT")], + warehouse="ANALYTICS_WH", ) ``` -##### External SQL Files - -```python -worker_ids = pipeline.query_and_batch( - input_query=Path("queries/input_query.sql"), - ctx={"schema": "production"}, -) -``` - -#### Error Handling & Validation - -The pipeline validates execution order and provides clear error messages: - -```python -pipeline = BatchInferencePipeline() - -# ❌ This will raise RuntimeError -pipeline.process_batch(worker_id=1, predict_fn=my_fn) -# Error: "Cannot process batch: query_and_batch() must be called first." +## Notes -# ❌ This will also raise RuntimeError -pipeline.publish_results(output_table_name="results") -# Error: "Cannot publish results: No batches have been processed." -``` - -Re-execution warnings: - -```python -# First execution -worker_ids = pipeline.query_and_batch(input_query="SELECT * FROM table") -pipeline.process_batch(worker_id=1, predict_fn=my_fn) - -# Second execution - warns about state reset -worker_ids = pipeline.query_and_batch(input_query="SELECT * FROM table") -# ⚠️ Warning: Re-executing query_and_batch() will reset batch processing state. - -# Publishing again - warns about duplicates -pipeline.publish_results(output_table_name="results") # First time - OK -pipeline.publish_results(output_table_name="results") # Second time -# ⚠️ Warning: Results have already been published. Publishing again may cause duplicate data. -``` - -#### Performance Tips - -1. **Batch Size**: Tune `batch_size_in_mb` based on your data and memory constraints - - Larger batches = fewer S3 operations but more memory usage - - Recommended: 128-512 MB per batch - -2. **Parallel Workers**: Balance parallelization with Metaflow cluster capacity - - More workers = faster processing but more resources - - Consider your data size and available compute - -3. **Timeouts**: Adjust `timeout_per_batch` for long-running inference - - Default: 300 seconds (5 minutes) - - Increase for complex models or large batches - -#### Troubleshooting - -##### "Worker X not found" -- The worker_id doesn't match any created worker -- Check that you're using worker_ids from `query_and_batch()` - -##### Timeout Errors -- Increase `timeout_per_batch` parameter -- Reduce `batch_size_in_mb` to process smaller chunks -- Check model inference performance - -##### Memory Issues -- Reduce `batch_size_in_mb` -- Ensure predict_fn doesn't accumulate data -- Monitor Metaflow task memory usage - -#### Architecture - -``` -┌──────────────┐ -│ Snowflake │ -│ (Query) │ -└──────┬───────┘ - │ COPY INTO - ▼ -┌──────────────┐ ┌─────────────────────────┐ -│ S3 │ │ Metaflow Workers │ -│ (Stage) │◄────►│ (Foreach Parallel) │ -│ Input Data │ │ │ -└──────────────┘ │ ┌───────────────────┐ │ - │ │ │ Queue Pipeline: │ │ - │ │ │ Download ──→ │ │ - │ │ │ Inference ──→ │ │ - │ │ │ Upload │ │ - │ │ └───────────────────┘ │ - │ └─────────┬───────────────┘ - ▼ │ -┌──────────────┐ │ -│ S3 │◄──────────────┘ -│ (Stage) │ -│ Output Data │ -└──────┬───────┘ - │ COPY INTO - ▼ -┌──────────────┐ -│ Snowflake │ -│ (Publish) │ -└──────────────┘ -``` +- `input_query` accepts either raw SQL text or a file path. +- SQL templates can use placeholders (for example `{schema}`) and be resolved before execution. +- Call order matters: `query_and_batch()` must run before `process_batch()`, and at least one `process_batch()` must run before `publish_results()`. diff --git a/docs/metaflow/make_pydantic_parser_fn.md b/docs/metaflow/make_pydantic_parser_fn.md new file mode 100644 index 0000000..7b105f5 --- /dev/null +++ b/docs/metaflow/make_pydantic_parser_fn.md @@ -0,0 +1,29 @@ +# `make_pydantic_parser_fn` + +Source: `ds_platform_utils.metaflow.validate_config.make_pydantic_parser_fn` + +Creates a Metaflow `Config(..., parser=...)` parser backed by a Pydantic model. + +## Signature + +```python +make_pydantic_parser_fn( + pydantic_model: type[BaseModel], +) -> Callable[[str], dict] +``` + +## What it does + +- Parses config content as JSON, TOML, or YAML. +- Validates and normalizes with Pydantic. +- Returns a dict with applied defaults from the model. + +## Typical usage + +```python +config: MyConfig = Config( + name="config", + default="./configs/default.yaml", + parser=make_pydantic_parser_fn(MyConfig), +) +``` diff --git a/docs/metaflow/pandas.md b/docs/metaflow/pandas.md deleted file mode 100644 index 98653aa..0000000 --- a/docs/metaflow/pandas.md +++ /dev/null @@ -1,434 +0,0 @@ -# Pandas Integration - -[← Back to Metaflow Docs](README.md) - -Query and publish pandas DataFrames with Snowflake. - -## Table of Contents - -- [Overview](#overview) -- [Querying Data](#querying-data) -- [Publishing Data](#publishing-data) -- [Using SQL Files](#using-sql-files) -- [Advanced Usage](#advanced-usage) - -## Overview - -The pandas integration provides simple functions to move data between Snowflake and pandas DataFrames: - -```python -from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas -``` - -## Querying Data - -### Basic Query - -```python -from ds_platform_utils.metaflow import query_pandas_from_snowflake - -df = query_pandas_from_snowflake( - query="SELECT * FROM my_table WHERE date >= '2024-01-01'", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", -) -``` - -### Query from SQL File - -```python -df = query_pandas_from_snowflake( - query="sql/extract_data.sql", # Pass file path as query parameter - ctx={ - "start_date": "2024-01-01", - "end_date": "2024-12-31", - "min_value": 100, - }, - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", -) -``` - -```sql --- sql/extract_data.sql -SELECT * -FROM transactions -WHERE date >= '{{start_date}}' - AND date <= '{{end_date}}' - AND amount >= {{min_value}} -``` - -### Large Datasets via S3 - -For datasets > 1GB, use S3 staging: - -```python -df = query_pandas_from_snowflake( - query="SELECT * FROM large_table", - use_s3_stage=True, # ← Enable S3 staging - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", -) -``` - -**Benefits:** -- Much faster for large datasets (3-5x speedup) -- Reduces memory pressure -- More reliable for very large results - -**When to use:** -- Dataset > 1 GB -- Many columns (wide tables) -- Network bandwidth limited - -### Custom Timeouts - -Note: Timeouts are managed by Metaflow decorators and Outerbounds, not the query function directly. - -```python -from metaflow import FlowSpec, step, timeout - -class MyFlow(FlowSpec): - @timeout(seconds=1800) # 30 minutes - @step - def query_large_data(self): - self.df = query_pandas_from_snowflake( - query="SELECT * FROM huge_table", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", - ) - self.next(self.end) -``` - -## Publishing Data - -### Basic Publish - -```python -from ds_platform_utils.metaflow import publish_pandas - -publish_pandas( - table_name="my_results", - df=results_df, - auto_create_table=True, - overwrite=True, -) -``` - -### Replace vs. Append - -```python -# Replace existing table (overwrite=True) -publish_pandas( - table_name="my_table", - df=df, - auto_create_table=True, - overwrite=True, # Drops table first -) - -# Append to existing table (overwrite=False) -publish_pandas( - table_name="my_table", - df=df, - auto_create_table=False, # Table must already exist - overwrite=False, # Appends data -) -``` - -### Add Created Date - -```python -publish_pandas( - table_name="my_table", - df=df, - add_created_date=True, # Adds 'created_date' column with UTC timestamp - auto_create_table=True, -) -``` - -### Specify Warehouse - -```python -publish_pandas( - table_name="large_table", - df=large_df, - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", # Use larger warehouse - auto_create_table=True, - overwrite=True, -) -``` - -### Large DataFrame via S3 Staging - -For very large DataFrames, use S3 staging for better performance: - -```python -publish_pandas( - table_name="large_table", - df=large_df, - use_s3_stage=True, - table_definition=[ - ("id", "NUMBER"), - ("name", "STRING"), - ("score", "FLOAT"), - ], -) -``` - -## Using SQL Files - -### Write-Audit-Publish Pattern - -The `publish()` function implements the write-audit-publish pattern for data quality: - -```python -from ds_platform_utils.metaflow import publish - -publish( - table_name="DAILY_FEATURES", - query="sql/create_features.sql", - audits=[ - "sql/audit_row_count.sql", - "sql/audit_null_check.sql", - ], - ctx={"start_date": "2024-01-01", "end_date": "2024-12-31"}, -) -``` - -```sql --- sql/create_features.sql -CREATE OR REPLACE TABLE {{schema}}.{{table_name}} AS -SELECT - user_id, - COUNT(*) as event_count, - AVG(value) as avg_value, - MAX(timestamp) as last_seen -FROM events -WHERE date >= '{{start_date}}' - AND date <= '{{end_date}}' -GROUP BY user_id; -``` - -```sql --- sql/audit_row_count.sql (should return 0 rows if passing) -SELECT 1 WHERE (SELECT COUNT(*) FROM {{schema}}.{{table_name}}) < 100; -``` - -```sql --- sql/audit_null_check.sql (should return 0 rows if passing) -SELECT 1 WHERE EXISTS ( - SELECT 1 FROM {{schema}}.{{table_name}} WHERE user_id IS NULL -); -``` - -### Feature Engineering in Python - -For Python transformations, combine query and publish_pandas: - -```python -from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish_pandas -from datetime import datetime - -# Query raw data -df = query_pandas_from_snowflake( - query="sql/create_features.sql", - ctx={"start_date": "2024-01-01", "end_date": "2024-12-31"}, -) - -# Transform in Python -def transform_features(df): - df['recency_days'] = ( - datetime.now() - pd.to_datetime(df['last_seen']) - ).dt.days - df['frequency_per_day'] = df['event_count'] / 30 - return df - -df = transform_features(df) - -# Publish -publish_pandas( - table_name="user_features", - df=df, - auto_create_table=True, - overwrite=True, -) -``` - -## Advanced Usage - -### Multiple Queries in Sequence - -```python -# Query 1: Get user data -users_df = query_pandas_from_snowflake( - query="SELECT * FROM users WHERE active = TRUE" -) - -# Query 2: Get events for these users -user_ids = tuple(users_df['user_id'].tolist()) -events_df = query_pandas_from_snowflake( - query=f"SELECT * FROM events WHERE user_id IN {user_ids}" -) - -# Join in pandas -result = users_df.merge(events_df, on='user_id') - -# Publish -publish_pandas(table_name="user_events", df=result) -``` - -### Chunked Publishing - -For very large DataFrames, use chunk_size parameter: - -```python -publish_pandas( - table_name="large_table", - df=large_df, - chunk_size=100000, # Insert 100k rows at a time - auto_create_table=True, - overwrite=True, -) -``` - -Or use S3 staging for even better performance: - -```python -publish_pandas( - table_name="large_table", - df=large_df, - use_s3_stage=True, - table_definition=[ - ("id", "NUMBER"), - ("value", "FLOAT"), - ], -) -``` - -### Query with Date Range - -```python -from datetime import datetime, timedelta - -# Query last 7 days -end_date = datetime.now() -start_date = end_date - timedelta(days=7) - -df = query_pandas_from_snowflake( - query=f""" - SELECT * - FROM events - WHERE date >= '{start_date.strftime('%Y-%m-%d')}' - AND date < '{end_date.strftime('%Y-%m-%d')}' - """ -) -``` - -### Error Handling - -```python -from metaflow import retry - -@retry(times=3) -def query_with_retry(): - """Query with automatic retries.""" - try: - df = query_pandas_from_snowflake( - query="SELECT * FROM sometimes_flaky_table", - ) - return df - except Exception as e: - print(f"⚠️ Query failed: {e}") - raise # Will trigger retry - -df = query_with_retry() -``` - -## API Reference - -### query_pandas_from_snowflake() - -Query Snowflake and return a pandas DataFrame. - -**Parameters:** -- `query` (str | Path): SQL query string or path to .sql file -- `warehouse` (str, optional): Snowflake warehouse name -- `ctx` (dict, optional): Template variables for query substitution -- `use_utc` (bool): Use UTC timezone (default: True) -- `use_s3_stage` (bool): Use S3 staging for large results (default: False) - -**Returns:** `pandas.DataFrame` (column names lowercased) - -**Example:** -```python -df = query_pandas_from_snowflake( - query="SELECT * FROM my_table", - warehouse="OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", -) -``` - -### publish_pandas() - -Publish a pandas DataFrame to Snowflake. - -**Parameters:** -- `table_name` (str): Target table name (auto-uppercased) -- `df` (pd.DataFrame): DataFrame to publish -- `add_created_date` (bool): Add created_date column (default: False) -- `chunk_size` (int, optional): Rows per insert batch -- `compression` (str): Parquet compression "snappy" or "gzip" (default: "snappy") -- `warehouse` (str, optional): Snowflake warehouse name -- `parallel` (int): Upload threads (default: 4) -- `quote_identifiers` (bool): Quote column names (default: True) -- `auto_create_table` (bool): Create table if missing (default: False) -- `overwrite` (bool): Drop/truncate before write (default: False) -- `use_logical_type` (bool): Parquet logical types for timestamps (default: True) -- `use_utc` (bool): Use UTC timezone (default: True) -- `use_s3_stage` (bool): Use S3 staging (default: False) -- `table_definition` (list, optional): Schema as [(col, type), ...] for S3 staging - -**Returns:** None - -**Example:** -```python -publish_pandas( - table_name="my_results", - df=results_df, - auto_create_table=True, - overwrite=True, -) -``` - -### publish() - -Publish a Snowflake table using the write-audit-publish pattern. - -**Parameters:** -- `table_name` (str): Name of the Snowflake table to publish -- `query` (str | Path): SQL query string or path to .sql file -- `audits` (list, optional): SQL audit scripts or file paths for validation -- `ctx` (dict, optional): Template variables for SQL substitution -- `warehouse` (str, optional): Snowflake warehouse name -- `use_utc` (bool): Use UTC timezone (default: True) - -**Returns:** None - -**Example:** -```python -publish( - table_name="DAILY_FEATURES", - query="sql/create_features.sql", - audits=["sql/audit_row_count.sql"], - ctx={"date": "2024-01-01"}, -) -``` - -## Performance Tips - -1. **Filter early**: Apply WHERE clauses in SQL, not pandas -2. **Select only needed columns**: Avoid `SELECT *` when possible -3. **Use S3 staging**: For datasets > 1GB -4. **Choose right warehouse**: Larger warehouse for larger datasets -5. **Optimize data types**: Use `int32`, `float32`, `category` to reduce memory - -## Related Documentation - -- [BatchInferencePipeline](batch_inference_pipeline.md) - For very large datasets -- [S3 Integration](s3.md) - Direct S3 operations -- [Configuration Validation](validate_config.md) - Pydantic integration diff --git a/docs/metaflow/publish.md b/docs/metaflow/publish.md new file mode 100644 index 0000000..ebc4217 --- /dev/null +++ b/docs/metaflow/publish.md @@ -0,0 +1,36 @@ +# `publish` + +Source: `ds_platform_utils.metaflow.write_audit_publish.publish` + +Publishes data to a Snowflake table using the write-audit-publish (WAP) pattern. + +## Signature + +```python +publish( + table_name: str, + query: str | Path, + audits: list[str | Path] | None = None, + ctx: dict[str, Any] | None = None, + warehouse: Literal["XS", "MED", "XL"] | None = None, + use_utc: bool = True, +) -> None +``` + +## What it does + +- Reads SQL from a string or `.sql` path. +- Runs write/audit/publish operations through Snowflake. +- Adds operation details and table links to the Metaflow card when available. + +## Typical usage + +```python +from ds_platform_utils.metaflow import publish + +publish( + table_name="MY_TABLE", + query="SELECT * FROM PATTERN_DB.{{schema}}.SOURCE", + audits=["SELECT COUNT(*) > 0 FROM PATTERN_DB.{{schema}}.{{table_name}}"], +) +``` diff --git a/docs/metaflow/publish_pandas.md b/docs/metaflow/publish_pandas.md new file mode 100644 index 0000000..18e8902 --- /dev/null +++ b/docs/metaflow/publish_pandas.md @@ -0,0 +1,32 @@ +# `publish_pandas` + +Source: `ds_platform_utils.metaflow.pandas.publish_pandas` + +Writes a pandas DataFrame to Snowflake. + +## Signature + +```python +publish_pandas( + table_name: str, + df: pd.DataFrame, + add_created_date: bool = False, + chunk_size: int | None = None, + compression: Literal["snappy", "gzip"] = "snappy", + warehouse: Literal["XS", "MED", "XL"] | None = None, + parallel: int = 4, + quote_identifiers: bool = False, + auto_create_table: bool = False, + overwrite: bool = False, + use_logical_type: bool = True, + use_utc: bool = True, + use_s3_stage: bool = False, + table_definition: list[tuple[str, str]] | None = None, +) -> None +``` + +## What it does + +- Validates DataFrame input. +- Writes directly via `write_pandas` or via S3 stage flow for large data. +- Adds a Snowflake table URL to Metaflow card output. diff --git a/docs/metaflow/query_pandas_from_snowflake.md b/docs/metaflow/query_pandas_from_snowflake.md new file mode 100644 index 0000000..35e8931 --- /dev/null +++ b/docs/metaflow/query_pandas_from_snowflake.md @@ -0,0 +1,24 @@ +# `query_pandas_from_snowflake` + +Source: `ds_platform_utils.metaflow.pandas.query_pandas_from_snowflake` + +Executes a Snowflake query and returns results as a pandas DataFrame. + +## Signature + +```python +query_pandas_from_snowflake( + query: str | Path, + warehouse: Literal["XS", "MED", "XL"] | None = None, + ctx: dict[str, Any] | None = None, + use_utc: bool = True, + use_s3_stage: bool = False, +) -> pd.DataFrame +``` + +## What it does + +- Accepts SQL text or `.sql` file path. +- Substitutes template values, including `{schema}`. +- Runs query directly or through Snowflake → S3 path. +- Normalizes resulting columns to lowercase. diff --git a/docs/metaflow/restore_step_state.md b/docs/metaflow/restore_step_state.md new file mode 100644 index 0000000..9c17cd2 --- /dev/null +++ b/docs/metaflow/restore_step_state.md @@ -0,0 +1,25 @@ +# `restore_step_state` + +Source: `ds_platform_utils.metaflow.restore_step_state.restore_step_state` + +Restores Metaflow run state so step logic can be reproduced locally (for debugging/notebooks). + +## Signature + +```python +restore_step_state( + flow_class: type[T] | None = None, + flow_name: str | None = None, + step_name: str = "end", + flow_run_id: Literal["latest_successful_run", "latest"] | str = "latest_successful_run", + secrets: list[str] | None = None, + namespace: str | None = None, +) -> T +``` + +## What it does + +- Loads run artifacts from Metaflow metadata. +- Exposes restored values as `self.` style attributes. +- Optionally exports requested Metaflow secrets into env vars. +- Patches `metaflow.current` with a mock context for local execution. diff --git a/docs/metaflow/validate_config.md b/docs/metaflow/validate_config.md deleted file mode 100644 index f8572c9..0000000 --- a/docs/metaflow/validate_config.md +++ /dev/null @@ -1,477 +0,0 @@ -# Configuration Validation - -[← Back to Metaflow Docs](README.md) - -Use Pydantic for type-safe flow configuration. - -## Table of Contents - -- [Overview](#overview) -- [Basic Usage](#basic-usage) -- [Advanced Validation](#advanced-validation) -- [Best Practices](#best-practices) - -## Overview - -`make_pydantic_parser_fn` integrates Pydantic models with Metaflow Parameters for type-safe configuration: - -```python -from pydantic import BaseModel -from metaflow import FlowSpec, Parameter -from ds_platform_utils.metaflow import make_pydantic_parser_fn - -class FlowConfig(BaseModel): - start_date: str - end_date: str - threshold: float = 0.5 - -class MyFlow(FlowSpec): - config = Parameter( - 'config', - type=make_pydantic_parser_fn(FlowConfig), - default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}', - ) -``` - -## Basic Usage - -### Simple Configuration - -```python -from pydantic import BaseModel -from metaflow import FlowSpec, Parameter, step -from ds_platform_utils.metaflow import make_pydantic_parser_fn - -class Config(BaseModel): - """Flow configuration.""" - table_name: str - warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" - limit: int = 1000 - -class SimpleFlow(FlowSpec): - """Flow with validated config.""" - - config = Parameter( - 'config', - type=make_pydantic_parser_fn(Config), - default='{"table_name": "my_table"}', - help='JSON configuration' - ) - - @step - def start(self): - # Access validated config - print(f"Table: {self.config.table_name}") - print(f"Warehouse: {self.config.warehouse}") - print(f"Limit: {self.config.limit}") - self.next(self.end) - - @step - def end(self): - pass -``` - -**Run:** -```bash -# Use default config -python flow.py run - -# Override config -python flow.py run --config '{"table_name": "other_table", "limit": 5000}' -``` - -### Date Range Configuration - -```python -from pydantic import BaseModel, validator -from datetime import datetime - -class DateRangeConfig(BaseModel): - """Configuration with date validation.""" - start_date: str - end_date: str - - @validator('start_date', 'end_date') - def validate_date_format(cls, v): - """Ensure dates are in YYYY-MM-DD format.""" - try: - datetime.strptime(v, '%Y-%m-%d') - return v - except ValueError: - raise ValueError(f"Date must be in YYYY-MM-DD format, got: {v}") - - @validator('end_date') - def end_after_start(cls, v, values): - """Ensure end_date is after start_date.""" - if 'start_date' in values and v < values['start_date']: - raise ValueError("end_date must be after start_date") - return v - -class DateRangeFlow(FlowSpec): - config = Parameter( - 'config', - type=make_pydantic_parser_fn(DateRangeConfig), - default='{"start_date": "2024-01-01", "end_date": "2024-12-31"}', - ) - - @step - def start(self): - print(f"Processing {self.config.start_date} to {self.config.end_date}") - self.next(self.end) - - @step - def end(self): - pass -``` - -**Run:** -```bash -# Valid -python flow.py run --config '{"start_date": "2024-01-01", "end_date": "2024-12-31"}' - -# Invalid - will fail validation -python flow.py run --config '{"start_date": "2024-12-31", "end_date": "2024-01-01"}' -# Error: end_date must be after start_date -``` - -## Advanced Validation - -### Nested Configuration - -```python -from pydantic import BaseModel -from typing import List - -class SnowflakeConfig(BaseModel): - """Snowflake settings.""" - warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" - database: str = "PATTERN_DB" - schema: str = "my_dev_schema" - -class ModelConfig(BaseModel): - """Model settings.""" - model_path: str - threshold: float = 0.5 - features: List[str] - -class FullConfig(BaseModel): - """Complete flow configuration.""" - snowflake: SnowflakeConfig - model: ModelConfig - debug: bool = False - -class AdvancedFlow(FlowSpec): - config = Parameter( - 'config', - type=make_pydantic_parser_fn(FullConfig), - default=''' - { - "snowflake": { - "warehouse": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH", - "database": "PATTERN_DB", - "schema": "my_dev_schema" - }, - "model": { - "model_path": "models/my_model.pkl", - "threshold": 0.5, - "features": ["feature_1", "feature_2", "feature_3"] - }, - "debug": false - } - ''', - ) - - @step - def start(self): - print(f"Warehouse: {self.config.snowflake.warehouse}") - print(f"Model: {self.config.model.model_path}") - print(f"Features: {self.config.model.features}") - self.next(self.end) - - @step - def end(self): - pass -``` - -### Custom Validators - -```python -from pydantic import BaseModel, validator, root_validator - -class MLConfig(BaseModel): - """ML pipeline configuration.""" - training_start: str - training_end: str - inference_date: str - min_samples: int = 1000 - max_samples: int = 1_000_000 - - @validator('inference_date') - def inference_after_training(cls, v, values): - """Inference date must be after training period.""" - if 'training_end' in values and v <= values['training_end']: - raise ValueError("inference_date must be after training_end") - return v - - @validator('min_samples', 'max_samples') - def positive_samples(cls, v): - """Sample counts must be positive.""" - if v <= 0: - raise ValueError("Sample count must be positive") - return v - - @root_validator - def check_sample_range(cls, values): - """min_samples must be less than max_samples.""" - min_s = values.get('min_samples') - max_s = values.get('max_samples') - - if min_s and max_s and min_s >= max_s: - raise ValueError("min_samples must be less than max_samples") - - return values -``` - -### Enum Validation - -```python -from enum import Enum -from pydantic import BaseModel - -class Warehouse(str, Enum): - """Valid warehouse names.""" - XS = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH" - SMALL = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_S_WH" - MEDIUM = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" - LARGE = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_L_WH" - XL = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH" - -class QueryConfig(BaseModel): - """Query configuration.""" - query: str - warehouse: Warehouse # Only accepts valid warehouses - limit: int = 10000 - -# Valid -config = QueryConfig( - query="SELECT * FROM table", - warehouse=Warehouse.MEDIUM, # or "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" -) - -# Invalid - will fail -config = QueryConfig( - query="SELECT * FROM table", - warehouse="INVALID_WAREHOUSE", # Error! -) -``` - -## Best Practices - -### ✅ DO: Use Type Hints - -```python -from typing import List, Optional - -class Config(BaseModel): - # Clear types - features: List[str] - threshold: float - warehouse: Optional[str] = None # Explicitly optional -``` - -### ✅ DO: Provide Defaults - -```python -class Config(BaseModel): - # Sensible defaults - warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" - batch_size: int = 1000 - timeout: int = 3600 -``` - -### ✅ DO: Add Docstrings - -```python -class Config(BaseModel): - """Flow configuration. - - Attributes: - start_date: Start date in YYYY-MM-DD format - end_date: End date in YYYY-MM-DD format - warehouse: Snowflake warehouse name - """ - start_date: str - end_date: str - warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" -``` - -### ✅ DO: Validate Early - -```python -@validator('threshold') -def threshold_in_range(cls, v): - """Threshold must be between 0 and 1.""" - if not 0 <= v <= 1: - raise ValueError(f"threshold must be in [0, 1], got {v}") - return v -``` - -### ❌ DON'T: Over-validate - -```python -# ❌ Bad - too restrictive -class Config(BaseModel): - table_name: str - - @validator('table_name') - def specific_table(cls, v): - if v != "exactly_this_table": # Too rigid! - raise ValueError("Only specific table allowed") - return v - -# ✅ Good - validate format, not content -class Config(BaseModel): - table_name: str - - @validator('table_name') - def valid_table_name(cls, v): - if not v.replace('_', '').isalnum(): # Allow alphanumeric + underscore - raise ValueError("Invalid table name format") - return v -``` - -## Example: Production Configuration - -```python -from pydantic import BaseModel, validator, Field -from typing import List, Optional -from datetime import datetime -from enum import Enum - -class Environment(str, Enum): - """Deployment environment.""" - DEV = "dev" - STAGING = "staging" - PROD = "prod" - -class Schedule(BaseModel): - """Schedule configuration.""" - enabled: bool = True - cron: str = "0 2 * * *" # Daily at 2 AM - timezone: str = "UTC" - -class ProductionConfig(BaseModel): - """Production-ready flow configuration.""" - - # Environment - env: Environment = Environment.DEV - - # Data - start_date: str = Field(..., description="Start date (YYYY-MM-DD)") - end_date: str = Field(..., description="End date (YYYY-MM-DD)") - table_name: str = Field(..., description="Input table name") - - # Model - model_path: str = Field(..., description="Path to model file") - features: List[str] = Field(..., description="Feature columns") - threshold: float = Field(0.5, ge=0, le=1, description="Prediction threshold") - - # Snowflake - warehouse: str = "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_MED_WH" - schema_override: Optional[str] = None - - # Performance - use_s3_stage: bool = True - parallel_workers: int = Field(10, ge=1, le=50) - batch_size_mb: int = Field(256, ge=64, le=512) - - # Monitoring - enable_alerts: bool = True - alert_email: Optional[str] = None - - # Schedule - schedule: Optional[Schedule] = None - - @validator('start_date', 'end_date') - def validate_date(cls, v): - """Validate date format.""" - try: - datetime.strptime(v, '%Y-%m-%d') - return v - except ValueError: - raise ValueError(f"Invalid date format: {v}") - - @validator('warehouse') - def validate_warehouse(cls, v, values): - """Validate warehouse based on environment.""" - env = values.get('env') - if env == Environment.PROD and 'DEV' in v: - raise ValueError("Cannot use DEV warehouse in PROD environment") - return v - - @root_validator - def validate_alerts(cls, values): - """If alerts enabled, email is required.""" - if values.get('enable_alerts') and not values.get('alert_email'): - raise ValueError("alert_email required when enable_alerts=true") - return values - -class ProductionFlow(FlowSpec): - config = Parameter( - 'config', - type=make_pydantic_parser_fn(ProductionConfig), - default='{"start_date": "2024-01-01", "end_date": "2024-12-31", "table_name": "input_data", "model_path": "model.pkl", "features": ["f1", "f2"]}', - ) - - @step - def start(self): - print(f"Environment: {self.config.env.value}") - print(f"Date range: {self.config.start_date} to {self.config.end_date}") - self.next(self.end) - - @step - def end(self): - pass -``` - -## Troubleshooting - -### Validation Error - -```bash -# Error: ValidationError -python flow.py run --config '{"invalid": "config"}' - -# Error message shows which fields are missing/invalid -ValidationError: 2 validation errors for ProductionConfig -start_date - field required (type=value_error.missing) -end_date - field required (type=value_error.missing) -``` - -**Solution**: Provide all required fields. - -### JSON Parsing Error - -```bash -# Error: Invalid JSON -python flow.py run --config '{"start_date": 2024-01-01}' # Missing quotes! - -# Fix: properly quote values -python flow.py run --config '{"start_date": "2024-01-01"}' -``` - -### Type Mismatch - -```python -# Error: wrong type -config = Config(threshold="0.5") # String, not float - -# Fix: use correct type -config = Config(threshold=0.5) # Float -``` - -## Related Documentation - -- [Pydantic Documentation](https://docs.pydantic.dev/) diff --git a/docs/snowflake/README.md b/docs/snowflake/README.md deleted file mode 100644 index 439f5af..0000000 --- a/docs/snowflake/README.md +++ /dev/null @@ -1,288 +0,0 @@ -# Snowflake Utilities - -Core utilities for interacting with Snowflake in Pattern's data platform. - -## Overview - -The Snowflake utilities module provides low-level functions for executing queries and implementing the write-audit-publish pattern. These utilities are integrated with Outerbounds, which automatically handles Snowflake authentication and connection management. - -> **Note:** Most users should use the higher-level [Metaflow Pandas Integration](../metaflow/pandas.md) functions (`query_pandas_from_snowflake`, `publish_pandas`, `publish`) rather than calling these utilities directly. - -## Key Features - -### 1. Query Execution - -Execute SQL statements against Snowflake with automatic connection handling via Outerbounds: - -```python -from ds_platform_utils._snowflake.run_query import _execute_sql - -# Execute returns the cursor for the last statement -cursor = _execute_sql(conn, """ - SELECT * FROM my_table LIMIT 10; - SELECT COUNT(*) FROM my_table; -""") -``` - -**Features:** -- Supports batch execution (multiple statements separated by semicolons) -- Returns cursor from the last executed statement -- Handles empty SQL statements gracefully -- Provides clear error messages for SQL syntax errors - -### 2. Write-Audit-Publish Pattern - -Implement data quality checks during table writes: - -```python -from ds_platform_utils._snowflake.write_audit_publish import write_audit_publish - -# Write table with audit checks -operations = write_audit_publish( - table_name="my_feature_table", - query="SELECT * FROM source_table WHERE date > '2024-01-01'", - audits=[ - "SELECT COUNT(*) > 0 AS has_rows FROM {{schema}}.{{table_name}}", - "SELECT COUNT(DISTINCT user_id) > 100 FROM {{schema}}.{{table_name}}" - ], - cursor=cursor, - is_production=False -) - -# Execute each operation -for op in operations: - print(f"Executing: {op.description}") - op.execute() -``` - -**The Pattern:** -1. **Write**: Create/replace table in dev schema -2. **Audit**: Run validation queries to check data quality -3. **Publish**: If audits pass, promote table to production schema - -**Benefits:** -- Catch data quality issues before production -- Atomic operations (all-or-nothing) -- Automatic schema management (dev vs prod) -- Query templating with Jinja2 - -## Authentication - -All Snowflake operations automatically use credentials managed by Outerbounds. No manual configuration required! - -Outerbounds handles: -- ✅ Snowflake authentication -- ✅ Warehouse selection -- ✅ Database/schema configuration -- ✅ Connection pooling -- ✅ Query tagging for audit trails - -## Common Patterns - -### Pattern 1: Simple Query Execution - -```python -from ds_platform_utils.metaflow import query_pandas_from_snowflake - -# High-level API (recommended) -df = query_pandas_from_snowflake("SELECT * FROM my_table") -``` - -### Pattern 2: Table Creation with Validation - -```python -from ds_platform_utils.metaflow import publish - -# High-level API with audits -publish( - table_name="features", - query="CREATE TABLE {{schema}}.{{table_name}} AS SELECT ...", - audits=["SELECT COUNT(*) > 1000 FROM {{schema}}.{{table_name}}"] -) -``` - -### Pattern 3: pandas DataFrame Publishing - -```python -from ds_platform_utils.metaflow import publish_pandas -import pandas as pd - -df = pd.DataFrame({"col1": [1, 2, 3]}) - -# Publish with automatic schema inference -publish_pandas( - table_name="my_data", - df=df, - auto_create_table=True, - overwrite=True, -) -``` - -## Query Templating - -The write-audit-publish functions use Jinja2 templating for dynamic table names: - -```sql --- Use template variables in your queries -CREATE OR REPLACE TABLE {{schema}}.{{table_name}} AS -SELECT * FROM source_table; - --- In audits -SELECT - COUNT(*) > 0 AS has_data, - MAX(created_at) > CURRENT_DATE - 7 AS is_recent -FROM {{schema}}.{{table_name}}; -``` - -**Template Variables:** -- `{{schema}}` - Automatically set to dev or prod schema -- `{{table_name}}` - The table name you specify -- Custom context via `ctx` parameter - -## Schema Management - -### Development vs Production - -```python -# Development (default) -publish(..., is_production=False) # Writes to DEV_SCHEMA - -# Production -publish(..., is_production=True) # Writes to PROD_SCHEMA -``` - -The library automatically manages schema selection: -- **Dev**: Fast iteration, no data quality gates -- **Prod**: Requires audit checks to pass - -### Branch-based Development - -```python -# Tables can be scoped to git branches -publish( - ..., - branch_name="feature-xyz" # Creates feature-xyz_table_name -) -``` - -## Error Handling - -The Snowflake utilities provide detailed error messages: - -```python -try: - cursor = _execute_sql(conn, bad_sql) -except Exception as e: - # Detailed error includes: - # - SQL syntax errors with line numbers - # - Table/column not found errors - # - Permission errors - print(f"Query failed: {e}") -``` - -**Common Errors:** -- `Empty SQL statement` - Query contains only whitespace/comments -- `SQL compilation error` - Syntax or schema errors -- `Insufficient privileges` - Permission issues (contact DevOps) - -## Best Practices - -### 1. Use High-Level APIs - -Prefer `query_pandas_from_snowflake` and `publish_pandas` over low-level utilities: - -```python -# ✅ Recommended -from ds_platform_utils.metaflow import query_pandas_from_snowflake -df = query_pandas_from_snowflake("SELECT ...") - -# ❌ Avoid (unless you need low-level control) -from ds_platform_utils._snowflake.run_query import _execute_sql -cursor = _execute_sql(conn, "SELECT ...") -``` - -### 2. Always Add Audit Checks - -Data quality checks prevent bad data from reaching production: - -```python -audits = [ - "SELECT COUNT(*) > 0 FROM {{schema}}.{{table_name}}", # Non-empty - "SELECT COUNT(*) = COUNT(DISTINCT id) FROM {{schema}}.{{table_name}}", # Unique IDs - "SELECT MAX(updated_at) > CURRENT_DATE - 1 FROM {{schema}}.{{table_name}}" # Fresh data -] -``` - -### 3. Use Template Variables - -Always use `{{schema}}.{{table_name}}` in queries for write-audit-publish: - -```sql --- ✅ Correct -CREATE TABLE {{schema}}.{{table_name}} AS SELECT ... - --- ❌ Wrong (hardcoded schema) -CREATE TABLE production.my_table AS SELECT ... -``` - -### 4. Handle Cursors Properly - -Cursors should be closed after use: - -```python -cursor = _execute_sql(conn, sql) -try: - results = cursor.fetchall() -finally: - cursor.close() -``` - -## Integration with Metaflow - -The Snowflake utilities are designed to work seamlessly with Metaflow flows: - -```python -from metaflow import FlowSpec, step -from ds_platform_utils.metaflow import query_pandas_from_snowflake, publish - -class MyFlow(FlowSpec): - @step - def start(self): - # Query data - self.df = query_pandas_from_snowflake(""" - SELECT * FROM raw_data - WHERE date = CURRENT_DATE - """) - self.next(self.transform) - - @step - def transform(self): - # Transform data - self.features = self.df.groupby('user_id').size() - self.next(self.publish) - - @step - def publish(self): - # Publish DataFrame - publish_pandas( - table_name="daily_features", - df=self.features.reset_index(), - auto_create_table=True, - overwrite=True, - ) - self.next(self.end) - - @step - def end(self): - print("Pipeline complete!") -``` - -## Related Documentation - -- [Metaflow Pandas Integration](../metaflow/pandas.md) - High-level query/publish functions -- [API Reference](../api/index.md) - Complete function signatures - -## See Also - -- [Write-Audit-Publish Examples](../examples/README.md) -- [Snowflake Official Docs](https://docs.snowflake.com/) From 13764bb476803797e94e655f98f739c2e18a58ba Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:28:42 +0530 Subject: [PATCH 144/167] fix: ensure parallel_workers parameter is set in query_and_batch step --- .../functional_tests/metaflow/test__batch_inference_pipeline.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py index 101bc1f..4538aca 100644 --- a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py +++ b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py @@ -30,6 +30,7 @@ def query_and_batch(self): input_query=query, ctx={"extra": "value"}, warehouse="XS", + parallel_workers=2, ) self.next(self.process_batch, foreach="worker_ids") From 091a143f1f0b224b2d807ecc1830df4b81577d17 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:30:30 +0530 Subject: [PATCH 145/167] fix: add query_pandas_from_snowflake import and validate output row count in query_and_batch step --- .../metaflow/test__batch_inference_pipeline.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py index 4538aca..f30e862 100644 --- a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py +++ b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py @@ -8,6 +8,7 @@ from metaflow import FlowSpec, project, step from ds_platform_utils.metaflow import BatchInferencePipeline +from ds_platform_utils.metaflow.pandas import query_pandas_from_snowflake @project(name="test_batch_inference_pipeline") @@ -23,8 +24,8 @@ def start(self): def query_and_batch(self): """Run the query and batch step.""" os.environ["DEBUG_QUERY"] = "1" - n = 10000000 - query = f"SELECT UNIFORM(0::FLOAT, 10::FLOAT, RANDOM()) F1 , UNIFORM(0::INT, 1000::INT, RANDOM()) F2 FROM TABLE(GENERATOR(ROWCOUNT => {n}));" + self.n = 10000000 + query = f"SELECT UNIFORM(0::FLOAT, 10::FLOAT, RANDOM()) F1 , UNIFORM(0::INT, 1000::INT, RANDOM()) F2 FROM TABLE(GENERATOR(ROWCOUNT => {self.n}));" self.pipeline = BatchInferencePipeline() self.worker_ids = self.pipeline.query_and_batch( input_query=query, @@ -58,6 +59,13 @@ def publish_results(self, inputs): overwrite=True, auto_create_table=True, ) + + df = query_pandas_from_snowflake( + query="SELECT * FROM DS_PLATFORM_UTILS_TEST_BATCH_INFERENCE_OUTPUT", warehouse="XS" + ) + if len(df) != inputs[0].n: + raise ValueError(f"Expected {inputs[0].n} rows but got {len(df)}") + self.next(self.end) @step From 321bee35eccad5f982d9d6152201f65f0df71d73 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:31:19 +0530 Subject: [PATCH 146/167] fix: improve error message for row count mismatch in query_and_batch step --- .../metaflow/test__batch_inference_pipeline.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py index f30e862..6092186 100644 --- a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py +++ b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py @@ -64,8 +64,7 @@ def publish_results(self, inputs): query="SELECT * FROM DS_PLATFORM_UTILS_TEST_BATCH_INFERENCE_OUTPUT", warehouse="XS" ) if len(df) != inputs[0].n: - raise ValueError(f"Expected {inputs[0].n} rows but got {len(df)}") - + raise ValueError(f"Output row count {len(df)} does not match input row count {inputs[0].n}") self.next(self.end) @step From 0611896cc07219e7f000aa39a8e7977da58cfecf Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:39:35 +0530 Subject: [PATCH 147/167] fix: remove redundant warehouse selection in query_pandas_from_snowflake function --- src/ds_platform_utils/metaflow/pandas.py | 2 -- .../metaflow/test__warehouse.py | 24 ++++++++++--------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index 193a7ec..eaa0f8e 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -211,8 +211,6 @@ def query_pandas_from_snowflake( df = _get_df_from_s3_files(s3_files) else: conn: SnowflakeConnection = get_snowflake_connection(warehouse=warehouse, use_utc=use_utc) - if warehouse is not None: - _execute_sql(conn, f"USE WAREHOUSE {warehouse};") _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") cursor_result = _execute_sql(conn, query) if cursor_result is None: diff --git a/tests/functional_tests/metaflow/test__warehouse.py b/tests/functional_tests/metaflow/test__warehouse.py index 6f168cb..6bd4a07 100644 --- a/tests/functional_tests/metaflow/test__warehouse.py +++ b/tests/functional_tests/metaflow/test__warehouse.py @@ -24,29 +24,29 @@ def test_publish_with_warehouse(self): # Publish a simple query to Snowflake with a specific warehouse warehouse_map = [ { - "size": None, + "warehouse": None, "domain": "content", - "warehouse": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", + "warehouse_out": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", }, { - "size": "XS", + "warehouse": "XS", "domain": "content", - "warehouse": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", + "warehouse_out": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", }, { - "size": "MED", + "warehouse": "MED", "domain": "advertising", - "warehouse": "OUTERBOUNDS_DATA_SCIENCE_ADS_DEV_MED_WH", + "warehouse_out": "OUTERBOUNDS_DATA_SCIENCE_ADS_DEV_MED_WH", }, { - "size": "XL", + "warehouse": "XL", "domain": "reference", - "warehouse": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", + "warehouse_out": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XL_WH", }, { - "size": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", + "warehouse": "XS", "domain": "content", - "warehouse": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", + "warehouse_out": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", }, ] @@ -60,7 +60,9 @@ def test_publish_with_warehouse(self): ) current.tags.pop() # Clean up tag after query df_warehouse = df_warehouse.iloc[0, 0] - assert df_warehouse == item["warehouse"], f"Expected warehouse {item['warehouse']}, got {df_warehouse}" + assert df_warehouse == item["warehouse_out"], ( + f"Expected warehouse {item['warehouse_out']}, got {df_warehouse}" + ) print(f"Successfully queried warehouse: {df_warehouse}") From 68f3984e72d3f1f9a7413f6a982c2bd653ea03a4 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:46:49 +0530 Subject: [PATCH 148/167] fix: refactor test steps to streamline warehouse query and publish process --- .../metaflow/test__warehouse.py | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/tests/functional_tests/metaflow/test__warehouse.py b/tests/functional_tests/metaflow/test__warehouse.py index 6bd4a07..5706de4 100644 --- a/tests/functional_tests/metaflow/test__warehouse.py +++ b/tests/functional_tests/metaflow/test__warehouse.py @@ -7,6 +7,7 @@ from metaflow import FlowSpec, project, step from ds_platform_utils.metaflow import query_pandas_from_snowflake +from ds_platform_utils.metaflow.write_audit_publish import publish @project(name="test_warehouse_flow") @@ -16,13 +17,7 @@ class TestWarehouseFlow(FlowSpec): @step def start(self): """Start the flow.""" - self.next(self.test_publish_with_warehouse) - - @step - def test_publish_with_warehouse(self): - """Test the publish function with warehouse parameter.""" - # Publish a simple query to Snowflake with a specific warehouse - warehouse_map = [ + self.warehouse_map = [ { "warehouse": None, "domain": "content", @@ -49,8 +44,14 @@ def test_publish_with_warehouse(self): "warehouse_out": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", }, ] + self.next(self.test_query_with_warehouse) + + @step + def test_query_with_warehouse(self): + """Test the query function with warehouse parameter.""" + # Query a simple query to Snowflake with a specific warehouse - for item in warehouse_map: + for item in self.warehouse_map: from metaflow import current current.tags.add(f"ds.domain:{item['domain']}") @@ -66,6 +67,37 @@ def test_publish_with_warehouse(self): print(f"Successfully queried warehouse: {df_warehouse}") + self.next(self.test_publish_with_warehouse) + + @step + def test_publish_with_warehouse(self): + """Test the publish function with warehouse parameter.""" + # Publish a simple dataframe to Snowflake with a specific warehouse + query = """ + CREATE OR REPLACE TABLE {{table_name}} AS ( SELECT CURRENT_WAREHOUSE() AS WAREHOUSE ); + """ + for item in self.warehouse_map: + from metaflow import current + + current.tags.add(f"ds.domain:{item['domain']}") + + publish( + query=query, + table_name="DS_PLATFORM_UTILS_TEST_WAREHOUSE_PUBLISH", + warehouse=item["warehouse"], + ) + current.tags.pop() # Clean up tag after publish + df_warehouse = query_pandas_from_snowflake( + query="SELECT WAREHOUSE FROM DS_PLATFORM_UTILS_TEST_WAREHOUSE_PUBLISH;", + warehouse=item["warehouse"], + ) + df_warehouse = df_warehouse.iloc[0, 0] + assert df_warehouse == item["warehouse_out"], ( + f"Expected warehouse {item['warehouse_out']}, got {df_warehouse}" + ) + + print(f"Successfully published to warehouse: {df_warehouse}") + self.next(self.end) @step From f78aaf566c6b577e1293376bebc986d4dad00532 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:57:16 +0530 Subject: [PATCH 149/167] fix: update table names to follow DS_PLATFORM_UTILS naming convention in test cases --- tests/functional_tests/metaflow/test__pandas.py | 4 ++-- .../functional_tests/metaflow/test__pandas_s3.py | 6 +++--- .../metaflow/test__pandas_utc.py | 16 ++++++++++------ tests/functional_tests/metaflow/test__publish.py | 8 ++++---- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/tests/functional_tests/metaflow/test__pandas.py b/tests/functional_tests/metaflow/test__pandas.py index 2e92af4..e99f484 100644 --- a/tests/functional_tests/metaflow/test__pandas.py +++ b/tests/functional_tests/metaflow/test__pandas.py @@ -33,7 +33,7 @@ def test_publish_pandas(self): # Publish the DataFrame to Snowflake publish_pandas( - table_name="pandas_test_table", + table_name="DS_PLATFORM_UTILS_TEST_PANDAS", df=df, auto_create_table=True, overwrite=True, @@ -58,7 +58,7 @@ def test_publish_pandas_with_warehouse(self): # Publish the DataFrame to Snowflake with a specific warehouse publish_pandas( - table_name="pandas_test_table", + table_name="DS_PLATFORM_UTILS_TEST_PANDAS", df=df, auto_create_table=True, overwrite=True, diff --git a/tests/functional_tests/metaflow/test__pandas_s3.py b/tests/functional_tests/metaflow/test__pandas_s3.py index 824b069..b8b0bb0 100644 --- a/tests/functional_tests/metaflow/test__pandas_s3.py +++ b/tests/functional_tests/metaflow/test__pandas_s3.py @@ -33,7 +33,7 @@ def test_publish_pandas_with_schema(self): # Publish the DataFrame to Snowflake publish_pandas( - table_name="pandas_test_table_via_s3", + table_name="DS_PLATFORM_UTILS_TEST_PANDAS_VIA_S3", df=df, auto_create_table=True, overwrite=True, @@ -64,7 +64,7 @@ def test_publish_pandas_without_schema(self): # Publish the DataFrame to Snowflake with a specific warehouse publish_pandas( - table_name="pandas_test_table_via_s3", + table_name="DS_PLATFORM_UTILS_TEST_PANDAS_VIA_S3", df=df, auto_create_table=True, overwrite=True, @@ -79,7 +79,7 @@ def test_query_pandas(self): from ds_platform_utils.metaflow import query_pandas_from_snowflake # Query to retrieve the data we just published - query = "SELECT * FROM PATTERN_DB.{{schema}}.PANDAS_TEST_TABLE_VIA_S3;" + query = "SELECT * FROM PATTERN_DB.{{schema}}.DS_PLATFORM_UTILS_TEST_PANDAS_VIA_S3;" # Query the data back result_df = query_pandas_from_snowflake(query, use_s3_stage=True) diff --git a/tests/functional_tests/metaflow/test__pandas_utc.py b/tests/functional_tests/metaflow/test__pandas_utc.py index f07f4cd..b04ceee 100644 --- a/tests/functional_tests/metaflow/test__pandas_utc.py +++ b/tests/functional_tests/metaflow/test__pandas_utc.py @@ -40,7 +40,7 @@ def test_publish_pandas_with_use_utc(self): # Publish the table to Snowflake (UTC=True is default) publish_pandas( - table_name="PANDAS_TEST_TABLE_UTC", + table_name="DS_PLATFORM_UTILS_TEST_PANDAS_UTC", df=df, auto_create_table=True, overwrite=True, @@ -54,7 +54,7 @@ def test_publish_with_use_utc(self): # Publish the same table which was published via publish_pandas in previous step (UTC=True is default) publish( - table_name="PANDAS_TEST_TABLE_UTC", + table_name="DS_PLATFORM_UTILS_TEST_PANDAS_UTC", query="SELECT * FROM PATTERN_DB.{{schema}}.{{table_name}};", ) self.next(self.test_query_pandas_with_use_utc) @@ -66,7 +66,7 @@ def test_query_pandas_with_use_utc(self): from ds_platform_utils.metaflow import query_pandas_from_snowflake - query = "SELECT * FROM PATTERN_DB.{{schema}}.PANDAS_TEST_TABLE_UTC;" + query = "SELECT * FROM PATTERN_DB.{{schema}}.DS_PLATFORM_UTILS_TEST_PANDAS_UTC;" df = query_pandas_from_snowflake(query) print(df) @@ -107,7 +107,11 @@ def test_publish_pandas_with_use_utc_false(self): # Use use_utc=False this time publish_pandas( - table_name="PANDAS_TEST_TABLE_NOUTC", df=df, auto_create_table=True, overwrite=True, use_utc=False + table_name="DS_PLATFORM_UTILS_TEST_PANDAS_NOUTC", + df=df, + auto_create_table=True, + overwrite=True, + use_utc=False, ) self.next(self.test_publish_with_use_utc_false) @@ -118,7 +122,7 @@ def test_publish_with_use_utc_false(self): # Publish the same table which was published via publish_pandas in previous step publish( - table_name="PANDAS_TEST_TABLE_NOUTC", + table_name="DS_PLATFORM_UTILS_TEST_PANDAS_NOUTC", query="SELECT * FROM PATTERN_DB.{{schema}}.{{table_name}};", use_utc=False, ) @@ -131,7 +135,7 @@ def test_query_pandas_with_use_utc_false(self): from ds_platform_utils.metaflow import query_pandas_from_snowflake - query = "SELECT * FROM PATTERN_DB.{{schema}}.PANDAS_TEST_TABLE_NOUTC;" + query = "SELECT * FROM PATTERN_DB.{{schema}}.DS_PLATFORM_UTILS_TEST_PANDAS_NOUTC;" df = query_pandas_from_snowflake(query, use_utc=False) print(df) diff --git a/tests/functional_tests/metaflow/test__publish.py b/tests/functional_tests/metaflow/test__publish.py index a3a8201..154e0df 100644 --- a/tests/functional_tests/metaflow/test__publish.py +++ b/tests/functional_tests/metaflow/test__publish.py @@ -55,7 +55,7 @@ def start(self): """ publish( - table_name="sample_table", + table_name="DS_PLATFORM_UTILS_TEST_PUBLISH", query=query, audits=[AUDIT], ) @@ -71,7 +71,7 @@ def start(self): # insert queries without creating/replacing the table publish( - table_name="sample_table", + table_name="DS_PLATFORM_UTILS_TEST_PUBLISH", query=query, audits=[ # assert that there are 8 rows. The initial 5, plus the recent 3. @@ -81,7 +81,7 @@ def start(self): # no audit publish( - table_name="sample_table", + table_name="DS_PLATFORM_UTILS_TEST_PUBLISH", query=query, audits=[], ) @@ -89,7 +89,7 @@ def start(self): try: # intentionally fail an audit publish( - table_name="sample_table", + table_name="DS_PLATFORM_UTILS_TEST_PUBLISH", query=query, audits=["SELECT COUNT(*) = 0 FROM PATTERN_DB.{{schema}}.{{table_name}}"], ) From 0b191a6563ceb7050967fdb82eabc4c8f33b30d5 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:59:30 +0530 Subject: [PATCH 150/167] fix: specify schema in CREATE TABLE statement for warehouse publish test --- tests/functional_tests/metaflow/test__warehouse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional_tests/metaflow/test__warehouse.py b/tests/functional_tests/metaflow/test__warehouse.py index 5706de4..603ce77 100644 --- a/tests/functional_tests/metaflow/test__warehouse.py +++ b/tests/functional_tests/metaflow/test__warehouse.py @@ -74,7 +74,7 @@ def test_publish_with_warehouse(self): """Test the publish function with warehouse parameter.""" # Publish a simple dataframe to Snowflake with a specific warehouse query = """ - CREATE OR REPLACE TABLE {{table_name}} AS ( SELECT CURRENT_WAREHOUSE() AS WAREHOUSE ); + CREATE OR REPLACE TABLE PATTERN_DB.{{schema}}.{{table_name}} AS ( SELECT CURRENT_WAREHOUSE() AS WAREHOUSE ); """ for item in self.warehouse_map: from metaflow import current From 709be05e5679c8a4056d3a3b44e3308d755e6fa8 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:19:12 +0530 Subject: [PATCH 151/167] fix: update default values for auto_create_table and overwrite in BatchInferencePipeline --- docs/metaflow/batch_inference_pipeline.md | 128 ++++++++++++++---- docs/metaflow/make_pydantic_parser_fn.md | 8 ++ docs/metaflow/publish.md | 13 ++ docs/metaflow/publish_pandas.md | 21 +++ docs/metaflow/query_pandas_from_snowflake.md | 12 ++ docs/metaflow/restore_step_state.md | 13 ++ .../metaflow/batch_inference_pipeline.py | 4 +- 7 files changed, 169 insertions(+), 30 deletions(-) diff --git a/docs/metaflow/batch_inference_pipeline.md b/docs/metaflow/batch_inference_pipeline.md index a09d461..31d6c19 100644 --- a/docs/metaflow/batch_inference_pipeline.md +++ b/docs/metaflow/batch_inference_pipeline.md @@ -9,6 +9,8 @@ Utility class to orchestrate batch inference with Snowflake + S3 in Metaflow ste - `query_and_batch(...)`: export source data to S3 and create worker batches. - `process_batch(...)`: run download → inference → upload for one worker. - `publish_results(...)`: copy prediction outputs from S3 to Snowflake. + + Or - `run(...)`: convenience method to execute full flow sequentially. ## Detailed example (Metaflow foreach) @@ -36,8 +38,14 @@ def predict_fn(df: pd.DataFrame) -> pd.DataFrame: class BatchPredictFlow(FlowSpec): + + start @step def start(self): + self.next(self.query_and_batch) + + @step + def query_and_batch(self): self.pipeline = BatchInferencePipeline() # Query can be inline SQL or a file path. @@ -56,21 +64,21 @@ class BatchPredictFlow(FlowSpec): use_utc=True, ) - self.next(self.predict, foreach="worker_ids") + self.next(self.process_batch, foreach="worker_ids") @step - def predict(self): + def process_batch(self): # In a foreach step, self.input contains one worker_id. self.pipeline.process_batch( worker_id=self.input, predict_fn=predict_fn, batch_size_in_mb=256, - timeout_per_batch=600, + timeout_per_batch=300, ) - self.next(self.join) + self.next(self.publish_results) @step - def join(self, inputs): + def publish_results(self, inputs): # Reuse one pipeline object from foreach branches. self.pipeline = inputs[0].pipeline @@ -83,7 +91,7 @@ class BatchPredictFlow(FlowSpec): ], auto_create_table=True, overwrite=True, - warehouse="ANALYTICS_WH", + warehouse="MED", use_utc=True, ) self.next(self.end) @@ -102,30 +110,94 @@ from ds_platform_utils.metaflow import BatchInferencePipeline import pandas as pd -def predict_fn(df: pd.DataFrame) -> pd.DataFrame: - return pd.DataFrame( - { - "id": df["id"], - "score": (df["feature_1"] * 0.9).fillna(0), - } +@step +def batch_inference_step(self): + def predict_fn(df: pd.DataFrame) -> pd.DataFrame: + return pd.DataFrame( + { + "id": df["id"], + "score": (df["feature_1"] * 0.9).fillna(0), + } + ) + + pipeline = BatchInferencePipeline() + pipeline.run( + input_query=""" + SELECT id, feature_1 + FROM {schema}.model_features + WHERE ds = '2026-02-26' + """, + output_table_name="MODEL_PREDICTIONS_DAILY", + predict_fn=predict_fn, + output_table_definition=[("id", "NUMBER"), ("score", "FLOAT")], + warehouse="XL", ) -pipeline = BatchInferencePipeline() -pipeline.run( - input_query=""" - SELECT id, feature_1 - FROM {schema}.model_features - WHERE ds = '2026-02-26' - """, - output_table_name="MODEL_PREDICTIONS_DAILY", - predict_fn=predict_fn, - output_table_definition=[("id", "NUMBER"), ("score", "FLOAT")], - warehouse="ANALYTICS_WH", -) + self.next(self.end) ``` -## Notes + +## Parameters + +### `query_and_batch(...)` + +| Parameter | Type | Required | Description | +| ------------------ | ------------- | -------: | ----------------------------------------------------------------------------------------------------------------------- | +| `input_query` | `str \| Path` | Yes | SQL query string or SQL file path used to fetch source rows. `{schema}` placeholder is resolved by `ds_platform_utils`. | +| `ctx` | `dict` | No | Optional substitution map for templated SQL; merged with the internal `{"schema": ...}` mapping before query execution. | +| `warehouse` | `str` | No | Snowflake warehouse used to execute the source query/export. | +| `use_utc` | `bool` | No | If `True`, uses UTC timestamps/paths for partitioning and run metadata. | +| `parallel_workers` | `int` | No | Number of worker partitions to create for downstream processing. | + +**Returns:** `list[int]` of `worker_id` values for Metaflow `foreach`. + +--- + +### `process_batch(...)` + +| Parameter | Type | Required | Description | +| ------------------- | ---------------------------------------- | -------: | -------------------------------------------------------------------------------------------------------- | +| `worker_id` | `int` | Yes | Worker partition identifier generated by `query_and_batch()`. | +| `predict_fn` | `Callable[[pd.DataFrame], pd.DataFrame]` | Yes | Inference function applied to each input chunk. Must return a DataFrame matching expected output schema. | +| `batch_size_in_mb` | `int` | No | Target chunk size for reading/processing batch files. | +| `timeout_per_batch` | `int` | No | Processing time for each batch in seconds. (Used for Queuing operations) | + +**Returns:** `None` + +--- + +### `publish_results(...)` + +| Parameter | Type | Required | Description | +| ------------------------- | ------------------------------- | -------: | ----------------------------------------------------------------- | +| `output_table_name` | `str` | Yes | Destination Snowflake table for predictions. | +| `output_table_definition` | `list[tuple[str, str]] \| None` | No | Optional output schema as `(column_name, snowflake_type)` tuples. | +| `auto_create_table` | `bool` | No | If `True`, creates destination table when missing. | +| `overwrite` | `bool` | No | If `True`, replaces existing table data before loading results. | +| `warehouse` | `str` | No | Snowflake warehouse used for load/publish operations. | +| `use_utc` | `bool` | No | If `True`, uses UTC for load metadata/time handling. | + +**Returns:** `None` + +--- + +### `run(...)` (convenience method) + +Runs `query_and_batch()` → `process_batch()` → `publish_results()` in a single sequential call. + +| Parameter | Type | Required | Description | +| ------------------------- | ---------------------------------------- | -------: | ------------------------------------------------------------ | +| `input_query` | `str \| Path` | Yes | Source query (or SQL file path) used to retrieve input rows. | +| `output_table_name` | `str` | Yes | Destination Snowflake table name. | +| `predict_fn` | `Callable[[pd.DataFrame], pd.DataFrame]` | Yes | Inference function. | +| `ctx` | `dict` | No | Optional substitution map for templated SQL. | +| `output_table_definition` | `list[tuple[str, str]] \| None` | No | Optional output table schema definition. | +| `batch_size_in_mb` | `int` | No | Processing chunk size. | +| `timeout_per_batch` | `int` | No | Timeout per processing chunk (seconds). | +| `auto_create_table` | `bool` | No | Auto-create destination table if needed. | +| `overwrite` | `bool` | No | Replace destination table data before load. | +| `warehouse` | `str` | No | Snowflake warehouse used in query/load operations. | +| `use_utc` | `bool` | No | Use UTC-based timestamps/paths. | -- `input_query` accepts either raw SQL text or a file path. -- SQL templates can use placeholders (for example `{schema}`) and be resolved before execution. -- Call order matters: `query_and_batch()` must run before `process_batch()`, and at least one `process_batch()` must run before `publish_results()`. +**Returns:** `None` + \ No newline at end of file diff --git a/docs/metaflow/make_pydantic_parser_fn.md b/docs/metaflow/make_pydantic_parser_fn.md index 7b105f5..2a8c2ea 100644 --- a/docs/metaflow/make_pydantic_parser_fn.md +++ b/docs/metaflow/make_pydantic_parser_fn.md @@ -18,6 +18,14 @@ make_pydantic_parser_fn( - Validates and normalizes with Pydantic. - Returns a dict with applied defaults from the model. +## Parameters + +| Parameter | Type | Required | Description | +| ---------------- | ----------------- | -------: | ------------------------------------------------------------------- | +| `pydantic_model` | `type[BaseModel]` | Yes | Pydantic model class used to validate and normalize config content. | + +**Returns:** `Callable[[str], dict]` parser function for Metaflow `Config(..., parser=...)`. + ## Typical usage ```python diff --git a/docs/metaflow/publish.md b/docs/metaflow/publish.md index ebc4217..eb963e1 100644 --- a/docs/metaflow/publish.md +++ b/docs/metaflow/publish.md @@ -23,6 +23,19 @@ publish( - Runs write/audit/publish operations through Snowflake. - Adds operation details and table links to the Metaflow card when available. +## Parameters + +| Parameter | Type | Required | Description | +| ------------ | ------------------------------------ | -------: | -------------------------------------------------------------------------- | +| `table_name` | `str` | Yes | Destination Snowflake table name for the publish operation. | +| `query` | `str \| Path` | Yes | SQL query text or path to SQL file that produces the table data. | +| `audits` | `list[str \| Path] \| None` | No | Optional SQL audits (strings or file paths) executed as validation checks. | +| `ctx` | `dict[str, Any] \| None` | No | Optional template substitution context for SQL operations. | +| `warehouse` | `Literal["XS", "MED", "XL"] \| None` | No | Snowflake warehouse override for this operation. | +| `use_utc` | `bool` | No | If `True`, uses UTC timezone for Snowflake session. | + +**Returns:** `None` + ## Typical usage ```python diff --git a/docs/metaflow/publish_pandas.md b/docs/metaflow/publish_pandas.md index 18e8902..fc37efd 100644 --- a/docs/metaflow/publish_pandas.md +++ b/docs/metaflow/publish_pandas.md @@ -30,3 +30,24 @@ publish_pandas( - Validates DataFrame input. - Writes directly via `write_pandas` or via S3 stage flow for large data. - Adds a Snowflake table URL to Metaflow card output. + +## Parameters + +| Parameter | Type | Required | Description | +| ------------------- | ------------------------------------ | -------: | ------------------------------------------------------------------------------------- | +| `table_name` | `str` | Yes | Destination Snowflake table name. | +| `df` | `pd.DataFrame` | Yes | DataFrame to publish. | +| `add_created_date` | `bool` | No | If `True`, adds a `created_date` UTC timestamp column before publish. | +| `chunk_size` | `int \| None` | No | Number of rows per uploaded chunk. | +| `compression` | `Literal["snappy", "gzip"]` | No | Compression codec used for staged parquet files. | +| `warehouse` | `Literal["XS", "MED", "XL"] \| None` | No | Snowflake warehouse override for this operation. | +| `parallel` | `int` | No | Number of upload threads used by `write_pandas` path. | +| `quote_identifiers` | `bool` | No | If `False`, passes identifiers unquoted so Snowflake applies uppercase coercion. | +| `auto_create_table` | `bool` | No | If `True`, creates destination table when missing. | +| `overwrite` | `bool` | No | If `True`, replaces existing table contents. | +| `use_logical_type` | `bool` | No | Controls parquet logical type handling when loading data. | +| `use_utc` | `bool` | No | If `True`, uses UTC timezone for Snowflake session. | +| `use_s3_stage` | `bool` | No | If `True`, publishes via S3 stage flow; otherwise uses direct `write_pandas`. | +| `table_definition` | `list[tuple[str, str]] \| None` | No | Optional Snowflake table schema; used by S3 stage flow when table creation is needed. | + +**Returns:** `None` diff --git a/docs/metaflow/query_pandas_from_snowflake.md b/docs/metaflow/query_pandas_from_snowflake.md index 35e8931..197407d 100644 --- a/docs/metaflow/query_pandas_from_snowflake.md +++ b/docs/metaflow/query_pandas_from_snowflake.md @@ -22,3 +22,15 @@ query_pandas_from_snowflake( - Substitutes template values, including `{schema}`. - Runs query directly or through Snowflake → S3 path. - Normalizes resulting columns to lowercase. + +## Parameters + +| Parameter | Type | Required | Description | +| -------------- | ------------------------------------ | -------: | --------------------------------------------------------------------------------------- | +| `query` | `str \| Path` | Yes | SQL query text or path to a `.sql` file. | +| `warehouse` | `Literal["XS", "MED", "XL"] \| None` | No | Snowflake warehouse override for this query. | +| `ctx` | `dict[str, Any] \| None` | No | Optional substitutions for SQL templating (merged with internal `{schema}` resolution). | +| `use_utc` | `bool` | No | If `True`, uses UTC timezone for Snowflake session. | +| `use_s3_stage` | `bool` | No | If `True`, executes Snowflake → S3 → pandas path for large-query handling. | + +**Returns:** `pd.DataFrame` query results with lowercase column names. diff --git a/docs/metaflow/restore_step_state.md b/docs/metaflow/restore_step_state.md index 9c17cd2..4409be0 100644 --- a/docs/metaflow/restore_step_state.md +++ b/docs/metaflow/restore_step_state.md @@ -23,3 +23,16 @@ restore_step_state( - Exposes restored values as `self.` style attributes. - Optionally exports requested Metaflow secrets into env vars. - Patches `metaflow.current` with a mock context for local execution. + +## Parameters + +| Parameter | Type | Required | Description | +| ------------- | --------------------------------------------------- | -------: | --------------------------------------------------------------------------------------- | +| `flow_class` | `type[T] \| None` | No | Optional flow class used for typed return/autocomplete and default flow name inference. | +| `flow_name` | `str \| None` | No | Optional explicit flow name used to fetch metadata; overrides class-derived name. | +| `step_name` | `str` | No | Target step context name for restoring/patching state (default: `"end"`). | +| `flow_run_id` | `Literal["latest_successful_run", "latest"] \| str` | No | Run selector: latest successful, latest, or an explicit run id. | +| `secrets` | `list[str] \| None` | No | Optional Metaflow secret sources to export as environment variables. | +| `namespace` | `str \| None` | No | Optional Metaflow namespace filter when locating flow runs. | + +**Returns:** `T` typed flow-like state object with restored run artifacts. diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index d6c30f8..e406a0a 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -285,8 +285,8 @@ def publish_results( # noqa: PLR0913 self, output_table_name: str, output_table_definition: Optional[List[Tuple[str, str]]] = None, - auto_create_table: bool = True, - overwrite: bool = True, + auto_create_table: bool = False, + overwrite: bool = False, warehouse: Optional[str] = None, use_utc: bool = True, ) -> None: From faff8dd2e20a314ca3f9ca26482949722b81a588 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:29:42 +0530 Subject: [PATCH 152/167] docs: add recommendation for tuning batch_size_in_mb for cost-effective tasks --- docs/metaflow/batch_inference_pipeline.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/metaflow/batch_inference_pipeline.md b/docs/metaflow/batch_inference_pipeline.md index 31d6c19..347182a 100644 --- a/docs/metaflow/batch_inference_pipeline.md +++ b/docs/metaflow/batch_inference_pipeline.md @@ -164,7 +164,7 @@ def batch_inference_step(self): **Returns:** `None` ---- +**Recommended**: Tune `batch_size_in_mb` for Outerbounds Small tasks (3 CPU, 15 GB memory), which are about 6x more cost-effective than Medium tasks. ### `publish_results(...)` @@ -200,4 +200,5 @@ Runs `query_and_batch()` → `process_batch()` → `publish_results()` in a sing | `use_utc` | `bool` | No | Use UTC-based timestamps/paths. | **Returns:** `None` - \ No newline at end of file + +**Recommended**: Tune `batch_size_in_mb` for Outerbounds Small tasks (3 CPU, 15 GB memory), which are about 6x more cost-effective than Medium tasks. \ No newline at end of file From d4754a950798461dac4791d60c8a565122774988 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:33:11 +0530 Subject: [PATCH 153/167] fix: update parameter descriptions in BatchInferencePipeline documentation for clarity and consistency --- docs/metaflow/batch_inference_pipeline.md | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/metaflow/batch_inference_pipeline.md b/docs/metaflow/batch_inference_pipeline.md index 347182a..6bb1742 100644 --- a/docs/metaflow/batch_inference_pipeline.md +++ b/docs/metaflow/batch_inference_pipeline.md @@ -185,19 +185,19 @@ def batch_inference_step(self): Runs `query_and_batch()` → `process_batch()` → `publish_results()` in a single sequential call. -| Parameter | Type | Required | Description | -| ------------------------- | ---------------------------------------- | -------: | ------------------------------------------------------------ | -| `input_query` | `str \| Path` | Yes | Source query (or SQL file path) used to retrieve input rows. | -| `output_table_name` | `str` | Yes | Destination Snowflake table name. | -| `predict_fn` | `Callable[[pd.DataFrame], pd.DataFrame]` | Yes | Inference function. | -| `ctx` | `dict` | No | Optional substitution map for templated SQL. | -| `output_table_definition` | `list[tuple[str, str]] \| None` | No | Optional output table schema definition. | -| `batch_size_in_mb` | `int` | No | Processing chunk size. | -| `timeout_per_batch` | `int` | No | Timeout per processing chunk (seconds). | -| `auto_create_table` | `bool` | No | Auto-create destination table if needed. | -| `overwrite` | `bool` | No | Replace destination table data before load. | -| `warehouse` | `str` | No | Snowflake warehouse used in query/load operations. | -| `use_utc` | `bool` | No | Use UTC-based timestamps/paths. | +| Parameter | Type | Required | Description | +| ------------------------- | ---------------------------------------- | -------: | ----------------------------------------------------------------------------------------------------------------------- | +| `input_query` | `str \| Path` | Yes | SQL query string or SQL file path used to fetch source rows. `{schema}` placeholder is resolved by `ds_platform_utils`. | +| `output_table_name` | `str` | Yes | Destination Snowflake table for predictions. | +| `predict_fn` | `Callable[[pd.DataFrame], pd.DataFrame]` | Yes | Inference function applied to each input chunk. Must return a DataFrame matching expected output schema. | +| `ctx` | `dict` | No | Optional substitution map for templated SQL; merged with the internal `{"schema": ...}` mapping before query execution. | +| `output_table_definition` | `list[tuple[str, str]] \| None` | No | Optional output schema as `(column_name, snowflake_type)` tuples. | +| `batch_size_in_mb` | `int` | No | Target chunk size for reading/processing batch files. | +| `timeout_per_batch` | `int` | No | Processing time for each batch in seconds. (Used for Queuing operations) | +| `auto_create_table` | `bool` | No | If `True`, creates destination table when missing. | +| `overwrite` | `bool` | No | If `True`, replaces existing table data before loading results. | +| `warehouse` | `str` | No | Snowflake warehouse used for load/publish operations. | +| `use_utc` | `bool` | No | If `True`, uses UTC for load metadata/time handling. | **Returns:** `None` From d41dd2e3e64c71649167e2bca98a27bef1cbdf83 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:49:17 +0530 Subject: [PATCH 154/167] feat: add estimate_chunk_size utility and improve chunk_size validation in S3 upload --- src/ds_platform_utils/metaflow/pandas.py | 8 +++++++- src/ds_platform_utils/metaflow/s3.py | 17 +---------------- src/ds_platform_utils/pandas_utils.py | 16 ++++++++++++++++ 3 files changed, 24 insertions(+), 17 deletions(-) create mode 100644 src/ds_platform_utils/pandas_utils.py diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index eaa0f8e..f18f21b 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -26,6 +26,7 @@ from ds_platform_utils.metaflow.write_audit_publish import ( _make_snowflake_table_url, ) +from ds_platform_utils.pandas_utils import estimate_chunk_size from ds_platform_utils.sql_utils import get_query_from_string_or_fpath, substitute_map_into_string @@ -99,6 +100,12 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) if add_created_date: df["created_date"] = datetime.now().astimezone(pytz.utc) + if chunk_size is not None and chunk_size <= 0: + raise ValueError("chunk_size must be a positive integer.") + + if chunk_size is None: + chunk_size = estimate_chunk_size(df) + table_name = table_name.upper() schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA @@ -116,7 +123,6 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) data_folder = "publish_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{data_folder}" - # Write DataFrame to S3 as Parquet # Upload DataFrame to S3 as parquet files _put_df_to_s3_folder( df=df, diff --git a/src/ds_platform_utils/metaflow/s3.py b/src/ds_platform_utils/metaflow/s3.py index 2505c0b..9e191fd 100644 --- a/src/ds_platform_utils/metaflow/s3.py +++ b/src/ds_platform_utils/metaflow/s3.py @@ -72,27 +72,12 @@ def _put_df_to_s3_file(df: pd.DataFrame, path: str) -> None: s3.put_files(key_paths=[[path, tmp_file.name]]) -def _put_df_to_s3_folder(df: pd.DataFrame, path: str, chunk_size=None, compression="snappy") -> None: +def _put_df_to_s3_folder(df: pd.DataFrame, path: str, chunk_size: int, compression="snappy") -> None: if not path.startswith("s3://"): raise ValueError("Invalid S3 URI. Must start with 's3://'.") path = path.rstrip("/") # Remove trailing slash if present - target_chunk_size_mb = 50 - target_chunk_size_bytes = target_chunk_size_mb * 1024 * 1024 - - if len(df) == 0: - raise ValueError("DataFrame is empty. Cannot write empty DataFrame to S3.") - - def estimate_bytes_per_row(df_sample): - return df_sample.memory_usage(deep=True).sum() / len(df_sample) - - if chunk_size is None: - sample = df.head(10000) - bytes_per_row = estimate_bytes_per_row(sample) - chunk_size = int(target_chunk_size_bytes / bytes_per_row) - chunk_size = max(1, chunk_size) - with tempfile.TemporaryDirectory(prefix=str(Path(current.tempdir).absolute()) + "/") as temp_dir: # type: ignore with _get_metaflow_s3_client() as s3: template_path = f"{temp_dir}/data_part_{{}}.parquet" diff --git a/src/ds_platform_utils/pandas_utils.py b/src/ds_platform_utils/pandas_utils.py new file mode 100644 index 0000000..537a290 --- /dev/null +++ b/src/ds_platform_utils/pandas_utils.py @@ -0,0 +1,16 @@ +import pandas as pd + + +def estimate_chunk_size( + df: pd.DataFrame, + target_chunk_size_in_mb: int = 20, + sample_rows: int = 10000, +) -> int: + if len(df) == 0: + raise ValueError("DataFrame is empty. Cannot estimate chunk size.") + + sample = df.head(sample_rows) + bytes_per_row = sample.memory_usage(deep=True).sum() / len(sample) + target_chunk_size_bytes = target_chunk_size_in_mb * 1024 * 1024 + chunk_size = int(target_chunk_size_bytes / bytes_per_row) + return max(1, chunk_size) From cb701566b0987a6935af2c926150bdf0e6feeb00 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:19:17 +0530 Subject: [PATCH 155/167] refactor: rename copy functions to internal and add S3 stage path generation --- .../metaflow/batch_inference_pipeline.py | 8 +-- src/ds_platform_utils/metaflow/pandas.py | 8 +-- src/ds_platform_utils/metaflow/s3_stage.py | 71 ++++++++++--------- 3 files changed, 46 insertions(+), 41 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index e406a0a..c153aa1 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -18,9 +18,9 @@ S3_DATA_FOLDER, ) from ds_platform_utils.metaflow.s3_stage import ( + _copy_s3_to_snowflake, + _copy_snowflake_to_s3, _get_s3_config, - copy_s3_to_snowflake, - copy_snowflake_to_s3, ) from ds_platform_utils.sql_utils import get_query_from_string_or_fpath, substitute_map_into_string @@ -171,7 +171,7 @@ def query_and_batch( _debug(f"⏳ Exporting data from Snowflake to S3 to {self._input_path}...") # Export from Snowflake to S3 - input_files = copy_snowflake_to_s3( + input_files = _copy_snowflake_to_s3( query=input_query, warehouse=warehouse, use_utc=use_utc, @@ -319,7 +319,7 @@ def publish_results( # noqa: PLR0913 print(f"📤 Writing predictions to Snowflake table: {output_table_name}") - copy_s3_to_snowflake( + _copy_s3_to_snowflake( s3_path=self._output_path, table_name=output_table_name, table_definition=output_table_definition, diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index f18f21b..afe4049 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -18,9 +18,9 @@ ) from ds_platform_utils.metaflow.s3 import _get_df_from_s3_files, _put_df_to_s3_folder from ds_platform_utils.metaflow.s3_stage import ( + _copy_s3_to_snowflake, + _copy_snowflake_to_s3, _get_s3_config, - copy_s3_to_snowflake, - copy_snowflake_to_s3, ) from ds_platform_utils.metaflow.snowflake_connection import get_snowflake_connection from ds_platform_utils.metaflow.write_audit_publish import ( @@ -131,7 +131,7 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) compression=compression, ) - copy_s3_to_snowflake( + _copy_s3_to_snowflake( s3_path=s3_path, table_name=table_name, table_definition=table_definition, @@ -209,7 +209,7 @@ def query_pandas_from_snowflake( current.card.append(Markdown(f"```sql\n{query}\n```")) if use_s3_stage: - s3_files = copy_snowflake_to_s3( + s3_files = _copy_snowflake_to_s3( query=query, warehouse=warehouse, use_utc=use_utc, diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 60b9761..532e37a 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -1,6 +1,7 @@ from typing import List, Optional, Tuple import pandas as pd +import sqlparse from metaflow import current from ds_platform_utils._snowflake.run_query import _execute_sql @@ -29,6 +30,28 @@ def _get_s3_config(is_production: bool) -> Tuple[str, str]: return s3_bucket, snowflake_stage +def _generate_s3_stage_path(): + """Generate a unique S3 stage path based on the current flow and run context.""" + s3_bucket, snowflake_stage = _get_s3_config(current.is_production) + flow_name = current.flow_name if hasattr(current, "flow_name") else "unknown" + run_id = current.run_id if hasattr(current, "run_id") else "unknown" + timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") + + s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{flow_name}/{run_id}/{timestamp}" + snowflake_stage_path = s3_path.replace(s3_bucket, snowflake_stage) + + return s3_path, snowflake_stage_path + + +def _get_snowflake_stage_path(s3_path: str) -> str: + """Convert an S3 path to the corresponding Snowflake stage path.""" + s3_bucket, snowflake_stage = _get_s3_config(current.is_production) + if not s3_path.startswith(s3_bucket + "/"): + raise ValueError(f"s3_path must start with {s3_bucket}") + snowflake_stage_path = s3_path.replace(s3_bucket, snowflake_stage) + return snowflake_stage_path + + def _generate_snowflake_to_s3_copy_query( query: str, snowflake_stage_path: str, @@ -39,18 +62,12 @@ def _generate_snowflake_to_s3_copy_query( :param snowflake_stage_path: The path to the Snowflake stage where the data will be exported. This should include the stage name and any necessary subfolders (e.g., 'my_snowflake_stage/my_folder'). :return: COPY INTO SQL command """ - if snowflake_stage_path.endswith(".parquet"): - single = "TRUE" - max_file_size = 100 * 1024 * 1024 * 1024 # 100 GB - else: - single = "FALSE" - max_file_size = 16 * 1024 * 1024 # 16 MB - snowflake_stage_path = snowflake_stage_path.strip("/") + "/" + max_file_size = 16 * 1024 * 1024 # 16 MB - if query.count(";") > 1: - raise ValueError("Multiple SQL statements detected. Please provide a single query statement.") - query = query.replace(";", "") # Remove trailing semicolon if present + if sqlparse.split(query) != 1: + raise ValueError("Only single SQL statements are allowed in the query.") + query = sqlparse.format(query, strip_comments=True).strip().rstrip(";") copy_query = f""" COPY INTO @{snowflake_stage_path} FROM ( @@ -59,7 +76,6 @@ def _generate_snowflake_to_s3_copy_query( OVERWRITE = TRUE FILE_FORMAT = (TYPE = 'parquet') MAX_FILE_SIZE = {max_file_size} - SINGLE = {single} HEADER = TRUE DETAILED_OUTPUT = TRUE; """ @@ -90,7 +106,7 @@ def _generate_s3_to_snowflake_copy_query( # noqa: PLR0913 :return: Complete SQL script with table management and COPY INTO commands """ sql_statements = [] - + snowflake_stage_path = snowflake_stage_path.strip("/") + "/" if auto_create_table and not overwrite: table_create_columns_str = ",\n ".join([f"{col_name} {col_type}" for col_name, col_type in table_definition]) create_table_query = f"""CREATE TABLE IF NOT EXISTS {table_name} ( {table_create_columns_str} );""" @@ -122,6 +138,7 @@ def _infer_table_schema(conn, snowflake_stage_path: str, use_logical_type: bool) :param snowflake_stage_path: The path to the Snowflake stage where the Parquet files are located. This should include the stage name and any necessary subfolders (e.g., 'my_snowflake_stage/my_folder'). :return: List of tuples with column names and inferred Snowflake data types """ + snowflake_stage_path = snowflake_stage_path.strip("/") + "/" _execute_sql( conn, f"CREATE OR REPLACE TEMP FILE FORMAT PQT_FILE_FORMAT TYPE = PARQUET USE_LOGICAL_TYPE = {use_logical_type};", @@ -141,7 +158,7 @@ def _infer_table_schema(conn, snowflake_stage_path: str, use_logical_type: bool) return list(zip(result["COLUMN_NAME"], result["TYPE"])) -def copy_snowflake_to_s3( +def _copy_snowflake_to_s3( query: str, warehouse: Optional[str] = None, use_utc: bool = True, @@ -156,18 +173,11 @@ def copy_snowflake_to_s3( :return: List of S3 file paths where the data was exported """ schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA - s3_bucket, snowflake_stage = _get_s3_config(current.is_production) - if s3_path is not None and not s3_path.startswith(s3_bucket): - raise ValueError(f"s3_path must start with {s3_bucket}") if s3_path is None: - # Build paths: s3://bucket/data/{flow}/{run_id}/{pipeline_id}/ - flow_name = current.flow_name if hasattr(current, "flow_name") else "local" - run_id = current.run_id if hasattr(current, "run_id") else "dev" - timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") - s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{flow_name}/{run_id}/{timestamp}" - - sf_stage_path = s3_path.replace(s3_bucket, snowflake_stage) + s3_path, sf_stage_path = _generate_s3_stage_path() + else: + sf_stage_path = _get_snowflake_stage_path(s3_path) query = _generate_snowflake_to_s3_copy_query( query=query, @@ -183,7 +193,7 @@ def copy_snowflake_to_s3( return file_paths -def copy_s3_to_snowflake( # noqa: PLR0913 +def _copy_s3_to_snowflake( # noqa: PLR0913 s3_path: str, table_name: str, table_definition: Optional[List[Tuple[str, str]]] = None, @@ -210,20 +220,15 @@ def copy_s3_to_snowflake( # noqa: PLR0913 """ table_name = table_name.upper() schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA - if current.is_production: - if not s3_path.startswith(PROD_S3_BUCKET): - raise ValueError(f"In production environment, s3_path must start with s3://{PROD_S3_BUCKET}") - elif not s3_path.startswith(DEV_S3_BUCKET): - raise ValueError(f"In development environment, s3_path must start with s3://{DEV_S3_BUCKET}") + snowflake_stage_path = _get_snowflake_stage_path(s3_path) - s3_bucket, snowflake_stage = _get_s3_config(current.is_production) - sf_stage_path = s3_path.replace(s3_bucket, snowflake_stage) conn = get_snowflake_connection(warehouse=warehouse, use_utc=use_utc) _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") if table_definition is None: # Infer table schema from the Parquet files in the Snowflake stage - table_definition = _infer_table_schema(conn, sf_stage_path, use_logical_type) + table_definition = _infer_table_schema(conn, snowflake_stage_path, use_logical_type) + if table_definition is None or len(table_definition) == 0: raise ValueError( "Failed to determine table schema. Please provide a valid table_definition or ensure that the S3 path contains valid Parquet files." @@ -231,7 +236,7 @@ def copy_s3_to_snowflake( # noqa: PLR0913 copy_query = _generate_s3_to_snowflake_copy_query( table_name=table_name, - snowflake_stage_path=sf_stage_path, + snowflake_stage_path=snowflake_stage_path, table_definition=table_definition, overwrite=overwrite, auto_create_table=auto_create_table, From fa30112469473f549bbba6c194ba3d74079c8ad2 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:21:16 +0530 Subject: [PATCH 156/167] fix: remove unnecessary whitespace in BatchInferencePipeline documentation --- docs/metaflow/batch_inference_pipeline.md | 4 ++-- tests/functional_tests/metaflow/test__pandas.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/metaflow/batch_inference_pipeline.md b/docs/metaflow/batch_inference_pipeline.md index 6bb1742..9051d59 100644 --- a/docs/metaflow/batch_inference_pipeline.md +++ b/docs/metaflow/batch_inference_pipeline.md @@ -9,7 +9,7 @@ Utility class to orchestrate batch inference with Snowflake + S3 in Metaflow ste - `query_and_batch(...)`: export source data to S3 and create worker batches. - `process_batch(...)`: run download → inference → upload for one worker. - `publish_results(...)`: copy prediction outputs from S3 to Snowflake. - + Or - `run(...)`: convenience method to execute full flow sequentially. @@ -110,7 +110,7 @@ from ds_platform_utils.metaflow import BatchInferencePipeline import pandas as pd -@step +@step def batch_inference_step(self): def predict_fn(df: pd.DataFrame) -> pd.DataFrame: return pd.DataFrame( diff --git a/tests/functional_tests/metaflow/test__pandas.py b/tests/functional_tests/metaflow/test__pandas.py index e99f484..5761e24 100644 --- a/tests/functional_tests/metaflow/test__pandas.py +++ b/tests/functional_tests/metaflow/test__pandas.py @@ -73,7 +73,7 @@ def test_query_pandas(self): from ds_platform_utils.metaflow import query_pandas_from_snowflake # Query to retrieve the data we just published - query = "SELECT * FROM PATTERN_DB.{{schema}}.PANDAS_TEST_TABLE;" + query = "SELECT * FROM PATTERN_DB.{{schema}}.DS_PLATFORM_UTILS_TEST_PANDAS;" # Query the data back result_df = query_pandas_from_snowflake(query) From d3fe5ecf07047feb4bf6e72b2fd8a0e25439ef1d Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:34:23 +0530 Subject: [PATCH 157/167] refactor: streamline S3 stage path generation and update related functions --- src/ds_platform_utils/metaflow/pandas.py | 24 ++++++++++------------ src/ds_platform_utils/metaflow/s3_stage.py | 12 +++++------ 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/ds_platform_utils/metaflow/pandas.py b/src/ds_platform_utils/metaflow/pandas.py index afe4049..99836ba 100644 --- a/src/ds_platform_utils/metaflow/pandas.py +++ b/src/ds_platform_utils/metaflow/pandas.py @@ -14,13 +14,12 @@ from ds_platform_utils.metaflow._consts import ( DEV_SCHEMA, PROD_SCHEMA, - S3_DATA_FOLDER, ) -from ds_platform_utils.metaflow.s3 import _get_df_from_s3_files, _put_df_to_s3_folder +from ds_platform_utils.metaflow.s3 import _get_df_from_s3_folder, _put_df_to_s3_folder from ds_platform_utils.metaflow.s3_stage import ( _copy_s3_to_snowflake, _copy_snowflake_to_s3, - _get_s3_config, + _generate_s3_stage_paths, ) from ds_platform_utils.metaflow.snowflake_connection import get_snowflake_connection from ds_platform_utils.metaflow.write_audit_publish import ( @@ -115,13 +114,8 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) current.card.append(Markdown(f"## Publishing DataFrame to Snowflake table: `{table_name}`")) current.card.append(Table.from_dataframe(df.head())) - conn: SnowflakeConnection = get_snowflake_connection(warehouse=warehouse, use_utc=use_utc) - _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") - if use_s3_stage: - s3_bucket, _ = _get_s3_config(current.is_production) - data_folder = "publish_" + str(pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f")) - s3_path = f"{s3_bucket}/{S3_DATA_FOLDER}/{data_folder}" + s3_path, _ = _generate_s3_stage_paths() # Upload DataFrame to S3 as parquet files _put_df_to_s3_folder( @@ -143,7 +137,10 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) ) else: - # https://docs.snowflake.com/en/developer-guide/snowpark/reference/python/latest/snowpark/api/snowflake.snowpark.Session.write_pandas + conn: SnowflakeConnection = get_snowflake_connection(warehouse=warehouse, use_utc=use_utc) + _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") + + # https://docs.snowflake.com/en/developer-guide/python-connector/python-connector-api#module-snowflake-connector-pandas-tools write_pandas( conn=conn, df=df, @@ -157,7 +154,8 @@ def publish_pandas( # noqa: PLR0913 (too many arguments) overwrite=overwrite, use_logical_type=use_logical_type, ) - conn.close() + conn.close() + # Add a link to the table in Snowflake to the card table_url = _make_snowflake_table_url( database="PATTERN_DB", @@ -209,12 +207,12 @@ def query_pandas_from_snowflake( current.card.append(Markdown(f"```sql\n{query}\n```")) if use_s3_stage: - s3_files = _copy_snowflake_to_s3( + s3_path = _copy_snowflake_to_s3( query=query, warehouse=warehouse, use_utc=use_utc, ) - df = _get_df_from_s3_files(s3_files) + df = _get_df_from_s3_folder(s3_path) else: conn: SnowflakeConnection = get_snowflake_connection(warehouse=warehouse, use_utc=use_utc) _execute_sql(conn, f"USE SCHEMA PATTERN_DB.{schema};") diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 532e37a..44ac723 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -5,7 +5,6 @@ from metaflow import current from ds_platform_utils._snowflake.run_query import _execute_sql -from ds_platform_utils.metaflow import s3 from ds_platform_utils.metaflow._consts import ( DEV_S3_BUCKET, DEV_SCHEMA, @@ -30,7 +29,7 @@ def _get_s3_config(is_production: bool) -> Tuple[str, str]: return s3_bucket, snowflake_stage -def _generate_s3_stage_path(): +def _generate_s3_stage_paths(): """Generate a unique S3 stage path based on the current flow and run context.""" s3_bucket, snowflake_stage = _get_s3_config(current.is_production) flow_name = current.flow_name if hasattr(current, "flow_name") else "unknown" @@ -163,19 +162,19 @@ def _copy_snowflake_to_s3( warehouse: Optional[str] = None, use_utc: bool = True, s3_path: Optional[str] = None, -) -> List[str]: +) -> str: """Generate SQL COPY INTO command to export Snowflake query results to S3. :param query: SQL query to execute :param warehouse: Snowflake warehouse to use :param use_utc: Whether to use UTC time - :return: List of S3 file paths where the data was exported + :return: S3 path where the data was exported """ schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA if s3_path is None: - s3_path, sf_stage_path = _generate_s3_stage_path() + s3_path, sf_stage_path = _generate_s3_stage_paths() else: sf_stage_path = _get_snowflake_stage_path(s3_path) @@ -189,8 +188,7 @@ def _copy_snowflake_to_s3( print(f"✅ Data exported to S3 path: {s3_path}") - file_paths = s3._list_files_in_s3_folder(s3_path) - return file_paths + return s3_path def _copy_s3_to_snowflake( # noqa: PLR0913 From 06434a4f15009fd1c92d95aa420c0eb1ee754b51 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:39:33 +0530 Subject: [PATCH 158/167] fix: update return type description in _copy_snowflake_to_s3 function --- src/ds_platform_utils/metaflow/s3_stage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index 44ac723..afccd29 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -169,7 +169,7 @@ def _copy_snowflake_to_s3( :param warehouse: Snowflake warehouse to use :param use_utc: Whether to use UTC time - :return: S3 path where the data was exported + :return: List of S3 file paths where the data was exported """ schema = PROD_SCHEMA if current.is_production else DEV_SCHEMA From 958cd6c5b300181ca7e492ea1690e6b5f8c93df7 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:43:45 +0530 Subject: [PATCH 159/167] Refactor code structure for improved readability and maintainability --- .../functional_tests/metaflow/test__batch_inference_pipeline.py | 2 +- tests/functional_tests/metaflow/test__pandas.py | 2 +- tests/functional_tests/metaflow/test__pandas_s3.py | 2 +- tests/functional_tests/metaflow/test__pandas_utc.py | 2 +- tests/functional_tests/metaflow/test__publish.py | 2 +- tests/functional_tests/metaflow/test__warehouse.py | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py index 6092186..ef08f3f 100644 --- a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py +++ b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py @@ -11,7 +11,7 @@ from ds_platform_utils.metaflow.pandas import query_pandas_from_snowflake -@project(name="test_batch_inference_pipeline") +@project(name="ds_platform_utils_tests") class TestBatchInferencePipeline(FlowSpec): """A sample flow.""" diff --git a/tests/functional_tests/metaflow/test__pandas.py b/tests/functional_tests/metaflow/test__pandas.py index 5761e24..ec2142c 100644 --- a/tests/functional_tests/metaflow/test__pandas.py +++ b/tests/functional_tests/metaflow/test__pandas.py @@ -7,7 +7,7 @@ from metaflow import FlowSpec, project, step -@project(name="test_pandas_read_write_flow") +@project(name="ds_platform_utils_tests") class TestPandasReadWriteFlow(FlowSpec): """A sample flow.""" diff --git a/tests/functional_tests/metaflow/test__pandas_s3.py b/tests/functional_tests/metaflow/test__pandas_s3.py index b8b0bb0..d51ba3d 100644 --- a/tests/functional_tests/metaflow/test__pandas_s3.py +++ b/tests/functional_tests/metaflow/test__pandas_s3.py @@ -7,7 +7,7 @@ from metaflow import FlowSpec, project, step -@project(name="test_pandas_read_write_flow_via_s3") +@project(name="ds_platform_utils_tests") class TestPandasReadWriteFlowViaS3(FlowSpec): """A sample flow.""" diff --git a/tests/functional_tests/metaflow/test__pandas_utc.py b/tests/functional_tests/metaflow/test__pandas_utc.py index b04ceee..f3a2a15 100644 --- a/tests/functional_tests/metaflow/test__pandas_utc.py +++ b/tests/functional_tests/metaflow/test__pandas_utc.py @@ -7,7 +7,7 @@ from metaflow import FlowSpec, project, step -@project(name="test_pandas_read_write_utc_flow") +@project(name="ds_platform_utils_tests") class TestPandasReadWriteUTCFlow(FlowSpec): """A sample flow.""" diff --git a/tests/functional_tests/metaflow/test__publish.py b/tests/functional_tests/metaflow/test__publish.py index 154e0df..d020acd 100644 --- a/tests/functional_tests/metaflow/test__publish.py +++ b/tests/functional_tests/metaflow/test__publish.py @@ -23,7 +23,7 @@ """ -@project(name="test_publish_flow") +@project(name="ds_platform_utils_tests") class TestPublishFlow(FlowSpec): """A sample flow.""" diff --git a/tests/functional_tests/metaflow/test__warehouse.py b/tests/functional_tests/metaflow/test__warehouse.py index 603ce77..bda703d 100644 --- a/tests/functional_tests/metaflow/test__warehouse.py +++ b/tests/functional_tests/metaflow/test__warehouse.py @@ -10,7 +10,7 @@ from ds_platform_utils.metaflow.write_audit_publish import publish -@project(name="test_warehouse_flow") +@project(name="ds_platform_utils_tests") class TestWarehouseFlow(FlowSpec): """A sample flow.""" From 37533be6e65155eb54003056cab1bf6a52a18ca9 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:49:35 +0530 Subject: [PATCH 160/167] fix: correct condition for single SQL statement validation in _generate_snowflake_to_s3_copy_query --- src/ds_platform_utils/metaflow/s3_stage.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ds_platform_utils/metaflow/s3_stage.py b/src/ds_platform_utils/metaflow/s3_stage.py index afccd29..81d0ef5 100644 --- a/src/ds_platform_utils/metaflow/s3_stage.py +++ b/src/ds_platform_utils/metaflow/s3_stage.py @@ -64,8 +64,9 @@ def _generate_snowflake_to_s3_copy_query( snowflake_stage_path = snowflake_stage_path.strip("/") + "/" max_file_size = 16 * 1024 * 1024 # 16 MB - if sqlparse.split(query) != 1: + if len(sqlparse.split(query)) != 1: raise ValueError("Only single SQL statements are allowed in the query.") + query = sqlparse.format(query, strip_comments=True).strip().rstrip(";") copy_query = f""" COPY INTO @{snowflake_stage_path} From 79b20d12a018c2dcbefd25396e8ddf2b248ddf77 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:57:08 +0530 Subject: [PATCH 161/167] fix: update command tags in Metaflow test scripts for improved tracking --- .../metaflow/test__batch_inference_pipeline.py | 10 +++++++++- tests/functional_tests/metaflow/test__pandas.py | 10 +++++++++- tests/functional_tests/metaflow/test__pandas_s3.py | 10 +++++++++- tests/functional_tests/metaflow/test__pandas_utc.py | 10 +++++++++- tests/functional_tests/metaflow/test__publish.py | 10 +++++++++- tests/functional_tests/metaflow/test__warehouse.py | 10 +++++++++- 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py index ef08f3f..a21f8a3 100644 --- a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py +++ b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py @@ -80,7 +80,15 @@ def end(self): @pytest.mark.slow def test_warehouse_flow(): """Test that the publish flow runs successfully.""" - cmd = [sys.executable, __file__, "--environment=local", "--with=card", "run"] + cmd = [ + sys.executable, + __file__, + "--environment=local", + "--with=card", + "run", + "--tag ds.domain:ml-platform", + "--tag ds.project:ds-platform-utils-tests", + ] print("\n=== Metaflow Output ===") for line in execute_with_output(cmd): diff --git a/tests/functional_tests/metaflow/test__pandas.py b/tests/functional_tests/metaflow/test__pandas.py index ec2142c..86316ec 100644 --- a/tests/functional_tests/metaflow/test__pandas.py +++ b/tests/functional_tests/metaflow/test__pandas.py @@ -99,7 +99,15 @@ def end(self): @pytest.mark.slow def test_pandas_read_write_flow(): """Test that the publish flow runs successfully.""" - cmd = [sys.executable, __file__, "--environment=local", "--with=card", "run"] + cmd = [ + sys.executable, + __file__, + "--environment=local", + "--with=card", + "run", + "--tag ds.domain:ml-platform", + "--tag ds.project:ds-platform-utils-tests", + ] print("\n=== Metaflow Output ===") for line in execute_with_output(cmd): diff --git a/tests/functional_tests/metaflow/test__pandas_s3.py b/tests/functional_tests/metaflow/test__pandas_s3.py index d51ba3d..90248de 100644 --- a/tests/functional_tests/metaflow/test__pandas_s3.py +++ b/tests/functional_tests/metaflow/test__pandas_s3.py @@ -105,7 +105,15 @@ def end(self): @pytest.mark.slow def test_pandas_read_write_flow_via_s3(): """Test the pandas read/write flow via S3.""" - cmd = [sys.executable, __file__, "--environment=local", "--with=card", "run"] + cmd = [ + sys.executable, + __file__, + "--environment=local", + "--with=card", + "run", + "--tag ds.domain:ml-platform", + "--tag ds.project:ds-platform-utils-tests", + ] print("\n=== Metaflow Output ===") for line in execute_with_output(cmd): diff --git a/tests/functional_tests/metaflow/test__pandas_utc.py b/tests/functional_tests/metaflow/test__pandas_utc.py index f3a2a15..ba8e7b8 100644 --- a/tests/functional_tests/metaflow/test__pandas_utc.py +++ b/tests/functional_tests/metaflow/test__pandas_utc.py @@ -160,7 +160,15 @@ def end(self): @pytest.mark.slow def test_pandas_read_write_flow(): """Test that the publish flow runs successfully.""" - cmd = [sys.executable, __file__, "--environment=local", "--with=card", "run"] + cmd = [ + sys.executable, + __file__, + "--environment=local", + "--with=card", + "run", + "--tag ds.domain:ml-platform", + "--tag ds.project:ds-platform-utils-tests", + ] print("\n=== Metaflow Output ===") for line in execute_with_output(cmd): diff --git a/tests/functional_tests/metaflow/test__publish.py b/tests/functional_tests/metaflow/test__publish.py index d020acd..baf51b2 100644 --- a/tests/functional_tests/metaflow/test__publish.py +++ b/tests/functional_tests/metaflow/test__publish.py @@ -112,7 +112,15 @@ def end(self): @pytest.mark.slow def test_publish_flow(): """Test that the publish flow runs successfully.""" - cmd = [sys.executable, __file__, "--environment=local", "--with=card", "run"] + cmd = [ + sys.executable, + __file__, + "--environment=local", + "--with=card", + "run", + "--tag ds.domain:ml-platform", + "--tag ds.project:ds-platform-utils-tests", + ] print("\n=== Metaflow Output ===") for line in execute_with_output(cmd): diff --git a/tests/functional_tests/metaflow/test__warehouse.py b/tests/functional_tests/metaflow/test__warehouse.py index bda703d..8bf5135 100644 --- a/tests/functional_tests/metaflow/test__warehouse.py +++ b/tests/functional_tests/metaflow/test__warehouse.py @@ -113,7 +113,15 @@ def end(self): @pytest.mark.slow def test_warehouse_flow(): """Test that the publish flow runs successfully.""" - cmd = [sys.executable, __file__, "--environment=local", "--with=card", "run"] + cmd = [ + sys.executable, + __file__, + "--environment=local", + "--with=card", + "run", + "--tag ds.domain:ml-platform", + "--tag ds.project:ds-platform-utils-tests", + ] print("\n=== Metaflow Output ===") for line in execute_with_output(cmd): From 9d9492629f9ee1a9403734794294ad67409e5069 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:19:11 +0530 Subject: [PATCH 162/167] refactor: simplify S3 path generation and improve batch processing logic --- .../metaflow/batch_inference_pipeline.py | 75 +++++++------------ 1 file changed, 26 insertions(+), 49 deletions(-) diff --git a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py index c153aa1..beb7257 100644 --- a/src/ds_platform_utils/metaflow/batch_inference_pipeline.py +++ b/src/ds_platform_utils/metaflow/batch_inference_pipeline.py @@ -15,13 +15,8 @@ from ds_platform_utils.metaflow._consts import ( DEV_SCHEMA, PROD_SCHEMA, - S3_DATA_FOLDER, -) -from ds_platform_utils.metaflow.s3_stage import ( - _copy_s3_to_snowflake, - _copy_snowflake_to_s3, - _get_s3_config, ) +from ds_platform_utils.metaflow.s3_stage import _copy_s3_to_snowflake, _copy_snowflake_to_s3, _generate_s3_stage_paths from ds_platform_utils.sql_utils import get_query_from_string_or_fpath, substitute_map_into_string @@ -100,33 +95,16 @@ def end(self): def __init__(self): """Initialize S3 paths based on Metaflow context.""" is_production = current.is_production if hasattr(current, "is_production") else False - self._s3_bucket, _ = _get_s3_config(is_production) self._schema = PROD_SCHEMA if is_production else DEV_SCHEMA - # Build paths: s3://bucket/data/{flow}/{run_id}/{pipeline_id}/ - flow_name = current.flow_name if hasattr(current, "flow_name") else "local" - run_id = current.run_id if hasattr(current, "run_id") else "dev" - - timestamp = pd.Timestamp.now().strftime("%Y%m%d_%H%M%S_%f") - self._base_path = f"{self._s3_bucket}/{S3_DATA_FOLDER}/{flow_name}/{run_id}/{timestamp}" - self._input_path = f"{self._base_path}/input" - self._output_path = f"{self._base_path}/output" + self._input_s3_path, _ = _generate_s3_stage_paths() + self._output_s3_path, _ = _generate_s3_stage_paths() # Execution state flags self._query_executed = False self._batch_processed = False self._results_published = False - @property - def input_path(self) -> str: - """S3 path where input data is stored.""" - return self._input_path - - @property - def output_path(self) -> str: - """S3 path where output predictions are stored.""" - return self._output_path - def _split_files_into_workers(self, files: List[str], parallel_workers: int) -> dict[int, List[str]]: """Split list of files into batches for each worker.""" if len(files) < parallel_workers: @@ -157,7 +135,7 @@ def query_and_batch( """ # Warn if re-executing query_and_batch after processing - if self._query_executed and self._batch_processed: + if self._query_executed or self._batch_processed: raise RuntimeError( "Cannot re-execute query_and_batch(): Batches have already been processed. " "This would reset the state of the pipeline. " @@ -169,14 +147,15 @@ def query_and_batch( input_query = get_query_from_string_or_fpath(input_query) input_query = substitute_map_into_string(input_query, (ctx or {}) | {"schema": self._schema}) - _debug(f"⏳ Exporting data from Snowflake to S3 to {self._input_path}...") + _debug(f"⏳ Exporting data from Snowflake to S3 to {self._input_s3_path}...") # Export from Snowflake to S3 - input_files = _copy_snowflake_to_s3( + _copy_snowflake_to_s3( query=input_query, warehouse=warehouse, use_utc=use_utc, - s3_path=self._input_path, + s3_path=self._input_s3_path, ) + input_files = s3._list_files_in_s3_folder(self._input_s3_path) _debug(f"✅ Exported data to S3: {len(input_files)} files created.") # Create worker batches based on file sizes @@ -185,10 +164,8 @@ def query_and_batch( # Mark query as executed self._query_executed = True - self._batch_processed = False print(f"📊 Created {len(self.worker_ids)} workers for parallel processing") - return self.worker_ids def process_batch( @@ -197,7 +174,7 @@ def process_batch( predict_fn: Callable[[pd.DataFrame], pd.DataFrame], batch_size_in_mb: int = 128, timeout_per_batch: int = 300, - ) -> str: + ) -> None: """Step 2: Process a single batch using parallel download→inference→upload pipeline. Uses a queue-based 3-thread pipeline for efficient processing: @@ -212,7 +189,7 @@ def process_batch( timeout_per_batch: Timeout in seconds for each batch operation (default: 300) Returns: - S3 path where predictions were written + None """ # Validate that query_and_batch was called first @@ -221,9 +198,15 @@ def process_batch( "Cannot process batch: query_and_batch() must be called first. " "Call query_and_batch() to export data from Snowflake before processing batches." ) + if self._batch_processed: + raise RuntimeError( + "Cannot process batch: A batch has already been processed. " + "This would reset the state of the pipeline. " + "If you need to re-run the batch processing, create a new instance of BatchInferencePipeline." + ) if worker_id not in self.worker_files: - raise ValueError(f"Worker {worker_id} not found. Available: {list(self.worker_files.keys())}") + raise ValueError(f"Worker {worker_id} not found.") file_paths = self.worker_files[worker_id] file_batches = self._make_batches(file_paths, batch_size_in_mb=batch_size_in_mb) @@ -231,7 +214,7 @@ def process_batch( download_queue: queue.Queue = queue.Queue(maxsize=1) inference_queue: queue.Queue = queue.Queue(maxsize=1) - output_path = self._output_path + output_path = self._output_s3_path def download_worker(file_batches: List[List[str]]): for file_id, file_batch in enumerate(file_batches, 1): @@ -279,7 +262,6 @@ def upload_worker(): self._batch_processed = True print(f"✅ Worker {worker_id} complete ({len(file_batches)} batches processed in {t1 - t0:.2f}s)") - return self._output_path def publish_results( # noqa: PLR0913 self, @@ -303,10 +285,7 @@ def publish_results( # noqa: PLR0913 """ # Validate that batches were processed if not self._query_executed: - raise RuntimeError( - "Cannot publish results: query_and_batch() must be called first. " - "Call query_and_batch() to export data from Snowflake." - ) + raise RuntimeError("Cannot publish results: query_and_batch() and process_batch() must be called first. ") if not self._batch_processed: raise RuntimeError( @@ -320,7 +299,7 @@ def publish_results( # noqa: PLR0913 print(f"📤 Writing predictions to Snowflake table: {output_table_name}") _copy_s3_to_snowflake( - s3_path=self._output_path, + s3_path=self._output_s3_path, table_name=output_table_name, table_definition=output_table_definition, warehouse=warehouse, @@ -383,14 +362,12 @@ def run( # noqa: PLR0913 warehouse=warehouse, ) - # Step 2: Process all batches sequentially - for worker_id in worker_ids: - self.process_batch( - worker_id=worker_id, - predict_fn=predict_fn, - batch_size_in_mb=batch_size_in_mb, - timeout_per_batch=timeout_per_batch, - ) + self.process_batch( + worker_id=worker_ids[0], + predict_fn=predict_fn, + batch_size_in_mb=batch_size_in_mb, + timeout_per_batch=timeout_per_batch, + ) # Step 3: Publish results self.publish_results( From 9f8726be41bb336f173274ebcc0aac2eb7fae5ea Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:30:01 +0530 Subject: [PATCH 163/167] fix: update tag syntax in Metaflow test scripts for consistency --- .../metaflow/test__batch_inference_pipeline.py | 4 ++-- tests/functional_tests/metaflow/test__pandas.py | 4 ++-- tests/functional_tests/metaflow/test__pandas_utc.py | 4 ++-- tests/functional_tests/metaflow/test__publish.py | 4 ++-- tests/functional_tests/metaflow/test__warehouse.py | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py index a21f8a3..f803de2 100644 --- a/tests/functional_tests/metaflow/test__batch_inference_pipeline.py +++ b/tests/functional_tests/metaflow/test__batch_inference_pipeline.py @@ -86,8 +86,8 @@ def test_warehouse_flow(): "--environment=local", "--with=card", "run", - "--tag ds.domain:ml-platform", - "--tag ds.project:ds-platform-utils-tests", + "--tag=ds.domain:ml-platform", + "--tag=ds.project:ds-platform-utils-tests", ] print("\n=== Metaflow Output ===") diff --git a/tests/functional_tests/metaflow/test__pandas.py b/tests/functional_tests/metaflow/test__pandas.py index 86316ec..a77b64f 100644 --- a/tests/functional_tests/metaflow/test__pandas.py +++ b/tests/functional_tests/metaflow/test__pandas.py @@ -105,8 +105,8 @@ def test_pandas_read_write_flow(): "--environment=local", "--with=card", "run", - "--tag ds.domain:ml-platform", - "--tag ds.project:ds-platform-utils-tests", + "--tag=ds.domain:ml-platform", + "--tag=ds.project:ds-platform-utils-tests", ] print("\n=== Metaflow Output ===") diff --git a/tests/functional_tests/metaflow/test__pandas_utc.py b/tests/functional_tests/metaflow/test__pandas_utc.py index ba8e7b8..86bb5e6 100644 --- a/tests/functional_tests/metaflow/test__pandas_utc.py +++ b/tests/functional_tests/metaflow/test__pandas_utc.py @@ -166,8 +166,8 @@ def test_pandas_read_write_flow(): "--environment=local", "--with=card", "run", - "--tag ds.domain:ml-platform", - "--tag ds.project:ds-platform-utils-tests", + "--tag=ds.domain:ml-platform", + "--tag=ds.project:ds-platform-utils-tests", ] print("\n=== Metaflow Output ===") diff --git a/tests/functional_tests/metaflow/test__publish.py b/tests/functional_tests/metaflow/test__publish.py index baf51b2..c134689 100644 --- a/tests/functional_tests/metaflow/test__publish.py +++ b/tests/functional_tests/metaflow/test__publish.py @@ -118,8 +118,8 @@ def test_publish_flow(): "--environment=local", "--with=card", "run", - "--tag ds.domain:ml-platform", - "--tag ds.project:ds-platform-utils-tests", + "--tag=ds.domain:ml-platform", + "--tag=ds.project:ds-platform-utils-tests", ] print("\n=== Metaflow Output ===") diff --git a/tests/functional_tests/metaflow/test__warehouse.py b/tests/functional_tests/metaflow/test__warehouse.py index 8bf5135..0cdc8ba 100644 --- a/tests/functional_tests/metaflow/test__warehouse.py +++ b/tests/functional_tests/metaflow/test__warehouse.py @@ -119,8 +119,8 @@ def test_warehouse_flow(): "--environment=local", "--with=card", "run", - "--tag ds.domain:ml-platform", - "--tag ds.project:ds-platform-utils-tests", + "--tag=ds.domain:ml-platform", + "--tag=ds.project:ds-platform-utils-tests", ] print("\n=== Metaflow Output ===") From c2ae8966c3e9ce94f712fd710f76af030a03eec0 Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:53:22 +0530 Subject: [PATCH 164/167] fix: ensure proper cleanup of tags in TestWarehouseFlow to prevent tag leakage --- tests/functional_tests/metaflow/test__warehouse.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/functional_tests/metaflow/test__warehouse.py b/tests/functional_tests/metaflow/test__warehouse.py index 0cdc8ba..d6c1186 100644 --- a/tests/functional_tests/metaflow/test__warehouse.py +++ b/tests/functional_tests/metaflow/test__warehouse.py @@ -54,12 +54,17 @@ def test_query_with_warehouse(self): for item in self.warehouse_map: from metaflow import current + for i in list(current.tags): + if i.startswith("ds.domain:"): + current.tags.remove(i) + current.tags.add(f"ds.domain:{item['domain']}") + df_warehouse = query_pandas_from_snowflake( query="SELECT CURRENT_WAREHOUSE();", warehouse=item["warehouse"], ) - current.tags.pop() # Clean up tag after query + current.tags.remove(f"ds.domain:{item['domain']}") # Clean up tag after publish df_warehouse = df_warehouse.iloc[0, 0] assert df_warehouse == item["warehouse_out"], ( f"Expected warehouse {item['warehouse_out']}, got {df_warehouse}" @@ -79,6 +84,9 @@ def test_publish_with_warehouse(self): for item in self.warehouse_map: from metaflow import current + for i in list(current.tags): + if i.startswith("ds.domain:"): + current.tags.remove(i) current.tags.add(f"ds.domain:{item['domain']}") publish( @@ -86,7 +94,7 @@ def test_publish_with_warehouse(self): table_name="DS_PLATFORM_UTILS_TEST_WAREHOUSE_PUBLISH", warehouse=item["warehouse"], ) - current.tags.pop() # Clean up tag after publish + current.tags.remove(f"ds.domain:{item['domain']}") # Clean up tag after publish df_warehouse = query_pandas_from_snowflake( query="SELECT WAREHOUSE FROM DS_PLATFORM_UTILS_TEST_WAREHOUSE_PUBLISH;", warehouse=item["warehouse"], From 08588f7bafad7d004fb10c307798fffe27023b1c Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 17:58:58 +0530 Subject: [PATCH 165/167] fix: update paths-ignore in CI/CD workflow and improve parameter table formatting in publish_pandas documentation --- .../workflows/ci-cd-ds-platform-utils.yaml | 6 ++++ docs/metaflow/publish_pandas.md | 32 +++++++++---------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-cd-ds-platform-utils.yaml b/.github/workflows/ci-cd-ds-platform-utils.yaml index bc1a001..bb38e63 100644 --- a/.github/workflows/ci-cd-ds-platform-utils.yaml +++ b/.github/workflows/ci-cd-ds-platform-utils.yaml @@ -5,8 +5,14 @@ on: push: branches: - main + paths-ignore: + - "README.md" + - "docs/**" pull_request: types: [opened, synchronize] + paths-ignore: + - "README.md" + - "docs/**" jobs: check-version: diff --git a/docs/metaflow/publish_pandas.md b/docs/metaflow/publish_pandas.md index fc37efd..0016de7 100644 --- a/docs/metaflow/publish_pandas.md +++ b/docs/metaflow/publish_pandas.md @@ -33,21 +33,21 @@ publish_pandas( ## Parameters -| Parameter | Type | Required | Description | -| ------------------- | ------------------------------------ | -------: | ------------------------------------------------------------------------------------- | -| `table_name` | `str` | Yes | Destination Snowflake table name. | -| `df` | `pd.DataFrame` | Yes | DataFrame to publish. | -| `add_created_date` | `bool` | No | If `True`, adds a `created_date` UTC timestamp column before publish. | -| `chunk_size` | `int \| None` | No | Number of rows per uploaded chunk. | -| `compression` | `Literal["snappy", "gzip"]` | No | Compression codec used for staged parquet files. | -| `warehouse` | `Literal["XS", "MED", "XL"] \| None` | No | Snowflake warehouse override for this operation. | -| `parallel` | `int` | No | Number of upload threads used by `write_pandas` path. | -| `quote_identifiers` | `bool` | No | If `False`, passes identifiers unquoted so Snowflake applies uppercase coercion. | -| `auto_create_table` | `bool` | No | If `True`, creates destination table when missing. | -| `overwrite` | `bool` | No | If `True`, replaces existing table contents. | -| `use_logical_type` | `bool` | No | Controls parquet logical type handling when loading data. | -| `use_utc` | `bool` | No | If `True`, uses UTC timezone for Snowflake session. | -| `use_s3_stage` | `bool` | No | If `True`, publishes via S3 stage flow; otherwise uses direct `write_pandas`. | -| `table_definition` | `list[tuple[str, str]] \| None` | No | Optional Snowflake table schema; used by S3 stage flow when table creation is needed. | +| Parameter | Type | Required | Description | +| ------------------- | ------------------------------------ | -------: | ----------------------------------------------------------------------------------------------- | +| `table_name` | `str` | Yes | Destination Snowflake table name. | +| `df` | `pd.DataFrame` | Yes | DataFrame to publish. | +| `add_created_date` | `bool` | No | If `True`, adds a `created_date` UTC timestamp column before publish. | +| `chunk_size` | `int \| None` | No | Number of rows per uploaded chunk. If not provided, defaults to Snowflake's default chunk size. | +| `compression` | `Literal["snappy", "gzip"]` | No | Compression codec used for staged parquet files. | +| `warehouse` | `Literal["XS", "MED", "XL"] \| None` | No | Snowflake warehouse override for this operation. | +| `parallel` | `int` | No | Number of upload threads used by `write_pandas` path. | +| `quote_identifiers` | `bool` | No | If `False`, passes identifiers unquoted so Snowflake applies uppercase coercion. | +| `auto_create_table` | `bool` | No | If `True`, creates destination table when missing. | +| `overwrite` | `bool` | No | If `True`, replaces existing table contents. | +| `use_logical_type` | `bool` | No | Controls parquet logical type handling when loading data. | +| `use_utc` | `bool` | No | If `True`, uses UTC timezone for Snowflake session. | +| `use_s3_stage` | `bool` | No | If `True`, publishes via S3 stage flow; otherwise uses direct `write_pandas`. | +| `table_definition` | `list[tuple[str, str]] \| None` | No | Optional Snowflake table schema; used by S3 stage flow when table creation is needed. | **Returns:** `None` From f071bdb47dedacf62777003843271a562d0dce8a Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:00:22 +0530 Subject: [PATCH 166/167] fix: update parameter table formatting in publish_pandas documentation for clarity and completeness --- docs/metaflow/publish_pandas.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/metaflow/publish_pandas.md b/docs/metaflow/publish_pandas.md index 0016de7..642efc5 100644 --- a/docs/metaflow/publish_pandas.md +++ b/docs/metaflow/publish_pandas.md @@ -33,21 +33,21 @@ publish_pandas( ## Parameters -| Parameter | Type | Required | Description | -| ------------------- | ------------------------------------ | -------: | ----------------------------------------------------------------------------------------------- | -| `table_name` | `str` | Yes | Destination Snowflake table name. | -| `df` | `pd.DataFrame` | Yes | DataFrame to publish. | -| `add_created_date` | `bool` | No | If `True`, adds a `created_date` UTC timestamp column before publish. | -| `chunk_size` | `int \| None` | No | Number of rows per uploaded chunk. If not provided, defaults to Snowflake's default chunk size. | -| `compression` | `Literal["snappy", "gzip"]` | No | Compression codec used for staged parquet files. | -| `warehouse` | `Literal["XS", "MED", "XL"] \| None` | No | Snowflake warehouse override for this operation. | -| `parallel` | `int` | No | Number of upload threads used by `write_pandas` path. | -| `quote_identifiers` | `bool` | No | If `False`, passes identifiers unquoted so Snowflake applies uppercase coercion. | -| `auto_create_table` | `bool` | No | If `True`, creates destination table when missing. | -| `overwrite` | `bool` | No | If `True`, replaces existing table contents. | -| `use_logical_type` | `bool` | No | Controls parquet logical type handling when loading data. | -| `use_utc` | `bool` | No | If `True`, uses UTC timezone for Snowflake session. | -| `use_s3_stage` | `bool` | No | If `True`, publishes via S3 stage flow; otherwise uses direct `write_pandas`. | -| `table_definition` | `list[tuple[str, str]] \| None` | No | Optional Snowflake table schema; used by S3 stage flow when table creation is needed. | +| Parameter | Type | Required | Description | +| ------------------- | ------------------------------------ | -------: | -------------------------------------------------------------------------------------- | +| `table_name` | `str` | Yes | Destination Snowflake table name. | +| `df` | `pd.DataFrame` | Yes | DataFrame to publish. | +| `add_created_date` | `bool` | No | If `True`, adds a `created_date` UTC timestamp column before publish. | +| `chunk_size` | `int \| None` | No | Number of rows per uploaded chunk. If not provided, calculate based on DataFrame size. | +| `compression` | `Literal["snappy", "gzip"]` | No | Compression codec used for staged parquet files. | +| `warehouse` | `Literal["XS", "MED", "XL"] \| None` | No | Snowflake warehouse override for this operation. | +| `parallel` | `int` | No | Number of upload threads used by `write_pandas` path. | +| `quote_identifiers` | `bool` | No | If `False`, passes identifiers unquoted so Snowflake applies uppercase coercion. | +| `auto_create_table` | `bool` | No | If `True`, creates destination table when missing. | +| `overwrite` | `bool` | No | If `True`, replaces existing table contents. | +| `use_logical_type` | `bool` | No | Controls parquet logical type handling when loading data. | +| `use_utc` | `bool` | No | If `True`, uses UTC timezone for Snowflake session. | +| `use_s3_stage` | `bool` | No | If `True`, publishes via S3 stage flow; otherwise uses direct `write_pandas`. | +| `table_definition` | `list[tuple[str, str]] \| None` | No | Optional Snowflake table schema; used by S3 stage flow when table creation is needed. | **Returns:** `None` From 66acefc77433fafc5cc14392653d794b214d0b4b Mon Sep 17 00:00:00 2001 From: Abhishek Patil <129820441+abhk2@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:09:36 +0530 Subject: [PATCH 167/167] lowercase warehouse name in test and clean up tag after query instead of publish to avoid affecting other tests --- tests/functional_tests/metaflow/test__warehouse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/functional_tests/metaflow/test__warehouse.py b/tests/functional_tests/metaflow/test__warehouse.py index d6c1186..dbf6612 100644 --- a/tests/functional_tests/metaflow/test__warehouse.py +++ b/tests/functional_tests/metaflow/test__warehouse.py @@ -29,7 +29,7 @@ def start(self): "warehouse_out": "OUTERBOUNDS_DATA_SCIENCE_SHARED_DEV_XS_WH", }, { - "warehouse": "MED", + "warehouse": "med", "domain": "advertising", "warehouse_out": "OUTERBOUNDS_DATA_SCIENCE_ADS_DEV_MED_WH", },