From a34e698811f8ff9ca497b0458d2fa9ca6696dc15 Mon Sep 17 00:00:00 2001 From: park-peter Date: Tue, 3 Mar 2026 16:33:06 +0900 Subject: [PATCH 1/7] Refactor: foundation, schema, writers, credentials, features Phase 1: Rewrite package API (Brickbyte -> client()), clean logging, pin dependencies, update CI matrix, replace Makefile with uv targets. Phase 2: Canonical schema (record_id/extracted_at/data/run_id), stream name sanitization with collision detection, SQL identifier validation, backtick-quoted table names, UTC-aware timestamps, fixed buffer size estimation. Phase 3: Safe overwrite via staging tables with atomic INSERT OVERWRITE, writer close in finally block, fatal error handling fix, SQL writer hardening (Volume guard, flatten DDL, deterministic filenames, proper cleanup), cooperative timeout, cleanup=False default. Phase 4: Fix credential scope resolution bug for explicit scope/key, add dotted-key nested mapping, warning logs for unresolved secrets. Phase 5: Incremental sync state manager, deduplication with positional _dk_N columns, concurrent stream processing with isolated per-thread writers, progress reporting with callback and tqdm support. --- .github/workflows/test.yml | 23 +- Makefile | 18 +- pyproject.toml | 20 +- src/brickbyte/__init__.py | 387 +--------- src/brickbyte/_client.py | 669 ++++++++++++++++++ src/brickbyte/_dedup.py | 86 +++ src/brickbyte/_progress.py | 100 +++ src/brickbyte/_sanitize.py | 61 ++ src/brickbyte/_schema.py | 31 + src/brickbyte/_state.py | 125 ++++ src/brickbyte/credentials.py | 181 +++-- src/brickbyte/enrichment/__init__.py | 11 +- src/brickbyte/preview.py | 194 ++--- src/brickbyte/writers/__init__.py | 51 +- src/brickbyte/writers/base.py | 82 ++- .../writers/spark_streaming_writer.py | 235 ++++-- src/brickbyte/writers/sql_streaming_writer.py | 290 +++++--- 17 files changed, 1812 insertions(+), 752 deletions(-) create mode 100644 src/brickbyte/_client.py create mode 100644 src/brickbyte/_dedup.py create mode 100644 src/brickbyte/_progress.py create mode 100644 src/brickbyte/_sanitize.py create mode 100644 src/brickbyte/_schema.py create mode 100644 src/brickbyte/_state.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index db4e0a2..071d36c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,36 +12,39 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 - + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - + - name: Install dependencies run: | pip install -e ".[dev]" - + - name: Run tests run: | - python -m pytest tests/ -v --tb=short + pytest tests/ -v --tb=short -m "not integration" lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - + - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.10" - + - name: Install dependencies - run: pip install ruff - - - name: Run ruff + run: pip install ruff==0.6.4 + + - name: Run ruff check run: ruff check src/ + + - name: Run ruff format check + run: ruff format --check src/ diff --git a/Makefile b/Makefile index a432e31..60a08da 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,17 @@ -lock: - @cd integrations/destination-databricks-py && poetry lock +.PHONY: install test lint format clean install: - @cd integrations/destination-databricks-py && poetry install \ No newline at end of file + uv pip install -e ".[dev]" + +test: + uv run pytest tests/ -v -m "not integration" + +lint: + uv run ruff check src/ + +format: + uv run ruff format src/ + +clean: + rm -rf build/ dist/ *.egg-info src/*.egg-info .pytest_cache + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true diff --git a/pyproject.toml b/pyproject.toml index b66b907..958a104 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ { name="Peter Park", email="peter.park@databricks.com" }, ] readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.10,<3.13" keywords = ["databricks", "airbyte", "etl", "data-engineering", "unity-catalog", "delta-lake"] classifiers = [ "Development Status :: 4 - Beta", @@ -26,11 +26,12 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "virtualenv", - "databricks-sdk>=0.74.0", - "databricks-sql-connector>=4.2.2", - "airbyte>=0.34.0", - "pyarrow>=14.0.0", + "virtualenv==20.29.3", + "databricks-sdk==0.95.0", + "databricks-sql-connector==4.2.5", + "airbyte==0.38.0", + "pyarrow==21.0.0", + "pyyaml==6.0.3", ] [project.urls] @@ -46,6 +47,9 @@ dev = [ "pytest", "ruff==0.6.4", ] +progress = [ + "tqdm==4.67.3", +] local-spark = [ "delta-spark>=3.2.0", "pyspark>=3.5.0", @@ -84,4 +88,6 @@ docstring-code-format = true docstring-code-line-length = 88 [tool.pytest.ini_options] -pythonpath = ["src"] \ No newline at end of file +pythonpath = ["src"] +markers = ["integration: requires configured Databricks workspace"] +addopts = "--strict-markers" diff --git a/src/brickbyte/__init__.py b/src/brickbyte/__init__.py index 74c5772..4155bb4 100644 --- a/src/brickbyte/__init__.py +++ b/src/brickbyte/__init__.py @@ -1,40 +1,13 @@ """ -Brickbyte - Sync data from 600+ sources directly into Databricks. +brickbyte - Sync data from 600+ sources directly into Databricks. """ import logging -import os -import shutil -import subprocess from dataclasses import dataclass, field -from pathlib import Path -from typing import Dict, List, Optional +from typing import List from brickbyte.types import Source -# Configure logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" -) -# Suppress noisy third-party DEBUG/INFO logs -logging.getLogger().setLevel(logging.WARNING) - -_noisy_loggers = [ - "py4j", - "pyspark", - "pyspark.sql.connect", - "pyspark.sql.connect.client", - "databricks", - "databricks.sdk", - "urllib3", - "grpc", - "airbyte", -] -for _logger_name in _noisy_loggers: - logging.getLogger(_logger_name).setLevel(logging.WARNING) - -logger = logging.getLogger("brickbyte") -logger.setLevel(logging.INFO) +logging.getLogger("brickbyte").addHandler(logging.NullHandler()) @dataclass @@ -47,338 +20,32 @@ class SyncResult: enriched_tables: List[str] = field(default_factory=list) -class VirtualEnvManager: - """Manages isolated Python virtual environments for source connectors.""" - - def __init__(self, env_dir: str): - self.env_dir = env_dir - - def create_virtualenv(self): - import virtualenv - virtualenv.cli_run([self.env_dir]) - - def install_source( - self, source: str, override_install: Optional[str] = None - ): - library = override_install or f"airbyte-{source}" - subprocess.check_call( - [os.path.join(self.env_dir, "bin", "pip"), "install", library], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - def delete_virtualenv(self): - if os.path.exists(self.env_dir): - shutil.rmtree(self.env_dir) - - @property - def bin_path(self): - return os.path.join(self.env_dir, "bin") - - -class Brickbyte: +def client( + base_venv_directory: str | None = None, + secrets_scope: str = "brickbyte", + profiles: str | None = None, +): """ - Brickbyte - Sync data from any source connector to Databricks. - - Uses a streaming architecture to bypass local disk storage and - write directly to Unity Catalog. - - Supports automatic credential discovery from Databricks Secrets: - - Default scope: "brickbyte" - - Key convention: "{source-name}/{field}" (e.g., "source-s3/aws_access_key_id") - - Optional YAML profiles for credential reuse across sources + Create a brickbyte Client. + + Args: + base_venv_directory: Directory to store virtual environments. + Defaults to user's home directory. + secrets_scope: Databricks Secrets scope for credential discovery + (default: "brickbyte") + profiles: Optional path to YAML profiles file for advanced + credential configuration (e.g., credential reuse) + + Returns: + Client instance """ + from brickbyte._client import Client - def __init__( - self, - base_venv_directory: Optional[str] = None, - secrets_scope: str = "brickbyte", - profiles: Optional[str] = None, - ): - """ - Initialize Brickbyte. - - Args: - base_venv_directory: Directory to store virtual environments. - Defaults to user's home directory. - secrets_scope: Databricks Secrets scope for credential discovery - (default: "brickbyte") - profiles: Optional path to YAML profiles file for advanced - credential configuration (e.g., credential reuse) - """ - self._base_venv_directory = base_venv_directory or str(Path.home()) - self._source_env_managers: Dict[str, VirtualEnvManager] = {} - - # Initialize credential resolver - from brickbyte.credentials import CredentialResolver - self._credential_resolver = CredentialResolver( - secrets_scope=secrets_scope, - profiles_path=profiles, - ) - - def _setup_source(self, source: str, source_install: Optional[str] = None): - """Install source connector in isolated venv.""" - if source in self._source_env_managers: - return - - path = os.path.join(self._base_venv_directory, f"brickbyte-{source}") - manager = VirtualEnvManager(path) - manager.create_virtualenv() - manager.install_source(source, source_install) - self._source_env_managers[source] = manager - - def _get_source_exec_path(self, source: str) -> str: - """Get path to source connector executable.""" - return os.path.join(self._source_env_managers[source].bin_path, source) - - def _validate_sync_params( - self, - mode: str, - staging_volume: str, - ): - """Validate sync parameters.""" - valid_modes = ("append", "overwrite") - if mode not in valid_modes: - if mode == "merge": - raise NotImplementedError("Merge mode is not yet supported.") - raise ValueError( - f"Invalid mode '{mode}'. Must be one of: {', '.join(valid_modes)}" - ) - - def preview( - self, - source: str, - source_config: dict, - catalog: str, - schema: str, - streams: Optional[List[str]] = None, - source_install: Optional[str] = None, - sample_size: int = 5, - ): - """ - Preview a sync operation. - - Args: - source: Source connector name - source_config: Configuration dictionary for the source - catalog: Unity Catalog name - schema: Target schema name - streams: List of streams to preview (None = all streams) - source_install: Override source installation - sample_size: Number of sample records per stream - - Returns: - PreviewResult with detailed comparison - """ - import airbyte as ab - - from brickbyte.preview import PreviewEngine - - merged_config = self._credential_resolver.merge_credentials(source, source_config) - - try: - logger.info(f"Setting up {source}...") - self._setup_source(source, source_install) - - ab_source = ab.get_source( - source, - config=merged_config, - local_executable=self._get_source_exec_path(source), - ) - ab_source.check() - - if streams: - ab_source.select_streams(streams) - else: - ab_source.select_all_streams() - - selected = list(ab_source.get_selected_streams()) - - logger.info("Generating preview (streaming)...") - engine = PreviewEngine(catalog=catalog, schema=schema) - result = engine.preview( - ab_source=ab_source, - streams=selected, - sample_size=sample_size, - ) - - return result - - finally: - self.cleanup() - - def sync( - self, - source: str, - source_config: dict, - catalog: str, - schema: str, - staging_volume: Optional[str] = None, - streams: Optional[List[str]] = None, - mode: str = "overwrite", - flatten: bool = False, - enrich_metadata: bool = False, - enrich_model: Optional[str] = None, - warehouse_id: Optional[str] = None, - source_install: Optional[str] = None, - cleanup: bool = True, - buffer_size_records: int = 50000, - buffer_size_mb: int = 100, - continue_on_error: bool = False, - ) -> SyncResult: - """ - Sync data from a source connector to Databricks (Streaming). - - Args: - source: Source connector name (e.g., "source-github") - source_config: Configuration dictionary for the source connector - catalog: Unity Catalog name (e.g., "main") - schema: Target schema name (e.g., "bronze") - staging_volume: Unity Catalog Volume path (REQUIRED for remote) - streams: List of streams to sync. None = all streams (default) - mode: Write mode ("overwrite" or "append") - flatten: If True, flatten record fields into columns. - If False (default), store as JSON in 'data' column. - enrich_metadata: If True, use AI to generate column descriptions - enrich_model: Foundation Model endpoint for enrichment - warehouse_id: SQL warehouse ID (optional, auto-discovered) - source_install: Override source installation (e.g., custom git URL) - cleanup: Whether to cleanup venvs after sync (default: True) - buffer_size_records: Records per micro-batch (default: 50k) - buffer_size_mb: Max batch size in MB (default: 100MB) - continue_on_error: If True, continue syncing other streams if one fails - - Returns: - SyncResult with records_written, streams_synced, failed_streams, enriched_tables - """ - import airbyte as ab - - from brickbyte.writers import create_streaming_writer - - self._validate_sync_params(mode, staging_volume) - merged_config = self._credential_resolver.merge_credentials(source, source_config) - - try: - logger.info(f"Setting up {source}...") - self._setup_source(source, source_install) - - logger.info(f"Configuring {source}...") - ab_source = ab.get_source( - source, - config=merged_config, - local_executable=self._get_source_exec_path(source), - ) - - logger.info("Validating source connection...") - ab_source.check() - - if streams: - ab_source.select_streams(streams) - else: - ab_source.select_all_streams() - - selected = list(ab_source.get_selected_streams()) - - via_msg = f" via {staging_volume}" if staging_volume else " (Native Spark)" - logger.info(f"Streaming {len(selected)} streams to {catalog}.{schema}{via_msg}...") - - writer = create_streaming_writer( - catalog=catalog, - schema=schema, - staging_volume=staging_volume, - warehouse_id=warehouse_id, - buffer_size_records=buffer_size_records, - buffer_size_mb=buffer_size_mb, - flatten=flatten, - ) - - total_records = 0 - failed_streams: List[str] = [] - - for stream_name in selected: - logger.info(f" Streaming: {stream_name}") - - if mode == "overwrite": - writer.drop_table(stream_name) - - try: - records_generator = ab_source.get_records(stream_name) - count = 0 - for record in records_generator: - writer.write_record(stream_name, record) - count += 1 - if count % 10000 == 0: - logger.info(f" ...streamed {count} records") - - writer.flush_stream(stream_name) - logger.info(f" ✓ {count} records streamed") - total_records += count - except Exception as e: - error_name = type(e).__name__ - logger.error(f" ✗ Failed to stream {stream_name}: {e}") - failed_streams.append(stream_name) - - is_fatal = "ConnectorFailed" in error_name - if is_fatal and not continue_on_error: - raise - if not continue_on_error: - raise - - if failed_streams: - if continue_on_error: - logger.warning( - f"Completed with {len(failed_streams)} failed streams: {failed_streams}" - ) - else: - raise RuntimeError(f"Sync failed. Failed streams: {failed_streams}") - - writer.close() - - successful_streams = [s for s in selected if s not in failed_streams] - - enriched_tables = [] - if enrich_metadata and successful_streams: - logger.info("Enriching metadata with AI...") - from brickbyte.enrichment import enrich_table - - model = enrich_model or "databricks-meta-llama-3-3-70b-instruct" - for stream_name in successful_streams: - try: - enrich_table( - catalog=catalog, - schema=schema, - table=stream_name, - apply_to_catalog=True, - model_name=model, - ) - enriched_tables.append(stream_name) - except Exception as e: - logger.warning(f" Warning: Could not enrich {stream_name}: {e}") - - return SyncResult( - records_written=total_records, - streams_synced=successful_streams, - failed_streams=failed_streams, - enriched_tables=enriched_tables, - ) - - finally: - if cleanup: - self.cleanup() - - def cleanup(self): - """Remove virtual environments.""" - for manager in self._source_env_managers.values(): - manager.delete_virtualenv() - self._source_env_managers.clear() - - def list_configured_sources(self) -> List[str]: - """List all sources that have credentials configured.""" - return self._credential_resolver.list_available_sources() - - def validate_credentials(self, source: str) -> bool: - """Check if credentials are configured for a source.""" - return self._credential_resolver.validate(source) + return Client( + base_venv_directory=base_venv_directory, + secrets_scope=secrets_scope, + profiles=profiles, + ) -__all__ = ["Brickbyte", "SyncResult", "Source"] +__all__ = ["client", "SyncResult", "Source"] diff --git a/src/brickbyte/_client.py b/src/brickbyte/_client.py new file mode 100644 index 0000000..7a3e0ec --- /dev/null +++ b/src/brickbyte/_client.py @@ -0,0 +1,669 @@ +""" +Internal Client class for brickbyte. +""" +import logging +import os +import shutil +import subprocess +import threading +import uuid +from pathlib import Path +from typing import Callable, Dict, List, Optional, Union + +from brickbyte import SyncResult + +logger = logging.getLogger("brickbyte") + + +class VirtualEnvManager: + """Manages isolated Python virtual environments for source connectors.""" + + def __init__(self, env_dir: str): + self.env_dir = env_dir + + def create_virtualenv(self): + import virtualenv + + virtualenv.cli_run([self.env_dir]) + + def install_source(self, source: str, override_install: Optional[str] = None): + library = override_install or f"airbyte-{source}" + subprocess.check_call( + [os.path.join(self.env_dir, "bin", "pip"), "install", library], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def delete_virtualenv(self): + if os.path.exists(self.env_dir): + shutil.rmtree(self.env_dir) + + @property + def bin_path(self): + return os.path.join(self.env_dir, "bin") + + +class Client: + """ + brickbyte Client - Sync data from any source connector to Databricks. + + Uses a streaming architecture to bypass local disk storage and + write directly to Unity Catalog. + + Supports automatic credential discovery from Databricks Secrets: + - Default scope: "brickbyte" + - Key convention: "{source-name}/{field}" (e.g., "source-s3/aws_access_key_id") + - Optional YAML profiles for credential reuse across sources + """ + + def __init__( + self, + base_venv_directory: Optional[str] = None, + secrets_scope: str = "brickbyte", + profiles: Optional[str] = None, + ): + self._base_venv_directory = base_venv_directory or str(Path.home()) + self._source_env_managers: Dict[str, VirtualEnvManager] = {} + + from brickbyte.credentials import CredentialResolver + + self._credential_resolver = CredentialResolver( + secrets_scope=secrets_scope, + profiles_path=profiles, + ) + + def _setup_source(self, source: str, source_install: Optional[str] = None): + """Install source connector in isolated venv.""" + if source in self._source_env_managers: + return + + path = os.path.join(self._base_venv_directory, f"brickbyte-{source}") + manager = VirtualEnvManager(path) + manager.create_virtualenv() + manager.install_source(source, source_install) + self._source_env_managers[source] = manager + + def _get_source_exec_path(self, source: str) -> str: + """Get path to source connector executable.""" + return os.path.join(self._source_env_managers[source].bin_path, source) + + def _validate_sync_params(self, mode: str): + """Validate sync parameters.""" + valid_modes = ("append", "overwrite") + if mode not in valid_modes: + if mode == "merge": + raise NotImplementedError("Merge mode is not yet supported.") + raise ValueError( + f"Invalid mode '{mode}'. Must be one of: {', '.join(valid_modes)}" + ) + + def preview( + self, + source: str, + source_config: dict, + catalog: str, + schema: str, + streams: Optional[List[str]] = None, + source_install: Optional[str] = None, + sample_size: int = 5, + ): + """ + Preview a sync operation. + + Args: + source: Source connector name + source_config: Configuration dictionary for the source + catalog: Unity Catalog name + schema: Target schema name + streams: List of streams to preview (None = all streams) + source_install: Override source installation + sample_size: Number of sample records per stream + + Returns: + PreviewResult with detailed comparison + """ + import airbyte as ab + + from brickbyte.preview import PreviewEngine + + merged_config = self._credential_resolver.merge_credentials(source, source_config) + + try: + logger.info(f"Setting up {source}...") + self._setup_source(source, source_install) + + ab_source = ab.get_source( + source, + config=merged_config, + local_executable=self._get_source_exec_path(source), + ) + ab_source.check() + + if streams: + ab_source.select_streams(streams) + else: + ab_source.select_all_streams() + + selected = list(ab_source.get_selected_streams()) + + logger.info("Generating preview (streaming)...") + engine = PreviewEngine(catalog=catalog, schema=schema) + result = engine.preview( + ab_source=ab_source, + streams=selected, + sample_size=sample_size, + ) + + return result + + finally: + self.cleanup() + + def sync( + self, + source: str, + source_config: dict, + catalog: str, + schema: str, + staging_volume: Optional[str] = None, + streams: Optional[List[str]] = None, + mode: str = "overwrite", + flatten: bool = False, + enrich_metadata: bool = False, + enrich_model: Optional[str] = None, + warehouse_id: Optional[str] = None, + source_install: Optional[str] = None, + cleanup: bool = False, + buffer_size_records: int = 50000, + buffer_size_mb: int = 100, + continue_on_error: bool = False, + timeout_seconds: Optional[int] = None, + incremental: bool = False, + deduplicate: bool = False, + dedup_keys: Optional[Union[List[str], Dict[str, List[str]]]] = None, + max_parallel_streams: int = 1, + progress_callback: Optional[Callable] = None, + ) -> SyncResult: + """ + Sync data from a source connector to Databricks (Streaming). + + Args: + source: Source connector name (e.g., "source-github") + source_config: Configuration dictionary for the source connector + catalog: Unity Catalog name (e.g., "main") + schema: Target schema name (e.g., "bronze") + staging_volume: Unity Catalog Volume path (REQUIRED for remote) + streams: List of streams to sync. None = all streams (default) + mode: Write mode ("overwrite" or "append") + flatten: If True, flatten record fields into columns. + If False (default), store as JSON in 'data' column. + enrich_metadata: If True, use AI to generate column descriptions + enrich_model: Foundation Model endpoint for enrichment + warehouse_id: SQL warehouse ID (optional, auto-discovered) + source_install: Override source installation (e.g., custom git URL) + cleanup: Whether to cleanup venvs after sync (default: False) + buffer_size_records: Records per micro-batch (default: 50k) + buffer_size_mb: Max batch size in MB (default: 100MB) + continue_on_error: If True, continue syncing other streams if one fails + timeout_seconds: Optional timeout in seconds for the sync operation + incremental: If True, use incremental sync with state management + deduplicate: If True, deduplicate records after sync + dedup_keys: Column(s) to use as dedup keys (required when deduplicate=True) + max_parallel_streams: Max number of streams to write in parallel (default: 1) + progress_callback: Optional callback for progress reporting + + Returns: + SyncResult with records_written, streams_synced, failed_streams, enriched_tables + """ + import airbyte as ab + + from brickbyte._sanitize import sanitize_stream_name + from brickbyte.writers import create_streaming_writer + + self._validate_sync_params(mode) + merged_config = self._credential_resolver.merge_credentials(source, source_config) + + run_id = str(uuid.uuid4()) + + # Normalize dedup_keys if deduplicate is enabled + normalized_dedup_keys = None + if deduplicate: + normalized_dedup_keys = self._normalize_dedup_keys(dedup_keys, streams) + + # Set up timeout + cancel_event = None + timer = None + if timeout_seconds is not None: + cancel_event = threading.Event() + timer = threading.Timer(timeout_seconds, cancel_event.set) + timer.daemon = True + timer.start() + + writer = None + try: + logger.info(f"Setting up {source}...") + self._setup_source(source, source_install) + + logger.info(f"Configuring {source}...") + ab_source = ab.get_source( + source, + config=merged_config, + local_executable=self._get_source_exec_path(source), + ) + + logger.info("Validating source connection...") + ab_source.check() + + if streams: + ab_source.select_streams(streams) + else: + ab_source.select_all_streams() + + selected = list(ab_source.get_selected_streams()) + + # Sanitize stream names upfront and check for collisions + sanitized_map = {} + for stream in selected: + sanitized = sanitize_stream_name(stream) + if sanitized in sanitized_map and sanitized_map[sanitized] != stream: + raise ValueError( + f"Stream name collision after sanitization: " + f"'{sanitized_map[sanitized]}' and '{stream}' both map to '{sanitized}'" + ) + sanitized_map[sanitized] = stream + + # If dedup_keys is a dict, validate keys match selected streams (original names) + if deduplicate and isinstance(normalized_dedup_keys, dict): + for dk_stream in normalized_dedup_keys: + if dk_stream not in selected: + # Check if user used sanitized name by mistake + sanitized_to_original = {v: k for k, v in sanitized_map.items()} + if dk_stream in sanitized_to_original: + orig = sanitized_to_original[dk_stream] + raise ValueError( + f"dedup_keys key '{dk_stream}' is a sanitized name. " + f"Use the original Airbyte stream name '{orig}' instead." + ) + raise ValueError( + f"dedup_keys key '{dk_stream}' does not match any selected stream. " + f"Selected streams: {selected}" + ) + + # Load incremental state if needed + state_manager = None + if incremental: + from brickbyte._state import StateManager + + state_manager = StateManager(catalog=catalog, schema=schema) + # TODO: load and pass state to PyAirbyte + + via_msg = f" via {staging_volume}" if staging_volume else " (Native Spark)" + logger.info( + f"Streaming {len(selected)} streams to {catalog}.{schema}{via_msg}..." + ) + + # Set up progress reporter + progress_reporter = None + if progress_callback is not None: + from brickbyte._progress import ProgressReporter + + progress_reporter = ProgressReporter( + total_streams=len(selected), + callback=progress_callback, + ) + + total_records = 0 + failed_streams: List[str] = [] + successful_streams: List[str] = [] + lock = threading.Lock() + + if max_parallel_streams > 1: + import concurrent.futures + + executor = concurrent.futures.ThreadPoolExecutor( + max_workers=max_parallel_streams + ) + futures = [] + in_flight = 0 + in_flight_lock = threading.Lock() + + def _write_stream_records(stream_name, records_list, _run_id, _mode): + """Write a list of records in a thread-owned writer.""" + thread_writer = create_streaming_writer( + catalog=catalog, + schema=schema, + staging_volume=staging_volume, + warehouse_id=warehouse_id, + buffer_size_records=buffer_size_records, + buffer_size_mb=buffer_size_mb, + flatten=flatten, + run_id=_run_id, + dedup_keys=normalized_dedup_keys, + ) + try: + if _mode == "overwrite": + thread_writer.safe_overwrite_begin(stream_name, _run_id) + + for record in records_list: + thread_writer.write_record(stream_name, record) + thread_writer.flush_stream(stream_name) + + if _mode == "overwrite": + thread_writer.safe_overwrite_finish(stream_name, _run_id) + + return len(records_list) + finally: + thread_writer.close() + with in_flight_lock: + nonlocal in_flight + in_flight -= 1 + + for stream_name in selected: + logger.info(f" Streaming: {stream_name}") + + try: + records_generator = ab_source.get_records(stream_name) + records_list = [] + accumulated_size = 0 + oversized = False + + for record in records_generator: + if cancel_event and cancel_event.is_set(): + raise TimeoutError( + f"Sync timed out after {timeout_seconds} seconds" + ) + + record_size = sum( + len(str(v).encode("utf-8")) for v in record.values() + ) + accumulated_size += record_size + records_list.append(record) + + if accumulated_size >= buffer_size_mb * 1024 * 1024: + # Oversized: switch to synchronous mode + oversized = True + break + + if oversized: + # Process synchronously in main thread + sync_writer = create_streaming_writer( + catalog=catalog, + schema=schema, + staging_volume=staging_volume, + warehouse_id=warehouse_id, + buffer_size_records=buffer_size_records, + buffer_size_mb=buffer_size_mb, + flatten=flatten, + run_id=run_id, + dedup_keys=normalized_dedup_keys, + ) + try: + if mode == "overwrite": + sync_writer.safe_overwrite_begin(stream_name, run_id) + + for rec in records_list: + sync_writer.write_record(stream_name, rec) + # Continue consuming remaining records + count = len(records_list) + for record in records_generator: + if cancel_event and cancel_event.is_set(): + raise TimeoutError( + f"Sync timed out after {timeout_seconds} seconds" + ) + sync_writer.write_record(stream_name, record) + count += 1 + sync_writer.flush_stream(stream_name) + + if mode == "overwrite": + sync_writer.safe_overwrite_finish(stream_name, run_id) + + with lock: + total_records += count + successful_streams.append(stream_name) + logger.info(f" {count} records streamed (sync)") + finally: + sync_writer.close() + else: + # Wait until in-flight count is below limit + import time + + while True: + with in_flight_lock: + if in_flight < max_parallel_streams: + in_flight += 1 + break + time.sleep(0.01) + + future = executor.submit( + _write_stream_records, + stream_name, + records_list, + run_id, + mode, + ) + futures.append((stream_name, future)) + + except Exception as e: + error_name = type(e).__name__ + logger.error(f" Failed to stream {stream_name}: {e}") + failed_streams.append(stream_name) + + is_fatal = "ConnectorFailed" in error_name + if is_fatal or not continue_on_error: + # Cancel remaining futures + for _, f in futures: + f.cancel() + raise + + # Collect results from futures + for stream_name, future in futures: + try: + count = future.result() + with lock: + total_records += count + successful_streams.append(stream_name) + logger.info(f" {count} records streamed") + except Exception as e: + logger.error(f" Failed to stream {stream_name}: {e}") + with lock: + failed_streams.append(stream_name) + if not continue_on_error: + for _, f in futures: + f.cancel() + raise + + executor.shutdown(wait=True) + + else: + # Sequential processing (default) + writer = create_streaming_writer( + catalog=catalog, + schema=schema, + staging_volume=staging_volume, + warehouse_id=warehouse_id, + buffer_size_records=buffer_size_records, + buffer_size_mb=buffer_size_mb, + flatten=flatten, + run_id=run_id, + dedup_keys=normalized_dedup_keys, + ) + + for stream_name in selected: + logger.info(f" Streaming: {stream_name}") + + if mode == "overwrite": + writer.safe_overwrite_begin(stream_name, run_id) + + try: + records_generator = ab_source.get_records(stream_name) + count = 0 + for record in records_generator: + if cancel_event and cancel_event.is_set(): + raise TimeoutError( + f"Sync timed out after {timeout_seconds} seconds" + ) + + writer.write_record(stream_name, record) + count += 1 + if count % 10000 == 0: + logger.info(f" ...streamed {count} records") + + if ( + cancel_event + and count % 1000 == 0 + and cancel_event.is_set() + ): + raise TimeoutError( + f"Sync timed out after {timeout_seconds} seconds" + ) + + writer.flush_stream(stream_name) + + if mode == "overwrite": + writer.safe_overwrite_finish(stream_name, run_id) + + logger.info(f" {count} records streamed") + total_records += count + successful_streams.append(stream_name) + + if progress_reporter: + progress_reporter.stream_completed(stream_name, count) + + # Save incremental state on success + if state_manager and incremental: + # TODO: save state from PyAirbyte + pass + + except Exception as e: + error_name = type(e).__name__ + logger.error(f" Failed to stream {stream_name}: {e}") + failed_streams.append(stream_name) + + is_fatal = "ConnectorFailed" in error_name + if is_fatal: + raise + if not continue_on_error: + raise + + if failed_streams: + if continue_on_error: + logger.warning( + f"Completed with {len(failed_streams)} failed streams: " + f"{failed_streams}" + ) + else: + raise RuntimeError( + f"Sync failed. Failed streams: {failed_streams}" + ) + + # Run deduplication if enabled + if deduplicate and normalized_dedup_keys and successful_streams: + from brickbyte._dedup import deduplicate_stream + from brickbyte._sanitize import sanitize_stream_name as _sanitize + + for stream_name in successful_streams: + sanitized = _sanitize(stream_name) + stream_keys = normalized_dedup_keys.get(stream_name) + if stream_keys is None: + continue + + table_name = f"`{catalog}`.`{schema}`.`{sanitized}`" + if flatten: + deduplicate_stream( + executor=writer if writer else None, + table_name=table_name, + key_columns=stream_keys, + run_id_col="_run_id", + extracted_at_col="_extracted_at", + record_id_col="_record_id", + flatten=True, + ) + else: + dk_cols = [f"_dk_{i}" for i in range(len(stream_keys))] + deduplicate_stream( + executor=writer if writer else None, + table_name=table_name, + key_columns=dk_cols, + run_id_col="run_id", + extracted_at_col="extracted_at", + record_id_col="record_id", + flatten=False, + ) + + enriched_tables = [] + if enrich_metadata and successful_streams: + logger.info("Enriching metadata with AI...") + from brickbyte.enrichment import enrich_table + + model = enrich_model or "databricks-meta-llama-3-3-70b-instruct" + for stream_name in successful_streams: + try: + enrich_table( + catalog=catalog, + schema=schema, + table=stream_name, + apply_to_catalog=True, + model_name=model, + ) + enriched_tables.append(stream_name) + except Exception as e: + logger.warning( + f" Warning: Could not enrich {stream_name}: {e}" + ) + + return SyncResult( + records_written=total_records, + streams_synced=successful_streams, + failed_streams=failed_streams, + enriched_tables=enriched_tables, + ) + + finally: + if timer is not None: + timer.cancel() + if writer is not None: + writer.close() + if cleanup: + self.cleanup() + + def _normalize_dedup_keys( + self, + dedup_keys: Optional[Union[List[str], Dict[str, List[str]]]], + streams: Optional[List[str]], + ) -> Dict[str, List[str]]: + """Normalize dedup_keys to Dict[str, List[str]].""" + if dedup_keys is None: + raise ValueError( + "dedup_keys is required when deduplicate=True. " + "Provide a list of column names or a dict mapping stream names to column lists." + ) + + if isinstance(dedup_keys, list): + if len(dedup_keys) == 0: + raise ValueError("dedup_keys must be non-empty") + # Will be expanded to all selected streams later + return {"__all__": dedup_keys} + + if isinstance(dedup_keys, dict): + for stream_name, keys in dedup_keys.items(): + if not isinstance(keys, list) or len(keys) == 0: + raise ValueError( + f"dedup_keys for stream '{stream_name}' must be non-empty" + ) + return dedup_keys + + raise ValueError("dedup_keys must be a list or dict") + + def cleanup(self): + """Remove virtual environments.""" + for manager in self._source_env_managers.values(): + manager.delete_virtualenv() + self._source_env_managers.clear() + + def list_configured_sources(self) -> List[str]: + """List all sources that have credentials configured.""" + return self._credential_resolver.list_available_sources() + + def validate_credentials(self, source: str) -> bool: + """Check if credentials are configured for a source.""" + return self._credential_resolver.validate(source) diff --git a/src/brickbyte/_dedup.py b/src/brickbyte/_dedup.py new file mode 100644 index 0000000..c8dc7a9 --- /dev/null +++ b/src/brickbyte/_dedup.py @@ -0,0 +1,86 @@ +""" +Deduplication logic for brickbyte. + +Uses MERGE to remove duplicate records based on user-specified keys. +""" +import logging +from typing import List, Optional + +logger = logging.getLogger("brickbyte") + + +def deduplicate_stream( + executor, + table_name: str, + key_columns: List[str], + run_id_col: str, + extracted_at_col: str, + record_id_col: str, + flatten: bool = True, + dk_missing_col: str = "_dk_missing", +): + """ + Deduplicate a stream's table using MERGE. + + Keeps the row with the latest extracted_at per unique key combo. + On timestamp ties, breaks by record_id (lexicographic max). + Records with _dk_missing=true are excluded from dedup. + + Args: + executor: Writer instance with _execute (SQL) or spark (Spark) attribute + table_name: Fully qualified table name + key_columns: Columns to use as dedup keys + run_id_col: Name of the run_id column + extracted_at_col: Name of the extracted_at column + record_id_col: Name of the record_id column + flatten: Whether the table is in flatten mode + dk_missing_col: Name of the dk_missing indicator column + """ + if not key_columns: + return + + key_match = " AND ".join( + f"t.`{col}` <=> s.`{col}`" for col in key_columns + ) + + # Build the dedup MERGE statement + # This keeps only the latest record per key combo + merge_sql = f""" + MERGE INTO {table_name} t + USING ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY {', '.join(f'`{c}`' for c in key_columns)} + ORDER BY `{extracted_at_col}` DESC, `{record_id_col}` DESC + ) AS _rn + FROM {table_name} + WHERE `{dk_missing_col}` = false + ) s + ON {key_match} + AND t.`{record_id_col}` = s.`{record_id_col}` + WHEN MATCHED AND s._rn > 1 THEN DELETE + """ + + _execute_sql(executor, merge_sql) + + +def _execute_sql(executor, sql: str): + """Execute SQL via whatever executor is available.""" + if executor is None: + # Try Spark + try: + from pyspark.sql import SparkSession + + spark = SparkSession.getActiveSession() + if spark: + spark.sql(sql) + return + except ImportError: + pass + raise RuntimeError("No executor available for dedup SQL") + + if hasattr(executor, "spark"): + executor.spark.sql(sql) + elif hasattr(executor, "_execute"): + executor._execute(sql) + else: + raise RuntimeError(f"Unknown executor type: {type(executor)}") diff --git a/src/brickbyte/_progress.py b/src/brickbyte/_progress.py new file mode 100644 index 0000000..1c74291 --- /dev/null +++ b/src/brickbyte/_progress.py @@ -0,0 +1,100 @@ +""" +Progress reporting for brickbyte sync operations. +""" +import time +from dataclasses import dataclass +from typing import Callable, Optional + + +@dataclass +class ProgressEvent: + """Progress event emitted during sync.""" + + stream_name: str + records_processed: int + total_streams: int + streams_completed: int + elapsed_seconds: float + + +class ProgressReporter: + """ + Reports sync progress via callback and optional tqdm bar. + """ + + def __init__( + self, + total_streams: int, + callback: Optional[Callable[[ProgressEvent], None]] = None, + use_tqdm: bool = False, + ): + self.total_streams = total_streams + self.callback = callback + self.streams_completed = 0 + self._start_time = time.monotonic() + self._tqdm_bar = None + self._records_by_stream: dict = {} + + if use_tqdm or self._is_notebook(): + try: + from tqdm.auto import tqdm + + self._tqdm_bar = tqdm( + total=total_streams, + desc="brickbyte sync", + unit="stream", + ) + except ImportError: + pass + + def _is_notebook(self) -> bool: + """Detect if running in a notebook environment.""" + try: + from IPython import get_ipython + + shell = get_ipython() + if shell is None: + return False + return "ZMQInteractiveShell" in type(shell).__name__ + except (ImportError, NameError): + return False + + def record_processed(self, stream_name: str, count: int): + """Called periodically during record processing.""" + self._records_by_stream[stream_name] = count + + if count % 5000 == 0 and self.callback: + event = ProgressEvent( + stream_name=stream_name, + records_processed=count, + total_streams=self.total_streams, + streams_completed=self.streams_completed, + elapsed_seconds=time.monotonic() - self._start_time, + ) + self.callback(event) + + def stream_completed(self, stream_name: str, records: int): + """Called when a stream finishes.""" + self.streams_completed += 1 + self._records_by_stream[stream_name] = records + + if self._tqdm_bar: + self._tqdm_bar.update(1) + self._tqdm_bar.set_postfix( + stream=stream_name, records=records + ) + + if self.callback: + event = ProgressEvent( + stream_name=stream_name, + records_processed=records, + total_streams=self.total_streams, + streams_completed=self.streams_completed, + elapsed_seconds=time.monotonic() - self._start_time, + ) + self.callback(event) + + def close(self): + """Close tqdm bar if present.""" + if self._tqdm_bar: + self._tqdm_bar.close() diff --git a/src/brickbyte/_sanitize.py b/src/brickbyte/_sanitize.py new file mode 100644 index 0000000..b938114 --- /dev/null +++ b/src/brickbyte/_sanitize.py @@ -0,0 +1,61 @@ +""" +Stream name sanitization and SQL identifier validation for brickbyte. +""" +import re + + +def sanitize_stream_name(name: str) -> str: + """ + Sanitize a stream name for use as a table name. + + - Lowercase + - Replace hyphens, dots, and whitespace with underscores + - Strip characters that are invalid even in backtick-quoted identifiers + - Prefix with underscore if starts with digit + """ + result = name.lower() + result = re.sub(r"[-.\s]+", "_", result) + # Remove null bytes, backticks, semicolons + result = re.sub(r"[\x00`;]+", "", result) + # Strip leading/trailing underscores from the substitution + result = result.strip("_") or "_" + # Prefix with underscore if starts with digit + if result[0].isdigit(): + result = f"_{result}" + return result + + +def validate_identifier(name: str) -> str: + """ + Validate that a name is safe for use inside backtick-quoted identifiers. + + Only rejects characters that are unsafe even inside backtick-quoted + identifiers: null bytes, backticks, semicolons. Does NOT reject hyphens, + dots, or unicode — Databricks allows these inside backtick-quoted identifiers. + + Returns the validated name. + Raises ValueError if the name contains dangerous characters. + """ + if not name: + raise ValueError("Identifier cannot be empty") + + dangerous = re.search(r"[\x00`;]", name) + if dangerous: + raise ValueError( + f"Identifier '{name}' contains unsafe character: " + f"{repr(dangerous.group())}" + ) + + return name + + +def quoted_table_name(catalog: str, schema: str, table: str) -> str: + """ + Build a fully-qualified, backtick-quoted table name. + + Validates all parts and returns `catalog`.`schema`.`table`. + """ + validate_identifier(catalog) + validate_identifier(schema) + validate_identifier(table) + return f"`{catalog}`.`{schema}`.`{table}`" diff --git a/src/brickbyte/_schema.py b/src/brickbyte/_schema.py new file mode 100644 index 0000000..525c1f8 --- /dev/null +++ b/src/brickbyte/_schema.py @@ -0,0 +1,31 @@ +""" +Canonical schema constants and DDL for brickbyte tables. +""" + +# Raw mode columns (no underscore prefix - all columns are brickbyte-owned) +RAW_RECORD_ID = "record_id" +RAW_EXTRACTED_AT = "extracted_at" +RAW_DATA = "data" +RAW_RUN_ID = "run_id" + +RAW_COLUMNS = [RAW_RECORD_ID, RAW_EXTRACTED_AT, RAW_DATA, RAW_RUN_ID] + +# Flatten mode metadata columns (underscore prefix to avoid collision with source fields) +FLATTEN_RECORD_ID = "_record_id" +FLATTEN_EXTRACTED_AT = "_extracted_at" +FLATTEN_RUN_ID = "_run_id" + +FLATTEN_META_COLUMNS = [FLATTEN_RECORD_ID, FLATTEN_EXTRACTED_AT, FLATTEN_RUN_ID] + +# SQL DDL for raw table creation +RAW_TABLE_DDL = """ +CREATE TABLE IF NOT EXISTS {table_name} ( + record_id STRING, + extracted_at TIMESTAMP, + data STRING, + run_id STRING +) +""" + +# Dedup key missing indicator column +DK_MISSING = "_dk_missing" diff --git a/src/brickbyte/_state.py b/src/brickbyte/_state.py new file mode 100644 index 0000000..82f3981 --- /dev/null +++ b/src/brickbyte/_state.py @@ -0,0 +1,125 @@ +""" +Incremental sync state management for brickbyte. + +Manages the `__brickbyte_state` table to track sync state per (source, stream) pair. +""" +import json +import logging +from typing import Optional + +logger = logging.getLogger("brickbyte") + +STATE_TABLE_SUFFIX = "__brickbyte_state" + +STATE_TABLE_DDL = """ +CREATE TABLE IF NOT EXISTS {table_name} ( + source STRING, + stream_name STRING, + state STRING, + run_id STRING, + updated_at TIMESTAMP +) +""" + +UPSERT_STATE_SQL = """ +MERGE INTO {table_name} t +USING (SELECT :source AS source, :stream_name AS stream_name, + :state AS state, :run_id AS run_id, + current_timestamp() AS updated_at) s +ON t.source = s.source AND t.stream_name = s.stream_name +WHEN MATCHED THEN UPDATE SET + t.state = s.state, t.run_id = s.run_id, t.updated_at = s.updated_at +WHEN NOT MATCHED THEN INSERT (source, stream_name, state, run_id, updated_at) + VALUES (s.source, s.stream_name, s.state, s.run_id, s.updated_at) +""" + + +class StateManager: + """Manages incremental sync state in a Delta table.""" + + def __init__(self, catalog: str, schema: str): + self.catalog = catalog + self.schema = schema + self._state_table = f"`{catalog}`.`{schema}`.`{STATE_TABLE_SUFFIX}`" + self._spark = None + self._connection = None + self._initialized = False + + def _ensure_table(self): + """Create the state table if it doesn't exist.""" + if self._initialized: + return + + spark = self._get_spark() + if spark: + spark.sql(STATE_TABLE_DDL.format(table_name=self._state_table)) + else: + raise RuntimeError( + "StateManager requires either an active SparkSession or " + "a SQL connection to manage state." + ) + self._initialized = True + + def _get_spark(self): + """Get Spark session if available.""" + if self._spark is None: + try: + from pyspark.sql import SparkSession + + self._spark = SparkSession.getActiveSession() + except ImportError: + pass + return self._spark + + def save_state(self, source: str, stream_name: str, state: dict, run_id: str): + """Save state for a (source, stream) pair via MERGE upsert.""" + self._ensure_table() + state_json = json.dumps(state, default=str) + + spark = self._get_spark() + if spark: + spark.sql( + UPSERT_STATE_SQL.format(table_name=self._state_table), + args={ + "source": source, + "stream_name": stream_name, + "state": state_json, + "run_id": run_id, + }, + ) + else: + raise RuntimeError("StateManager requires Spark for state management.") + + def get_state(self, source: str, stream_name: str) -> Optional[dict]: + """Load state for a (source, stream) pair. Returns None if no state exists.""" + self._ensure_table() + + spark = self._get_spark() + if spark: + from pyspark.sql.functions import col + + df = ( + spark.table(self._state_table) + .filter( + (col("source") == source) & (col("stream_name") == stream_name) + ) + .select("state") + .limit(1) + ) + rows = df.collect() + if rows: + return json.loads(rows[0]["state"]) + return None + + raise RuntimeError("StateManager requires Spark for state management.") + + def clear_state(self, source: str, stream_name: str): + """Delete state for a (source, stream) pair.""" + self._ensure_table() + + spark = self._get_spark() + if spark: + spark.sql( + f"DELETE FROM {self._state_table} " + f"WHERE source = '{source}' AND stream_name = '{stream_name}'" + ) diff --git a/src/brickbyte/credentials.py b/src/brickbyte/credentials.py index b7b5369..c133b63 100644 --- a/src/brickbyte/credentials.py +++ b/src/brickbyte/credentials.py @@ -1,5 +1,5 @@ """ -Credential management for BrickByte. +Credential management for brickbyte. Provides automatic credential resolution from Databricks Secrets with optional YAML profiles for advanced use cases. @@ -14,29 +14,17 @@ class CredentialResolver: """ Resolves credentials from Databricks Secrets with convention-based discovery. - + Default convention: Scope: "brickbyte" (configurable) Keys: "{source-name}/{field}" (e.g., "source-s3/aws_access_key_id") - - Usage: - resolver = CredentialResolver() - creds = resolver.get_credentials("source-s3") - # Returns: {"aws_access_key_id": "...", "aws_secret_access_key": "..."} """ - + def __init__( self, secrets_scope: str = "brickbyte", profiles_path: Optional[str] = None, ): - """ - Initialize the credential resolver. - - Args: - secrets_scope: Databricks Secrets scope name (default: "brickbyte") - profiles_path: Optional path to YAML profiles file for advanced config - """ self.secrets_scope = secrets_scope self.profiles_path = profiles_path self._cache: Dict[str, Dict[str, Any]] = {} @@ -44,71 +32,99 @@ def __init__( self._mappings: Dict[str, str] = {} self._dbutils = None self._available_keys: Optional[List[str]] = None - + # Load profiles if provided if profiles_path: self._load_profiles(profiles_path) - + def _get_dbutils(self): """Get dbutils instance (lazy loading).""" if self._dbutils is None: try: from pyspark.sql import SparkSession + spark = SparkSession.getActiveSession() if spark: from pyspark.dbutils import DBUtils + self._dbutils = DBUtils(spark) except Exception: pass - + if self._dbutils is None: try: import IPython + self._dbutils = IPython.get_ipython().user_ns.get("dbutils") except Exception: pass - + return self._dbutils - + def _list_secrets_for_source(self, source: str) -> List[str]: """List all secret keys available for a source.""" dbutils = self._get_dbutils() if not dbutils: return [] - + try: # Cache the full list of keys if self._available_keys is None: secrets = dbutils.secrets.list(self.secrets_scope) self._available_keys = [s.key for s in secrets] - + # Filter keys that start with the source name prefix = f"{source}/" return [ - key[len(prefix):] for key in self._available_keys - if key.startswith(prefix) + key[len(prefix) :] for key in self._available_keys if key.startswith(prefix) ] except Exception as e: logger.debug(f"Could not list secrets: {e}") return [] - + def _get_secret(self, key: str) -> Optional[str]: - """Get a single secret value.""" + """Get a single secret value from the default scope.""" dbutils = self._get_dbutils() if not dbutils: return None - + try: return dbutils.secrets.get(scope=self.secrets_scope, key=key) except Exception as e: logger.debug(f"Could not get secret {key}: {e}") return None - + + def _get_secret_with_scope(self, scope: str, key: str) -> Optional[str]: + """Get a single secret value with explicit scope.""" + dbutils = self._get_dbutils() + if not dbutils: + return None + + try: + return dbutils.secrets.get(scope=scope, key=key) + except Exception as e: + logger.debug(f"Could not get secret {scope}/{key}: {e}") + return None + + def _set_nested(self, d: dict, dotted_key: str, value: Any): + """Set a value in a nested dict using dotted key notation. + + Example: _set_nested(d, "credentials.client_id", "val") + produces d = {"credentials": {"client_id": "val"}} + """ + keys = dotted_key.split(".") + current = d + for k in keys[:-1]: + if k not in current or not isinstance(current[k], dict): + current[k] = {} + current = current[k] + current[keys[-1]] = value + def _load_profiles(self, path: str): """Load YAML profiles from file.""" try: import yaml - + # Handle workspace paths if path.startswith("/Workspace"): dbutils = self._get_dbutils() @@ -120,7 +136,7 @@ def _load_profiles(self, path: str): else: with open(path, "r") as f: content = f.read() - + data = yaml.safe_load(content) self._profiles = data.get("profiles", {}) self._mappings = data.get("mappings", {}) @@ -129,64 +145,66 @@ def _load_profiles(self, path: str): logger.warning(f"Could not load profiles from {path}: {e}") self._profiles = {} self._mappings = {} - + def _resolve_profile(self, profile_name: str) -> Dict[str, Any]: """Resolve a named profile to credentials.""" if not self._profiles or profile_name not in self._profiles: return {} - + profile = self._profiles[profile_name] resolved = {} - + for key, value in profile.items(): if isinstance(value, str): # Check for secret reference: {{ secret('scope/key') }} or {{ secret('key') }} match = re.match(r"\{\{\s*secret\(['\"]([^'\"]+)['\"]\)\s*\}\}", value) if match: secret_ref = match.group(1) - # If no scope specified, use the default scope if "/" in secret_ref: - scope_key = secret_ref + # Explicit scope/key + scope, skey = secret_ref.split("/", 1) + secret_value = self._get_secret_with_scope(scope, skey) else: - scope_key = secret_ref - - secret_value = self._get_secret(scope_key) + # No scope specified, use default scope with source prefix + secret_value = self._get_secret(secret_ref) + if secret_value: resolved[key] = secret_value + else: + logger.warning( + f"Could not resolve secret reference '{secret_ref}' " + f"in profile '{profile_name}'" + ) else: resolved[key] = value else: resolved[key] = value - + return resolved - + def get_credentials(self, source: str) -> Dict[str, Any]: """ Get credentials for a source. - + Resolution order: 1. Check if source is mapped to a profile (from YAML) 2. Fall back to convention-based discovery from secrets - - Args: - source: Source connector name (e.g., "source-s3") - - Returns: - Dictionary of credentials for the source """ # Return cached if available if source in self._cache: return self._cache[source] - + credentials = {} - + # Check for profile mapping first if source in self._mappings: profile_name = self._mappings[source] credentials = self._resolve_profile(profile_name) if credentials: - logger.debug(f"Resolved credentials for {source} from profile '{profile_name}'") - + logger.debug( + f"Resolved credentials for {source} from profile '{profile_name}'" + ) + # Fall back to convention-based discovery if not credentials: keys = self._list_secrets_for_source(source) @@ -194,15 +212,18 @@ def get_credentials(self, source: str) -> Dict[str, Any]: full_key = f"{source}/{key}" value = self._get_secret(full_key) if value: - credentials[key] = value - + # Support dotted-key nested mapping + self._set_nested(credentials, key, value) + if credentials: - logger.debug(f"Discovered {len(credentials)} credentials for {source} from secrets") - + logger.debug( + f"Discovered {len(credentials)} credentials for {source} from secrets" + ) + # Cache the result self._cache[source] = credentials return credentials - + def merge_credentials( self, source: str, @@ -210,24 +231,17 @@ def merge_credentials( ) -> Dict[str, Any]: """ Merge discovered credentials into source_config. - + Explicit values in source_config take precedence over discovered credentials. - - Args: - source: Source connector name - source_config: User-provided configuration - - Returns: - Merged configuration with credentials """ discovered = self.get_credentials(source) if not discovered: return source_config - + # Deep merge - discovered credentials as base, source_config overrides merged = self._deep_merge(discovered, source_config) return merged - + def _deep_merge(self, base: Dict, override: Dict) -> Dict: """Deep merge two dictionaries, with override taking precedence.""" result = base.copy() @@ -237,45 +251,37 @@ def _deep_merge(self, base: Dict, override: Dict) -> Dict: else: result[key] = value return result - + def validate(self, source: str) -> bool: - """ - Validate that credentials exist for a source. - - Args: - source: Source connector name - - Returns: - True if credentials were found - """ + """Validate that credentials exist for a source.""" creds = self.get_credentials(source) return len(creds) > 0 - + def list_available_sources(self) -> List[str]: """List all sources that have credentials configured.""" dbutils = self._get_dbutils() if not dbutils: return list(self._mappings.keys()) - + try: if self._available_keys is None: secrets = dbutils.secrets.list(self.secrets_scope) self._available_keys = [s.key for s in secrets] - + # Extract unique source names from keys sources = set() for key in self._available_keys: if "/" in key: source = key.split("/")[0] sources.add(source) - + # Add mapped sources sources.update(self._mappings.keys()) - + return sorted(sources) except Exception: return list(self._mappings.keys()) - + def clear_cache(self): """Clear the credential cache.""" self._cache.clear() @@ -286,16 +292,7 @@ def create_credential_resolver( secrets_scope: str = "brickbyte", profiles_path: Optional[str] = None, ) -> CredentialResolver: - """ - Create a credential resolver. - - Args: - secrets_scope: Databricks Secrets scope (default: "brickbyte") - profiles_path: Optional path to YAML profiles file - - Returns: - CredentialResolver instance - """ + """Create a credential resolver.""" return CredentialResolver( secrets_scope=secrets_scope, profiles_path=profiles_path, diff --git a/src/brickbyte/enrichment/__init__.py b/src/brickbyte/enrichment/__init__.py index 2200174..6bf0a34 100644 --- a/src/brickbyte/enrichment/__init__.py +++ b/src/brickbyte/enrichment/__init__.py @@ -1,12 +1,15 @@ """ -BrickByte Enrichment Module. +brickbyte Enrichment Module. Provides AI-powered metadata enrichment for tables: - Column descriptions via Foundation Models - PII detection - Data classification """ -from brickbyte.enrichment.semantic import SemanticEnricher, enrich_table - -__all__ = ["SemanticEnricher", "enrich_table"] +from brickbyte.enrichment.semantic import ( + SQLSemanticEnricher, + SemanticEnricher, + enrich_table, +) +__all__ = ["SemanticEnricher", "SQLSemanticEnricher", "enrich_table"] diff --git a/src/brickbyte/preview.py b/src/brickbyte/preview.py index 5c7725e..96fdb58 100644 --- a/src/brickbyte/preview.py +++ b/src/brickbyte/preview.py @@ -1,20 +1,38 @@ """ -Preview engine for BrickByte. +Preview engine for brickbyte. Provides diff calculation and schema comparison before syncing. """ +import logging from dataclasses import dataclass, field from typing import Any, Dict, List, Optional +logger = logging.getLogger("brickbyte") + +# Mapping from Python types to approximate Spark types for comparison +_PYTHON_TO_SPARK = { + "int": "LongType", + "float": "DoubleType", + "str": "StringType", + "bool": "BooleanType", + "NoneType": "NullType", + "list": "ArrayType", + "dict": "MapType", + "datetime": "TimestampType", + "date": "DateType", + "bytes": "BinaryType", + "Decimal": "DecimalType", +} + @dataclass class SchemaChange: """Represents a schema change between source and target.""" - + column: str change_type: str # "added", "removed", "type_changed" source_type: Optional[str] = None target_type: Optional[str] = None - + def __str__(self) -> str: if self.change_type == "added": return f" + {self.column} ({self.source_type}) - NEW" @@ -27,7 +45,7 @@ def __str__(self) -> str: @dataclass class StreamPreview: """Preview information for a single stream.""" - + stream_name: str source_count: int target_count: int @@ -36,52 +54,50 @@ class StreamPreview: deleted_records: int = 0 schema_changes: List[SchemaChange] = field(default_factory=list) sample_records: List[dict] = field(default_factory=list) - + def __str__(self) -> str: parts = [] - - # Record counts + if self.new_records > 0: parts.append(f"+{self.new_records} new") if self.modified_records > 0: parts.append(f"~{self.modified_records} modified") if self.deleted_records > 0: parts.append(f"-{self.deleted_records} deleted") - + if not parts: if self.source_count >= 0: parts.append(f"{self.source_count} records") else: parts.append("Unknown records (Streaming)") - + line = f"{self.stream_name}: {' | '.join(parts)}" - - # Schema changes + if self.schema_changes: line += "\n Schema changes:" for change in self.schema_changes: line += f"\n {change}" - + return line @dataclass class PreviewResult: """Complete preview result for all streams.""" - + streams: List[StreamPreview] = field(default_factory=list) total_source_records: int = 0 total_new_records: int = 0 total_modified_records: int = 0 total_deleted_records: int = 0 has_schema_changes: bool = False - + def __str__(self) -> str: lines = ["=" * 60, "Sync Preview", "=" * 60, ""] - + for stream in self.streams: lines.append(str(stream)) - + lines.append("") lines.append("-" * 60) lines.append( @@ -90,121 +106,127 @@ def __str__(self) -> str: f"~{self.total_modified_records} modified, " f"-{self.total_deleted_records} deleted)" ) - + if self.has_schema_changes: - lines.append("⚠️ Schema changes detected") - + lines.append("Schema changes detected") + lines.append("=" * 60) - + return "\n".join(lines) class PreviewEngine: """ Generates previews of sync operations. - + Compares source data (sampled) with existing target tables to show: - Target record counts - Schema changes (inferred from samples) - Sample records """ - + def __init__(self, catalog: str, schema: str): - """ - Initialize the preview engine. - - Args: - catalog: Unity Catalog name - schema: Target schema name - """ self.catalog = catalog self.schema = schema self._spark = None - + @property def spark(self): """Get or create Spark session.""" if self._spark is None: try: from pyspark.sql import SparkSession + self._spark = SparkSession.builder.getOrCreate() except ImportError: return None return self._spark - + def get_table_name(self, stream_name: str) -> str: """Get fully qualified table name.""" return f"{self.catalog}.{self.schema}.{stream_name}" - + def table_exists(self, stream_name: str) -> bool: """Check if target table exists.""" if not self.spark: return False - + table_name = self.get_table_name(stream_name) return self.spark.catalog.tableExists(table_name) - + def get_target_count(self, stream_name: str) -> int: """Get record count from target table.""" if not self.table_exists(stream_name): return 0 - + table_name = self.get_table_name(stream_name) return self.spark.table(table_name).count() - + def get_target_schema(self, stream_name: str) -> Dict[str, str]: """Get schema of target table.""" if not self.table_exists(stream_name): return {} - + table_name = self.get_table_name(stream_name) df = self.spark.table(table_name) return {f.name: str(f.dataType) for f in df.schema.fields} - + def get_source_schema(self, sample_records: List[dict]) -> Dict[str, str]: """Infer schema from sample records.""" if not sample_records: return {} - - # Simple inference from first record - # In a real scenario, we might want to check Airbyte catalog + record = sample_records[0] return {k: type(v).__name__ for k, v in record.items()} - + def compare_schemas( self, source_schema: Dict[str, str], target_schema: Dict[str, str], ) -> List[SchemaChange]: - """Compare source and target schemas.""" + """Compare source and target schemas, including type changes.""" changes = [] - + source_cols = set(source_schema.keys()) target_cols = set(target_schema.keys()) - + # New columns for col in source_cols - target_cols: - changes.append(SchemaChange( - column=col, - change_type="added", - source_type=source_schema[col], - )) - + changes.append( + SchemaChange( + column=col, + change_type="added", + source_type=source_schema[col], + ) + ) + # Removed columns for col in target_cols - source_cols: - changes.append(SchemaChange( - column=col, - change_type="removed", - target_type=target_schema[col], - )) - - # Type changes - simplified - # Note: inferred source types (python types) vs target types (spark types) - # mismatch is expected, so we largely skip strict type comparison here - # unless we map them properly. For now, we omit type_changed to avoid noise. - + changes.append( + SchemaChange( + column=col, + change_type="removed", + target_type=target_schema[col], + ) + ) + + # Type changes (map Python types to Spark types for comparison) + for col in source_cols & target_cols: + source_type = source_schema[col] + target_type = target_schema[col] + mapped_source = _PYTHON_TO_SPARK.get(source_type, source_type) + if mapped_source != target_type and source_type != target_type: + changes.append( + SchemaChange( + column=col, + change_type="type_changed", + source_type=source_type, + target_type=target_type, + ) + ) + return changes - + def preview_stream( self, ab_source: Any, @@ -213,63 +235,47 @@ def preview_stream( ) -> StreamPreview: """Generate preview for a single stream.""" target_count = self.get_target_count(stream_name) - - # Get samples from stream + sample_records = [] try: - # We only peek at the first N records records_gen = ab_source.get_records(stream_name) for i, record in enumerate(records_gen): if i >= sample_size: break sample_records.append(record) - except Exception: - pass - + except Exception as e: + logger.warning(f"Could not sample records for {stream_name}: {e}") + # Compare schemas source_schema = self.get_source_schema(sample_records) target_schema = self.get_target_schema(stream_name) schema_changes = self.compare_schemas(source_schema, target_schema) - + return StreamPreview( stream_name=stream_name, - source_count=-1, # Unknown in streaming + source_count=-1, target_count=target_count, - new_records=-1, # Unknown - modified_records=-1, # Unknown - deleted_records=-1, # Unknown + new_records=-1, + modified_records=-1, + deleted_records=-1, schema_changes=schema_changes, sample_records=sample_records, ) - + def preview( self, ab_source: Any, streams: List[str], sample_size: int = 5, ) -> PreviewResult: - """ - Generate preview for all streams. - - Args: - ab_source: Initialized Airbyte source - streams: List of stream names - sample_size: Number of sample records per stream - - Returns: - PreviewResult with all stream previews - """ + """Generate preview for all streams.""" result = PreviewResult() - + for stream_name in streams: - stream_preview = self.preview_stream( - ab_source, stream_name, sample_size - ) + stream_preview = self.preview_stream(ab_source, stream_name, sample_size) result.streams.append(stream_preview) - - # Totals are less relevant with unknown counts, but we act best effort + if stream_preview.schema_changes: result.has_schema_changes = True - - return result + return result diff --git a/src/brickbyte/writers/__init__.py b/src/brickbyte/writers/__init__.py index 9a0a4ed..f987de1 100644 --- a/src/brickbyte/writers/__init__.py +++ b/src/brickbyte/writers/__init__.py @@ -1,15 +1,14 @@ """ -Brickbyte Writers Module. +brickbyte Writers Module. """ import logging -from typing import Optional, Union +from typing import Dict, List, Optional, Union from brickbyte.writers.base import BaseWriter -from brickbyte.writers.spark_streaming_writer import SparkStreamingWriter -from brickbyte.writers.sql_streaming_writer import SQLStreamingWriter logger = logging.getLogger(__name__) + def create_streaming_writer( catalog: str, schema: str, @@ -19,26 +18,31 @@ def create_streaming_writer( buffer_size_records: int = 50000, buffer_size_mb: int = 100, flatten: bool = False, -) -> Union[SparkStreamingWriter, SQLStreamingWriter]: + run_id: str = "", + dedup_keys: Optional[Dict[str, List[str]]] = None, +) -> BaseWriter: """ Create a streaming writer based on environment. - + Logic: 1. If Spark is active (and not force_sql=True) -> SparkStreamingWriter (No Volume needed). 2. Else -> SQLStreamingWriter (Volume REQUIRED). """ - + # 1. Attempt to detect Spark spark_active = False if not force_sql: try: from pyspark.sql import SparkSession + if SparkSession.getActiveSession(): spark_active = True except ImportError: pass - + if spark_active: + from brickbyte.writers.spark_streaming_writer import SparkStreamingWriter + logger.info("Spark detected. Using Native Spark Streaming Writer.") return SparkStreamingWriter( catalog=catalog, @@ -46,30 +50,33 @@ def create_streaming_writer( buffer_size_records=buffer_size_records, buffer_size_mb=buffer_size_mb, flatten=flatten, + run_id=run_id, + dedup_keys=dedup_keys, ) - + # 2. Fallback to SQL Writer logger.info("Spark not active (or forced off). Using SQL Streaming Writer.") - + if not staging_volume: raise ValueError( - "staging_volume is REQUIRED when running outside of Databricks (or when forcing SQL mode). " - "Because we cannot access local disk from the warehouse, we must stage files in a Volume." + "staging_volume is REQUIRED when running outside of Databricks " + "(or when forcing SQL mode). " + "Because we cannot access local disk from the warehouse, " + "we must stage files in a Volume." ) from databricks.sdk import WorkspaceClient - + + from brickbyte.writers.sql_streaming_writer import SQLStreamingWriter + w = WorkspaceClient() server_hostname = w.config.host.replace("https://", "").rstrip("/") access_token = w.config.token - + # Auto-discover warehouse if not provided if not warehouse_id: warehouses = list(w.warehouses.list()) - running = [ - wh for wh in warehouses - if wh.state and wh.state.value == "RUNNING" - ] + running = [wh for wh in warehouses if wh.state and wh.state.value == "RUNNING"] if running: warehouse_id = running[0].id else: @@ -77,9 +84,9 @@ def create_streaming_writer( "No running SQL warehouse found. " "Specify warehouse_id or start a warehouse." ) - + http_path = f"/sql/1.0/warehouses/{warehouse_id}" - + return SQLStreamingWriter( catalog=catalog, schema=schema, @@ -90,12 +97,12 @@ def create_streaming_writer( buffer_size_records=buffer_size_records, buffer_size_mb=buffer_size_mb, flatten=flatten, + run_id=run_id, + dedup_keys=dedup_keys, ) __all__ = [ "BaseWriter", - "SparkStreamingWriter", - "SQLStreamingWriter", "create_streaming_writer", ] diff --git a/src/brickbyte/writers/base.py b/src/brickbyte/writers/base.py index 25c9d29..b5add61 100644 --- a/src/brickbyte/writers/base.py +++ b/src/brickbyte/writers/base.py @@ -1,65 +1,91 @@ """ -Abstract base writer for BrickByte. +Abstract base writer for brickbyte. Defines the interface all writers must implement. """ from abc import ABC, abstractmethod -from typing import Dict, Optional +from typing import Dict, List, Optional class BaseWriter(ABC): """ - Abstract base class for all BrickByte writers. - + Abstract base class for all brickbyte writers. + Writers handle writing data from PyAirbyte cache to Databricks. """ - - def __init__(self, catalog: str, schema: str): - """ - Initialize the writer. - - Args: - catalog: Unity Catalog name - schema: Target schema name - """ + + def __init__( + self, + catalog: str, + schema: str, + run_id: str = "", + dedup_keys: Optional[Dict[str, List[str]]] = None, + ): self.catalog = catalog self.schema = schema - + self.run_id = run_id + self.dedup_keys = dedup_keys + def get_table_name(self, stream_name: str) -> str: - """Get fully qualified table name for a stream.""" - return f"{self.catalog}.{self.schema}.{stream_name}" - + """Get fully qualified, backtick-quoted table name for a stream.""" + from brickbyte._sanitize import quoted_table_name, sanitize_stream_name + + sanitized = sanitize_stream_name(stream_name) + return quoted_table_name(self.catalog, self.schema, sanitized) + + def get_staging_table_name(self, stream_name: str, run_id: str) -> str: + """Get staging table name for safe overwrite.""" + from brickbyte._sanitize import quoted_table_name, sanitize_stream_name + + sanitized = sanitize_stream_name(stream_name) + run_id_short = run_id[:8] + staging_name = f"{sanitized}__stg__{run_id_short}" + return quoted_table_name(self.catalog, self.schema, staging_name) + + def _get_dedup_keys_for_stream(self, stream_name: str) -> Optional[List[str]]: + """Get dedup keys for a specific stream.""" + if self.dedup_keys is None: + return None + # Check for __all__ (list was expanded to all streams) + if "__all__" in self.dedup_keys: + return self.dedup_keys["__all__"] + return self.dedup_keys.get(stream_name) + @abstractmethod def table_exists(self, stream_name: str) -> bool: """Check if a table exists.""" pass - + @abstractmethod def get_table_schema(self, stream_name: str) -> Optional[Dict[str, str]]: - """ - Get schema of an existing table. - - Returns: - Dict mapping column names to types, or None if table doesn't exist - """ + """Get schema of an existing table.""" pass - + @abstractmethod def drop_table(self, stream_name: str): """Drop a table if it exists.""" pass - + @abstractmethod def write_record(self, stream_name: str, record: dict): """Buffer a single record for writing.""" pass - + @abstractmethod def flush_stream(self, stream_name: str): """Flush buffered records for a specific stream.""" pass - + @abstractmethod def close(self): """Flush all buffers and clean up resources.""" pass + @abstractmethod + def safe_overwrite_begin(self, stream_name: str, run_id: str): + """Begin safe overwrite — redirect writes to staging table.""" + pass + + @abstractmethod + def safe_overwrite_finish(self, stream_name: str, run_id: str): + """Finish safe overwrite — atomic swap from staging to target.""" + pass diff --git a/src/brickbyte/writers/spark_streaming_writer.py b/src/brickbyte/writers/spark_streaming_writer.py index e93e746..a1b45ba 100644 --- a/src/brickbyte/writers/spark_streaming_writer.py +++ b/src/brickbyte/writers/spark_streaming_writer.py @@ -1,5 +1,5 @@ """ -Spark Streaming writer for Brickbyte using native Databricks/Spark execution. +Spark Streaming writer for brickbyte using native Databricks/Spark execution. Uses micro-batch streaming for: - Bounded memory usage (flushes at configurable thresholds) @@ -8,11 +8,11 @@ """ import json import logging -import sys -from datetime import datetime +from datetime import datetime, timezone from typing import Dict, List, Optional from uuid import uuid4 +from brickbyte._schema import DK_MISSING from brickbyte.writers.base import BaseWriter logger = logging.getLogger(__name__) @@ -21,11 +21,6 @@ class SparkStreamingWriter(BaseWriter): """ Writes data to Databricks using micro-batch streaming. - - Each flush writes to Delta immediately, providing: - - Implicit checkpointing (resume from last successful batch on failure) - - Bounded memory (configurable batch size) - - Databricks auto-optimize handles small file compaction """ def __init__( @@ -35,18 +30,10 @@ def __init__( buffer_size_records: int = 50000, buffer_size_mb: int = 100, flatten: bool = False, + run_id: str = "", + dedup_keys: Optional[Dict[str, List[str]]] = None, ): - """ - Initialize Spark Streaming Writer. - - Args: - catalog: Unity Catalog name - schema: Target schema name - buffer_size_records: Records per micro-batch (default: 50k) - buffer_size_mb: Max batch size in MB (default: 100MB) - flatten: If True, flatten record fields into columns (default: False) - """ - super().__init__(catalog, schema) + super().__init__(catalog, schema, run_id=run_id, dedup_keys=dedup_keys) self.buffer_size_records = buffer_size_records self.buffer_size_bytes = buffer_size_mb * 1024 * 1024 self.flatten = flatten @@ -55,95 +42,134 @@ def __init__( self._buffers: Dict[str, List[dict]] = {} self._buffer_counts: Dict[str, int] = {} self._buffer_sizes: Dict[str, int] = {} + self._overwrite_streams: Dict[str, str] = {} # stream_name -> staging_table_name @property def spark(self): """Get or create Spark session.""" if self._spark is None: from pyspark.sql import SparkSession + self._spark = SparkSession.builder.getOrCreate() return self._spark def table_exists(self, stream_name: str) -> bool: - """Check if a table exists.""" table_name = self.get_table_name(stream_name) return self.spark.catalog.tableExists(table_name) def get_table_schema(self, stream_name: str) -> Optional[Dict[str, str]]: - """Get schema of an existing table.""" if not self.table_exists(stream_name): return None - + table_name = self.get_table_name(stream_name) df = self.spark.table(table_name) return {f.name: str(f.dataType) for f in df.schema.fields} def drop_table(self, stream_name: str): - """Drop a table if it exists.""" table_name = self.get_table_name(stream_name) self.spark.sql(f"DROP TABLE IF EXISTS {table_name}") - def _transform_record(self, record: dict) -> dict: + def _transform_record(self, stream_name: str, record: dict) -> dict: """Transform record based on flatten mode.""" if self.flatten: - # Flattened: all fields as top-level columns + metadata transformed = dict(record) - transformed["_id"] = str(uuid4()) - transformed["_extracted_at"] = datetime.now() + transformed["_record_id"] = str(uuid4()) + transformed["_extracted_at"] = datetime.now(timezone.utc) + transformed["_run_id"] = self.run_id + + # Add dedup key columns if configured + dk_keys = self._get_dedup_keys_for_stream(stream_name) + if dk_keys is not None: + dk_missing = False + for i, key in enumerate(dk_keys): + if key in record: + transformed[f"_dk_{i}"] = record[key] + else: + transformed[f"_dk_{i}"] = None + dk_missing = True + transformed[DK_MISSING] = dk_missing + return transformed else: - # Raw: 3 columns with JSON blob - return { - "id": str(uuid4()), - "extracted_at": datetime.now(), - "data": json.dumps(record, default=str) + transformed = { + "record_id": str(uuid4()), + "extracted_at": datetime.now(timezone.utc), + "data": json.dumps(record, default=str), + "run_id": self.run_id, } + # Add dedup key columns if configured + dk_keys = self._get_dedup_keys_for_stream(stream_name) + if dk_keys is not None: + dk_missing = False + for i, key in enumerate(dk_keys): + if key in record: + transformed[f"_dk_{i}"] = record[key] + else: + transformed[f"_dk_{i}"] = None + dk_missing = True + transformed[DK_MISSING] = dk_missing + + return transformed + def write_record(self, stream_name: str, record: dict): """Buffer a single record.""" if stream_name not in self._buffers: self._buffers[stream_name] = [] self._buffer_counts[stream_name] = 0 self._buffer_sizes[stream_name] = 0 - - transformed = self._transform_record(record) + + transformed = self._transform_record(stream_name, record) self._buffers[stream_name].append(transformed) self._buffer_counts[stream_name] += 1 - - # Estimate size based on data field or full record + + # Estimate size if self.flatten: - self._buffer_sizes[stream_name] += sys.getsizeof(str(transformed)) + self._buffer_sizes[stream_name] += sum( + len(str(v).encode("utf-8")) for v in transformed.values() + ) else: - self._buffer_sizes[stream_name] += sys.getsizeof(transformed.get("data", "")) - + self._buffer_sizes[stream_name] += len( + transformed["data"].encode("utf-8") + ) + # Flush micro-batch when thresholds hit - if (self._buffer_counts[stream_name] >= self.buffer_size_records or - self._buffer_sizes[stream_name] >= self.buffer_size_bytes): + if ( + self._buffer_counts[stream_name] >= self.buffer_size_records + or self._buffer_sizes[stream_name] >= self.buffer_size_bytes + ): self._write_micro_batch(stream_name) + def _get_write_table_name(self, stream_name: str) -> str: + """Get the table name to write to (staging during overwrite, target otherwise).""" + if stream_name in self._overwrite_streams: + return self._overwrite_streams[stream_name] + return self.get_table_name(stream_name) + def _write_micro_batch(self, stream_name: str): - """Write a micro-batch to Delta (each call = implicit checkpoint).""" + """Write a micro-batch to Delta.""" if stream_name not in self._buffers or not self._buffers[stream_name]: return records = self._buffers[stream_name] batch_count = len(records) - table_name = self.get_table_name(stream_name) - + table_name = self._get_write_table_name(stream_name) + try: df = self.spark.createDataFrame(records) - (df.write - .format("delta") - .mode("append") - .option("mergeSchema", "true") - .saveAsTable(table_name)) - + ( + df.write.format("delta") + .mode("append") + .option("mergeSchema", "true") + .saveAsTable(table_name) + ) + logger.debug("Wrote %d records to %s", batch_count, table_name) - + except Exception as e: logger.error("Error writing batch for %s: %s", stream_name, e) raise - + # Reset buffer self._buffers[stream_name] = [] self._buffer_counts[stream_name] = 0 @@ -155,5 +181,108 @@ def flush_stream(self, stream_name: str): def close(self): """Flush all remaining buffers.""" - for stream_name in self._buffers: + for stream_name in list(self._buffers.keys()): self.flush_stream(stream_name) + + def safe_overwrite_begin(self, stream_name: str, run_id: str): + """Begin safe overwrite — redirect writes to staging table.""" + staging_name = self.get_staging_table_name(stream_name, run_id) + self._overwrite_streams[stream_name] = staging_name + # Drop any leftover staging table + self.spark.sql(f"DROP TABLE IF EXISTS {staging_name}") + + def safe_overwrite_finish(self, stream_name: str, run_id: str): + """Finish safe overwrite — atomic swap from staging to target.""" + staging_name = self.get_staging_table_name(stream_name, run_id) + target_name = self.get_table_name(stream_name) + + try: + target_exists = self.spark.catalog.tableExists(target_name) + + if target_exists: + self._atomic_overwrite(target_name, staging_name) + self.spark.sql(f"DROP TABLE IF EXISTS {staging_name}") + else: + self.spark.sql( + f"ALTER TABLE {staging_name} RENAME TO {target_name}" + ) + except Exception: + # On failure, drop staging table, target untouched + self.spark.sql(f"DROP TABLE IF EXISTS {staging_name}") + raise + finally: + self._overwrite_streams.pop(stream_name, None) + + def _atomic_overwrite(self, target_name: str, staging_name: str): + """Perform atomic INSERT OVERWRITE with schema alignment.""" + target_df = self.spark.table(target_name) + staging_df = self.spark.table(staging_name) + + target_schema = {f.name: str(f.dataType) for f in target_df.schema.fields} + staging_schema = {f.name: str(f.dataType) for f in staging_df.schema.fields} + + target_cols = set(target_schema.keys()) + staging_cols = set(staging_schema.keys()) + + # Check for incompatible type changes + _SAFE_WIDENINGS = { + ("IntegerType", "LongType"), + ("IntegerType", "DoubleType"), + ("LongType", "DoubleType"), + ("FloatType", "DoubleType"), + ("ShortType", "IntegerType"), + ("ShortType", "LongType"), + ("ByteType", "ShortType"), + ("ByteType", "IntegerType"), + ("ByteType", "LongType"), + } + + for col in target_cols & staging_cols: + t_type = target_schema[col] + s_type = staging_schema[col] + if t_type != s_type: + if (s_type, t_type) not in _SAFE_WIDENINGS and ( + t_type, + s_type, + ) not in _SAFE_WIDENINGS: + # Check if one can be cast to the other + if t_type != "StringType" and s_type != "StringType": + raise ValueError( + f"Incompatible type change for column '{col}': " + f"{t_type} -> {s_type}. " + f"Drop the table manually to reset schema." + ) + + # Add new columns from staging to target + new_cols = staging_cols - target_cols + for col in new_cols: + col_type = staging_schema[col] + self.spark.sql( + f"ALTER TABLE {target_name} ADD COLUMNS (`{col}` {col_type})" + ) + + # Build SELECT for INSERT OVERWRITE with all columns + all_cols = target_cols | staging_cols + select_parts = [] + for col in sorted(all_cols): + if col in staging_cols and col in target_cols: + s_type = staging_schema[col] + t_type = target_schema[col] + if s_type != t_type and (s_type, t_type) in _SAFE_WIDENINGS: + select_parts.append(f"CAST(`{col}` AS {t_type}) AS `{col}`") + elif s_type != t_type and (t_type, s_type) in _SAFE_WIDENINGS: + select_parts.append(f"CAST(`{col}` AS {s_type}) AS `{col}`") + else: + select_parts.append(f"`{col}`") + elif col in staging_cols: + select_parts.append(f"`{col}`") + else: + select_parts.append(f"NULL AS `{col}`") + + col_list = ", ".join(f"`{c}`" for c in sorted(all_cols)) + select_expr = ", ".join(select_parts) + + self.spark.sql( + f"INSERT OVERWRITE {target_name} ({col_list}) " + f"SELECT {select_expr} FROM {staging_name}" + ) diff --git a/src/brickbyte/writers/sql_streaming_writer.py b/src/brickbyte/writers/sql_streaming_writer.py index 2b499d9..4645515 100644 --- a/src/brickbyte/writers/sql_streaming_writer.py +++ b/src/brickbyte/writers/sql_streaming_writer.py @@ -1,5 +1,5 @@ """ -SQL Streaming writer for Brickbyte using PyArrow buffering and COPY INTO. +SQL Streaming writer for brickbyte using PyArrow buffering and COPY INTO. Uses micro-batch streaming for: - Bounded memory usage (flushes at configurable thresholds) @@ -9,14 +9,15 @@ import json import logging import os -import sys -from datetime import datetime +import shutil +from datetime import datetime, timezone from typing import Dict, List, Optional from uuid import uuid4 import pyarrow as pa import pyarrow.parquet as pq +from brickbyte._schema import DK_MISSING, RAW_TABLE_DDL from brickbyte.writers.base import BaseWriter logger = logging.getLogger(__name__) @@ -25,11 +26,6 @@ class SQLStreamingWriter(BaseWriter): """ Writes data to Databricks using micro-batch streaming via SQL Connector. - - Each flush writes to Delta immediately via COPY INTO, providing: - - Implicit checkpointing (resume from last successful batch on failure) - - Bounded memory (configurable batch size) - - Databricks auto-optimize handles small file compaction """ def __init__( @@ -43,28 +39,16 @@ def __init__( buffer_size_records: int = 50000, buffer_size_mb: int = 100, flatten: bool = False, + run_id: str = "", + dedup_keys: Optional[Dict[str, List[str]]] = None, ): - """ - Initialize SQL Streaming Writer. - - Args: - catalog: Unity Catalog name - schema: Target schema name - staging_volume: Unity Catalog Volume path for staging parquet files - server_hostname: Databricks server hostname - http_path: SQL Warehouse HTTP path - access_token: Databricks access token - buffer_size_records: Records per micro-batch (default: 50k) - buffer_size_mb: Max batch size in MB (default: 100MB) - flatten: If True, flatten record fields into columns (default: False) - """ - super().__init__(catalog, schema) + super().__init__(catalog, schema, run_id=run_id, dedup_keys=dedup_keys) self.staging_volume = staging_volume self.server_hostname = server_hostname self.http_path = http_path self._access_token = access_token self.flatten = flatten - + self.buffer_size_records = buffer_size_records self.buffer_size_bytes = buffer_size_mb * 1024 * 1024 @@ -72,7 +56,9 @@ def __init__( self._buffers: Dict[str, List[dict]] = {} self._buffer_counts: Dict[str, int] = {} self._buffer_sizes: Dict[str, int] = {} - + self._batch_index: int = 0 + self._overwrite_streams: Dict[str, str] = {} + parts = self.staging_volume.split(".") if len(parts) != 3: raise ValueError( @@ -81,10 +67,20 @@ def __init__( ) self._vol_subpath = os.path.join(parts[0], parts[1], parts[2]) + # Validate Volume path exists + vol_base = f"/Volumes/{self._vol_subpath}" + if not os.path.exists(vol_base): + raise EnvironmentError( + f"Volume path '{vol_base}' does not exist. " + f"Ensure you are running on Databricks and the Volume " + f"'{self.staging_volume}' has been created in Unity Catalog." + ) + def _get_connection(self): """Get or create database connection.""" if self._connection is None: from databricks import sql + self._connection = sql.connect( server_hostname=self.server_hostname, http_path=self.http_path, @@ -105,13 +101,15 @@ def _execute(self, query: str): def _get_staging_dir(self, stream_name: str) -> str: """Get staging directory path in Volume.""" + from brickbyte._sanitize import sanitize_stream_name + + sanitized = sanitize_stream_name(stream_name) base_path = f"/Volumes/{self._vol_subpath}" - stream_dir = os.path.join(base_path, "brickbyte_streaming", stream_name) + stream_dir = os.path.join(base_path, "brickbyte_streaming", sanitized) os.makedirs(stream_dir, exist_ok=True) return stream_dir def table_exists(self, stream_name: str) -> bool: - """Check if a table exists.""" table_name = self.get_table_name(stream_name) try: self._execute(f"DESCRIBE TABLE {table_name}") @@ -120,111 +118,175 @@ def table_exists(self, stream_name: str) -> bool: return False def get_table_schema(self, stream_name: str) -> Optional[Dict[str, str]]: - """Get schema of an existing table.""" if not self.table_exists(stream_name): return None - + table_name = self.get_table_name(stream_name) conn = self._get_connection() cursor = conn.cursor() - cursor.execute(f"DESCRIBE TABLE {table_name}") - results = cursor.fetchall() - cursor.close() - return {row[0]: row[1] for row in results} + try: + cursor.execute(f"DESCRIBE TABLE {table_name}") + results = cursor.fetchall() + return {row[0]: row[1] for row in results} + finally: + cursor.close() def drop_table(self, stream_name: str): - """Drop a table if it exists.""" table_name = self.get_table_name(stream_name) self._execute(f"DROP TABLE IF EXISTS {table_name}") - def _transform_record(self, record: dict) -> dict: + def _transform_record(self, stream_name: str, record: dict) -> dict: """Transform record based on flatten mode.""" if self.flatten: - # Flattened: all fields as top-level columns + metadata transformed = dict(record) - transformed["_id"] = str(uuid4()) - transformed["_extracted_at"] = datetime.now() + transformed["_record_id"] = str(uuid4()) + transformed["_extracted_at"] = datetime.now(timezone.utc) + transformed["_run_id"] = self.run_id + + dk_keys = self._get_dedup_keys_for_stream(stream_name) + if dk_keys is not None: + dk_missing = False + for i, key in enumerate(dk_keys): + if key in record: + transformed[f"_dk_{i}"] = record[key] + else: + transformed[f"_dk_{i}"] = None + dk_missing = True + transformed[DK_MISSING] = dk_missing + return transformed else: - # Raw: 3 columns with JSON blob - return { - "id": str(uuid4()), - "extracted_at": datetime.now(), - "data": json.dumps(record, default=str) + transformed = { + "record_id": str(uuid4()), + "extracted_at": datetime.now(timezone.utc), + "data": json.dumps(record, default=str), + "run_id": self.run_id, } + dk_keys = self._get_dedup_keys_for_stream(stream_name) + if dk_keys is not None: + dk_missing = False + for i, key in enumerate(dk_keys): + if key in record: + transformed[f"_dk_{i}"] = record[key] + else: + transformed[f"_dk_{i}"] = None + dk_missing = True + transformed[DK_MISSING] = dk_missing + + return transformed + def write_record(self, stream_name: str, record: dict): """Buffer a single record.""" if stream_name not in self._buffers: self._buffers[stream_name] = [] self._buffer_counts[stream_name] = 0 self._buffer_sizes[stream_name] = 0 - - transformed = self._transform_record(record) + + transformed = self._transform_record(stream_name, record) self._buffers[stream_name].append(transformed) self._buffer_counts[stream_name] += 1 - - # Estimate size based on data field or full record + + # Estimate size if self.flatten: - self._buffer_sizes[stream_name] += sys.getsizeof(str(transformed)) + self._buffer_sizes[stream_name] += sum( + len(str(v).encode("utf-8")) for v in transformed.values() + ) else: - self._buffer_sizes[stream_name] += sys.getsizeof(transformed.get("data", "")) - + self._buffer_sizes[stream_name] += len( + transformed["data"].encode("utf-8") + ) + # Check both thresholds - if (self._buffer_counts[stream_name] >= self.buffer_size_records or - self._buffer_sizes[stream_name] >= self.buffer_size_bytes): + if ( + self._buffer_counts[stream_name] >= self.buffer_size_records + or self._buffer_sizes[stream_name] >= self.buffer_size_bytes + ): self.flush_stream(stream_name) + def _get_write_table_name(self, stream_name: str) -> str: + """Get the table name to write to.""" + if stream_name in self._overwrite_streams: + return self._overwrite_streams[stream_name] + return self.get_table_name(stream_name) + def flush_stream(self, stream_name: str): """Flush buffer for a specific stream.""" if stream_name not in self._buffers or not self._buffers[stream_name]: return records = self._buffers[stream_name] - + table_name = self._get_write_table_name(stream_name) + + staging_dir = self._get_staging_dir(stream_name) + filename = f"{self.run_id}_{self._batch_index:06d}.parquet" + file_path = os.path.join(staging_dir, filename) + self._batch_index += 1 + try: table = pa.Table.from_pylist(records) - - staging_dir = self._get_staging_dir(stream_name) - filename = f"batch_{datetime.now().strftime('%Y%m%d%H%M%S%f')}.parquet" - file_path = os.path.join(staging_dir, filename) - - pq.write_table(table, file_path, compression='zstd') - - table_name = self.get_table_name(stream_name) - - # For raw mode, create table with known schema - # For flatten mode, rely on COPY INTO with mergeSchema + pq.write_table(table, file_path, compression="zstd") + if not self.flatten: - create_query = f""" - CREATE TABLE IF NOT EXISTS {table_name} ( - id STRING, - extracted_at TIMESTAMP, - data STRING - ) - """ + create_query = RAW_TABLE_DDL.format(table_name=table_name) self._execute(create_query) - + else: + # Flatten first-write: infer DDL from PyArrow schema + if not self._table_exists_by_name(table_name): + ddl = self._infer_ddl_from_arrow(table.schema, table_name) + self._execute(ddl) + copy_query = f""" COPY INTO {table_name} FROM '{file_path}' FILEFORMAT = PARQUET FORMAT_OPTIONS ('mergeSchema' = 'true') - COPY_OPTIONS ('force' = 'true') """ self._execute(copy_query) - - os.remove(file_path) - + except Exception as e: logger.error(f"Error flushing stream {stream_name}: {e}") raise - + finally: + # Always clean up the parquet file + if os.path.exists(file_path): + os.remove(file_path) + # Reset buffer self._buffers[stream_name] = [] self._buffer_counts[stream_name] = 0 self._buffer_sizes[stream_name] = 0 + def _table_exists_by_name(self, table_name: str) -> bool: + """Check if a table exists by its full quoted name.""" + try: + self._execute(f"DESCRIBE TABLE {table_name}") + return True + except Exception: + return False + + def _infer_ddl_from_arrow(self, arrow_schema: pa.Schema, table_name: str) -> str: + """Generate CREATE TABLE DDL from a PyArrow schema.""" + _TYPE_MAP = { + pa.string(): "STRING", + pa.int64(): "BIGINT", + pa.int32(): "INT", + pa.float64(): "DOUBLE", + pa.float32(): "FLOAT", + pa.bool_(): "BOOLEAN", + pa.date32(): "DATE", + } + + columns = [] + for field in arrow_schema: + sql_type = _TYPE_MAP.get(field.type, "STRING") + if pa.types.is_timestamp(field.type): + sql_type = "TIMESTAMP" + columns.append(f" `{field.name}` {sql_type}") + + cols_ddl = ",\n".join(columns) + return f"CREATE TABLE IF NOT EXISTS {table_name} (\n{cols_ddl}\n)" + def close(self): """Flush all remaining buffers and close connection.""" for stream_name in list(self._buffers.keys()): @@ -233,10 +295,80 @@ def close(self): try: staging_dir = self._get_staging_dir(stream_name) if os.path.exists(staging_dir): - os.rmdir(staging_dir) + shutil.rmtree(staging_dir) except Exception: pass - + if self._connection: self._connection.close() self._connection = None + + def safe_overwrite_begin(self, stream_name: str, run_id: str): + """Begin safe overwrite — redirect writes to staging table.""" + staging_name = self.get_staging_table_name(stream_name, run_id) + self._overwrite_streams[stream_name] = staging_name + try: + self._execute(f"DROP TABLE IF EXISTS {staging_name}") + except Exception: + pass + + def safe_overwrite_finish(self, stream_name: str, run_id: str): + """Finish safe overwrite — atomic swap from staging to target.""" + staging_name = self.get_staging_table_name(stream_name, run_id) + target_name = self.get_table_name(stream_name) + + try: + target_exists = self._table_exists_by_name(target_name) + + if target_exists: + self._atomic_overwrite_sql(target_name, staging_name) + self._execute(f"DROP TABLE IF EXISTS {staging_name}") + else: + self._execute( + f"ALTER TABLE {staging_name} RENAME TO {target_name}" + ) + except Exception: + self._execute(f"DROP TABLE IF EXISTS {staging_name}") + raise + finally: + self._overwrite_streams.pop(stream_name, None) + + def _atomic_overwrite_sql(self, target_name: str, staging_name: str): + """Perform atomic INSERT OVERWRITE via SQL with schema alignment.""" + conn = self._get_connection() + cursor = conn.cursor() + try: + cursor.execute(f"DESCRIBE TABLE {target_name}") + target_schema = {row[0]: row[1] for row in cursor.fetchall()} + + cursor.execute(f"DESCRIBE TABLE {staging_name}") + staging_schema = {row[0]: row[1] for row in cursor.fetchall()} + finally: + cursor.close() + + target_cols = set(target_schema.keys()) + staging_cols = set(staging_schema.keys()) + + # Add new columns from staging to target + new_cols = staging_cols - target_cols + for col in new_cols: + col_type = staging_schema[col] + self._execute( + f"ALTER TABLE {target_name} ADD COLUMNS (`{col}` {col_type})" + ) + + all_cols = target_cols | staging_cols + select_parts = [] + for col in sorted(all_cols): + if col in staging_cols: + select_parts.append(f"`{col}`") + else: + select_parts.append(f"NULL AS `{col}`") + + col_list = ", ".join(f"`{c}`" for c in sorted(all_cols)) + select_expr = ", ".join(select_parts) + + self._execute( + f"INSERT OVERWRITE {target_name} ({col_list}) " + f"SELECT {select_expr} FROM {staging_name}" + ) From 3f90dce618a208ecce2e8b5e34fcda5e5782d3b9 Mon Sep 17 00:00:00 2001 From: park-peter Date: Tue, 3 Mar 2026 16:35:04 +0900 Subject: [PATCH 2/7] Preview type detection, SQL enrichment path Preview: add Python-to-Spark type mapping for real type change detection in compare_schemas(). Log skipped records instead of silent pass. Enrichment: add SQLSemanticEnricher for SQL-based enrichment without Spark. Flatten mode now uses COMMENT ON COLUMN for column-level descriptions. Spark enricher guards for active session with actionable error message. JSON parse errors are logged per-row. --- src/brickbyte/enrichment/semantic.py | 425 ++++++++++++++++++++------- 1 file changed, 315 insertions(+), 110 deletions(-) diff --git a/src/brickbyte/enrichment/semantic.py b/src/brickbyte/enrichment/semantic.py index 0b96fc6..3790dc5 100644 --- a/src/brickbyte/enrichment/semantic.py +++ b/src/brickbyte/enrichment/semantic.py @@ -1,5 +1,5 @@ """ -AI-powered semantic enrichment for BrickByte. +AI-powered semantic enrichment for brickbyte. Uses Databricks Foundation Models to generate metadata. """ import json @@ -8,25 +8,25 @@ from dataclasses import dataclass, field from typing import Dict, List, Optional -logger = logging.getLogger(__name__) +logger = logging.getLogger("brickbyte") @dataclass class ColumnEnrichment: """Enrichment results for a single column.""" - + column_name: str description: Optional[str] = None is_pii: bool = False - pii_type: Optional[str] = None # e.g., "email", "phone", "ssn", "name" - data_classification: Optional[str] = None # e.g., "public", "internal", "confidential" - + pii_type: Optional[str] = None + data_classification: Optional[str] = None + def __str__(self) -> str: parts = [f"{self.column_name}:"] if self.description: parts.append(f' "{self.description}"') if self.is_pii: - parts.append(f" ⚠️ PII detected: {self.pii_type}") + parts.append(f" PII detected: {self.pii_type}") if self.data_classification: parts.append(f" Classification: {self.data_classification}") return "\n".join(parts) @@ -35,11 +35,11 @@ def __str__(self) -> str: @dataclass class TableEnrichment: """Enrichment results for a table.""" - + table_name: str columns: List[ColumnEnrichment] = field(default_factory=list) table_description: Optional[str] = None - + def __str__(self) -> str: lines = [f"Table: {self.table_name}"] if self.table_description: @@ -50,7 +50,6 @@ def __str__(self) -> str: return "\n".join(lines) -# Prompt template for Foundation Model ENRICHMENT_PROMPT = """Analyze this database table and provide metadata enrichment. Table: {table_name} @@ -84,52 +83,49 @@ def __str__(self) -> str: class SemanticEnricher: """ AI-powered semantic enrichment using Databricks Foundation Models. - - Generates: - - Column descriptions from data samples - - PII detection - - Data classification suggestions + Requires an active Spark session. """ - + def __init__( self, model_name: str = "databricks-meta-llama-3-3-70b-instruct", sample_rows: int = 50, ): - """ - Initialize the enricher. - - Args: - model_name: Foundation Model endpoint name - sample_rows: Number of sample rows to analyze - """ self.model_name = model_name self.sample_rows = sample_rows self._spark = None self._client = None - + @property def spark(self): - """Get or create Spark session.""" + """Get active Spark session, raising if unavailable.""" if self._spark is None: from pyspark.sql import SparkSession - self._spark = SparkSession.builder.getOrCreate() + + session = SparkSession.getActiveSession() + if session is None: + raise RuntimeError( + "SemanticEnricher requires an active SparkSession. " + "Use SQLSemanticEnricher for SQL-based enrichment, " + "or ensure you are running in a Databricks notebook." + ) + self._spark = session return self._spark - + @property def client(self): """Get or create Databricks SDK client.""" if self._client is None: from databricks.sdk import WorkspaceClient + self._client = WorkspaceClient() return self._client - + def _get_column_samples(self, table_name: str) -> Dict[str, List[str]]: - """Get sample values for each column by parsing data JSON column.""" - # Try new column name first, fall back to legacy name + """Get sample values for each column.""" schema = self.spark.table(table_name).schema col_names = [f.name for f in schema.fields] - + if "data" in col_names: data_col = "data" elif "_airbyte_data" in col_names: @@ -145,11 +141,11 @@ def _get_column_samples(self, table_name: str) -> Dict[str, List[str]]: vals = df[col].dropna().astype(str).head(5).tolist() samples[col] = [v[:100] for v in vals] return samples - + df = self.spark.sql( f"SELECT {data_col} FROM {table_name} LIMIT {self.sample_rows}" ).toPandas() - + samples = {} for _, row in df.iterrows(): try: @@ -159,11 +155,12 @@ def _get_column_samples(self, table_name: str) -> Dict[str, List[str]]: samples[col] = [] if value is not None and len(samples[col]) < 5: samples[col].append(str(value)[:100]) - except (json.JSONDecodeError, KeyError, TypeError): + except (json.JSONDecodeError, KeyError, TypeError) as e: + logger.debug(f"Error parsing JSON row: {e}") continue - + return samples - + def _format_samples_for_prompt(self, samples: Dict[str, List[str]]) -> str: """Format column samples for the prompt.""" lines = [] @@ -171,7 +168,7 @@ def _format_samples_for_prompt(self, samples: Dict[str, List[str]]) -> str: values_str = ", ".join(f'"{v}"' for v in values[:3]) lines.append(f"- {col}: {values_str}") return "\n".join(lines) - + def _call_foundation_model(self, prompt: str) -> str: """Call the Foundation Model API.""" try: @@ -179,16 +176,16 @@ def _call_foundation_model(self, prompt: str) -> str: ChatMessage, ChatMessageRole, ) - + response = self.client.serving_endpoints.query( name=self.model_name, messages=[ChatMessage(role=ChatMessageRole.USER, content=prompt)], ) return response.choices[0].message.content except Exception as e: - logger.warning(f"Warning: Foundation Model call failed: {e}") + logger.warning(f"Foundation Model call failed: {e}") return "{}" - + def _parse_enrichment_response( self, response: str, @@ -196,20 +193,18 @@ def _parse_enrichment_response( ) -> TableEnrichment: """Parse the Foundation Model response into structured enrichment.""" enrichment = TableEnrichment(table_name=table_name) - - # Try to extract JSON from response + try: - # Find JSON in response (may have surrounding text) - json_match = re.search(r'\{[\s\S]*\}', response) + json_match = re.search(r"\{[\s\S]*\}", response) if json_match: data = json.loads(json_match.group()) else: data = {} except json.JSONDecodeError: data = {} - + enrichment.table_description = data.get("table_description") - + for col_data in data.get("columns", []): col = ColumnEnrichment( column_name=col_data.get("name", ""), @@ -220,55 +215,40 @@ def _parse_enrichment_response( ) if col.column_name: enrichment.columns.append(col) - + return enrichment - + def enrich(self, table_name: str) -> TableEnrichment: - """ - Generate semantic enrichment for a table. - - Args: - table_name: Fully qualified table name (catalog.schema.table) - - Returns: - TableEnrichment with AI-generated metadata - """ + """Generate semantic enrichment for a table.""" logger.info(f" Analyzing table: {table_name}") - - # Get column samples + samples = self._get_column_samples(table_name) - + if not samples: logger.info(" No data columns found to analyze") return TableEnrichment(table_name=table_name) - - # Build prompt + samples_str = self._format_samples_for_prompt(samples) prompt = ENRICHMENT_PROMPT.format( table_name=table_name, column_samples=samples_str, ) - - # Call Foundation Model + logger.info(" Calling Foundation Model...") response = self._call_foundation_model(prompt) - - # Parse response + enrichment = self._parse_enrichment_response(response, table_name) - - logger.info(f" ✓ Generated descriptions for {len(enrichment.columns)} columns") - + + logger.info( + f" Generated descriptions for {len(enrichment.columns)} columns" + ) + return enrichment - + def apply_to_catalog(self, enrichment: TableEnrichment): - """ - Apply enrichment metadata to Unity Catalog. - - Since data may be stored as JSON in data column, we store field-level - metadata as table tags and set the table description. - """ + """Apply enrichment metadata to Unity Catalog.""" logger.info(f" Applying metadata to {enrichment.table_name}") - + # Set table comment if enrichment.table_description: try: @@ -276,16 +256,16 @@ def apply_to_catalog(self, enrichment: TableEnrichment): self.spark.sql( f"COMMENT ON TABLE {enrichment.table_name} IS '{escaped_desc}'" ) - logger.info(" ✓ Set table description") + logger.info(" Set table description") except Exception as e: - logger.warning(f" Warning: Could not set table comment: {e}") - - # Store field metadata as table tags (fields are inside JSON) + logger.warning(f" Could not set table comment: {e}") + + # Set PII tags pii_fields = [] for col in enrichment.columns: if col.is_pii: pii_fields.append(f"{col.column_name}:{col.pii_type or 'pii'}") - + if pii_fields: try: pii_value = ",".join(pii_fields) @@ -293,17 +273,36 @@ def apply_to_catalog(self, enrichment: TableEnrichment): f"ALTER TABLE {enrichment.table_name} " f"SET TAGS ('pii_fields' = '{pii_value}')" ) - logger.info(f" ✓ Tagged PII fields: {pii_fields}") + logger.info(f" Tagged PII fields: {pii_fields}") except Exception as e: - logger.warning(f" Warning: Could not set PII tags: {e}") - - # Store column descriptions as table property for reference - if enrichment.columns: + logger.warning(f" Could not set PII tags: {e}") + + # In flatten mode, use COMMENT ON COLUMN for each column + # In raw mode, use TBLPROPERTIES + schema = self.spark.table(enrichment.table_name).schema + col_names = {f.name for f in schema.fields} + + flatten_mode = "data" not in col_names and "_airbyte_data" not in col_names + + if flatten_mode and enrichment.columns: + for col in enrichment.columns: + if col.description and col.column_name in col_names: + try: + escaped = col.description.replace("'", "''") + self.spark.sql( + f"COMMENT ON COLUMN {enrichment.table_name}." + f"`{col.column_name}` IS '{escaped}'" + ) + except Exception as e: + logger.warning( + f" Could not set comment on {col.column_name}: {e}" + ) + logger.info(" Set column-level comments") + elif enrichment.columns: try: - # Build a summary of field descriptions desc_summary = "; ".join( f"{c.column_name}: {c.description}" - for c in enrichment.columns[:10] # Limit to avoid huge properties + for c in enrichment.columns[:10] if c.description ) if desc_summary: @@ -312,11 +311,230 @@ def apply_to_catalog(self, enrichment: TableEnrichment): f"ALTER TABLE {enrichment.table_name} " f"SET TBLPROPERTIES ('brickbyte.field_descriptions' = '{escaped}')" ) - logger.info(" ✓ Stored field descriptions in table properties") + logger.info(" Stored field descriptions in table properties") + except Exception as e: + logger.warning(f" Could not set field descriptions: {e}") + + logger.info(" Applied metadata to catalog") + + +class SQLSemanticEnricher: + """ + SQL-based semantic enrichment using SQL connector. + Does not require Spark. + """ + + def __init__( + self, + server_hostname: str, + http_path: str, + access_token: str, + catalog: str, + schema: str, + model_name: str = "databricks-meta-llama-3-3-70b-instruct", + sample_rows: int = 50, + ): + self.server_hostname = server_hostname + self.http_path = http_path + self._access_token = access_token + self.catalog = catalog + self.schema = schema + self.model_name = model_name + self.sample_rows = sample_rows + self._connection = None + self._client = None + + def _get_connection(self): + """Get or create database connection.""" + if self._connection is None: + from databricks import sql + + self._connection = sql.connect( + server_hostname=self.server_hostname, + http_path=self.http_path, + access_token=self._access_token, + catalog=self.catalog, + schema=self.schema, + ) + return self._connection + + def _execute(self, query: str): + """Execute a SQL query and return results.""" + conn = self._get_connection() + cursor = conn.cursor() + try: + cursor.execute(query) + return cursor.fetchall() + finally: + cursor.close() + + @property + def client(self): + """Get Databricks SDK client.""" + if self._client is None: + from databricks.sdk import WorkspaceClient + + self._client = WorkspaceClient() + return self._client + + def _get_column_samples(self, table_name: str) -> Dict[str, List[str]]: + """Get sample values via SQL.""" + # Get columns + columns = self._execute(f"DESCRIBE TABLE {table_name}") + col_names = [row[0] for row in columns] + + if "data" in col_names: + # Raw mode + rows = self._execute( + f"SELECT data FROM {table_name} LIMIT {self.sample_rows}" + ) + samples = {} + for row in rows: + try: + record = json.loads(row[0]) + for col, value in record.items(): + if col not in samples: + samples[col] = [] + if value is not None and len(samples[col]) < 5: + samples[col].append(str(value)[:100]) + except (json.JSONDecodeError, KeyError, TypeError) as e: + logger.debug(f"Error parsing JSON row: {e}") + continue + return samples + else: + # Flatten mode + sample_cols = [c for c in col_names if not c.startswith("_")][:20] + if not sample_cols: + return {} + + cols_str = ", ".join(f"`{c}`" for c in sample_cols) + rows = self._execute( + f"SELECT {cols_str} FROM {table_name} LIMIT {self.sample_rows}" + ) + samples = {} + for col_idx, col in enumerate(sample_cols): + vals = [] + for row in rows: + if row[col_idx] is not None and len(vals) < 5: + vals.append(str(row[col_idx])[:100]) + if vals: + samples[col] = vals + return samples + + def _call_foundation_model(self, prompt: str) -> str: + """Call the Foundation Model API.""" + try: + from databricks.sdk.service.serving import ( + ChatMessage, + ChatMessageRole, + ) + + response = self.client.serving_endpoints.query( + name=self.model_name, + messages=[ChatMessage(role=ChatMessageRole.USER, content=prompt)], + ) + return response.choices[0].message.content + except Exception as e: + logger.warning(f"Foundation Model call failed: {e}") + return "{}" + + def enrich(self, table_name: str) -> TableEnrichment: + """Generate semantic enrichment for a table.""" + logger.info(f" Analyzing table: {table_name}") + + samples = self._get_column_samples(table_name) + if not samples: + return TableEnrichment(table_name=table_name) + + lines = [] + for col, values in samples.items(): + values_str = ", ".join(f'"{v}"' for v in values[:3]) + lines.append(f"- {col}: {values_str}") + samples_str = "\n".join(lines) + + prompt = ENRICHMENT_PROMPT.format( + table_name=table_name, + column_samples=samples_str, + ) + + response = self._call_foundation_model(prompt) + + enrichment = TableEnrichment(table_name=table_name) + try: + json_match = re.search(r"\{[\s\S]*\}", response) + if json_match: + data = json.loads(json_match.group()) + else: + data = {} + except json.JSONDecodeError: + data = {} + + enrichment.table_description = data.get("table_description") + for col_data in data.get("columns", []): + col = ColumnEnrichment( + column_name=col_data.get("name", ""), + description=col_data.get("description"), + is_pii=col_data.get("is_pii", False), + pii_type=col_data.get("pii_type"), + data_classification=col_data.get("classification"), + ) + if col.column_name: + enrichment.columns.append(col) + + return enrichment + + def apply_to_catalog(self, enrichment: TableEnrichment): + """Apply enrichment metadata via SQL.""" + logger.info(f" Applying metadata to {enrichment.table_name}") + + if enrichment.table_description: + try: + escaped = enrichment.table_description.replace("'", "''") + self._execute( + f"COMMENT ON TABLE {enrichment.table_name} IS '{escaped}'" + ) except Exception as e: - logger.warning(f" Warning: Could not set field descriptions: {e}") - - logger.info(" ✓ Applied metadata to catalog") + logger.warning(f" Could not set table comment: {e}") + + pii_fields = [] + for col in enrichment.columns: + if col.is_pii: + pii_fields.append(f"{col.column_name}:{col.pii_type or 'pii'}") + + if pii_fields: + try: + pii_value = ",".join(pii_fields) + self._execute( + f"ALTER TABLE {enrichment.table_name} " + f"SET TAGS ('pii_fields' = '{pii_value}')" + ) + except Exception as e: + logger.warning(f" Could not set PII tags: {e}") + + # Use COMMENT ON COLUMN in flatten mode + columns = self._execute(f"DESCRIBE TABLE {enrichment.table_name}") + col_names = {row[0] for row in columns} + flatten_mode = "data" not in col_names + + if flatten_mode: + for col in enrichment.columns: + if col.description and col.column_name in col_names: + try: + escaped = col.description.replace("'", "''") + self._execute( + f"COMMENT ON COLUMN {enrichment.table_name}." + f"`{col.column_name}` IS '{escaped}'" + ) + except Exception as e: + logger.warning( + f" Could not set comment on {col.column_name}: {e}" + ) + + def close(self): + """Close the SQL connection.""" + if self._connection: + self._connection.close() + self._connection = None def enrich_table( @@ -326,26 +544,13 @@ def enrich_table( apply_to_catalog: bool = True, model_name: str = "databricks-meta-llama-3-3-70b-instruct", ) -> TableEnrichment: - """ - Convenience function to enrich a single table. - - Args: - catalog: Unity Catalog name - schema: Schema name - table: Table name - apply_to_catalog: Whether to apply metadata to Unity Catalog - model_name: Foundation Model to use - - Returns: - TableEnrichment with AI-generated metadata - """ + """Convenience function to enrich a single table using Spark.""" table_name = f"{catalog}.{schema}.{table}" - + enricher = SemanticEnricher(model_name=model_name) enrichment = enricher.enrich(table_name) - + if apply_to_catalog: enricher.apply_to_catalog(enrichment) - - return enrichment + return enrichment From 4efbc09878b9284962c0258398da0e9dc7c2fade Mon Sep 17 00:00:00 2001 From: park-peter Date: Tue, 3 Mar 2026 17:04:40 +0900 Subject: [PATCH 3/7] rewrite tests, notebooks, documentation Rewrite all 9 existing test files to use brickbyte.client() API, canonical schema (record_id/extracted_at/data/run_id), run_id param, safe overwrite assertions, and UTC-aware timestamps. Add 7 new test files: - test_sanitize.py: stream name sanitization, identifier validation - test_safe_overwrite.py: staging table lifecycle, atomic overwrite, schema alignment, incompatible type rejection - test_incremental.py: state table CRUD - test_concurrent.py: parallel writer isolation, error propagation - test_dedup.py: key normalization, _dk_N extraction, _dk_missing, null keys, validation errors, MERGE execution - test_progress.py: callback invocation, event correctness - test_enrichment_sql.py: SQL enrichment path, column comments Update all 10 notebooks to use import brickbyte / brickbyte.client(), lowercase naming, pinned dependencies in _setup.py. Rewrite README.md with Lakeflow positioning, v2 API, canonical schema, and documentation for all new features. Fix ruff lint issues (unused imports, import sorting). --- README.md | 252 +- notebooks/_setup.py | 2 +- notebooks/brickbyte-azure-blob.py | 6 +- notebooks/brickbyte-confluence.py | 6 +- notebooks/brickbyte-datadog.py | 6 +- notebooks/brickbyte-example.py | 38 +- notebooks/brickbyte-gcs.py | 6 +- notebooks/brickbyte-github.py | 6 +- notebooks/brickbyte-google-drive.py | 6 +- notebooks/brickbyte-microsoft-teams.py | 8 +- notebooks/brickbyte-s3.py | 6 +- src/brickbyte/_client.py | 8 +- src/brickbyte/_dedup.py | 2 +- src/brickbyte/enrichment/__init__.py | 2 +- src/brickbyte/writers/__init__.py | 2 +- tests/conftest.py | 55 + tests/test_base_writer.py | 162 +- tests/test_buffer_thresholds.py | 150 +- tests/test_concurrent.py | 136 + tests/test_credentials.py | 233 +- tests/test_dedup.py | 386 +++ tests/test_enrichment_sql.py | 110 + tests/test_functional.py | 145 +- tests/test_hybrid.py | 53 +- tests/test_incremental.py | 70 + tests/test_mode_validation.py | 92 +- tests/test_preview.py | 108 +- tests/test_progress.py | 56 + tests/test_safe_overwrite.py | 218 ++ tests/test_sanitize.py | 86 + tests/test_spark_streaming_writer.py | 121 +- tests/test_streaming.py | 83 +- uv.lock | 3380 ++++++++++++++++++++++++ 33 files changed, 5230 insertions(+), 770 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_concurrent.py create mode 100644 tests/test_dedup.py create mode 100644 tests/test_enrichment_sql.py create mode 100644 tests/test_incremental.py create mode 100644 tests/test_progress.py create mode 100644 tests/test_safe_overwrite.py create mode 100644 tests/test_sanitize.py create mode 100644 uv.lock diff --git a/README.md b/README.md index cb00bb1..5098fb3 100644 --- a/README.md +++ b/README.md @@ -1,168 +1,232 @@ -# Brickbyte 🧱 +# brickbyte -**Sync data from 600+ source connectors to Databricks with streaming performance.** +Sync data from 600+ source connectors to Databricks Unity Catalog with streaming performance. -Brickbyte wraps [PyAirbyte](https://github.com/airbytehq/airbyte) to extract data from any source and streams it directly to Databricks Unity Catalog. +brickbyte wraps [PyAirbyte](https://github.com/airbytehq/PyAirbyte) to extract data from 600+ sources into Databricks. For sources that Lakeflow Connect already covers, use Lakeflow Connect. brickbyte fills the gap for everything else. Schedule with Lakeflow Jobs, transform downstream with Declarative Pipelines. ## Features -- **600+ Sources** - All Airbyte connectors work out of the box -- **Streaming Architecture** - Bypasses local disk, no OOM issues -- **High Performance** - Uses Unity Catalog Volumes and `COPY INTO` -- **Flexible Output** - Raw JSON or flattened columns -- **AI Enrichment** - Auto-generate table descriptions and detect PII via Foundation Models -- **Preview** - See what schema changes will occur before syncing -- **Simple API** - One-line sync +- **600+ Sources** - Any Airbyte connector works out of the box +- **Streaming Architecture** - Bounded memory, no local disk needed +- **Raw + Flattened Output** - JSON blob or spread columns +- **Safe Overwrite** - Atomic staged replace preserves metadata +- **Incremental Sync** - State-managed delta processing for connectors that support state APIs +- **Deduplication** - MERGE-based dedup with validated user-defined keys +- **Concurrent Streams** - Parallel writes with isolated per-thread writers +- **Progress Reporting** - Callback events every 5000 records plus per-stream completion +- **Timeout Control** - Cooperative timeout for long-running syncs +- **AI Enrichment** - Column descriptions, PII detection via Foundation Models +- **Preview** - Schema comparison before committing ## Quick Start ```python -%pip install airbyte databricks-sdk databricks-sql-connector virtualenv -%pip install git+https://github.com/park-peter/brickbyte.git -dbutils.library.restartPython() -``` +import brickbyte -```python -from brickbyte import Brickbyte +bb = brickbyte.client() -bb = Brickbyte() -bb.sync( +result = bb.sync( source="source-faker", - source_config={"count": 100}, + source_config={"count": 1000}, catalog="main", schema="bronze", ) + +print(f"Synced {result.records_written} records") ``` -## Output Formats +## Output Schema -### Raw Mode (Default) -Stores data as JSON for schema flexibility: +### Raw Mode (default) -| id | extracted_at | data | -|----|--------------|------| -| abc-123 | 2026-01-13 10:00:00 | {"displayName": "John", "email": "john@..."} | +| Column | Type | Description | +|--------|------|-------------| +| `record_id` | STRING | Unique UUID per record | +| `extracted_at` | TIMESTAMP | UTC extraction time | +| `data` | STRING | JSON blob of source record | +| `run_id` | STRING | UUID identifying the sync run | + +### Flatten Mode (`flatten=True`) + +Source fields become top-level columns, plus metadata: + +| Column | Type | Description | +|--------|------|-------------| +| `_record_id` | STRING | Unique UUID per record | +| `_extracted_at` | TIMESTAMP | UTC extraction time | +| `_run_id` | STRING | UUID identifying the sync run | +| *(source columns)* | *(inferred)* | Source data fields | + +## Architecture -Query with JSON syntax: -```sql -SELECT data:displayName::STRING as name FROM my_table +``` + ┌──────────────────┐ + │ PyAirbyte │ + │ 600+ Connectors │ + └────────┬─────────┘ + │ + ┌────────▼─────────┐ + │ brickbyte │ + │ Streaming Core │ + └───┬─────────┬────┘ + │ │ + ┌────────────▼──┐ ┌──▼────────────┐ + │ Spark Writer │ │ SQL Writer │ + │ (in-notebook) │ │ (COPY INTO) │ + │ No Volume │ │ Volume needed │ + └───────┬───────┘ └───────┬────────┘ + │ │ + └─────────┬────────┘ + ┌────────▼─────────┐ + │ Delta Lake │ + │ Unity Catalog │ + └──────────────────┘ ``` -### Flattened Mode -Expands all fields into columns: +## Examples + +### Flattened Output ```python -bb.sync(..., flatten=True) +result = bb.sync( + source="source-faker", + source_config={"count": 100}, + catalog="main", + schema="bronze", + flatten=True, +) ``` -| displayName | email | _id | _extracted_at | -|-------------|-------|-----|---------------| -| John | john@... | abc-123 | 2026-01-13 10:00:00 | +### Incremental Sync -## Examples +```python +result = bb.sync( + source="source-github", + source_config={"repository": "owner/repo"}, + catalog="main", + schema="bronze", + incremental=True, +) +``` + +Incremental mode requires connector state APIs (`set_stream_state`, `set_state_for_stream`, or `set_state`) to apply saved state before reading. If saved state exists but the connector does not support state injection, the sync fails fast. -### Simple Sync (Overwrite) +### Deduplication ```python -bb.sync( - source="source-github", - source_config={ - "credentials": {"personal_access_token": "ghp_..."}, - "repositories": ["owner/repo"], - }, +result = bb.sync( + source="source-faker", + source_config={"count": 100}, catalog="main", schema="bronze", - staging_volume="main.staging.brickbyte_volume", + deduplicate=True, + dedup_keys=["email"], # or per-stream: {"users": ["email"], "orders": ["order_id"]} ) ``` -### Flattened Output +`dedup_keys` is applied only when `deduplicate=True`; otherwise it is ignored. Dedup key names must be valid identifier strings (unsafe characters like backticks/semicolons are rejected). + +### Concurrent Streams + +```python +result = bb.sync( + source="source-faker", + source_config={"count": 100}, + catalog="main", + schema="bronze", + max_parallel_streams=4, +) +``` + +### Progress Reporting + +```python +def on_progress(event): + print(f"{event.stream_name}: {event.records_processed} records") + +result = bb.sync( + source="source-faker", + source_config={"count": 100}, + catalog="main", + schema="bronze", + progress_callback=on_progress, +) +``` + +Progress callbacks fire every 5000 processed records per stream and once when each stream completes. + +### Timeout ```python -bb.sync( - source="source-salesforce", - source_config={...}, +result = bb.sync( + source="source-github", + source_config={"repository": "owner/repo"}, catalog="main", schema="bronze", - flatten=True, # All fields as top-level columns + timeout_seconds=300, ) ``` -### With AI Metadata Enrichment +### AI Metadata Enrichment ```python result = bb.sync( - source="source-salesforce", - source_config={...}, + source="source-faker", + source_config={"count": 100}, catalog="main", schema="bronze", enrich_metadata=True, ) -# Tables get: -# - AI-generated table description (COMMENT ON TABLE) -# - Field descriptions stored in TBLPROPERTIES -# - PII detection stored as table TAGS ``` -### Preview Before Sync +### Preview ```python preview = bb.preview( - source="source-github", - source_config={...}, + source="source-faker", + source_config={"count": 100}, catalog="main", schema="bronze", ) print(preview) ``` -## Architecture - -### Hybrid Mode -Brickbyte automatically selects the best write strategy: - -1. **Native Spark** (Default in Databricks Notebooks/Jobs) - - Uses `createDataFrame` + micro-batch writes to Delta - - **Fastest performance**. No Volume required. +## Credential Management -2. **SQL Streaming** (Remote / Local) - - Writes to Volume → `COPY INTO` via SQL Warehouse - - Robust remote execution. Requires `staging_volume`. +brickbyte auto-discovers credentials from Databricks Secrets: +```bash +# Store credentials (one-time setup) +databricks secrets put-secret brickbyte source-s3/aws_access_key_id +databricks secrets put-secret brickbyte source-s3/aws_secret_access_key ``` -[In Notebook] ──▶ Spark createDataFrame ──▶ Delta Table (No Volume) -[Remote] ──▶ SQL Streaming ──▶ Volume ──▶ COPY INTO ──▶ Delta Table +```python +# Credentials auto-discovered - just provide non-sensitive config +result = bb.sync( + source="source-s3", + source_config={"bucket": "my-bucket"}, + catalog="main", + schema="bronze", +) ``` +Supports dotted keys for nested config (`source-x/credentials.client_id` maps to `{"credentials": {"client_id": "..."}}`), custom scopes, and YAML profiles for credential reuse. + ## Requirements -- Python 3.10+ -- Databricks workspace with Unity Catalog -- SQL Warehouse -- Unity Catalog Volume for staging (Required only for Remote/SQL mode) - -## Dependencies - -```toml -[project] -dependencies = [ - "virtualenv", - "databricks-sdk>=0.74.0", - "databricks-sql-connector>=4.2.2", - "airbyte>=0.34.0", - "pyarrow>=14.0.0", -] - -[project.optional-dependencies] -local-spark = ["delta-spark>=3.0.0", "pyspark>=3.5.0"] -``` +- Python 3.10 - 3.12 +- Databricks Unity Catalog +- For SQL mode: SQL Warehouse + Unity Catalog Volume + +## Development -For local Spark + Delta development: ```bash -pip install brickbyte[local-spark] +uv pip install -e ".[dev]" +uv run pytest tests/ -v -m "not integration" +uv run ruff check src/ ``` ## License -Apache-2.0 License +Apache 2.0 diff --git a/notebooks/_setup.py b/notebooks/_setup.py index 65910c5..980663f 100644 --- a/notebooks/_setup.py +++ b/notebooks/_setup.py @@ -1,5 +1,5 @@ # Databricks notebook source -# MAGIC %pip install airbyte databricks-sdk databricks-sql-connector virtualenv pyarrow +# MAGIC %pip install airbyte==0.38.0 databricks-sdk==0.95.0 databricks-sql-connector==4.2.5 virtualenv==20.29.3 pyarrow==21.0.0 pyyaml==6.0.3 # MAGIC %pip install git+https://github.com/park-peter/brickbyte.git --force-reinstall --no-deps # COMMAND ---------- diff --git a/notebooks/brickbyte-azure-blob.py b/notebooks/brickbyte-azure-blob.py index f363b32..1d9543e 100644 --- a/notebooks/brickbyte-azure-blob.py +++ b/notebooks/brickbyte-azure-blob.py @@ -4,7 +4,7 @@ # COMMAND ---------- # MAGIC %md -# MAGIC # Azure Blob Storage to Databricks with BrickByte +# MAGIC # Azure Blob Storage to Databricks with brickbyte # MAGIC # MAGIC This notebook syncs files from Azure Blob Storage to Delta Lake tables in Unity Catalog. # MAGIC @@ -22,9 +22,9 @@ # COMMAND ---------- -from brickbyte import Brickbyte +import brickbyte -bb = Brickbyte() +bb = brickbyte.client() # COMMAND ---------- diff --git a/notebooks/brickbyte-confluence.py b/notebooks/brickbyte-confluence.py index 02e7624..c50dc78 100644 --- a/notebooks/brickbyte-confluence.py +++ b/notebooks/brickbyte-confluence.py @@ -1,6 +1,6 @@ # Databricks notebook source # MAGIC %md -# MAGIC # BrickByte - Confluence Example +# MAGIC # brickbyte - Confluence Example # MAGIC # MAGIC Sync data from Atlassian Confluence to Databricks. # MAGIC @@ -14,9 +14,9 @@ # COMMAND ---------- -from brickbyte import Brickbyte +import brickbyte -bb = Brickbyte() +bb = brickbyte.client() # COMMAND ---------- diff --git a/notebooks/brickbyte-datadog.py b/notebooks/brickbyte-datadog.py index 61b004e..e3a3ca7 100644 --- a/notebooks/brickbyte-datadog.py +++ b/notebooks/brickbyte-datadog.py @@ -1,6 +1,6 @@ # Databricks notebook source # MAGIC %md -# MAGIC # BrickByte - DataDog Example +# MAGIC # brickbyte - DataDog Example # MAGIC # MAGIC Sync monitoring data from DataDog to Databricks. # MAGIC @@ -14,9 +14,9 @@ # COMMAND ---------- -from brickbyte import Brickbyte +import brickbyte -bb = Brickbyte() +bb = brickbyte.client() # COMMAND ---------- diff --git a/notebooks/brickbyte-example.py b/notebooks/brickbyte-example.py index 8ea1c4d..161f524 100644 --- a/notebooks/brickbyte-example.py +++ b/notebooks/brickbyte-example.py @@ -4,31 +4,31 @@ # COMMAND ---------- # MAGIC %md -# MAGIC # BrickByte Quick Start -# MAGIC -# MAGIC BrickByte bridges Airbyte's 600+ connectors directly into Databricks. -# MAGIC +# MAGIC # brickbyte Quick Start +# MAGIC +# MAGIC brickbyte bridges Airbyte's 600+ connectors directly into Databricks. +# MAGIC # MAGIC ## Credential Management -# MAGIC -# MAGIC BrickByte automatically discovers credentials from **Databricks Secrets**: -# MAGIC +# MAGIC +# MAGIC brickbyte automatically discovers credentials from **Databricks Secrets**: +# MAGIC # MAGIC | Scope | Key Pattern | Example | # MAGIC |-------|-------------|---------| # MAGIC | `brickbyte` | `{source-name}/{field}` | `source-s3/aws_access_key_id` | -# MAGIC +# MAGIC # MAGIC **Setup your secrets once:** # MAGIC ``` # MAGIC databricks secrets put-secret brickbyte source-s3/aws_access_key_id # MAGIC databricks secrets put-secret brickbyte source-s3/aws_secret_access_key # MAGIC ``` -# MAGIC +# MAGIC # MAGIC Then just sync - credentials are discovered automatically! # COMMAND ---------- -from brickbyte import Brickbyte +import brickbyte -bb = Brickbyte() +bb = brickbyte.client() # COMMAND ---------- @@ -51,7 +51,7 @@ # MAGIC %md # MAGIC ## Sync with Auto-Discovered Credentials -# MAGIC +# MAGIC # MAGIC If you've set up secrets in scope `brickbyte`, credentials are merged automatically: # COMMAND ---------- @@ -85,9 +85,9 @@ # Validate specific source if bb.validate_credentials("source-s3"): - print("✓ S3 credentials found") + print("S3 credentials found") else: - print("✗ S3 credentials not configured") + print("S3 credentials not configured") # COMMAND ---------- @@ -97,15 +97,15 @@ # COMMAND ---------- # Use a different secrets scope -bb_custom = BrickByte(secrets_scope="my-team-secrets") +bb_custom = brickbyte.client(secrets_scope="my-team-secrets") # COMMAND ---------- # MAGIC %md # MAGIC ## YAML Profiles (Advanced) -# MAGIC +# MAGIC # MAGIC For credential reuse across multiple sources, use a YAML profiles file: -# MAGIC +# MAGIC # MAGIC ```yaml # MAGIC # /Workspace/Shared/brickbyte/profiles.yml # MAGIC profiles: @@ -113,7 +113,7 @@ # MAGIC tenant_id: "{{ secret('azure/tenant_id') }}" # MAGIC client_id: "{{ secret('azure/client_id') }}" # MAGIC client_secret: "{{ secret('azure/client_secret') }}" -# MAGIC +# MAGIC # MAGIC mappings: # MAGIC source-microsoft-teams: azure-shared # MAGIC source-azure-blob-storage: azure-shared @@ -122,7 +122,7 @@ # COMMAND ---------- # Load with YAML profiles -# bb_profiles = BrickByte(profiles="/Workspace/Shared/brickbyte/profiles.yml") +# bb_profiles = brickbyte.client(profiles="/Workspace/Shared/brickbyte/profiles.yml") # COMMAND ---------- diff --git a/notebooks/brickbyte-gcs.py b/notebooks/brickbyte-gcs.py index 8977a47..66e8845 100644 --- a/notebooks/brickbyte-gcs.py +++ b/notebooks/brickbyte-gcs.py @@ -4,7 +4,7 @@ # COMMAND ---------- # MAGIC %md -# MAGIC # Google Cloud Storage (GCS) to Databricks with BrickByte +# MAGIC # Google Cloud Storage (GCS) to Databricks with brickbyte # MAGIC # MAGIC This notebook syncs files from Google Cloud Storage to Delta Lake tables in Unity Catalog. # MAGIC @@ -17,9 +17,9 @@ # COMMAND ---------- -from brickbyte import Brickbyte +import brickbyte -bb = Brickbyte() +bb = brickbyte.client() # COMMAND ---------- diff --git a/notebooks/brickbyte-github.py b/notebooks/brickbyte-github.py index 420d0f8..d7ba9db 100644 --- a/notebooks/brickbyte-github.py +++ b/notebooks/brickbyte-github.py @@ -1,6 +1,6 @@ # Databricks notebook source # MAGIC %md -# MAGIC # BrickByte - GitHub Example +# MAGIC # brickbyte - GitHub Example # MAGIC # MAGIC Sync data from GitHub to Databricks. # MAGIC @@ -13,9 +13,9 @@ # COMMAND ---------- -from brickbyte import Brickbyte +import brickbyte -bb = Brickbyte() +bb = brickbyte.client() # COMMAND ---------- diff --git a/notebooks/brickbyte-google-drive.py b/notebooks/brickbyte-google-drive.py index 4dd2665..629a228 100644 --- a/notebooks/brickbyte-google-drive.py +++ b/notebooks/brickbyte-google-drive.py @@ -4,7 +4,7 @@ # COMMAND ---------- # MAGIC %md -# MAGIC # Google Drive to Databricks with BrickByte +# MAGIC # Google Drive to Databricks with brickbyte # MAGIC # MAGIC This notebook syncs files from Google Drive to Delta Lake tables in Unity Catalog. # MAGIC @@ -16,9 +16,9 @@ # COMMAND ---------- -from brickbyte import Brickbyte +import brickbyte -bb = Brickbyte() +bb = brickbyte.client() # COMMAND ---------- diff --git a/notebooks/brickbyte-microsoft-teams.py b/notebooks/brickbyte-microsoft-teams.py index baca7fc..887fa85 100644 --- a/notebooks/brickbyte-microsoft-teams.py +++ b/notebooks/brickbyte-microsoft-teams.py @@ -4,7 +4,7 @@ # COMMAND ---------- # MAGIC %md -# MAGIC # Microsoft Teams to Databricks with BrickByte +# MAGIC # Microsoft Teams to Databricks with brickbyte # MAGIC # MAGIC This notebook syncs data from Microsoft Teams to Delta Lake tables in Unity Catalog. # MAGIC @@ -30,9 +30,9 @@ # COMMAND ---------- -from brickbyte import Brickbyte +import brickbyte -bb = Brickbyte() +bb = brickbyte.client() # COMMAND ---------- @@ -40,7 +40,7 @@ # MAGIC ## Azure AD App Setup # MAGIC # MAGIC 1. Go to [Azure Portal](https://portal.azure.com/) → Azure Active Directory → App registrations -# MAGIC 2. Click **New registration**, name it (e.g., "BrickByte Teams Connector") +# MAGIC 2. Click **New registration**, name it (e.g., "brickbyte Teams Connector") # MAGIC 3. Under **API permissions**, add Microsoft Graph **Application permissions**: # MAGIC - `Group.Read.All` # MAGIC - `Channel.Read.All` diff --git a/notebooks/brickbyte-s3.py b/notebooks/brickbyte-s3.py index 58c726d..82e5ef4 100644 --- a/notebooks/brickbyte-s3.py +++ b/notebooks/brickbyte-s3.py @@ -4,7 +4,7 @@ # COMMAND ---------- # MAGIC %md -# MAGIC # Amazon S3 to Databricks with BrickByte +# MAGIC # Amazon S3 to Databricks with brickbyte # MAGIC # MAGIC This notebook syncs files from Amazon S3 to Delta Lake tables in Unity Catalog. # MAGIC @@ -21,9 +21,9 @@ # COMMAND ---------- -from brickbyte import Brickbyte +import brickbyte -bb = Brickbyte() +bb = brickbyte.client() # COMMAND ---------- diff --git a/src/brickbyte/_client.py b/src/brickbyte/_client.py index 7a3e0ec..7faf2a2 100644 --- a/src/brickbyte/_client.py +++ b/src/brickbyte/_client.py @@ -275,11 +275,13 @@ def sync( # If dedup_keys is a dict, validate keys match selected streams (original names) if deduplicate and isinstance(normalized_dedup_keys, dict): for dk_stream in normalized_dedup_keys: + if dk_stream == "__all__": + continue if dk_stream not in selected: # Check if user used sanitized name by mistake - sanitized_to_original = {v: k for k, v in sanitized_map.items()} - if dk_stream in sanitized_to_original: - orig = sanitized_to_original[dk_stream] + # sanitized_map: sanitized_name -> original_name + if dk_stream in sanitized_map: + orig = sanitized_map[dk_stream] raise ValueError( f"dedup_keys key '{dk_stream}' is a sanitized name. " f"Use the original Airbyte stream name '{orig}' instead." diff --git a/src/brickbyte/_dedup.py b/src/brickbyte/_dedup.py index c8dc7a9..fc79ec4 100644 --- a/src/brickbyte/_dedup.py +++ b/src/brickbyte/_dedup.py @@ -4,7 +4,7 @@ Uses MERGE to remove duplicate records based on user-specified keys. """ import logging -from typing import List, Optional +from typing import List logger = logging.getLogger("brickbyte") diff --git a/src/brickbyte/enrichment/__init__.py b/src/brickbyte/enrichment/__init__.py index 6bf0a34..6956605 100644 --- a/src/brickbyte/enrichment/__init__.py +++ b/src/brickbyte/enrichment/__init__.py @@ -7,8 +7,8 @@ - Data classification """ from brickbyte.enrichment.semantic import ( - SQLSemanticEnricher, SemanticEnricher, + SQLSemanticEnricher, enrich_table, ) diff --git a/src/brickbyte/writers/__init__.py b/src/brickbyte/writers/__init__.py index f987de1..1c8bb00 100644 --- a/src/brickbyte/writers/__init__.py +++ b/src/brickbyte/writers/__init__.py @@ -2,7 +2,7 @@ brickbyte Writers Module. """ import logging -from typing import Dict, List, Optional, Union +from typing import Dict, List, Optional from brickbyte.writers.base import BaseWriter diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bb97ea4 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,55 @@ +""" +Shared pytest fixtures for fast and isolated test runs. +""" +import sys +from unittest.mock import MagicMock, patch + +import pytest + + +def pytest_addoption(parser): + parser.addoption( + "--run-integration", + action="store_true", + default=False, + help="Run tests marked as integration.", + ) + + +def pytest_collection_modifyitems(config, items): + if config.getoption("--run-integration"): + return + + skip_integration = pytest.mark.skip( + reason="integration test: pass --run-integration to run" + ) + for item in items: + if "integration" in item.keywords: + item.add_marker(skip_integration) + + +@pytest.fixture(autouse=True) +def isolate_connector_setup(request, monkeypatch): + """ + Keep unit tests hermetic by skipping connector venv creation/install work. + """ + if request.node.get_closest_marker("integration"): + return + + from brickbyte._client import Client + + def _noop_setup(self, source, source_install=None): + return None + + def _fake_exec_path(self, source): + return f"/tmp/brickbyte-{source}" + + monkeypatch.setattr(Client, "_setup_source", _noop_setup) + monkeypatch.setattr(Client, "_get_source_exec_path", _fake_exec_path) + + +@pytest.fixture +def mock_airbyte(): + mock_ab = MagicMock() + with patch.dict(sys.modules, {"airbyte": mock_ab}): + yield mock_ab diff --git a/tests/test_base_writer.py b/tests/test_base_writer.py index fa8b646..8627cce 100644 --- a/tests/test_base_writer.py +++ b/tests/test_base_writer.py @@ -2,7 +2,6 @@ Tests for BaseWriter abstract class and common functionality. """ import json -from datetime import datetime from unittest.mock import MagicMock, patch import pytest @@ -16,40 +15,24 @@ class TestBaseWriter: """Test BaseWriter abstract class.""" def test_cannot_instantiate_directly(self): - """Test that BaseWriter cannot be instantiated directly.""" with pytest.raises(TypeError, match="Can't instantiate abstract class"): BaseWriter(catalog="main", schema="test") - def test_get_table_name(self): - """Test get_table_name via concrete implementation.""" - with patch("databricks.sql.connect"): - writer = SQLStreamingWriter( - catalog="main", - schema="bronze", - staging_volume="a.b.c", - server_hostname="h", - http_path="p", - access_token="t", - ) - - assert writer.get_table_name("users") == "main.bronze.users" - assert writer.get_table_name("my_table") == "main.bronze.my_table" - class TestTransformRecord: """Test _transform_record across implementations.""" @pytest.fixture - def spark_writer(self, tmp_path): - import os - with patch.dict(os.environ, {"SPARK_LOCAL_DIRS": str(tmp_path)}): - writer = SparkStreamingWriter(catalog="main", schema="test") - writer._spark = MagicMock() - return writer + def spark_writer(self): + writer = SparkStreamingWriter( + catalog="main", schema="test", run_id="test-run-id" + ) + writer._spark = MagicMock() + return writer @pytest.fixture def sql_writer(self): - with patch("databricks.sql.connect"): + with patch("os.path.exists", return_value=True): return SQLStreamingWriter( catalog="main", schema="test", @@ -57,116 +40,84 @@ def sql_writer(self): server_hostname="h", http_path="p", access_token="t", + run_id="test-run-id", ) - def test_spark_transform_adds_metadata(self, spark_writer): - """Test SparkStreamingWriter adds Airbyte metadata.""" + def test_sql_raw_transform_adds_metadata(self, sql_writer): record = {"id": 1, "email": "test@example.com"} - transformed = spark_writer._transform_record(record) - - assert "_airbyte_raw_id" in transformed - assert "_airbyte_extracted_at" in transformed - assert "_airbyte_data" in transformed - - def test_sql_transform_adds_metadata(self, sql_writer): - """Test SQLStreamingWriter adds Airbyte metadata.""" - record = {"id": 1, "email": "test@example.com"} - transformed = sql_writer._transform_record(record) - - assert "_airbyte_raw_id" in transformed - assert "_airbyte_extracted_at" in transformed - assert "_airbyte_data" in transformed - - def test_raw_id_is_uuid(self, spark_writer): - """Test that _airbyte_raw_id is a valid UUID.""" - record = {"id": 1} - transformed = spark_writer._transform_record(record) - - raw_id = transformed["_airbyte_raw_id"] - # UUID format: 8-4-4-4-12 hex digits - assert len(raw_id) == 36 - assert raw_id.count("-") == 4 - - def test_extracted_at_is_datetime(self, spark_writer): - """Test that _airbyte_extracted_at is a datetime.""" - record = {"id": 1} - transformed = spark_writer._transform_record(record) - - assert isinstance(transformed["_airbyte_extracted_at"], datetime) + transformed = sql_writer._transform_record("stream1", record) + + assert "record_id" in transformed + assert "extracted_at" in transformed + assert "data" in transformed + assert "run_id" in transformed def test_data_is_json_string(self, spark_writer): - """Test that _airbyte_data is a valid JSON string.""" record = {"id": 1, "nested": {"key": "value"}, "list": [1, 2, 3]} - transformed = spark_writer._transform_record(record) - - data_str = transformed["_airbyte_data"] + transformed = spark_writer._transform_record("stream1", record) + + data_str = transformed["data"] assert isinstance(data_str, str) - - # Should be valid JSON + parsed = json.loads(data_str) assert parsed["id"] == 1 assert parsed["nested"]["key"] == "value" assert parsed["list"] == [1, 2, 3] - def test_unique_raw_ids(self, spark_writer): - """Test that each transform generates unique raw_id.""" + def test_unique_record_ids(self, spark_writer): record = {"id": 1} - + ids = set() for _ in range(100): - transformed = spark_writer._transform_record(record) - ids.add(transformed["_airbyte_raw_id"]) - - assert len(ids) == 100 # All unique + transformed = spark_writer._transform_record("stream1", record) + ids.add(transformed["record_id"]) + + assert len(ids) == 100 def test_transform_handles_special_characters(self, spark_writer): - """Test that transform handles special characters in data.""" record = { "text": 'Hello "world"', "unicode": "日本語", "newlines": "line1\nline2", } - transformed = spark_writer._transform_record(record) - - parsed = json.loads(transformed["_airbyte_data"]) + transformed = spark_writer._transform_record("stream1", record) + + parsed = json.loads(transformed["data"]) assert parsed["text"] == 'Hello "world"' assert parsed["unicode"] == "日本語" assert parsed["newlines"] == "line1\nline2" def test_transform_handles_none_values(self, spark_writer): - """Test that transform handles None values.""" record = {"id": 1, "optional": None} - transformed = spark_writer._transform_record(record) - - parsed = json.loads(transformed["_airbyte_data"]) + transformed = spark_writer._transform_record("stream1", record) + + parsed = json.loads(transformed["data"]) assert parsed["optional"] is None def test_transform_handles_empty_record(self, spark_writer): - """Test that transform handles empty records.""" record = {} - transformed = spark_writer._transform_record(record) - - assert transformed["_airbyte_data"] == "{}" + transformed = spark_writer._transform_record("stream1", record) + + assert transformed["data"] == "{}" class TestWriterConsistency: """Test that both writers behave consistently.""" @pytest.fixture - def spark_writer(self, tmp_path): - import os - with patch.dict(os.environ, {"SPARK_LOCAL_DIRS": str(tmp_path)}): - writer = SparkStreamingWriter( - catalog="main", - schema="test", - buffer_size_records=100, - ) - writer._spark = MagicMock() - return writer + def spark_writer(self): + writer = SparkStreamingWriter( + catalog="main", + schema="test", + buffer_size_records=100, + run_id="test-run", + ) + writer._spark = MagicMock() + return writer @pytest.fixture def sql_writer(self): - with patch("databricks.sql.connect"): + with patch("os.path.exists", return_value=True): return SQLStreamingWriter( catalog="main", schema="test", @@ -175,25 +126,20 @@ def sql_writer(self): http_path="p", access_token="t", buffer_size_records=100, + run_id="test-run", ) def test_same_table_name_format(self, spark_writer, sql_writer): - """Test both writers generate same table names.""" assert spark_writer.get_table_name("users") == sql_writer.get_table_name("users") - assert spark_writer.get_table_name("test") == sql_writer.get_table_name("test") - def test_same_transform_schema(self, spark_writer, sql_writer): - """Test both writers produce same transformed schema.""" + def test_same_raw_transform_schema(self, spark_writer, sql_writer): record = {"id": 1, "name": "test"} - - spark_result = spark_writer._transform_record(record) - sql_result = sql_writer._transform_record(record) - - # Same keys - assert set(spark_result.keys()) == set(sql_result.keys()) - - # Same types - assert type(spark_result["_airbyte_raw_id"]) == type(sql_result["_airbyte_raw_id"]) - assert type(spark_result["_airbyte_extracted_at"]) == type(sql_result["_airbyte_extracted_at"]) - assert type(spark_result["_airbyte_data"]) == type(sql_result["_airbyte_data"]) + spark_result = spark_writer._transform_record("stream1", record) + sql_result = sql_writer._transform_record("stream1", record) + + assert set(spark_result.keys()) == set(sql_result.keys()) + assert type(spark_result["record_id"]) == type(sql_result["record_id"]) + assert type(spark_result["extracted_at"]) == type(sql_result["extracted_at"]) + assert type(spark_result["data"]) == type(sql_result["data"]) + assert type(spark_result["run_id"]) == type(sql_result["run_id"]) diff --git a/tests/test_buffer_thresholds.py b/tests/test_buffer_thresholds.py index 9c1de22..57bd255 100644 --- a/tests/test_buffer_thresholds.py +++ b/tests/test_buffer_thresholds.py @@ -1,7 +1,6 @@ """ Tests for buffer size thresholds (records AND bytes). """ -import os from unittest.mock import MagicMock, patch import pytest @@ -14,23 +13,21 @@ class TestBufferSizeBytes: """Test byte-based buffer thresholds.""" @pytest.fixture - def spark_writer(self, tmp_path): - """SparkStreamingWriter with low byte threshold.""" - with patch.dict(os.environ, {"SPARK_LOCAL_DIRS": str(tmp_path)}): - writer = SparkStreamingWriter( - catalog="main", - schema="test", - buffer_size_records=1000, # High record limit - buffer_size_mb=1, # 1MB byte limit (will hit first) - ) - writer._spark = MagicMock() - writer._write_micro_batch = MagicMock() - return writer + def spark_writer(self): + writer = SparkStreamingWriter( + catalog="main", + schema="test", + buffer_size_records=1000, + buffer_size_mb=1, + run_id="test-run", + ) + writer._spark = MagicMock() + writer._write_micro_batch = MagicMock() + return writer @pytest.fixture def sql_writer(self): - """SQLStreamingWriter with low byte threshold.""" - with patch("databricks.sql.connect"): + with patch("os.path.exists", return_value=True): writer = SQLStreamingWriter( catalog="main", schema="test", @@ -38,99 +35,90 @@ def sql_writer(self): server_hostname="host", http_path="/sql", access_token="token", - buffer_size_records=1000, # High record limit - buffer_size_mb=1, # 1MB byte limit + buffer_size_records=1000, + buffer_size_mb=1, + run_id="test-run", ) writer.flush_stream = MagicMock() return writer def test_spark_flushes_on_byte_threshold(self, spark_writer): - """Test SparkStreamingWriter flushes when byte threshold is hit.""" - # sys.getsizeof returns ~50 bytes overhead per string - # So we need many medium-sized records to exceed 1MB - # 1MB = 1,048,576 bytes / ~100 bytes per record = ~10k records - # Use 20k char string to make each record ~20KB large_data = "x" * 20_000 - - # Write records until we exceed threshold - for i in range(40): # 40 * ~25KB = ~1MB + + for i in range(40): spark_writer.write_record("stream1", {"data": large_data, "i": i}) - + assert spark_writer._write_micro_batch.call_count == 0 - - # One more should trigger flush + for i in range(20): spark_writer.write_record("stream1", {"data": large_data, "i": i}) - + assert spark_writer._write_micro_batch.call_count >= 1 def test_sql_flushes_on_byte_threshold(self, sql_writer): - """Test SQLStreamingWriter flushes when byte threshold is hit.""" large_data = "x" * 20_000 - + for i in range(40): sql_writer.write_record("stream1", {"data": large_data, "i": i}) - + assert sql_writer.flush_stream.call_count == 0 - + for i in range(20): sql_writer.write_record("stream1", {"data": large_data, "i": i}) - + assert sql_writer.flush_stream.call_count >= 1 - def test_record_threshold_still_works(self, tmp_path): - """Test that record count threshold works independently.""" - with patch.dict(os.environ, {"SPARK_LOCAL_DIRS": str(tmp_path)}): - writer = SparkStreamingWriter( - catalog="main", - schema="test", - buffer_size_records=2, # Low record limit - buffer_size_mb=1000, # High byte limit (won't hit) - ) - writer._spark = MagicMock() - writer._write_micro_batch = MagicMock() - - # Small records - byte threshold won't be hit - writer.write_record("stream1", {"id": 1}) - assert writer._write_micro_batch.call_count == 0 - - writer.write_record("stream1", {"id": 2}) - assert writer._write_micro_batch.call_count == 1 + def test_record_threshold_still_works(self): + writer = SparkStreamingWriter( + catalog="main", + schema="test", + buffer_size_records=2, + buffer_size_mb=1000, + run_id="test-run", + ) + writer._spark = MagicMock() + writer._write_micro_batch = MagicMock() + + writer.write_record("stream1", {"id": 1}) + assert writer._write_micro_batch.call_count == 0 + + writer.write_record("stream1", {"id": 2}) + assert writer._write_micro_batch.call_count == 1 def test_buffer_size_tracking(self, spark_writer): - """Test that buffer sizes are tracked correctly.""" - spark_writer._write_micro_batch = MagicMock() # Prevent actual flush - + spark_writer._write_micro_batch = MagicMock() + spark_writer.write_record("stream1", {"data": "small"}) - + assert spark_writer._buffer_sizes["stream1"] > 0 initial_size = spark_writer._buffer_sizes["stream1"] - + spark_writer.write_record("stream1", {"data": "another"}) - + assert spark_writer._buffer_sizes["stream1"] > initial_size - def test_buffer_reset_after_flush(self, tmp_path): - """Test that all buffer tracking is reset after flush.""" - with patch.dict(os.environ, {"SPARK_LOCAL_DIRS": str(tmp_path)}): - writer = SparkStreamingWriter( - catalog="main", - schema="test", - buffer_size_records=2, - buffer_size_mb=100, - ) - writer._spark = MagicMock() - - # Mock the write operations - with patch("pyarrow.parquet.write_table"), patch("os.remove"): - mock_df = MagicMock() - writer._spark.read.parquet.return_value = mock_df - - writer.write_record("stream1", {"id": 1}) - writer.write_record("stream1", {"id": 2}) # Triggers flush - - # All tracking should be reset - assert writer._buffers["stream1"] == [] - assert writer._buffer_counts["stream1"] == 0 - assert writer._buffer_sizes["stream1"] == 0 + def test_buffer_reset_after_flush(self): + writer = SparkStreamingWriter( + catalog="main", + schema="test", + buffer_size_records=2, + buffer_size_mb=100, + run_id="test-run", + ) + writer._spark = MagicMock() + + # Mock the Spark write chain + mock_df = MagicMock() + mock_write = MagicMock() + mock_df.write = mock_write + mock_write.format.return_value = mock_write + mock_write.mode.return_value = mock_write + mock_write.option.return_value = mock_write + writer._spark.createDataFrame.return_value = mock_df + + writer.write_record("stream1", {"id": 1}) + writer.write_record("stream1", {"id": 2}) + assert writer._buffers["stream1"] == [] + assert writer._buffer_counts["stream1"] == 0 + assert writer._buffer_sizes["stream1"] == 0 diff --git a/tests/test_concurrent.py b/tests/test_concurrent.py new file mode 100644 index 0000000..eef116c --- /dev/null +++ b/tests/test_concurrent.py @@ -0,0 +1,136 @@ +""" +Tests for concurrent stream processing. +""" +from unittest.mock import MagicMock, patch + +import pytest + +import brickbyte + + +class TestConcurrentStreams: + @pytest.fixture + def bb(self, tmp_path): + return brickbyte.client(base_venv_directory=str(tmp_path)) + + def test_parallel_streams_each_get_own_writer(self, bb, mock_airbyte): + mock_source = MagicMock() + mock_airbyte.get_source.return_value = mock_source + mock_source.get_selected_streams.return_value = [ + "stream1", + "stream2", + "stream3", + ] + mock_source.get_records.side_effect = [ + [{"id": 1}], + [{"id": 2}], + [{"id": 3}], + ] + + writers_created = [] + + def mock_create_writer(**kwargs): + w = MagicMock() + writers_created.append(w) + return w + + with patch( + "brickbyte.writers.create_streaming_writer", side_effect=mock_create_writer + ): + result = bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + max_parallel_streams=3, + ) + + assert result.records_written == 3 + # Each stream gets its own writer (in thread pool) + no sequential writer + assert len(writers_created) == 3 + + def test_error_propagation_with_continue_on_error_false(self, bb, mock_airbyte): + mock_source = MagicMock() + mock_airbyte.get_source.return_value = mock_source + mock_source.get_selected_streams.return_value = ["stream1", "stream2"] + mock_source.get_records.side_effect = [ + RuntimeError("connection failed"), + [{"id": 1}], + ] + + with patch("brickbyte.writers.create_streaming_writer") as mock_factory: + mock_writer = MagicMock() + mock_factory.return_value = mock_writer + + with pytest.raises(RuntimeError, match="connection failed"): + bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + max_parallel_streams=2, + continue_on_error=False, + ) + + def test_sequential_mode_uses_single_writer(self, bb, mock_airbyte): + mock_source = MagicMock() + mock_airbyte.get_source.return_value = mock_source + mock_source.get_selected_streams.return_value = ["stream1", "stream2"] + mock_source.get_records.side_effect = [[{"id": 1}], [{"id": 2}]] + + with patch( + "brickbyte.writers.create_streaming_writer" + ) as mock_factory: + mock_writer = MagicMock() + mock_factory.return_value = mock_writer + + result = bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + max_parallel_streams=1, + ) + + assert result.records_written == 2 + # Single writer for sequential mode + mock_factory.assert_called_once() + + def test_parallel_incremental_saves_state_per_stream(self, bb, mock_airbyte): + mock_source = MagicMock() + mock_airbyte.get_source.return_value = mock_source + mock_source.get_selected_streams.return_value = ["users", "orders"] + mock_source.get_records.side_effect = [ + [{"id": 1}], + [{"id": 2}], + ] + mock_source.get_state.return_value = None + + with patch("brickbyte._state.StateManager") as mock_state_manager_cls: + mock_state_manager = MagicMock() + mock_state_manager.get_state.return_value = None + mock_state_manager_cls.return_value = mock_state_manager + + with patch("brickbyte.writers.create_streaming_writer") as mock_factory: + mock_writer = MagicMock() + mock_factory.return_value = mock_writer + + result = bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + max_parallel_streams=2, + incremental=True, + ) + + assert result.records_written == 2 + assert mock_state_manager.save_state.call_count == 2 diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 3efcdb5..ead2f0e 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -1,132 +1,87 @@ """Tests for credential resolution.""" -import pytest -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock -# Import credentials module directly to avoid virtualenv import from brickbyte.credentials import CredentialResolver class TestCredentialResolver: - """Tests for CredentialResolver class.""" - def test_init_default_scope(self): - """Test default secrets scope is 'brickbyte'.""" resolver = CredentialResolver() assert resolver.secrets_scope == "brickbyte" def test_init_custom_scope(self): - """Test custom secrets scope.""" resolver = CredentialResolver(secrets_scope="custom-scope") assert resolver.secrets_scope == "custom-scope" def test_merge_credentials_no_discovered(self): - """Test merge when no credentials discovered.""" resolver = CredentialResolver() - source_config = {"bucket": "my-bucket", "region": "us-east-1"} result = resolver.merge_credentials("source-s3", source_config) - - # Should return original config unchanged assert result == source_config def test_merge_credentials_with_discovered(self): - """Test merge with discovered credentials.""" resolver = CredentialResolver() - # Manually inject cached credentials resolver._cache["source-s3"] = { "aws_access_key_id": "discovered_key", "aws_secret_access_key": "discovered_secret", } - source_config = {"bucket": "my-bucket"} result = resolver.merge_credentials("source-s3", source_config) - - # Should merge discovered credentials assert result["bucket"] == "my-bucket" assert result["aws_access_key_id"] == "discovered_key" assert result["aws_secret_access_key"] == "discovered_secret" def test_merge_credentials_explicit_override(self): - """Test that explicit config overrides discovered credentials.""" resolver = CredentialResolver() - # Manually inject cached credentials resolver._cache["source-s3"] = { "aws_access_key_id": "discovered_key", "aws_secret_access_key": "discovered_secret", "region_name": "us-west-2", } - source_config = { "bucket": "my-bucket", - "aws_access_key_id": "explicit_key", # Override + "aws_access_key_id": "explicit_key", } result = resolver.merge_credentials("source-s3", source_config) - - # Explicit value should override discovered assert result["aws_access_key_id"] == "explicit_key" - # Non-overridden discovered value should remain assert result["aws_secret_access_key"] == "discovered_secret" assert result["region_name"] == "us-west-2" assert result["bucket"] == "my-bucket" def test_deep_merge_nested_dicts(self): - """Test deep merge with nested dictionaries.""" resolver = CredentialResolver() - base = { - "credentials": { - "client_id": "base_id", - "client_secret": "base_secret", - }, + "credentials": {"client_id": "base_id", "client_secret": "base_secret"}, "other": "value", } - override = { - "credentials": { - "client_id": "override_id", - }, - "bucket": "my-bucket", - } - + override = {"credentials": {"client_id": "override_id"}, "bucket": "my-bucket"} result = resolver._deep_merge(base, override) - assert result["credentials"]["client_id"] == "override_id" assert result["credentials"]["client_secret"] == "base_secret" assert result["other"] == "value" assert result["bucket"] == "my-bucket" def test_validate_with_credentials(self): - """Test validate returns True when credentials exist.""" resolver = CredentialResolver() resolver._cache["source-s3"] = {"aws_access_key_id": "key"} - assert resolver.validate("source-s3") is True def test_validate_without_credentials(self): - """Test validate returns False when no credentials exist.""" resolver = CredentialResolver() - assert resolver.validate("source-nonexistent") is False def test_clear_cache(self): - """Test cache clearing.""" resolver = CredentialResolver() resolver._cache["source-s3"] = {"key": "value"} resolver._available_keys = ["source-s3/key"] - resolver.clear_cache() - assert resolver._cache == {} assert resolver._available_keys is None class TestCredentialResolverWithMockedDbutils: - """Tests with mocked dbutils.""" - def test_list_secrets_for_source(self): - """Test listing secrets for a specific source.""" resolver = CredentialResolver() - - # Mock dbutils mock_dbutils = MagicMock() mock_secret1 = MagicMock() mock_secret1.key = "source-s3/aws_access_key_id" @@ -134,162 +89,183 @@ def test_list_secrets_for_source(self): mock_secret2.key = "source-s3/aws_secret_access_key" mock_secret3 = MagicMock() mock_secret3.key = "source-gcs/service_account" - mock_dbutils.secrets.list.return_value = [mock_secret1, mock_secret2, mock_secret3] resolver._dbutils = mock_dbutils - keys = resolver._list_secrets_for_source("source-s3") - assert "aws_access_key_id" in keys assert "aws_secret_access_key" in keys assert "service_account" not in keys def test_get_secret(self): - """Test getting a single secret.""" resolver = CredentialResolver() - mock_dbutils = MagicMock() mock_dbutils.secrets.get.return_value = "secret_value" resolver._dbutils = mock_dbutils - value = resolver._get_secret("source-s3/aws_access_key_id") - assert value == "secret_value" mock_dbutils.secrets.get.assert_called_once_with( - scope="brickbyte", - key="source-s3/aws_access_key_id" + scope="brickbyte", key="source-s3/aws_access_key_id" + ) + + def test_get_secret_with_scope(self): + resolver = CredentialResolver() + mock_dbutils = MagicMock() + mock_dbutils.secrets.get.return_value = "scoped_value" + resolver._dbutils = mock_dbutils + value = resolver._get_secret_with_scope("custom-scope", "my_key") + assert value == "scoped_value" + mock_dbutils.secrets.get.assert_called_once_with( + scope="custom-scope", key="my_key" ) def test_list_available_sources(self): - """Test listing all available sources.""" resolver = CredentialResolver() - mock_dbutils = MagicMock() mock_secrets = [] - for key in ["source-s3/key1", "source-s3/key2", "source-gcs/key1", "source-teams/key1"]: + for key in [ + "source-s3/key1", + "source-s3/key2", + "source-gcs/key1", + "source-teams/key1", + ]: mock_secret = MagicMock() mock_secret.key = key mock_secrets.append(mock_secret) - mock_dbutils.secrets.list.return_value = mock_secrets resolver._dbutils = mock_dbutils - sources = resolver.list_available_sources() - assert "source-s3" in sources assert "source-gcs" in sources assert "source-teams" in sources def test_get_credentials_convention_based(self): - """Test getting credentials via convention-based discovery.""" resolver = CredentialResolver() - mock_dbutils = MagicMock() mock_secret1 = MagicMock() mock_secret1.key = "source-s3/aws_access_key_id" mock_secret2 = MagicMock() mock_secret2.key = "source-s3/aws_secret_access_key" - mock_dbutils.secrets.list.return_value = [mock_secret1, mock_secret2] mock_dbutils.secrets.get.side_effect = lambda scope, key: { "source-s3/aws_access_key_id": "key123", "source-s3/aws_secret_access_key": "secret456", }.get(key) - resolver._dbutils = mock_dbutils - creds = resolver.get_credentials("source-s3") - assert creds["aws_access_key_id"] == "key123" assert creds["aws_secret_access_key"] == "secret456" + def test_dotted_key_nested_mapping(self): + resolver = CredentialResolver() + mock_dbutils = MagicMock() + mock_secret = MagicMock() + mock_secret.key = "source-x/credentials.client_id" + mock_dbutils.secrets.list.return_value = [mock_secret] + mock_dbutils.secrets.get.side_effect = lambda scope, key: { + "source-x/credentials.client_id": "my_client_id", + }.get(key) + resolver._dbutils = mock_dbutils + creds = resolver.get_credentials("source-x") + assert creds["credentials"]["client_id"] == "my_client_id" -class TestYamlProfiles: - """Tests for YAML profile loading.""" +class TestYamlProfiles: def test_resolve_profile_simple(self): - """Test resolving a simple profile without secret references.""" resolver = CredentialResolver() resolver._profiles = { - "test-profile": { - "region": "us-east-1", - "bucket": "my-bucket", - } + "test-profile": {"region": "us-east-1", "bucket": "my-bucket"} } - result = resolver._resolve_profile("test-profile") - assert result["region"] == "us-east-1" assert result["bucket"] == "my-bucket" def test_resolve_profile_nonexistent(self): - """Test resolving a nonexistent profile returns empty dict.""" resolver = CredentialResolver() resolver._profiles = {} - result = resolver._resolve_profile("nonexistent") - assert result == {} def test_mappings_take_precedence(self): - """Test that profile mappings are used when available.""" resolver = CredentialResolver() resolver._profiles = { - "azure-shared": { - "tenant_id": "tenant123", - "client_id": "client456", - } - } - resolver._mappings = { - "source-microsoft-teams": "azure-shared", + "azure-shared": {"tenant_id": "tenant123", "client_id": "client456"} } - + resolver._mappings = {"source-microsoft-teams": "azure-shared"} creds = resolver.get_credentials("source-microsoft-teams") - assert creds["tenant_id"] == "tenant123" assert creds["client_id"] == "client456" + def test_resolve_profile_with_explicit_scope_secret(self): + resolver = CredentialResolver() + mock_dbutils = MagicMock() + mock_dbutils.secrets.get.return_value = "resolved_secret" + resolver._dbutils = mock_dbutils -def _has_virtualenv(): - """Check if virtualenv is available.""" - try: - import virtualenv - return True - except ImportError: - return False + resolver._profiles = { + "test-profile": {"api_key": "{{ secret('custom-scope/my_key') }}"} + } + result = resolver._resolve_profile("test-profile") + assert result["api_key"] == "resolved_secret" + mock_dbutils.secrets.get.assert_called_with(scope="custom-scope", key="my_key") + + def test_unresolved_secret_logs_warning(self, caplog): + import logging + + resolver = CredentialResolver() + resolver._dbutils = MagicMock() + resolver._dbutils.secrets.get.return_value = None + + resolver._profiles = { + "test-profile": {"api_key": "{{ secret('missing_key') }}"} + } + + with caplog.at_level(logging.WARNING, logger="brickbyte.credentials"): + result = resolver._resolve_profile("test-profile") + + assert "api_key" not in result + assert "Could not resolve secret" in caplog.text + + +class TestSetNested: + def test_simple_key(self): + resolver = CredentialResolver() + d = {} + resolver._set_nested(d, "key", "value") + assert d == {"key": "value"} + + def test_dotted_key(self): + resolver = CredentialResolver() + d = {} + resolver._set_nested(d, "credentials.client_id", "my_id") + assert d == {"credentials": {"client_id": "my_id"}} + + def test_deep_dotted_key(self): + resolver = CredentialResolver() + d = {} + resolver._set_nested(d, "a.b.c", "deep") + assert d == {"a": {"b": {"c": "deep"}}} -@pytest.mark.skipif(not _has_virtualenv(), reason="virtualenv not installed") -class TestBrickbyteCredentialIntegration: - """Tests for Brickbyte credential integration.""" +class TestClientCredentialIntegration: + def test_client_init_with_default_scope(self): + import brickbyte - def test_brickbyte_init_with_default_scope(self): - """Test Brickbyte initializes credential resolver with default scope.""" - from brickbyte import Brickbyte - - bb = Brickbyte() - + bb = brickbyte.client() assert bb._credential_resolver.secrets_scope == "brickbyte" - def test_brickbyte_init_with_custom_scope(self): - """Test Brickbyte with custom secrets scope.""" - from brickbyte import Brickbyte - - bb = Brickbyte(secrets_scope="my-custom-scope") - + def test_client_init_with_custom_scope(self): + import brickbyte + + bb = brickbyte.client(secrets_scope="my-custom-scope") assert bb._credential_resolver.secrets_scope == "my-custom-scope" def test_list_configured_sources(self): - """Test listing configured sources.""" - from brickbyte import Brickbyte - - bb = Brickbyte() - bb._credential_resolver._cache = { - "source-s3": {"key": "value"}, - } + import brickbyte + + bb = brickbyte.client() + bb._credential_resolver._cache = {"source-s3": {"key": "value"}} bb._credential_resolver._available_keys = ["source-s3/key", "source-gcs/key"] - - # Mock dbutils to return the cached keys + mock_dbutils = MagicMock() mock_s3 = MagicMock() mock_s3.key = "source-s3/key" @@ -297,18 +273,15 @@ def test_list_configured_sources(self): mock_gcs.key = "source-gcs/key" mock_dbutils.secrets.list.return_value = [mock_s3, mock_gcs] bb._credential_resolver._dbutils = mock_dbutils - + sources = bb.list_configured_sources() - assert "source-s3" in sources assert "source-gcs" in sources def test_validate_credentials(self): - """Test credential validation.""" - from brickbyte import Brickbyte - - bb = Brickbyte() + import brickbyte + + bb = brickbyte.client() bb._credential_resolver._cache["source-s3"] = {"key": "value"} - assert bb.validate_credentials("source-s3") is True assert bb.validate_credentials("source-nonexistent") is False diff --git a/tests/test_dedup.py b/tests/test_dedup.py new file mode 100644 index 0000000..6e726c3 --- /dev/null +++ b/tests/test_dedup.py @@ -0,0 +1,386 @@ +""" +Tests for deduplication logic. +""" +from unittest.mock import MagicMock, patch + +import pytest + +import brickbyte +from brickbyte._dedup import deduplicate_stream +from brickbyte._schema import DK_MISSING +from brickbyte.writers.spark_streaming_writer import SparkStreamingWriter + + +class TestDedupKeyNormalization: + @pytest.fixture + def bb(self, tmp_path): + return brickbyte.client(base_venv_directory=str(tmp_path)) + + def test_deduplicate_true_without_keys_raises(self, bb): + with pytest.raises(ValueError, match="dedup_keys is required"): + bb._normalize_dedup_keys(None, None) + + def test_deduplicate_with_empty_list_raises(self, bb): + with pytest.raises(ValueError, match="non-empty"): + bb._normalize_dedup_keys([], None) + + def test_deduplicate_with_per_stream_empty_raises(self, bb): + with pytest.raises(ValueError, match="non-empty"): + bb._normalize_dedup_keys({"stream": []}, None) + + def test_list_keys_normalized_to_dict(self, bb): + result = bb._normalize_dedup_keys(["email"], None) + assert result == {"__all__": ["email"]} + + def test_dict_keys_pass_through(self, bb): + result = bb._normalize_dedup_keys({"users": ["email"]}, None) + assert result == {"users": ["email"]} + + def test_invalid_identifier_in_list_raises(self, bb): + with pytest.raises(ValueError, match="invalid key"): + bb._normalize_dedup_keys(["bad`key"], None) + + def test_invalid_identifier_in_dict_raises(self, bb): + with pytest.raises(ValueError, match="invalid key"): + bb._normalize_dedup_keys({"users": ["bad;key"]}, None) + + +class TestDedupTransformRecord: + def test_flatten_mode_dedup_keys_added(self): + writer = SparkStreamingWriter( + catalog="main", + schema="test", + flatten=True, + run_id="test-run", + dedup_keys={"stream1": ["email"]}, + ) + writer._spark = MagicMock() + + record = {"id": 1, "email": "test@example.com"} + transformed = writer._transform_record("stream1", record) + + assert "_dk_0" in transformed + assert transformed["_dk_0"] == "test@example.com" + assert transformed[DK_MISSING] is False + + def test_raw_mode_dedup_keys_extracted_from_source(self): + writer = SparkStreamingWriter( + catalog="main", + schema="test", + flatten=False, + run_id="test-run", + dedup_keys={"stream1": ["user-id"]}, + ) + writer._spark = MagicMock() + + record = {"user-id": "abc123", "name": "test"} + transformed = writer._transform_record("stream1", record) + + assert "_dk_0" in transformed + assert transformed["_dk_0"] == "abc123" + assert transformed[DK_MISSING] is False + + def test_missing_key_sets_dk_missing(self): + writer = SparkStreamingWriter( + catalog="main", + schema="test", + flatten=True, + run_id="test-run", + dedup_keys={"stream1": ["email"]}, + ) + writer._spark = MagicMock() + + record = {"id": 1} # No email field + transformed = writer._transform_record("stream1", record) + + assert transformed["_dk_0"] is None + assert transformed[DK_MISSING] is True + + def test_null_key_value_dk_missing_stays_false(self): + writer = SparkStreamingWriter( + catalog="main", + schema="test", + flatten=True, + run_id="test-run", + dedup_keys={"stream1": ["email"]}, + ) + writer._spark = MagicMock() + + record = {"id": 1, "email": None} + transformed = writer._transform_record("stream1", record) + + assert transformed["_dk_0"] is None + assert transformed[DK_MISSING] is False + + def test_no_dedup_keys_no_dk_columns(self): + writer = SparkStreamingWriter( + catalog="main", + schema="test", + flatten=True, + run_id="test-run", + ) + writer._spark = MagicMock() + + record = {"id": 1, "email": "test@example.com"} + transformed = writer._transform_record("stream1", record) + + assert "_dk_0" not in transformed + assert DK_MISSING not in transformed + + def test_stream_not_in_dedup_dict_no_dk_columns(self): + writer = SparkStreamingWriter( + catalog="main", + schema="test", + flatten=True, + run_id="test-run", + dedup_keys={"other_stream": ["email"]}, + ) + writer._spark = MagicMock() + + record = {"id": 1, "email": "test@example.com"} + transformed = writer._transform_record("stream1", record) + + assert "_dk_0" not in transformed + assert DK_MISSING not in transformed + + def test_multiple_dedup_keys(self): + writer = SparkStreamingWriter( + catalog="main", + schema="test", + flatten=True, + run_id="test-run", + dedup_keys={"stream1": ["email", "phone"]}, + ) + writer._spark = MagicMock() + + record = {"id": 1, "email": "test@example.com", "phone": "555-1234"} + transformed = writer._transform_record("stream1", record) + + assert transformed["_dk_0"] == "test@example.com" + assert transformed["_dk_1"] == "555-1234" + assert transformed[DK_MISSING] is False + + +class TestDedupKeyValidation: + @pytest.fixture + def bb(self, tmp_path): + return brickbyte.client(base_venv_directory=str(tmp_path)) + + def test_dict_key_using_sanitized_name_raises(self, bb, mock_airbyte): + mock_source = MagicMock() + mock_airbyte.get_source.return_value = mock_source + mock_source.get_selected_streams.return_value = ["my-stream"] + mock_source.get_records.return_value = [] + + with patch("brickbyte.writers.create_streaming_writer") as mock_factory: + mock_writer = MagicMock() + mock_factory.return_value = mock_writer + + with pytest.raises(ValueError, match="sanitized name"): + bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + deduplicate=True, + dedup_keys={"my_stream": ["email"]}, + ) + + def test_unmatched_dict_key_raises(self, bb, mock_airbyte): + mock_source = MagicMock() + mock_airbyte.get_source.return_value = mock_source + mock_source.get_selected_streams.return_value = ["users"] + mock_source.get_records.return_value = [] + + with patch("brickbyte.writers.create_streaming_writer") as mock_factory: + mock_writer = MagicMock() + mock_factory.return_value = mock_writer + + with pytest.raises(ValueError, match="does not match"): + bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + deduplicate=True, + dedup_keys={"nonexistent": ["email"]}, + ) + + def test_dedup_keys_ignored_when_deduplicate_false(self, bb, mock_airbyte): + mock_source = MagicMock() + mock_airbyte.get_source.return_value = mock_source + mock_source.get_selected_streams.return_value = ["users"] + mock_source.get_records.return_value = [{"id": 1}] + + with patch("brickbyte.writers.create_streaming_writer") as mock_factory: + mock_writer = MagicMock() + mock_factory.return_value = mock_writer + + result = bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + deduplicate=False, + dedup_keys=["email"], + ) + + assert result.records_written == 1 + + +class TestDedupListKeysExpansion: + """Test that List[str] dedup_keys are expanded to per-stream dict.""" + + @pytest.fixture + def bb(self, tmp_path): + return brickbyte.client(base_venv_directory=str(tmp_path)) + + def test_list_keys_applied_to_all_streams(self, bb, mock_airbyte): + """List[str] dedup_keys should apply to every selected stream.""" + mock_source = MagicMock() + mock_airbyte.get_source.return_value = mock_source + mock_source.get_selected_streams.return_value = ["users", "orders"] + mock_source.get_records.side_effect = [ + [{"id": 1, "email": "a@b.com"}], + [{"id": 2, "email": "c@d.com"}], + ] + + with patch("brickbyte.writers.create_streaming_writer") as mock_factory: + mock_writer = MagicMock() + mock_factory.return_value = mock_writer + + result = bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + deduplicate=True, + dedup_keys=["email"], + ) + + assert result.records_written == 2 + # The writer's dedup_keys kwarg should be a per-stream dict, + # NOT {"__all__": ["email"]} + call_kwargs = mock_factory.call_args[1] + dk = call_kwargs["dedup_keys"] + assert "users" in dk + assert "orders" in dk + assert "__all__" not in dk + assert dk["users"] == ["email"] + assert dk["orders"] == ["email"] + + def test_dedup_runs_with_executor_in_parallel_mode(self, bb, mock_airbyte): + """In parallel mode, dedup must not fail with executor=None.""" + mock_source = MagicMock() + mock_airbyte.get_source.return_value = mock_source + mock_source.get_selected_streams.return_value = ["users"] + mock_source.get_records.return_value = [{"id": 1, "email": "a@b.com"}] + + writers_created = [] + + def mock_create_writer(**kwargs): + w = MagicMock() + w.spark = MagicMock() # Has spark attr so _execute_sql works + writers_created.append(w) + return w + + with patch( + "brickbyte.writers.create_streaming_writer", + side_effect=mock_create_writer, + ): + result = bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + deduplicate=True, + dedup_keys=["email"], + max_parallel_streams=2, + ) + + assert result.records_written == 1 + # Dedup MERGE should have been called on the writer + assert any( + w.spark.sql.called for w in writers_created + ), "dedup MERGE should have been invoked on a writer" + + +class TestRunDedupRouting: + @pytest.fixture + def bb(self, tmp_path): + return brickbyte.client(base_venv_directory=str(tmp_path)) + + def test_flatten_mode_uses_internal_dk_columns(self, bb): + with patch("brickbyte._dedup.deduplicate_stream") as mock_dedup: + bb._run_dedup_for_stream( + stream_name="users", + deduplicate=True, + normalized_dedup_keys={"users": ["email", "phone"]}, + flatten=True, + catalog="main", + schema="test", + executor_writer=MagicMock(), + ) + + kwargs = mock_dedup.call_args.kwargs + assert kwargs["key_columns"] == ["_dk_0", "_dk_1"] + assert kwargs["run_id_col"] == "_run_id" + assert kwargs["extracted_at_col"] == "_extracted_at" + assert kwargs["record_id_col"] == "_record_id" + + +class TestDeduplicateStream: + def test_deduplicate_executes_merge(self): + mock_executor = MagicMock() + mock_executor.spark = MagicMock() + + deduplicate_stream( + executor=mock_executor, + table_name="`main`.`test`.`users`", + key_columns=["_dk_0"], + run_id_col="run_id", + extracted_at_col="extracted_at", + record_id_col="record_id", + flatten=False, + ) + + mock_executor.spark.sql.assert_called_once() + call_args = str(mock_executor.spark.sql.call_args) + assert "MERGE INTO" in call_args + assert "_dk_0" in call_args + + def test_deduplicate_empty_keys_noop(self): + mock_executor = MagicMock() + deduplicate_stream( + executor=mock_executor, + table_name="`main`.`test`.`users`", + key_columns=[], + run_id_col="run_id", + extracted_at_col="extracted_at", + record_id_col="record_id", + ) + mock_executor.spark.sql.assert_not_called() + + def test_deduplicate_invalid_key_identifier_raises(self): + mock_executor = MagicMock() + mock_executor.spark = MagicMock() + + with pytest.raises(ValueError, match="unsafe character"): + deduplicate_stream( + executor=mock_executor, + table_name="`main`.`test`.`users`", + key_columns=["bad`col"], + run_id_col="run_id", + extracted_at_col="extracted_at", + record_id_col="record_id", + ) diff --git a/tests/test_enrichment_sql.py b/tests/test_enrichment_sql.py new file mode 100644 index 0000000..f25ea88 --- /dev/null +++ b/tests/test_enrichment_sql.py @@ -0,0 +1,110 @@ +""" +Tests for SQL-mode enrichment. +""" +from unittest.mock import MagicMock, patch + +import pytest + +from brickbyte.enrichment.semantic import ( + ColumnEnrichment, + SQLSemanticEnricher, + TableEnrichment, +) + + +class TestSQLSemanticEnricher: + @pytest.fixture + def enricher(self): + with patch("databricks.sql.connect") as mock_connect: + e = SQLSemanticEnricher( + server_hostname="host", + http_path="/sql", + access_token="token", + catalog="main", + schema="test", + ) + e._connection = mock_connect.return_value + return e + + def test_get_column_samples_raw_mode(self, enricher): + cursor = MagicMock() + enricher._connection.cursor.return_value = cursor + + # DESCRIBE returns data column + cursor.fetchall.side_effect = [ + [("record_id",), ("extracted_at",), ("data",), ("run_id",)], + [('{"name": "Alice", "email": "alice@example.com"}',)], + ] + + samples = enricher._get_column_samples("`main`.`test`.`users`") + + assert "name" in samples + assert "email" in samples + assert samples["name"] == ["Alice"] + + def test_get_column_samples_flatten_mode(self, enricher): + cursor = MagicMock() + enricher._connection.cursor.return_value = cursor + + # DESCRIBE returns no data column (flatten mode) + cursor.fetchall.side_effect = [ + [("name",), ("email",), ("_record_id",), ("_extracted_at",)], + [("Alice", "alice@example.com"), ("Bob", "bob@example.com")], + ] + + samples = enricher._get_column_samples("`main`.`test`.`users`") + + assert "name" in samples + assert "email" in samples + assert "_record_id" not in samples # underscore-prefixed excluded + + def test_apply_to_catalog_column_comments_in_flatten(self, enricher): + cursor = MagicMock() + enricher._connection.cursor.return_value = cursor + + # DESCRIBE returns flatten columns (no data column) + cursor.fetchall.return_value = [ + ("name",), + ("email",), + ("_record_id",), + ] + + enrichment = TableEnrichment( + table_name="`main`.`test`.`users`", + columns=[ + ColumnEnrichment( + column_name="name", + description="User full name", + ), + ColumnEnrichment( + column_name="email", + description="User email address", + is_pii=True, + pii_type="email", + ), + ], + ) + + enricher.apply_to_catalog(enrichment) + + # Should have COMMENT ON COLUMN calls + execute_calls = [str(c) for c in cursor.execute.call_args_list] + comment_calls = [c for c in execute_calls if "COMMENT ON COLUMN" in c] + assert len(comment_calls) >= 1 + + def test_close_connection(self): + with patch("databricks.sql.connect") as mock_connect: + mock_conn = MagicMock() + mock_connect.return_value = mock_conn + + e = SQLSemanticEnricher( + server_hostname="host", + http_path="/sql", + access_token="token", + catalog="main", + schema="test", + ) + # Force connection creation + e._connection = mock_conn + e.close() + mock_conn.close.assert_called_once() diff --git a/tests/test_functional.py b/tests/test_functional.py index 08ce982..70ae10c 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -1,52 +1,145 @@ """ -Verification tests for Brickbyte functionalities (Streaming Only). +Verification tests for brickbyte functionalities (Streaming Only). """ from unittest.mock import MagicMock, patch import pytest -from brickbyte import Brickbyte +import brickbyte class TestBrickbyteFunctional: - @pytest.fixture - def mock_airbyte(self): - import sys - mock_ab = MagicMock() - with patch.dict(sys.modules, {"airbyte": mock_ab}): - yield mock_ab + def bb(self, tmp_path): + return brickbyte.client(base_venv_directory=str(tmp_path)) - @pytest.fixture - def brickbyte(self, tmp_path): - return Brickbyte(base_venv_directory=str(tmp_path)) - - def test_sync_streaming_default(self, brickbyte, mock_airbyte): - """Test the sync method with default streaming behavior.""" - # Mock dependencies + def test_sync_streaming_default(self, bb, mock_airbyte): mock_source = MagicMock() mock_airbyte.get_source.return_value = mock_source mock_source.get_selected_streams.return_value = ["test_stream"] - - # Mock records generator - mock_source.get_records.return_value = [{"id": 1, "val": "a"}, {"id": 2, "val": "b"}] - - # Mock writer factory + mock_source.get_records.return_value = [ + {"id": 1, "val": "a"}, + {"id": 2, "val": "b"}, + ] + with patch("brickbyte.writers.create_streaming_writer") as mock_create_writer: mock_writer = MagicMock() mock_create_writer.return_value = mock_writer - - result = brickbyte.sync( + + result = bb.sync( source="source-faker", source_config={}, catalog="main", schema="test", - staging_volume="main.staging.vol" + staging_volume="main.staging.vol", + mode="append", ) - - # Verifications + assert result.records_written == 2 mock_create_writer.assert_called_once() assert mock_writer.write_record.call_count == 2 mock_writer.flush_stream.assert_called_with("test_stream") - mock_writer.close.assert_called_once() + + def test_incremental_applies_saved_state(self, bb, mock_airbyte): + mock_source = MagicMock() + mock_airbyte.get_source.return_value = mock_source + mock_source.get_selected_streams.return_value = ["users"] + mock_source.get_records.return_value = [{"id": 1}] + mock_source.set_stream_state = MagicMock() + mock_source.get_stream_state.return_value = {"cursor": "2024-01-02"} + + with patch("brickbyte._state.StateManager") as mock_state_manager_cls: + mock_state_manager = MagicMock() + mock_state_manager.get_state.return_value = {"cursor": "2024-01-01"} + mock_state_manager_cls.return_value = mock_state_manager + + with patch("brickbyte.writers.create_streaming_writer") as mock_create_writer: + mock_writer = MagicMock() + mock_create_writer.return_value = mock_writer + + result = bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + incremental=True, + ) + + assert result.records_written == 1 + mock_source.set_stream_state.assert_called_once_with( + "users", + {"cursor": "2024-01-01"}, + ) + mock_state_manager.save_state.assert_called_once() + + def test_incremental_with_saved_state_without_state_api_raises(self, bb, mock_airbyte): + mock_source = MagicMock( + spec=[ + "check", + "select_all_streams", + "get_selected_streams", + "get_records", + ] + ) + mock_airbyte.get_source.return_value = mock_source + mock_source.get_selected_streams.return_value = ["users"] + mock_source.get_records.return_value = [] + + with patch("brickbyte._state.StateManager") as mock_state_manager_cls: + mock_state_manager = MagicMock() + mock_state_manager.get_state.return_value = {"cursor": "2024-01-01"} + mock_state_manager_cls.return_value = mock_state_manager + + with pytest.raises(NotImplementedError, match="state injection support"): + bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + incremental=True, + ) + + def test_progress_reporter_closed_on_error(self, bb, mock_airbyte): + mock_source = MagicMock() + mock_airbyte.get_source.return_value = mock_source + mock_source.get_selected_streams.return_value = ["users"] + mock_source.get_records.side_effect = RuntimeError("boom") + + with patch("brickbyte.writers.create_streaming_writer") as mock_create_writer: + mock_writer = MagicMock() + mock_create_writer.return_value = mock_writer + + with patch("brickbyte._progress.ProgressReporter") as mock_reporter_cls: + reporter = MagicMock() + mock_reporter_cls.return_value = reporter + + with pytest.raises(RuntimeError, match="boom"): + bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + progress_callback=lambda _evt: None, + ) + + reporter.close.assert_called_once() + + def test_client_factory_returns_client(self): + bb = brickbyte.client() + assert type(bb).__name__ == "Client" + + def test_sync_result_dataclass(self): + result = brickbyte.SyncResult( + records_written=100, + streams_synced=["a", "b"], + failed_streams=["c"], + enriched_tables=["a"], + ) + assert result.records_written == 100 + assert len(result.streams_synced) == 2 diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 25a03d4..8e339df 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -6,62 +6,57 @@ import pytest -from brickbyte.writers import SparkStreamingWriter, SQLStreamingWriter, create_streaming_writer +from brickbyte.writers import create_streaming_writer +from brickbyte.writers.spark_streaming_writer import SparkStreamingWriter +from brickbyte.writers.sql_streaming_writer import SQLStreamingWriter class TestHybridFactory: - @patch("os.makedirs") def test_factory_detects_spark(self, mock_makedirs): - """Test that SparkStreamingWriter is created when Spark is active.""" - - # Mock pyspark.sql.SparkSession.getActiveSession with patch.dict(sys.modules, {"pyspark.sql": MagicMock()}): mock_session = MagicMock() - sys.modules["pyspark.sql"].SparkSession.getActiveSession.return_value = mock_session - + sys.modules["pyspark.sql"].SparkSession.getActiveSession.return_value = ( + mock_session + ) + writer = create_streaming_writer( - catalog="main", - schema="test" + catalog="main", schema="test", run_id="test-run" ) - + assert isinstance(writer, SparkStreamingWriter) assert writer.catalog == "main" def test_factory_fallback_to_sql(self): - """Test that SQLStreamingWriter is created when Spark is missing.""" - - # Simulate import error for pyspark with patch.dict(sys.modules, {"pyspark.sql": None}): - # We also need to mock databricks.sdk with patch("databricks.sdk.WorkspaceClient") as mock_ws_client: mock_w = MagicMock() mock_ws_client.return_value = mock_w mock_w.config.host = "https://test-host" mock_w.config.token = "token" - - # Mock warehouse listing + mock_wh = MagicMock() mock_wh.state.value = "RUNNING" mock_wh.id = "wh-123" mock_w.warehouses.list.return_value = [mock_wh] - - writer = create_streaming_writer( - catalog="main", - schema="test", - staging_volume="main.test.vol" - ) - - assert isinstance(writer, SQLStreamingWriter) - assert writer.staging_volume == "main.test.vol" + + with patch("os.path.exists", return_value=True): + writer = create_streaming_writer( + catalog="main", + schema="test", + staging_volume="main.test.vol", + run_id="test-run", + ) + + assert isinstance(writer, SQLStreamingWriter) + assert writer.staging_volume == "main.test.vol" def test_factory_raises_error_no_volume_no_spark(self): - """Test that ValueError is raised if no Spark and no Volume.""" - with patch.dict(sys.modules, {"pyspark.sql": None}): - with pytest.raises(ValueError, match="staging_volume is REQUIRED"): + with pytest.raises(ValueError, match="staging_volume is REQUIRED"): create_streaming_writer( catalog="main", schema="test", - staging_volume=None + staging_volume=None, + run_id="test-run", ) diff --git a/tests/test_incremental.py b/tests/test_incremental.py new file mode 100644 index 0000000..a54282c --- /dev/null +++ b/tests/test_incremental.py @@ -0,0 +1,70 @@ +""" +Tests for incremental sync state management. +""" +from unittest.mock import MagicMock, patch + +import pytest + +from brickbyte._state import StateManager + + +class TestStateManager: + @pytest.fixture + def state_mgr(self): + mgr = StateManager(catalog="main", schema="test") + mgr._spark = MagicMock() + mgr._initialized = True + return mgr + + def test_state_table_name(self, state_mgr): + assert "__brickbyte_state" in state_mgr._state_table + + def test_save_state_calls_merge(self, state_mgr): + state_mgr.save_state( + source="source-faker", + stream_name="users", + state={"cursor": "2024-01-01"}, + run_id="test-run", + ) + state_mgr._spark.sql.assert_called_once() + call_args = str(state_mgr._spark.sql.call_args) + assert "MERGE INTO" in call_args + + @patch("brickbyte._state.col", create=True) + def test_get_state_returns_parsed_json(self, mock_col, state_mgr): + mock_df = MagicMock() + mock_row = MagicMock() + mock_row.__getitem__ = lambda self, key: '{"cursor": "2024-01-01"}' + mock_df.collect.return_value = [mock_row] + + state_mgr._spark.table.return_value.filter.return_value.select.return_value.limit.return_value = mock_df + + with patch.dict("sys.modules", {"pyspark.sql.functions": MagicMock()}): + result = state_mgr.get_state("source-faker", "users") + assert result == {"cursor": "2024-01-01"} + + def test_get_state_returns_none_when_empty(self, state_mgr): + mock_df = MagicMock() + mock_df.collect.return_value = [] + state_mgr._spark.table.return_value.filter.return_value.select.return_value.limit.return_value = mock_df + + with patch.dict("sys.modules", {"pyspark.sql.functions": MagicMock()}): + result = state_mgr.get_state("source-faker", "users") + assert result is None + + def test_ensure_table_creates_ddl(self): + mgr = StateManager(catalog="main", schema="test") + mgr._spark = MagicMock() + mgr._initialized = False + mgr._ensure_table() + + mgr._spark.sql.assert_called_once() + call_args = str(mgr._spark.sql.call_args) + assert "CREATE TABLE IF NOT EXISTS" in call_args + assert mgr._initialized is True + + def test_clear_state(self, state_mgr): + state_mgr.clear_state("source-faker", "users") + state_mgr._spark.sql.assert_called_once() + call_args = str(state_mgr._spark.sql.call_args) + assert "DELETE FROM" in call_args diff --git a/tests/test_mode_validation.py b/tests/test_mode_validation.py index 759b394..cff6f39 100644 --- a/tests/test_mode_validation.py +++ b/tests/test_mode_validation.py @@ -5,58 +5,39 @@ import pytest -from brickbyte import Brickbyte +import brickbyte class TestModeValidation: - """Test _validate_sync_params mode validation.""" - @pytest.fixture - def brickbyte(self, tmp_path): - return Brickbyte(base_venv_directory=str(tmp_path)) + def bb(self, tmp_path): + return brickbyte.client(base_venv_directory=str(tmp_path)) - def test_append_mode_valid(self, brickbyte): - """Test that append mode is valid.""" - # Should not raise - brickbyte._validate_sync_params(mode="append", staging_volume="a.b.c") + def test_append_mode_valid(self, bb): + bb._validate_sync_params(mode="append") - def test_overwrite_mode_valid(self, brickbyte): - """Test that overwrite mode is valid.""" - # Should not raise - brickbyte._validate_sync_params(mode="overwrite", staging_volume="a.b.c") + def test_overwrite_mode_valid(self, bb): + bb._validate_sync_params(mode="overwrite") - def test_merge_mode_not_implemented(self, brickbyte): - """Test that merge mode raises NotImplementedError.""" + def test_merge_mode_not_implemented(self, bb): with pytest.raises(NotImplementedError, match="Merge mode is not yet supported"): - brickbyte._validate_sync_params(mode="merge", staging_volume="a.b.c") + bb._validate_sync_params(mode="merge") - def test_invalid_mode_raises_error(self, brickbyte): - """Test that invalid mode raises ValueError.""" + def test_invalid_mode_raises_error(self, bb): with pytest.raises(ValueError, match="Invalid mode 'invalid'"): - brickbyte._validate_sync_params(mode="invalid", staging_volume="a.b.c") + bb._validate_sync_params(mode="invalid") - def test_unknown_mode_raises_error(self, brickbyte): - """Test that unknown modes raise ValueError.""" + def test_unknown_mode_raises_error(self, bb): with pytest.raises(ValueError, match="Invalid mode"): - brickbyte._validate_sync_params(mode="upsert", staging_volume="a.b.c") + bb._validate_sync_params(mode="upsert") class TestOverwriteMode: - """Test overwrite mode behavior (drop_table before sync).""" - @pytest.fixture - def mock_airbyte(self): - import sys - mock_ab = MagicMock() - with patch.dict(sys.modules, {"airbyte": mock_ab}): - yield mock_ab + def bb(self, tmp_path): + return brickbyte.client(base_venv_directory=str(tmp_path)) - @pytest.fixture - def brickbyte(self, tmp_path): - return Brickbyte(base_venv_directory=str(tmp_path)) - - def test_overwrite_drops_table_before_streaming(self, brickbyte, mock_airbyte): - """Test that overwrite mode calls drop_table before streaming.""" + def test_overwrite_uses_safe_overwrite(self, bb, mock_airbyte): mock_source = MagicMock() mock_airbyte.get_source.return_value = mock_source mock_source.get_selected_streams.return_value = ["users", "orders"] @@ -66,7 +47,7 @@ def test_overwrite_drops_table_before_streaming(self, brickbyte, mock_airbyte): mock_writer = MagicMock() mock_factory.return_value = mock_writer - brickbyte.sync( + bb.sync( source="source-faker", source_config={}, catalog="main", @@ -75,13 +56,12 @@ def test_overwrite_drops_table_before_streaming(self, brickbyte, mock_airbyte): mode="overwrite", ) - # Verify drop_table was called for each stream - assert mock_writer.drop_table.call_count == 2 - mock_writer.drop_table.assert_any_call("users") - mock_writer.drop_table.assert_any_call("orders") + # Should use safe_overwrite_begin/finish instead of drop_table + assert mock_writer.safe_overwrite_begin.call_count == 2 + assert mock_writer.safe_overwrite_finish.call_count == 2 + mock_writer.drop_table.assert_not_called() - def test_append_does_not_drop_table(self, brickbyte, mock_airbyte): - """Test that append mode does NOT call drop_table.""" + def test_append_does_not_drop_table(self, bb, mock_airbyte): mock_source = MagicMock() mock_airbyte.get_source.return_value = mock_source mock_source.get_selected_streams.return_value = ["users"] @@ -91,7 +71,7 @@ def test_append_does_not_drop_table(self, brickbyte, mock_airbyte): mock_writer = MagicMock() mock_factory.return_value = mock_writer - brickbyte.sync( + bb.sync( source="source-faker", source_config={}, catalog="main", @@ -100,26 +80,16 @@ def test_append_does_not_drop_table(self, brickbyte, mock_airbyte): mode="append", ) - # Verify drop_table was NOT called mock_writer.drop_table.assert_not_called() + mock_writer.safe_overwrite_begin.assert_not_called() class TestSyncModeIntegration: - """Integration tests for sync with different modes.""" - - @pytest.fixture - def mock_airbyte(self): - import sys - mock_ab = MagicMock() - with patch.dict(sys.modules, {"airbyte": mock_ab}): - yield mock_ab - @pytest.fixture - def brickbyte(self, tmp_path): - return Brickbyte(base_venv_directory=str(tmp_path)) + def bb(self, tmp_path): + return brickbyte.client(base_venv_directory=str(tmp_path)) - def test_default_mode_is_overwrite(self, brickbyte, mock_airbyte): - """Test that default mode is overwrite.""" + def test_default_mode_is_overwrite(self, bb, mock_airbyte): mock_source = MagicMock() mock_airbyte.get_source.return_value = mock_source mock_source.get_selected_streams.return_value = ["stream1"] @@ -129,8 +99,7 @@ def test_default_mode_is_overwrite(self, brickbyte, mock_airbyte): mock_writer = MagicMock() mock_factory.return_value = mock_writer - # Call without specifying mode - brickbyte.sync( + bb.sync( source="source-faker", source_config={}, catalog="main", @@ -138,6 +107,5 @@ def test_default_mode_is_overwrite(self, brickbyte, mock_airbyte): staging_volume="main.staging.vol", ) - # Default is overwrite, so drop_table should be called - mock_writer.drop_table.assert_called_once_with("stream1") - + # Default is overwrite, so safe_overwrite should be called + mock_writer.safe_overwrite_begin.assert_called_once_with("stream1", mock_factory.call_args[1]["run_id"]) diff --git a/tests/test_preview.py b/tests/test_preview.py index cc0b200..82d3562 100644 --- a/tests/test_preview.py +++ b/tests/test_preview.py @@ -14,33 +14,22 @@ class TestSchemaChange: - """Test SchemaChange dataclass.""" - def test_added_column_str(self): - """Test string representation of added column.""" - change = SchemaChange( - column="new_col", - change_type="added", - source_type="str", - ) + change = SchemaChange(column="new_col", change_type="added", source_type="str") result = str(change) assert "new_col" in result assert "NEW" in result assert "str" in result def test_removed_column_str(self): - """Test string representation of removed column.""" change = SchemaChange( - column="old_col", - change_type="removed", - target_type="StringType", + column="old_col", change_type="removed", target_type="StringType" ) result = str(change) assert "old_col" in result assert "REMOVED" in result def test_type_changed_str(self): - """Test string representation of type change.""" change = SchemaChange( column="col", change_type="type_changed", @@ -54,10 +43,7 @@ def test_type_changed_str(self): class TestStreamPreview: - """Test StreamPreview dataclass.""" - def test_str_with_counts(self): - """Test string representation with record counts.""" preview = StreamPreview( stream_name="users", source_count=100, @@ -73,18 +59,12 @@ def test_str_with_counts(self): assert "-5 deleted" in result def test_str_streaming_unknown_count(self): - """Test string representation with unknown counts (streaming).""" - preview = StreamPreview( - stream_name="events", - source_count=-1, - target_count=0, - ) + preview = StreamPreview(stream_name="events", source_count=-1, target_count=0) result = str(preview) assert "events" in result assert "Unknown" in result or "Streaming" in result def test_str_with_schema_changes(self): - """Test string representation with schema changes.""" preview = StreamPreview( stream_name="orders", source_count=50, @@ -99,10 +79,7 @@ def test_str_with_schema_changes(self): class TestPreviewResult: - """Test PreviewResult dataclass.""" - def test_str_output(self): - """Test complete preview result string output.""" result = PreviewResult( streams=[ StreamPreview("users", 100, 50, new_records=30), @@ -113,157 +90,114 @@ def test_str_output(self): has_schema_changes=True, ) output = str(result) - assert "Sync Preview" in output assert "users" in output assert "orders" in output assert "Schema changes detected" in output def test_str_no_schema_changes(self): - """Test output without schema changes warning.""" result = PreviewResult( - streams=[StreamPreview("data", 10, 10)], - has_schema_changes=False, + streams=[StreamPreview("data", 10, 10)], has_schema_changes=False ) output = str(result) - assert "Schema changes detected" not in output class TestPreviewEngine: - """Test PreviewEngine functionality.""" - @pytest.fixture def engine(self): - """Create a PreviewEngine with mocked Spark.""" engine = PreviewEngine(catalog="main", schema="test") engine._spark = MagicMock() return engine def test_get_table_name(self, engine): - """Test fully qualified table name generation.""" - assert engine.get_table_name("users") == "main.test.users" + assert engine.get_table_name("users") == "`main`.`test`.`users`" def test_table_exists_true(self, engine): - """Test table_exists when table exists.""" engine._spark.catalog.tableExists.return_value = True assert engine.table_exists("users") is True def test_table_exists_false(self, engine): - """Test table_exists when table doesn't exist.""" engine._spark.catalog.tableExists.return_value = False assert engine.table_exists("users") is False def test_table_exists_no_spark(self): - """Test table_exists returns False when no Spark.""" engine = PreviewEngine(catalog="main", schema="test") - # Directly set _spark to None and check behavior - # The spark property returns None when import fails engine._spark = None - - # Mock the spark property to return None - with patch.object(PreviewEngine, 'spark', property(lambda self: None)): + with patch.object(PreviewEngine, "spark", property(lambda self: None)): assert engine.table_exists("users") is False def test_get_target_count(self, engine): - """Test getting target table count.""" engine._spark.catalog.tableExists.return_value = True mock_df = MagicMock() mock_df.count.return_value = 42 engine._spark.table.return_value = mock_df - - count = engine.get_target_count("users") - - assert count == 42 + assert engine.get_target_count("users") == 42 def test_get_target_count_no_table(self, engine): - """Test getting count when table doesn't exist.""" engine._spark.catalog.tableExists.return_value = False - - count = engine.get_target_count("users") - - assert count == 0 + assert engine.get_target_count("users") == 0 def test_get_source_schema(self, engine): - """Test schema inference from sample records.""" - samples = [ - {"id": 1, "name": "test", "active": True, "score": 3.14}, - ] - + samples = [{"id": 1, "name": "test", "active": True, "score": 3.14}] schema = engine.get_source_schema(samples) - assert schema["id"] == "int" assert schema["name"] == "str" assert schema["active"] == "bool" assert schema["score"] == "float" def test_get_source_schema_empty(self, engine): - """Test schema inference with empty samples.""" - schema = engine.get_source_schema([]) - assert schema == {} + assert engine.get_source_schema([]) == {} def test_compare_schemas_added_columns(self, engine): - """Test detecting added columns.""" source = {"id": "int", "name": "str", "new_col": "str"} target = {"id": "LongType", "name": "StringType"} - changes = engine.compare_schemas(source, target) - added = [c for c in changes if c.change_type == "added"] assert len(added) == 1 assert added[0].column == "new_col" def test_compare_schemas_removed_columns(self, engine): - """Test detecting removed columns.""" source = {"id": "int"} target = {"id": "LongType", "old_col": "StringType"} - changes = engine.compare_schemas(source, target) - removed = [c for c in changes if c.change_type == "removed"] assert len(removed) == 1 assert removed[0].column == "old_col" + def test_compare_schemas_type_changes_detected(self, engine): + source = {"id": "int", "name": "str", "score": "int"} + target = {"id": "LongType", "name": "StringType", "score": "StringType"} + changes = engine.compare_schemas(source, target) + type_changed = [c for c in changes if c.change_type == "type_changed"] + assert len(type_changed) == 1 + assert type_changed[0].column == "score" + def test_compare_schemas_no_changes(self, engine): - """Test when schemas match.""" source = {"id": "int", "name": "str"} target = {"id": "LongType", "name": "StringType"} - changes = engine.compare_schemas(source, target) - - # Same columns, different type names (expected) - no changes reported assert len(changes) == 0 def test_preview_stream(self, engine): - """Test previewing a single stream.""" engine._spark.catalog.tableExists.return_value = True mock_df = MagicMock() mock_df.count.return_value = 100 engine._spark.table.return_value = mock_df - - # Mock source mock_source = MagicMock() - mock_source.get_records.return_value = iter([ - {"id": 1, "name": "a"}, - {"id": 2, "name": "b"}, - ]) - + mock_source.get_records.return_value = iter( + [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}] + ) preview = engine.preview_stream(mock_source, "users", sample_size=5) - assert preview.stream_name == "users" assert preview.target_count == 100 assert len(preview.sample_records) == 2 def test_preview_all_streams(self, engine): - """Test previewing multiple streams.""" engine._spark.catalog.tableExists.return_value = False - mock_source = MagicMock() mock_source.get_records.return_value = iter([{"id": 1}]) - result = engine.preview(mock_source, ["stream1", "stream2"]) - assert len(result.streams) == 2 assert result.streams[0].stream_name == "stream1" assert result.streams[1].stream_name == "stream2" - diff --git a/tests/test_progress.py b/tests/test_progress.py new file mode 100644 index 0000000..e079775 --- /dev/null +++ b/tests/test_progress.py @@ -0,0 +1,56 @@ +""" +Tests for progress reporting. +""" +from brickbyte._progress import ProgressReporter + + +class TestProgressReporter: + def test_callback_invoked_on_stream_completion(self): + events = [] + + def callback(event): + events.append(event) + + reporter = ProgressReporter(total_streams=3, callback=callback) + reporter.stream_completed("users", 100) + + assert len(events) == 1 + assert events[0].stream_name == "users" + assert events[0].records_processed == 100 + assert events[0].streams_completed == 1 + assert events[0].total_streams == 3 + + def test_callback_invoked_every_5000_records(self): + events = [] + + def callback(event): + events.append(event) + + reporter = ProgressReporter(total_streams=1, callback=callback) + reporter.record_processed("users", 5000) + + assert len(events) == 1 + assert events[0].records_processed == 5000 + + def test_callback_not_invoked_at_non_5000(self): + events = [] + + def callback(event): + events.append(event) + + reporter = ProgressReporter(total_streams=1, callback=callback) + reporter.record_processed("users", 1234) + + assert len(events) == 0 + + def test_streams_completed_counter(self): + reporter = ProgressReporter(total_streams=3) + assert reporter.streams_completed == 0 + reporter.stream_completed("a", 10) + assert reporter.streams_completed == 1 + reporter.stream_completed("b", 20) + assert reporter.streams_completed == 2 + + def test_close_without_tqdm(self): + reporter = ProgressReporter(total_streams=1) + reporter.close() # Should not raise diff --git a/tests/test_safe_overwrite.py b/tests/test_safe_overwrite.py new file mode 100644 index 0000000..3377570 --- /dev/null +++ b/tests/test_safe_overwrite.py @@ -0,0 +1,218 @@ +""" +Tests for safe overwrite (staged replace) behavior. +""" +from unittest.mock import MagicMock + +import pytest + +from brickbyte.writers.spark_streaming_writer import SparkStreamingWriter + + +class TestSafeOverwrite: + @pytest.fixture + def writer(self): + writer = SparkStreamingWriter( + catalog="main", + schema="test", + buffer_size_records=100, + run_id="abcdef12-3456-7890-abcd-ef1234567890", + ) + writer._spark = MagicMock() + return writer + + def test_staging_table_name_format(self, writer): + name = writer.get_staging_table_name("users", "abcdef12-3456-7890-abcd-ef1234567890") + assert "__stg__abcdef12" in name + assert "`main`.`test`." in name + + def test_safe_overwrite_begin_sets_redirect(self, writer): + writer.safe_overwrite_begin("users", "abcdef12") + assert "users" in writer._overwrite_streams + + def test_safe_overwrite_begin_drops_staging(self, writer): + writer.safe_overwrite_begin("users", "abcdef12") + writer._spark.sql.assert_called() + drop_calls = [ + c for c in writer._spark.sql.call_args_list if "DROP TABLE" in str(c) + ] + assert len(drop_calls) >= 1 + + def test_safe_overwrite_finish_rename_when_no_target(self, writer): + writer._overwrite_streams["users"] = "`main`.`test`.`users__stg__abcdef12`" + writer._spark.catalog.tableExists.return_value = False + + writer.safe_overwrite_finish("users", "abcdef12") + + rename_calls = [ + c for c in writer._spark.sql.call_args_list if "ALTER TABLE" in str(c) and "RENAME" in str(c) + ] + assert len(rename_calls) == 1 + + def test_safe_overwrite_finish_atomic_overwrite_when_target_exists(self, writer): + writer._overwrite_streams["users"] = "`main`.`test`.`users__stg__abcdef12`" + writer._spark.catalog.tableExists.return_value = True + + # Mock schemas + mock_target_df = MagicMock() + mock_target_field = MagicMock() + mock_target_field.name = "id" + mock_target_field.dataType = "StringType" + mock_target_df.schema.fields = [mock_target_field] + + mock_staging_df = MagicMock() + mock_staging_field = MagicMock() + mock_staging_field.name = "id" + mock_staging_field.dataType = "StringType" + mock_staging_df.schema.fields = [mock_staging_field] + + writer._spark.table.side_effect = lambda name: { + "`main`.`test`.`users`": mock_target_df, + "`main`.`test`.`users__stg__abcdef12`": mock_staging_df, + }[name] + + writer.safe_overwrite_finish("users", "abcdef12") + + # Should have INSERT OVERWRITE and DROP staging + sql_calls = [str(c) for c in writer._spark.sql.call_args_list] + insert_calls = [c for c in sql_calls if "INSERT OVERWRITE" in c] + drop_calls = [c for c in sql_calls if "DROP TABLE" in c] + assert len(insert_calls) == 1 + assert len(drop_calls) >= 1 + + def test_safe_overwrite_failure_drops_staging(self, writer): + writer._overwrite_streams["users"] = "`main`.`test`.`users__stg__abcdef12`" + writer._spark.catalog.tableExists.side_effect = RuntimeError("test error") + + with pytest.raises(RuntimeError): + writer.safe_overwrite_finish("users", "abcdef12") + + # Staging should be dropped on failure + drop_calls = [ + c for c in writer._spark.sql.call_args_list if "DROP TABLE" in str(c) + ] + assert len(drop_calls) >= 1 + + def test_writes_go_to_staging_during_overwrite(self, writer): + writer.safe_overwrite_begin("stream1", "run123") + + # Write should go to staging table + write_table = writer._get_write_table_name("stream1") + assert "__stg__" in write_table + + def test_writes_go_to_target_normally(self, writer): + write_table = writer._get_write_table_name("stream1") + assert "__stg__" not in write_table + + def test_schema_alignment_new_columns(self, writer): + """Staging has new columns -> target gets them via ALTER TABLE ADD.""" + writer._overwrite_streams["users"] = "`main`.`test`.`users__stg__abcdef12`" + writer._spark.catalog.tableExists.return_value = True + + target_field = MagicMock() + target_field.name = "id" + target_field.dataType = "StringType" + mock_target_df = MagicMock() + mock_target_df.schema.fields = [target_field] + + staging_field_id = MagicMock() + staging_field_id.name = "id" + staging_field_id.dataType = "StringType" + staging_field_new = MagicMock() + staging_field_new.name = "email" + staging_field_new.dataType = "StringType" + mock_staging_df = MagicMock() + mock_staging_df.schema.fields = [staging_field_id, staging_field_new] + + writer._spark.table.side_effect = lambda name: ( + mock_target_df + if "stg" not in name + else mock_staging_df + ) + + writer.safe_overwrite_finish("users", "abcdef12") + + alter_calls = [ + str(c) for c in writer._spark.sql.call_args_list if "ADD COLUMNS" in str(c) + ] + assert len(alter_calls) == 1 + assert "email" in alter_calls[0] + + def test_incompatible_type_change_raises(self, writer): + """Incompatible type changes should raise ValueError.""" + writer._overwrite_streams["users"] = "`main`.`test`.`users__stg__abcdef12`" + writer._spark.catalog.tableExists.return_value = True + + target_field = MagicMock() + target_field.name = "data" + target_field.dataType = "StructType" + mock_target_df = MagicMock() + mock_target_df.schema.fields = [target_field] + + staging_field = MagicMock() + staging_field.name = "data" + staging_field.dataType = "ArrayType" + mock_staging_df = MagicMock() + mock_staging_df.schema.fields = [staging_field] + + writer._spark.table.side_effect = lambda name: ( + mock_target_df + if "stg" not in name + else mock_staging_df + ) + + with pytest.raises(ValueError, match="Incompatible type change"): + writer.safe_overwrite_finish("users", "abcdef12") + + def test_safe_widening_handles_parenthesized_type_strings(self, writer): + writer._overwrite_streams["users"] = "`main`.`test`.`users__stg__abcdef12`" + writer._spark.catalog.tableExists.return_value = True + + target_field = MagicMock() + target_field.name = "id" + target_field.dataType = "LongType()" + mock_target_df = MagicMock() + mock_target_df.schema.fields = [target_field] + + staging_field = MagicMock() + staging_field.name = "id" + staging_field.dataType = "IntegerType()" + mock_staging_df = MagicMock() + mock_staging_df.schema.fields = [staging_field] + + writer._spark.table.side_effect = lambda name: ( + mock_target_df if "stg" not in name else mock_staging_df + ) + + writer.safe_overwrite_finish("users", "abcdef12") + + sql_calls = [str(c) for c in writer._spark.sql.call_args_list] + insert_calls = [c for c in sql_calls if "INSERT OVERWRITE" in c] + assert len(insert_calls) == 1 + assert "CAST(`id` AS BIGINT)" in insert_calls[0] + + def test_reverse_safe_widening_alters_target_to_sql_type(self, writer): + writer._overwrite_streams["users"] = "`main`.`test`.`users__stg__abcdef12`" + writer._spark.catalog.tableExists.return_value = True + + target_field = MagicMock() + target_field.name = "id" + target_field.dataType = "IntegerType()" + mock_target_df = MagicMock() + mock_target_df.schema.fields = [target_field] + + staging_field = MagicMock() + staging_field.name = "id" + staging_field.dataType = "LongType()" + mock_staging_df = MagicMock() + mock_staging_df.schema.fields = [staging_field] + + writer._spark.table.side_effect = lambda name: ( + mock_target_df if "stg" not in name else mock_staging_df + ) + + writer.safe_overwrite_finish("users", "abcdef12") + + sql_calls = [str(c) for c in writer._spark.sql.call_args_list] + alter_calls = [c for c in sql_calls if "ALTER COLUMN `id` TYPE" in c] + assert len(alter_calls) == 1 + assert "BIGINT" in alter_calls[0] diff --git a/tests/test_sanitize.py b/tests/test_sanitize.py new file mode 100644 index 0000000..6a03606 --- /dev/null +++ b/tests/test_sanitize.py @@ -0,0 +1,86 @@ +""" +Tests for stream name sanitization and SQL identifier validation. +""" +import pytest + +from brickbyte._sanitize import quoted_table_name, sanitize_stream_name, validate_identifier + + +class TestSanitizeStreamName: + def test_hyphen_to_underscore(self): + assert sanitize_stream_name("my-stream") == "my_stream" + + def test_dot_to_underscore(self): + assert sanitize_stream_name("my.stream") == "my_stream" + + def test_space_to_underscore(self): + assert sanitize_stream_name("my stream") == "my_stream" + + def test_mixed_separators(self): + assert sanitize_stream_name("my-stream.v2") == "my_stream_v2" + + def test_leading_digit_prefix(self): + assert sanitize_stream_name("123stream") == "_123stream" + + def test_valid_name_unchanged(self): + assert sanitize_stream_name("users") == "users" + + def test_uppercase_lowered(self): + assert sanitize_stream_name("MyStream") == "mystream" + + def test_collision_detection_in_client(self): + """Two streams that collide after sanitization should be caught.""" + # This would be detected in _client.py during sync + name1 = sanitize_stream_name("a-b") + name2 = sanitize_stream_name("a.b") + assert name1 == name2 == "a_b" + + def test_dangerous_chars_removed(self): + assert "`" not in sanitize_stream_name("stream`name") + assert ";" not in sanitize_stream_name("stream;name") + assert "\x00" not in sanitize_stream_name("stream\x00name") + + +class TestValidateIdentifier: + def test_valid_identifier(self): + assert validate_identifier("my_table") == "my_table" + + def test_empty_identifier_rejected(self): + with pytest.raises(ValueError, match="cannot be empty"): + validate_identifier("") + + def test_null_byte_rejected(self): + with pytest.raises(ValueError, match="unsafe character"): + validate_identifier("table\x00name") + + def test_backtick_rejected(self): + with pytest.raises(ValueError, match="unsafe character"): + validate_identifier("table`name") + + def test_semicolon_rejected(self): + with pytest.raises(ValueError, match="unsafe character"): + validate_identifier("table;name") + + def test_hyphen_allowed(self): + assert validate_identifier("my-table") == "my-table" + + def test_dot_allowed(self): + assert validate_identifier("my.table") == "my.table" + + def test_unicode_allowed(self): + assert validate_identifier("日本語テーブル") == "日本語テーブル" + + +class TestQuotedTableName: + def test_basic(self): + assert quoted_table_name("main", "bronze", "users") == "`main`.`bronze`.`users`" + + def test_with_hyphens(self): + assert ( + quoted_table_name("my-catalog", "my-schema", "my-table") + == "`my-catalog`.`my-schema`.`my-table`" + ) + + def test_rejects_dangerous_catalog(self): + with pytest.raises(ValueError): + quoted_table_name("main`", "bronze", "users") diff --git a/tests/test_spark_streaming_writer.py b/tests/test_spark_streaming_writer.py index c69b183..fdfa36d 100644 --- a/tests/test_spark_streaming_writer.py +++ b/tests/test_spark_streaming_writer.py @@ -1,8 +1,8 @@ """ Unit tests for SparkStreamingWriter. """ -from datetime import datetime -from unittest.mock import MagicMock, patch +from datetime import datetime, timezone +from unittest.mock import MagicMock import pytest @@ -10,84 +10,86 @@ class TestSparkStreamingWriter: - @pytest.fixture def writer(self): - """Create a SparkStreamingWriter with mocked Spark.""" writer = SparkStreamingWriter( catalog="main", schema="test", buffer_size_records=3, buffer_size_mb=1, + run_id="test-run-id", ) - # Mock Spark session writer._spark = MagicMock() return writer def test_init_defaults(self): - """Test default initialization values.""" writer = SparkStreamingWriter(catalog="main", schema="bronze") - assert writer.catalog == "main" assert writer.schema == "bronze" assert writer.buffer_size_records == 50000 - assert writer.buffer_size_bytes == 100 * 1024 * 1024 # 100MB + assert writer.buffer_size_bytes == 100 * 1024 * 1024 def test_get_table_name(self, writer): - """Test fully qualified table name generation.""" - assert writer.get_table_name("users") == "main.test.users" - assert writer.get_table_name("orders") == "main.test.orders" + assert writer.get_table_name("users") == "`main`.`test`.`users`" + assert writer.get_table_name("orders") == "`main`.`test`.`orders`" + + def test_transform_record_raw(self, writer): + record = {"id": 1, "name": "test"} + transformed = writer._transform_record("stream1", record) + + assert "record_id" in transformed + assert "extracted_at" in transformed + assert "data" in transformed + assert "run_id" in transformed + assert transformed["run_id"] == "test-run-id" - def test_transform_record(self, writer): - """Test that transform_record adds Airbyte metadata.""" + assert len(transformed["record_id"]) == 36 + assert isinstance(transformed["extracted_at"], datetime) + assert transformed["extracted_at"].tzinfo is not None + assert '"id": 1' in transformed["data"] + assert '"name": "test"' in transformed["data"] + + def test_transform_record_flatten(self): + writer = SparkStreamingWriter( + catalog="main", schema="test", flatten=True, run_id="test-run" + ) + writer._spark = MagicMock() record = {"id": 1, "name": "test"} - transformed = writer._transform_record(record) - - assert "_airbyte_raw_id" in transformed - assert "_airbyte_extracted_at" in transformed - assert "_airbyte_data" in transformed - - # Verify UUID format - assert len(transformed["_airbyte_raw_id"]) == 36 - - # Verify datetime - assert isinstance(transformed["_airbyte_extracted_at"], datetime) - - # Verify JSON serialization - assert '"id": 1' in transformed["_airbyte_data"] - assert '"name": "test"' in transformed["_airbyte_data"] + transformed = writer._transform_record("stream1", record) + + assert "_record_id" in transformed + assert "_extracted_at" in transformed + assert "_run_id" in transformed + assert transformed["id"] == 1 + assert transformed["name"] == "test" + assert "data" not in transformed def test_write_record_buffers(self, writer): - """Test that write_record buffers records correctly.""" writer.write_record("stream1", {"id": 1}) writer.write_record("stream1", {"id": 2}) - assert len(writer._buffers["stream1"]) == 2 assert writer._buffer_counts["stream1"] == 2 def test_write_record_flushes_at_threshold(self, writer): - """Test that write_record flushes when record threshold is hit.""" writer._write_micro_batch = MagicMock() - - # Write up to threshold (3 records) writer.write_record("stream1", {"id": 1}) writer.write_record("stream1", {"id": 2}) assert writer._write_micro_batch.call_count == 0 - - # Third record should trigger flush writer.write_record("stream1", {"id": 3}) assert writer._write_micro_batch.call_count == 1 def test_write_micro_batch(self, writer): - """Test micro-batch write to Delta via createDataFrame.""" - # Setup buffer writer._buffers["stream1"] = [ - {"_airbyte_raw_id": "1", "_airbyte_extracted_at": datetime.now(), "_airbyte_data": "{}"} + { + "record_id": "1", + "extracted_at": datetime.now(timezone.utc), + "data": "{}", + "run_id": "test", + } ] writer._buffer_counts["stream1"] = 1 writer._buffer_sizes["stream1"] = 100 - - # Mock Spark createDataFrame chain + mock_df = MagicMock() mock_write = MagicMock() mock_df.write = mock_write @@ -95,79 +97,58 @@ def test_write_micro_batch(self, writer): mock_write.mode.return_value = mock_write mock_write.option.return_value = mock_write writer._spark.createDataFrame.return_value = mock_df - + writer._write_micro_batch("stream1") - - # Verify createDataFrame was called + writer._spark.createDataFrame.assert_called_once() - - # Verify write chain mock_write.format.assert_called_with("delta") mock_write.mode.assert_called_with("append") - mock_write.saveAsTable.assert_called_with("main.test.stream1") - - # Verify buffer was reset + mock_write.saveAsTable.assert_called_with("`main`.`test`.`stream1`") + assert writer._buffers["stream1"] == [] assert writer._buffer_counts["stream1"] == 0 assert writer._buffer_sizes["stream1"] == 0 def test_flush_stream_calls_write_micro_batch(self, writer): - """Test that flush_stream delegates to _write_micro_batch.""" writer._write_micro_batch = MagicMock() writer._buffers["stream1"] = [{"id": 1}] - writer.flush_stream("stream1") - writer._write_micro_batch.assert_called_once_with("stream1") def test_close_flushes_all_streams(self, writer): - """Test that close flushes all buffered streams.""" writer.flush_stream = MagicMock() writer._buffers["stream1"] = [{"id": 1}] writer._buffers["stream2"] = [{"id": 2}] - writer.close() - assert writer.flush_stream.call_count == 2 def test_drop_table(self, writer): - """Test drop_table executes correct SQL.""" writer.drop_table("users") - - writer._spark.sql.assert_called_with("DROP TABLE IF EXISTS main.test.users") + writer._spark.sql.assert_called_with( + "DROP TABLE IF EXISTS `main`.`test`.`users`" + ) def test_table_exists(self, writer): - """Test table_exists check.""" writer._spark.catalog.tableExists.return_value = True assert writer.table_exists("users") is True - writer._spark.catalog.tableExists.return_value = False assert writer.table_exists("orders") is False def test_get_table_schema(self, writer): - """Test getting table schema.""" - # Mock schema mock_field1 = MagicMock() mock_field1.name = "id" mock_field1.dataType = "LongType" - mock_field2 = MagicMock() mock_field2.name = "name" mock_field2.dataType = "StringType" - mock_df = MagicMock() mock_df.schema.fields = [mock_field1, mock_field2] writer._spark.table.return_value = mock_df writer._spark.catalog.tableExists.return_value = True - schema = writer.get_table_schema("users") - assert schema == {"id": "LongType", "name": "StringType"} def test_transform_record_handles_datetime(self, writer): - """Test that datetime objects in records are serialized.""" record = {"id": 1, "created_at": datetime(2024, 1, 1, 12, 0, 0)} - transformed = writer._transform_record(record) - - # Should not raise, datetime converted to string via default=str - assert "2024-01-01" in transformed["_airbyte_data"] + transformed = writer._transform_record("stream1", record) + assert "2024-01-01" in transformed["data"] diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 73ae4ff..d62ef24 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -1,18 +1,17 @@ """ -Unit tests for StreamingWriter. +Unit tests for SQLStreamingWriter. """ from unittest.mock import MagicMock, patch import pytest -from brickbyte.writers import SQLStreamingWriter +from brickbyte.writers.sql_streaming_writer import SQLStreamingWriter class TestStreamingWriter: - @pytest.fixture def writer(self): - with patch("databricks.sql.connect") as mock_connect: + with patch("os.path.exists", return_value=True): writer = SQLStreamingWriter( catalog="main", schema="test", @@ -20,56 +19,76 @@ def writer(self): server_hostname="test-host", http_path="/sql", access_token="token", - buffer_size_records=2 + buffer_size_records=2, + run_id="test-run-id", ) - writer._connection = mock_connect.return_value + writer._connection = MagicMock() return writer def test_init_validation(self): - """Test validation of staging volume format.""" with pytest.raises(ValueError): - SQLStreamingWriter( - catalog="main", - schema="test", - staging_volume="invalid_format", - server_hostname="h", - http_path="p", - access_token="t" - ) + with patch("os.path.exists", return_value=True): + SQLStreamingWriter( + catalog="main", + schema="test", + staging_volume="invalid_format", + server_hostname="h", + http_path="p", + access_token="t", + ) + + def test_volume_path_guard(self): + with patch("os.path.exists", return_value=False): + with pytest.raises(EnvironmentError, match="Volume path"): + SQLStreamingWriter( + catalog="main", + schema="test", + staging_volume="main.staging.vol", + server_hostname="h", + http_path="p", + access_token="t", + ) @patch("pyarrow.parquet.write_table") + @patch("os.path.exists", return_value=True) @patch("os.remove") @patch("os.makedirs") - def test_flush_logic(self, mock_makedirs, mock_remove, mock_pq_write, writer): - """Test that data is flushed correctly when threshold is met.""" - # Mock execution + def test_flush_logic(self, mock_makedirs, mock_remove, mock_exists, mock_pq_write, writer): writer._execute = MagicMock() - - # Write records + writer.write_record("stream1", {"id": 1}) assert len(writer._buffers["stream1"]) == 1 - assert writer._execute.call_count == 0 # No flush yet - - # Write second record (hits threshold=2) + assert writer._execute.call_count == 0 + writer.write_record("stream1", {"id": 2}) - - # Should have flushed assert len(writer._buffers["stream1"]) == 0 - assert writer._execute.call_count == 2 # CREATE + COPY INTO + assert writer._execute.call_count == 2 # CREATE + COPY INTO mock_pq_write.assert_called_once() mock_remove.assert_called_once() - - # Verify COPY INTO query + copy_call = writer._execute.call_args_list[1] query = copy_call[0][0] assert "COPY INTO" in query - assert "main.test.stream1" in query + # force=true should NOT be in the query + assert "force" not in query.lower() + + @patch("pyarrow.parquet.write_table") + @patch("os.remove") + @patch("os.makedirs") + def test_deterministic_filenames(self, mock_makedirs, mock_remove, mock_pq_write, writer): + writer._execute = MagicMock() + + writer.write_record("stream1", {"id": 1}) + writer.write_record("stream1", {"id": 2}) + + # Check filename contains run_id and batch index + pq_call_args = mock_pq_write.call_args + file_path = pq_call_args[0][1] + assert "test-run-id" in file_path + assert "000000" in file_path def test_close_flushes_remaining(self, writer): - """Test that close flushes remaining records.""" writer.flush_stream = MagicMock() writer._buffers["s1"] = [{"id": 1}] - writer.close() - writer.flush_stream.assert_called_with("s1") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9231a9b --- /dev/null +++ b/uv.lock @@ -0,0 +1,3380 @@ +version = 1 +revision = 3 +requires-python = ">=3.10, <3.13" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] + +[[package]] +name = "airbyte" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "airbyte-api" }, + { name = "airbyte-cdk" }, + { name = "airbyte-protocol-models-pdv2" }, + { name = "click" }, + { name = "cryptography" }, + { name = "duckdb" }, + { name = "duckdb-engine" }, + { name = "fastmcp" }, + { name = "fastmcp-extensions" }, + { name = "google-auth" }, + { name = "google-cloud-bigquery" }, + { name = "google-cloud-bigquery-storage" }, + { name = "google-cloud-secret-manager" }, + { name = "jsonschema" }, + { name = "orjson" }, + { name = "overrides" }, + { name = "pandas" }, + { name = "psycopg", extra = ["binary", "pool"] }, + { name = "psycopg2-binary" }, + { name = "pyarrow" }, + { name = "pydantic" }, + { name = "pydantic-core" }, + { name = "python-dotenv" }, + { name = "python-ulid" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "rich" }, + { name = "snowflake-connector-python" }, + { name = "snowflake-sqlalchemy" }, + { name = "sqlalchemy" }, + { name = "sqlalchemy-bigquery" }, + { name = "structlog" }, + { name = "typing-extensions" }, + { name = "uuid7" }, + { name = "uv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/53/2f4f4d2af191d2a8a37416c9bdf516dd6a4e872b3dcc03c00da5e1641644/airbyte-0.38.0.tar.gz", hash = "sha256:bbe3f266cc1ea0149e8b57d42a82717abf836014923192c0773a86c0cdf4e56f", size = 466537, upload-time = "2026-02-07T06:34:44.021Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/1f/8307f17ce24bc4cfa93d3ef48455eae95f63716b36f2c441d0269ebff16d/airbyte-0.38.0-py3-none-any.whl", hash = "sha256:d06acc70feaa13e080be3992b705dc7723bbe6bcdcf3bf1c351d3d69f96bebda", size = 268108, upload-time = "2026-02-07T06:34:41.903Z" }, +] + +[[package]] +name = "airbyte-api" +version = "0.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "dataclasses-json" }, + { name = "idna" }, + { name = "jsonpath-python" }, + { name = "marshmallow" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "six" }, + { name = "typing-extensions" }, + { name = "typing-inspect" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/23/8debf9c4ca8652c9ceb60b8074e654191ba2053001616151993b39d6cdcb/airbyte-api-0.53.0.tar.gz", hash = "sha256:f054ed170f9a691c3304e93a5212670fd2a38e5debb667c72d5ef8eb89cf7e9d", size = 330090, upload-time = "2025-10-02T19:55:43.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/f5/ca44b6f3919f049a4441755958c042fcea09a44e876352a6005f5e5a9e46/airbyte_api-0.53.0-py3-none-any.whl", hash = "sha256:0bd86d5122789a7c97115a4b52ce492b013d0ea8eeab597b6495b0b310332f28", size = 773825, upload-time = "2025-10-02T19:55:42.043Z" }, +] + +[[package]] +name = "airbyte-cdk" +version = "7.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "airbyte-protocol-models-dataclasses" }, + { name = "anyascii" }, + { name = "backoff" }, + { name = "boltons" }, + { name = "cachetools" }, + { name = "click" }, + { name = "cryptography" }, + { name = "dateparser" }, + { name = "dpath" }, + { name = "dunamai" }, + { name = "genson" }, + { name = "google-cloud-secret-manager" }, + { name = "isodate" }, + { name = "jinja2" }, + { name = "jsonref" }, + { name = "jsonschema" }, + { name = "nltk" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pydantic" }, + { name = "pyjwt" }, + { name = "pyrate-limiter" }, + { name = "python-dateutil" }, + { name = "python-ulid" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "rapidfuzz" }, + { name = "referencing" }, + { name = "requests" }, + { name = "requests-cache" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "serpyco-rs" }, + { name = "setuptools" }, + { name = "typing-extensions" }, + { name = "unidecode" }, + { name = "wcmatch" }, + { name = "whenever" }, + { name = "xmltodict" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4a/77/7b0bcb7103b39934d519a13966f8b1fc68344bc01b55e158b210a5d7faa3/airbyte_cdk-7.10.1.tar.gz", hash = "sha256:0918b1bd0de6b167a2e02ef5d87c5bb0fe6f2f319667dbb1083a8f54a0c9cafb", size = 547281, upload-time = "2026-02-17T10:30:40.927Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/e2/e335bfb2a8e3b4379f5606d7307f50cfa7b2e62f0e3885cf81a98146ab52/airbyte_cdk-7.10.1-py3-none-any.whl", hash = "sha256:b11d796a54900d52cc32d1340da3f98736bbf6401ea2eff3743173883c93d9a7", size = 776298, upload-time = "2026-02-17T10:30:39.285Z" }, +] + +[[package]] +name = "airbyte-protocol-models-dataclasses" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/2b/c372db5dcab8a1602e7ca1affaff94cdfc0874d1c3ff70745a1e0a97eaa2/airbyte_protocol_models_dataclasses-0.17.1.tar.gz", hash = "sha256:cbccfdf84fabd0b6e325cc57fa0682ae9d386fce8fcb5943faa5df2b7e599919", size = 6558, upload-time = "2025-06-17T16:16:37.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/33/4316412499f5678bd0cfe46dc8cceb54335de97965126b35d8d8f9f795da/airbyte_protocol_models_dataclasses-0.17.1-py3-none-any.whl", hash = "sha256:ef83ac56de6208afe0a21ce05bcfbcfc98b98300a76fb3cdf4db2e7f720f1df0", size = 7121, upload-time = "2025-06-17T16:16:36.194Z" }, +] + +[[package]] +name = "airbyte-protocol-models-pdv2" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/f8/9846ce65f9fa9b363f7dde7003e03aaaa69b5b3c37e599e7f35018b96696/airbyte_protocol_models_pdv2-0.18.0.tar.gz", hash = "sha256:8ff9f2685f4ca77571db380419c37db5ba61b82d834758782fa817c2b437c68f", size = 13900, upload-time = "2025-06-27T21:01:36.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/24/414fe44348ca7c7ae71030012e77302b3e41d4ee217cc5931dabfbf90496/airbyte_protocol_models_pdv2-0.18.0-py3-none-any.whl", hash = "sha256:62b08659b3da0f03316c60d84736f12dbfd79340a49947155a1687c13314db8b", size = 12805, upload-time = "2025-06-27T21:01:35.637Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyascii" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/ba/edebda727008390936da4a9bf677c19cd63b32d51e864656d2cbd1028e25/anyascii-0.3.3.tar.gz", hash = "sha256:c94e9dd9d47b3d9494eca305fef9447d00b4bf1a32aff85aa746fa3ec7fb95c3", size = 264680, upload-time = "2025-06-29T03:33:30.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/76/783b75a21ce3563b8709050de030ae253853b147bd52e141edc1025aa268/anyascii-0.3.3-py3-none-any.whl", hash = "sha256:f5ab5e53c8781a36b5a40e1296a0eeda2f48c649ef10c3921c1381b1d00dee7a", size = 345090, upload-time = "2025-06-29T03:33:28.356Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "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, 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, upload-time = "2022-03-15T14:46:51.055Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "attributes-doc" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/8b/bcfe09838dfc88474c29aeca781860938b3909e6df47a3d0d70e97ad79c2/attributes-doc-0.4.0.tar.gz", hash = "sha256:b1576c94a714e9fc2c65c47cf10d0c8e1a5f7c4f5ae7f69006be108d95cbfbfb", size = 5242, upload-time = "2024-01-09T10:34:42.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/97/c2ca0e0e4c2de15996b5f738964d7ca0b9c8d6e116edb307ad30d5e56a59/attributes_doc-0.4.0-py2.py3-none-any.whl", hash = "sha256:4c3007d9e58f3a6cb4b9c614c4d4ce2d92161581f28e594ddd8241cc3a113bdd", size = 4598, upload-time = "2024-01-09T10:34:41.492Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "authlib" +version = "1.6.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134, upload-time = "2026-03-02T07:44:01.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197, upload-time = "2026-03-02T07:44:00.307Z" }, +] + +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "boltons" +version = "25.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/54/71a94d8e02da9a865587fb3fff100cb0fc7aa9f4d5ed9ed3a591216ddcc7/boltons-25.0.0.tar.gz", hash = "sha256:e110fbdc30b7b9868cb604e3f71d4722dd8f4dcb4a5ddd06028ba8f1ab0b5ace", size = 246294, upload-time = "2025-02-03T05:57:59.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/7f/0e961cf3908bc4c1c3e027de2794f867c6c89fb4916fc7dba295a0e80a2d/boltons-25.0.0-py3-none-any.whl", hash = "sha256:dc9fb38bf28985715497d1b54d00b62ea866eca3938938ea9043e254a3a6ca62", size = 194210, upload-time = "2025-02-03T05:57:56.705Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.59" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/4e/499cb52aaee9468c346bcc1158965e24e72b4e2a20052725b680e0ac949b/boto3-1.42.59.tar.gz", hash = "sha256:6c4a14a4eb37b58a9048901bdeefbe1c529638b73e8f55413319a25f010ca211", size = 112725, upload-time = "2026-02-27T20:25:33.228Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/c0/22d868b9408dc5a33935a72896ec8d638b2766c459668d1b37c3e5ac2066/boto3-1.42.59-py3-none-any.whl", hash = "sha256:7a66e3e8e2087ea4403e135e9de592e6d63fc9a91080d8dac415bb74df873a72", size = 140557, upload-time = "2026-02-27T20:25:31.774Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.59" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/45/ae/50fb33bdf1911c216d50f98d989dd032a506f054cf829ebd737c6fa7e3e6/botocore-1.42.59.tar.gz", hash = "sha256:5314f19e1da8fc0ebc41bdb8bbe17c9a7397d87f4d887076ac8bdef972a34138", size = 14950271, upload-time = "2026-02-27T20:25:20.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/df/9d52819e0d804ead073d53ab1823bc0f0cb172a250fba31107b0b43fbb04/botocore-1.42.59-py3-none-any.whl", hash = "sha256:d2f2ff7ecc31e86ef46b5daee112cfbca052c13801285fb23af909f7bff5b657", size = 14619293, upload-time = "2026-02-27T20:25:17.455Z" }, +] + +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, +] + +[[package]] +name = "brickbyte" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "airbyte" }, + { name = "databricks-sdk" }, + { name = "databricks-sql-connector" }, + { name = "pyarrow" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] +local-spark = [ + { name = "delta-spark" }, + { name = "pyspark" }, +] +progress = [ + { name = "tqdm" }, +] + +[package.metadata] +requires-dist = [ + { name = "airbyte", specifier = "==0.38.0" }, + { name = "databricks-sdk", specifier = "==0.95.0" }, + { name = "databricks-sql-connector", specifier = "==4.2.5" }, + { name = "delta-spark", marker = "extra == 'local-spark'", specifier = ">=3.2.0" }, + { name = "pyarrow", specifier = "==21.0.0" }, + { name = "pyspark", marker = "extra == 'local-spark'", specifier = ">=3.5.0" }, + { name = "pytest", marker = "extra == 'dev'" }, + { name = "pyyaml", specifier = "==6.0.3" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.6.4" }, + { name = "tqdm", marker = "extra == 'progress'", specifier = "==4.67.3" }, + { name = "virtualenv", specifier = "==20.29.3" }, +] +provides-extras = ["dev", "progress", "local-spark"] + +[[package]] +name = "cachetools" +version = "7.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/c7/342b33cc6877eebc6c9bb45cb9f78e170e575839699f6f3cc96050176431/cachetools-7.0.2.tar.gz", hash = "sha256:7e7f09a4ca8b791d8bb4864afc71e9c17e607a28e6839ca1a644253c97dbeae0", size = 36983, upload-time = "2026-03-02T19:45:16.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/04/4b6968e77c110f12da96fdbfcb39c6557c2e5e81bd7afcf8ed893d5bc588/cachetools-7.0.2-py3-none-any.whl", hash = "sha256:938dcad184827c5e94928c4fd5526e2b46692b7fb1ae94472da9131d0299343c", size = 13793, upload-time = "2026-03-02T19:45:15.495Z" }, +] + +[[package]] +name = "cattrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/ec/ba18945e7d6e55a58364d9fb2e46049c1c2998b3d805f19b703f14e81057/cattrs-26.1.0.tar.gz", hash = "sha256:fa239e0f0ec0715ba34852ce813986dfed1e12117e209b816ab87401271cdd40", size = 495672, upload-time = "2026-02-18T22:15:19.406Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +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, 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" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "croniter" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/2f/44d1ae153a0e27be56be43465e5cb39b9650c781e001e7864389deb25090/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577", size = 64481, upload-time = "2024-12-17T17:17:47.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" }, +] + +[[package]] +name = "cryptography" +version = "44.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/d6/1411ab4d6108ab167d06254c5be517681f1e331f90edf1379895bcb87020/cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053", size = 711096, upload-time = "2025-05-02T19:36:04.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/53/c776d80e9d26441bb3868457909b4e74dd9ccabd182e10b2b0ae7a07e265/cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88", size = 6670281, upload-time = "2025-05-02T19:34:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/6a/06/af2cf8d56ef87c77319e9086601bef621bedf40f6f59069e1b6d1ec498c5/cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137", size = 3959305, upload-time = "2025-05-02T19:34:53.042Z" }, + { url = "https://files.pythonhosted.org/packages/ae/01/80de3bec64627207d030f47bf3536889efee8913cd363e78ca9a09b13c8e/cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c", size = 4171040, upload-time = "2025-05-02T19:34:54.675Z" }, + { url = "https://files.pythonhosted.org/packages/bd/48/bb16b7541d207a19d9ae8b541c70037a05e473ddc72ccb1386524d4f023c/cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76", size = 3963411, upload-time = "2025-05-02T19:34:56.61Z" }, + { url = "https://files.pythonhosted.org/packages/42/b2/7d31f2af5591d217d71d37d044ef5412945a8a8e98d5a2a8ae4fd9cd4489/cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359", size = 3689263, upload-time = "2025-05-02T19:34:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/25/50/c0dfb9d87ae88ccc01aad8eb93e23cfbcea6a6a106a9b63a7b14c1f93c75/cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43", size = 4196198, upload-time = "2025-05-02T19:35:00.988Z" }, + { url = "https://files.pythonhosted.org/packages/66/c9/55c6b8794a74da652690c898cb43906310a3e4e4f6ee0b5f8b3b3e70c441/cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01", size = 3966502, upload-time = "2025-05-02T19:35:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f7/7cb5488c682ca59a02a32ec5f975074084db4c983f849d47b7b67cc8697a/cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d", size = 4196173, upload-time = "2025-05-02T19:35:05.018Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0b/2f789a8403ae089b0b121f8f54f4a3e5228df756e2146efdf4a09a3d5083/cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904", size = 4087713, upload-time = "2025-05-02T19:35:07.187Z" }, + { url = "https://files.pythonhosted.org/packages/1d/aa/330c13655f1af398fc154089295cf259252f0ba5df93b4bc9d9c7d7f843e/cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44", size = 4299064, upload-time = "2025-05-02T19:35:08.879Z" }, + { url = "https://files.pythonhosted.org/packages/10/a8/8c540a421b44fd267a7d58a1fd5f072a552d72204a3f08194f98889de76d/cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d", size = 2773887, upload-time = "2025-05-02T19:35:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0d/c4b1657c39ead18d76bbd122da86bd95bdc4095413460d09544000a17d56/cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d", size = 3209737, upload-time = "2025-05-02T19:35:12.12Z" }, + { url = "https://files.pythonhosted.org/packages/34/a3/ad08e0bcc34ad436013458d7528e83ac29910943cea42ad7dd4141a27bbb/cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f", size = 6673501, upload-time = "2025-05-02T19:35:13.775Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f0/7491d44bba8d28b464a5bc8cc709f25a51e3eac54c0a4444cf2473a57c37/cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759", size = 3960307, upload-time = "2025-05-02T19:35:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/e5c5d0e1364d3346a5747cdcd7ecbb23ca87e6dea4f942a44e88be349f06/cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645", size = 4170876, upload-time = "2025-05-02T19:35:18.138Z" }, + { url = "https://files.pythonhosted.org/packages/73/96/025cb26fc351d8c7d3a1c44e20cf9a01e9f7cf740353c9c7a17072e4b264/cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2", size = 3964127, upload-time = "2025-05-02T19:35:19.864Z" }, + { url = "https://files.pythonhosted.org/packages/01/44/eb6522db7d9f84e8833ba3bf63313f8e257729cf3a8917379473fcfd6601/cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54", size = 3689164, upload-time = "2025-05-02T19:35:21.449Z" }, + { url = "https://files.pythonhosted.org/packages/68/fb/d61a4defd0d6cee20b1b8a1ea8f5e25007e26aeb413ca53835f0cae2bcd1/cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93", size = 4198081, upload-time = "2025-05-02T19:35:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/1b/50/457f6911d36432a8811c3ab8bd5a6090e8d18ce655c22820994913dd06ea/cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c", size = 3967716, upload-time = "2025-05-02T19:35:25.426Z" }, + { url = "https://files.pythonhosted.org/packages/35/6e/dca39d553075980ccb631955c47b93d87d27f3596da8d48b1ae81463d915/cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f", size = 4197398, upload-time = "2025-05-02T19:35:27.678Z" }, + { url = "https://files.pythonhosted.org/packages/9b/9d/d1f2fe681eabc682067c66a74addd46c887ebacf39038ba01f8860338d3d/cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5", size = 4087900, upload-time = "2025-05-02T19:35:29.312Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f5/3599e48c5464580b73b236aafb20973b953cd2e7b44c7c2533de1d888446/cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b", size = 4301067, upload-time = "2025-05-02T19:35:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/d2c48c8137eb39d0c193274db5c04a75dab20d2f7c3f81a7dcc3a8897701/cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028", size = 2775467, upload-time = "2025-05-02T19:35:33.805Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ad/51f212198681ea7b0deaaf8846ee10af99fba4e894f67b353524eab2bbe5/cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334", size = 3210375, upload-time = "2025-05-02T19:35:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/7f/10/abcf7418536df1eaba70e2cfc5c8a0ab07aa7aa02a5cbc6a78b9d8b4f121/cryptography-44.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d", size = 3393192, upload-time = "2025-05-02T19:35:37.468Z" }, + { url = "https://files.pythonhosted.org/packages/06/59/ecb3ef380f5891978f92a7f9120e2852b1df6f0a849c277b8ea45b865db2/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8", size = 3898419, upload-time = "2025-05-02T19:35:39.065Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d0/35e2313dbb38cf793aa242182ad5bc5ef5c8fd4e5dbdc380b936c7d51169/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4", size = 4117892, upload-time = "2025-05-02T19:35:40.839Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c8/31fb6e33b56c2c2100d76de3fd820afaa9d4d0b6aea1ccaf9aaf35dc7ce3/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff", size = 3900855, upload-time = "2025-05-02T19:35:42.599Z" }, + { url = "https://files.pythonhosted.org/packages/43/2a/08cc2ec19e77f2a3cfa2337b429676406d4bb78ddd130a05c458e7b91d73/cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06", size = 4117619, upload-time = "2025-05-02T19:35:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/fc3d3f84022a75f2ac4b1a1c0e5d6a0c2ea259e14cd4aae3e0e68e56483c/cryptography-44.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9", size = 3136570, upload-time = "2025-05-02T19:35:46.94Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4b/c11ad0b6c061902de5223892d680e89c06c7c4d606305eb8de56c5427ae6/cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375", size = 3390230, upload-time = "2025-05-02T19:35:49.062Z" }, + { url = "https://files.pythonhosted.org/packages/58/11/0a6bf45d53b9b2290ea3cec30e78b78e6ca29dc101e2e296872a0ffe1335/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647", size = 3895216, upload-time = "2025-05-02T19:35:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/0a/27/b28cdeb7270e957f0077a2c2bfad1b38f72f1f6d699679f97b816ca33642/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259", size = 4115044, upload-time = "2025-05-02T19:35:53.044Z" }, + { url = "https://files.pythonhosted.org/packages/35/b0/ec4082d3793f03cb248881fecefc26015813199b88f33e3e990a43f79835/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff", size = 3898034, upload-time = "2025-05-02T19:35:54.72Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7f/adf62e0b8e8d04d50c9a91282a57628c00c54d4ae75e2b02a223bd1f2613/cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5", size = 4114449, upload-time = "2025-05-02T19:35:57.139Z" }, + { url = "https://files.pythonhosted.org/packages/87/62/d69eb4a8ee231f4bf733a92caf9da13f1c81a44e874b1d4080c25ecbb723/cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c", size = 3134369, upload-time = "2025-05-02T19:35:58.907Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/5c/88a4068c660a096bbe87efc5b7c190080c9e86919c36ec5f092cb08d852f/cyclopts-4.6.0.tar.gz", hash = "sha256:483c4704b953ea6da742e8de15972f405d2e748d19a848a4d61595e8e5360ee5", size = 162724, upload-time = "2026-02-23T15:44:49.286Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/eb/1e8337755a70dc7d7ff10a73dc8f20e9352c9ad6c2256ed863ac95cd3539/cyclopts-4.6.0-py3-none-any.whl", hash = "sha256:0a891cb55bfd79a3cdce024db8987b33316aba11071e5258c21ac12a640ba9f2", size = 200518, upload-time = "2026-02-23T15:44:47.854Z" }, +] + +[[package]] +name = "databricks-sdk" +version = "0.95.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/0b/f76daeb62f3f9b47eedb0e90b7f04f9d401c08bb2dfdb7f7804ac4ab7cdb/databricks_sdk-0.95.0.tar.gz", hash = "sha256:c958a2c662aebcac2ffc4a4b09926719ff4665ce02128e9d7c55dcb7bfa104ca", size = 864224, upload-time = "2026-03-02T08:15:46.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/55/29f4dce7e7d06b5d37c8d90561c81d55b6a0083ce1ae7c2ef1ae886d9931/databricks_sdk-0.95.0-py3-none-any.whl", hash = "sha256:bb2a851a7f58475b57da732e2e9aaea0a395cd6993cab97c3d14599711fb6e1e", size = 813458, upload-time = "2026-03-02T08:15:44.783Z" }, +] + +[[package]] +name = "databricks-sql-connector" +version = "4.2.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lz4" }, + { name = "oauthlib" }, + { name = "openpyxl" }, + { name = "pandas" }, + { name = "pybreaker" }, + { name = "pyjwt" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "thrift" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/0c/1e8179f427044a0c769e279b2c45b72a20cff902f4e92ca1bcca50549435/databricks_sql_connector-4.2.5.tar.gz", hash = "sha256:762df7568ef1998540f96b20cad6f1aaae87d1aad54e40e528f87e4524397291", size = 187223, upload-time = "2026-02-09T11:26:29.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/a7/0d6dd8323cb2249a979cf4c6a45694e975668c53b19d52d7e15490bafb4c/databricks_sql_connector-4.2.5-py3-none-any.whl", hash = "sha256:31cee10552ce77a830318ce9488fc5e67daca7abbcdf0d8d34f12a180bc55039", size = 213906, upload-time = "2026-02-09T11:26:28.566Z" }, +] + +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, +] + +[[package]] +name = "dateparser" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "regex" }, + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/668dfb8c073a5dde3efb80fa382de1502e3b14002fd386a8c1b0b49e92a9/dateparser-1.3.0.tar.gz", hash = "sha256:5bccf5d1ec6785e5be71cc7ec80f014575a09b4923e762f850e57443bddbf1a5", size = 337152, upload-time = "2026-02-04T16:00:06.162Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/c7/95349670e193b2891176e1b8e5f43e12b31bff6d9994f70e74ab385047f6/dateparser-1.3.0-py3-none-any.whl", hash = "sha256:8dc678b0a526e103379f02ae44337d424bd366aac727d3c6cf52ce1b01efbb5a", size = 318688, upload-time = "2026-02-04T16:00:04.652Z" }, +] + +[[package]] +name = "delta-spark" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "pyspark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/09/d394015eb956c4475f6a949fb5fccedf7af19f97e981acbc81629e868a5e/delta_spark-4.1.0.tar.gz", hash = "sha256:98f73c2744f972919e0472974467f85d157810b617341ebf586374d91b8eadc7", size = 36808, upload-time = "2026-02-20T18:37:59.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/03/2e440efd4a49c8ecfbcb665dea7db94583cf33a365d8480e47fb0aa0dc39/delta_spark-4.1.0-py3-none-any.whl", hash = "sha256:d80f6ebca542df48f257f2535f9c8c21bbfda65771b6ec21be843c0087e1dece", size = 43877, upload-time = "2026-02-20T18:37:58.15Z" }, +] + +[[package]] +name = "diskcache" +version = "5.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, +] + +[[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, 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, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + +[[package]] +name = "dpath" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/ce/e1fd64d36e4a5717bd5e6b2ad188f5eaa2e902fde871ea73a79875793fc9/dpath-2.2.0.tar.gz", hash = "sha256:34f7e630dc55ea3f219e555726f5da4b4b25f2200319c8e6902c394258dd6a3e", size = 28266, upload-time = "2024-06-12T22:08:03.686Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/d1/8952806fbf9583004ab479d8f58a9496c3d35f6b6009ddd458bdd9978eaf/dpath-2.2.0-py3-none-any.whl", hash = "sha256:b330a375ded0a0d2ed404440f6c6a715deae5313af40bbb01c8a41d891900576", size = 17618, upload-time = "2024-06-12T22:08:01.881Z" }, +] + +[[package]] +name = "duckdb" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/da/17c3eb5458af69d54dedc8d18e4a32ceaa8ce4d4c699d45d6d8287e790c3/duckdb-1.4.3.tar.gz", hash = "sha256:fea43e03604c713e25a25211ada87d30cd2a044d8f27afab5deba26ac49e5268", size = 18478418, upload-time = "2025-12-09T10:59:22.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/3a/ea8e237e1ba40203dea4ed6a8798ea51e66a4c4f34605697025e5fa06fdd/duckdb-1.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:efa7f1191c59e34b688fcd4e588c1b903a4e4e1f4804945902cf0b20e08a9001", size = 29016021, upload-time = "2025-12-09T10:57:46.847Z" }, + { url = "https://files.pythonhosted.org/packages/48/88/07615298a2871362b454237b6a2d7724e6ba0afba2bddedddde5bbf129d5/duckdb-1.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4fef6a053a1c485292000bf0c338bba60f89d334f6a06fc76ba4085a5a322b76", size = 15405906, upload-time = "2025-12-09T10:57:49.213Z" }, + { url = "https://files.pythonhosted.org/packages/fa/66/b407ab3cd4822191aa5defb27522213b6ba670437c7da09a062d8b75b0a4/duckdb-1.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:702dabbc22b27dc5b73e7599c60deef3d8c59968527c36b391773efddd8f4cf1", size = 13732991, upload-time = "2025-12-09T10:57:51.189Z" }, + { url = "https://files.pythonhosted.org/packages/33/f0/e8edab80446d87b4e0faf3aaa440f9cfd9d0609c21a4be56174c8ba7d23c/duckdb-1.4.3-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:854b79375fa618f6ffa8d84fb45cbc9db887f6c4834076ea10d20bc106f1fd90", size = 18471503, upload-time = "2025-12-09T10:57:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7a/8d257bc847f0ac6a6639ae0a6e7f35f0b5bfbae472ee4846ee32404670a6/duckdb-1.4.3-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bb8bd5a3dd205983726185b280a211eacc9f5bc0c4d4505bec8c87ac33a8ccb", size = 20466012, upload-time = "2025-12-09T10:57:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d1/8f6bdaf2da6a076dd63c84ed87fb82d0741c9f4acb3dd476d73ca0a08ffe/duckdb-1.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:d0ff08388ef8b1d1a4c95c321d6c5fa11201b241036b1ee740f9d841df3d6ba2", size = 12328392, upload-time = "2025-12-09T10:57:57.718Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bc/7c5e50e440c8629495678bc57bdfc1bb8e62f61090f2d5441e2bd0a0ed96/duckdb-1.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:366bf607088053dce845c9d24c202c04d78022436cc5d8e4c9f0492de04afbe7", size = 29019361, upload-time = "2025-12-09T10:57:59.845Z" }, + { url = "https://files.pythonhosted.org/packages/26/15/c04a4faf0dfddad2259cab72bf0bd4b3d010f2347642541bd254d516bf93/duckdb-1.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d080e8d1bf2d226423ec781f539c8f6b6ef3fd42a9a58a7160de0a00877a21f", size = 15407465, upload-time = "2025-12-09T10:58:02.465Z" }, + { url = "https://files.pythonhosted.org/packages/cb/54/a049490187c9529932fc153f7e1b92a9e145586281fe4e03ce0535a0497c/duckdb-1.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9dc049ba7e906cb49ca2b6d4fbf7b6615ec3883193e8abb93f0bef2652e42dda", size = 13735781, upload-time = "2025-12-09T10:58:04.847Z" }, + { url = "https://files.pythonhosted.org/packages/14/b7/ee594dcecbc9469ec3cd1fb1f81cb5fa289ab444b80cfb5640c8f467f75f/duckdb-1.4.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b30245375ea94ab528c87c61fc3ab3e36331180b16af92ee3a37b810a745d24", size = 18470729, upload-time = "2025-12-09T10:58:07.116Z" }, + { url = "https://files.pythonhosted.org/packages/df/5f/a6c1862ed8a96d8d930feb6af5e55aadd983310aab75142468c2cb32a2a3/duckdb-1.4.3-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7c864df027da1ee95f0c32def67e15d02cd4a906c9c1cbae82c09c5112f526b", size = 20471399, upload-time = "2025-12-09T10:58:09.714Z" }, + { url = "https://files.pythonhosted.org/packages/5b/80/c05c0b6a6107b618927b7dcabe3bba6a7eecd951f25c9dbcd9c1f9577cc8/duckdb-1.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:813f189039b46877b5517f1909c7b94a8fe01b4bde2640ab217537ea0fe9b59b", size = 12329359, upload-time = "2025-12-09T10:58:12.147Z" }, + { url = "https://files.pythonhosted.org/packages/b0/83/9d8fc3413f854effa680dcad1781f68f3ada8679863c0c94ba3b36bae6ff/duckdb-1.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:fbc63ffdd03835f660155b37a1b6db2005bcd46e5ad398b8cac141eb305d2a3d", size = 13070898, upload-time = "2025-12-09T10:58:14.301Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d7/fdc2139b94297fc5659110a38adde293d025e320673ae5e472b95d323c50/duckdb-1.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:6302452e57aef29aae3977063810ed7b2927967b97912947b9cca45c1c21955f", size = 29033112, upload-time = "2025-12-09T10:58:16.52Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/ca93df1ce19aef8f799e3aaacf754a4dde7e9169c0b333557752d21d076a/duckdb-1.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:deab351ac43b6282a3270e3d40e3d57b3b50f472d9fd8c30975d88a31be41231", size = 15414646, upload-time = "2025-12-09T10:58:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/16/90/9f2748e740f5fc05b739e7c5c25aab6ab4363e5da4c3c70419c7121dc806/duckdb-1.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5634e40e1e2d972e4f75bced1fbdd9e9e90faa26445c1052b27de97ee546944a", size = 13740477, upload-time = "2025-12-09T10:58:21.778Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ec/279723615b4fb454efd823b7efe97cf2504569e2e74d15defbbd6b027901/duckdb-1.4.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:274d4a31aba63115f23e7e7b401e3e3a937f3626dc9dea820a9c7d3073f450d2", size = 18483715, upload-time = "2025-12-09T10:58:24.346Z" }, + { url = "https://files.pythonhosted.org/packages/10/63/af20cd20fd7fd6565ea5a1578c16157b6a6e07923e459a6f9b0dc9ada308/duckdb-1.4.3-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f868a7e6d9b37274a1aa34849ea92aa964e9bd59a5237d6c17e8540533a1e4f", size = 20495188, upload-time = "2025-12-09T10:58:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/0acb4b64afb2cc6c1d458a391c64e36be40137460f176c04686c965ce0e0/duckdb-1.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:ef7ef15347ce97201b1b5182a5697682679b04c3374d5a01ac10ba31cf791b95", size = 12335622, upload-time = "2025-12-09T10:58:29.707Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/2a795745f6597a5e65770141da6efdc4fd754e5ee6d652f74bcb7f9c7759/duckdb-1.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:1b9b445970fd18274d5ac07a0b24c032e228f967332fb5ebab3d7db27738c0e4", size = 13075834, upload-time = "2025-12-09T10:58:32.036Z" }, +] + +[[package]] +name = "duckdb-engine" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "duckdb" }, + { name = "packaging" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/d5/c0d8d0a4ca3ffea92266f33d92a375e2794820ad89f9be97cf0c9a9697d0/duckdb_engine-0.17.0.tar.gz", hash = "sha256:396b23869754e536aa80881a92622b8b488015cf711c5a40032d05d2cf08f3cf", size = 48054, upload-time = "2025-03-29T09:49:17.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a2/e90242f53f7ae41554419b1695b4820b364df87c8350aa420b60b20cab92/duckdb_engine-0.17.0-py3-none-any.whl", hash = "sha256:3aa72085e536b43faab635f487baf77ddc5750069c16a2f8d9c6c3cb6083e979", size = 49676, upload-time = "2025-03-29T09:49:15.564Z" }, +] + +[[package]] +name = "dunamai" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/c4/346cef905782df6152f29f02d9c8ed4acf7ae66b0e66210b7156c5575ccb/dunamai-1.26.0.tar.gz", hash = "sha256:5396ac43aa20ed059040034e9f9798c7464cf4334c6fc3da3732e29273a2f97d", size = 45500, upload-time = "2026-02-15T02:58:55.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/10/2c7edbf230e5c507d38367af498fa94258ed97205d9b4b6f63a921fe9c49/dunamai-1.26.0-py3-none-any.whl", hash = "sha256:f584edf0fda0d308cce0961f807bc90a8fe3d9ff4d62f94e72eca7b43f0ed5f6", size = 27322, upload-time = "2026-02-15T02:58:54.143Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "fakeredis" +version = "2.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, + { name = "sortedcontainers" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/40/fd09efa66205eb32253d2b2ebc63537281384d2040f0a88bcd2289e120e4/fakeredis-2.34.1.tar.gz", hash = "sha256:4ff55606982972eecce3ab410e03d746c11fe5deda6381d913641fbd8865ea9b", size = 177315, upload-time = "2026-02-25T13:17:51.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/b5/82f89307d0d769cd9bf46a54fb9136be08e4e57c5570ae421db4c9a2ba62/fakeredis-2.34.1-py3-none-any.whl", hash = "sha256:0107ec99d48913e7eec2a5e3e2403d1bd5f8aa6489d1a634571b975289c48f12", size = 122160, upload-time = "2026-02-25T13:17:49.701Z" }, +] + +[package.optional-dependencies] +lua = [ + { name = "lupa" }, +] + +[[package]] +name = "fastmcp" +version = "2.14.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pydocket" }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/32/982678d44f13849530a74ab101ed80e060c2ee6cf87471f062dcf61705fd/fastmcp-2.14.5.tar.gz", hash = "sha256:38944dc582c541d55357082bda2241cedb42cd3a78faea8a9d6a2662c62a42d7", size = 8296329, upload-time = "2026-02-03T15:35:21.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/c1/1a35ec68ff76ea8443aa115b18bcdee748a4ada2124537ee90522899ff9f/fastmcp-2.14.5-py3-none-any.whl", hash = "sha256:d81e8ec813f5089d3624bec93944beaefa86c0c3a4ef1111cbef676a761ebccf", size = 417784, upload-time = "2026-02-03T15:35:18.489Z" }, +] + +[[package]] +name = "fastmcp-extensions" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/38/95549e7bb6bfe6ffedb5c388488b7c81b6a70d5e649d5a08047687a0e811/fastmcp_extensions-0.2.0.tar.gz", hash = "sha256:c456d4d00a96d9fe41b630e51cc6cb4b9920796e6943185e797669d10fe7e917", size = 156381, upload-time = "2026-01-19T23:02:38.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/bc/0d2edadb8629afaaec9f05fe05ca9184256d8f046601f71d07cbc8ff8aeb/fastmcp_extensions-0.2.0-py3-none-any.whl", hash = "sha256:b48f13ecfbceb8e5bc75569e41029f451efa2f0f69b390cf2ad23ed41d1160e0", size = 34431, upload-time = "2026-01-19T23:02:37.096Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, +] + +[[package]] +name = "genson" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/cf/2303c8ad276dcf5ee2ad6cf69c4338fd86ef0f471a5207b069adf7a393cf/genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37", size = 34919, upload-time = "2024-05-15T22:08:49.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/e226de133afd8bb267ec27eead9ae3d784b95b39a287ed404caab39a5f50/genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7", size = 21470, upload-time = "2024-05-15T22:08:47.056Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-auth" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" }, +] + +[[package]] +name = "google-cloud-bigquery" +version = "3.40.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-resumable-media" }, + { name = "packaging" }, + { name = "python-dateutil" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/11/0c/153ee546c288949fcc6794d58811ab5420f3ecad5fa7f9e73f78d9512a6e/google_cloud_bigquery-3.40.1.tar.gz", hash = "sha256:75afcfb6e007238fe1deefb2182105249321145ff921784fe7b1de2b4ba24506", size = 511761, upload-time = "2026-02-12T18:44:18.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/f5/081cf5b90adfe524ae0d671781b0d497a75a0f2601d075af518828e22d8f/google_cloud_bigquery-3.40.1-py3-none-any.whl", hash = "sha256:9082a6b8193aba87bed6a2c79cf1152b524c99bb7e7ac33a785e333c09eac868", size = 262018, upload-time = "2026-02-12T18:44:16.913Z" }, +] + +[[package]] +name = "google-cloud-bigquery-storage" +version = "2.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/fa/877e0059349369be38a64586b135c59ceadb87d0386084043d8c440ef929/google_cloud_bigquery_storage-2.36.2.tar.gz", hash = "sha256:ad49d8c09ad6cd82da4efe596fcfcdbc1458bf05b93915e3c5c00f1e700ae128", size = 308672, upload-time = "2026-02-19T16:03:10.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/07/62dbe78ef773569be0a1d2c1b845e9214889b404e506126519b4d33ee999/google_cloud_bigquery_storage-2.36.2-py3-none-any.whl", hash = "sha256:823a73db0c4564e8ad3eedcfd5049f3d5aa41775267863b5627211ec36be2dbf", size = 304398, upload-time = "2026-02-19T16:02:55.112Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027, upload-time = "2025-10-29T23:17:39.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, +] + +[[package]] +name = "google-cloud-secret-manager" +version = "2.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "grpc-google-iam-v1" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/9c/a6c7144bc96df77376ae3fcc916fb639c40814c2e4bba2051d31dc136cd0/google_cloud_secret_manager-2.26.0.tar.gz", hash = "sha256:0d1d6f76327685a0ed78a4cf50f289e1bfbbe56026ed0affa98663b86d6d50d6", size = 277603, upload-time = "2025-12-18T00:29:31.065Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/30/a58739dd12cec0f7f761ed1efb518aed2250a407d4ed14c5a0eeee7eaaf9/google_cloud_secret_manager-2.26.0-py3-none-any.whl", hash = "sha256:940a5447a6ec9951446fd1a0f22c81a4303fde164cd747aae152c5f5c8e6723e", size = 223623, upload-time = "2025-12-18T00:29:29.311Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/ac/6f7bc93886a823ab545948c2dd48143027b2355ad1944c7cf852b338dc91/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0470b8c3d73b5f4e3300165498e4cf25221c7eb37f1159e221d1825b6df8a7ff", size = 31296, upload-time = "2025-12-16T00:19:07.261Z" }, + { url = "https://files.pythonhosted.org/packages/f7/97/a5accde175dee985311d949cfcb1249dcbb290f5ec83c994ea733311948f/google_crc32c-1.8.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:119fcd90c57c89f30040b47c211acee231b25a45d225e3225294386f5d258288", size = 30870, upload-time = "2025-12-16T00:29:17.669Z" }, + { url = "https://files.pythonhosted.org/packages/3d/63/bec827e70b7a0d4094e7476f863c0dbd6b5f0f1f91d9c9b32b76dcdfeb4e/google_crc32c-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f35aaffc8ccd81ba3162443fabb920e65b1f20ab1952a31b13173a67811467d", size = 33214, upload-time = "2025-12-16T00:40:19.618Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/11b70614df04c289128d782efc084b9035ef8466b3d0a8757c1b6f5cf7ac/google_crc32c-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:864abafe7d6e2c4c66395c1eb0fe12dc891879769b52a3d56499612ca93b6092", size = 33589, upload-time = "2025-12-16T00:40:20.7Z" }, + { url = "https://files.pythonhosted.org/packages/3e/00/a08a4bc24f1261cc5b0f47312d8aebfbe4b53c2e6307f1b595605eed246b/google_crc32c-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:db3fe8eaf0612fc8b20fa21a5f25bd785bc3cd5be69f8f3412b0ac2ffd49e733", size = 34437, upload-time = "2025-12-16T00:35:19.437Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, + { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, +] + +[[package]] +name = "google-resumable-media" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/d7/520b62a35b23038ff005e334dba3ffc75fcf583bee26723f1fd8fd4b6919/google_resumable_media-2.8.0.tar.gz", hash = "sha256:f1157ed8b46994d60a1bc432544db62352043113684d4e030ee02e77ebe9a1ae", size = 2163265, upload-time = "2025-11-17T15:38:06.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/0b/93afde9cfe012260e9fe1522f35c9b72d6ee222f316586b1f23ecf44d518/google_resumable_media-2.8.0-py3-none-any.whl", hash = "sha256:dd14a116af303845a8d932ddae161a26e86cc229645bc98b39f026f9b1717582", size = 81340, upload-time = "2025-11-17T15:38:05.594Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, + { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, + { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, + { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, + { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, + { url = "https://files.pythonhosted.org/packages/ac/78/f93e840cbaef8becaf6adafbaf1319682a6c2d8c1c20224267a5c6c8c891/greenlet-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:5d0e35379f93a6d0222de929a25ab47b5eb35b5ef4721c2b9cbcc4036129ff1f", size = 230092, upload-time = "2026-02-20T20:17:09.379Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, + { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, + { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, + { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, + { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, + { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" }, + { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" }, +] + +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos", extra = ["grpc"] }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745, upload-time = "2025-10-15T21:14:53.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690, upload-time = "2025-10-15T21:14:51.72Z" }, +] + +[[package]] +name = "grpcio" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/a8/690a085b4d1fe066130de97a87de32c45062cf2ecd218df9675add895550/grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5", size = 5946986, upload-time = "2026-02-06T09:54:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1b/e5213c5c0ced9d2d92778d30529ad5bb2dcfb6c48c4e2d01b1f302d33d64/grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2", size = 11816533, upload-time = "2026-02-06T09:54:37.04Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1ba32dccf0a324cc5ace744c44331e300b000a924bf14840f948c559ede7/grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d", size = 6519964, upload-time = "2026-02-06T09:54:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f5/c0e178721b818072f2e8b6fde13faaba942406c634009caf065121ce246b/grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb", size = 7198058, upload-time = "2026-02-06T09:54:42.389Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b2/40d43c91ae9cd667edc960135f9f08e58faa1576dc95af29f66ec912985f/grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7", size = 6727212, upload-time = "2026-02-06T09:54:44.91Z" }, + { url = "https://files.pythonhosted.org/packages/ed/88/9da42eed498f0efcfcd9156e48ae63c0cde3bea398a16c99fb5198c885b6/grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec", size = 7300845, upload-time = "2026-02-06T09:54:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/23/3f/1c66b7b1b19a8828890e37868411a6e6925df5a9030bfa87ab318f34095d/grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a", size = 8284605, upload-time = "2026-02-06T09:54:50.475Z" }, + { url = "https://files.pythonhosted.org/packages/94/c4/ca1bd87394f7b033e88525384b4d1e269e8424ab441ea2fba1a0c5b50986/grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813", size = 7726672, upload-time = "2026-02-06T09:54:53.11Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/f16e487d4cc65ccaf670f6ebdd1a17566b965c74fc3d93999d3b2821e052/grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de", size = 4076715, upload-time = "2026-02-06T09:54:55.549Z" }, + { url = "https://files.pythonhosted.org/packages/2a/32/4ce60d94e242725fd3bcc5673c04502c82a8e87b21ea411a63992dc39f8f/grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf", size = 4799157, upload-time = "2026-02-06T09:54:59.838Z" }, + { url = "https://files.pythonhosted.org/packages/86/c7/d0b780a29b0837bf4ca9580904dfb275c1fc321ded7897d620af7047ec57/grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6", size = 5951525, upload-time = "2026-02-06T09:55:01.989Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/96920bf2ee61df85a9503cb6f733fe711c0ff321a5a697d791b075673281/grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e", size = 11830418, upload-time = "2026-02-06T09:55:04.462Z" }, + { url = "https://files.pythonhosted.org/packages/83/0c/7c1528f098aeb75a97de2bae18c530f56959fb7ad6c882db45d9884d6edc/grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911", size = 6524477, upload-time = "2026-02-06T09:55:07.111Z" }, + { url = "https://files.pythonhosted.org/packages/8d/52/e7c1f3688f949058e19a011c4e0dec973da3d0ae5e033909677f967ae1f4/grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e", size = 7198266, upload-time = "2026-02-06T09:55:10.016Z" }, + { url = "https://files.pythonhosted.org/packages/e5/61/8ac32517c1e856677282c34f2e7812d6c328fa02b8f4067ab80e77fdc9c9/grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303", size = 6730552, upload-time = "2026-02-06T09:55:12.207Z" }, + { url = "https://files.pythonhosted.org/packages/bd/98/b8ee0158199250220734f620b12e4a345955ac7329cfd908d0bf0fda77f0/grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04", size = 7304296, upload-time = "2026-02-06T09:55:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/bd/0f/7b72762e0d8840b58032a56fdbd02b78fc645b9fa993d71abf04edbc54f4/grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec", size = 8288298, upload-time = "2026-02-06T09:55:17.276Z" }, + { url = "https://files.pythonhosted.org/packages/24/ae/ae4ce56bc5bb5caa3a486d60f5f6083ac3469228faa734362487176c15c5/grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074", size = 7730953, upload-time = "2026-02-06T09:55:19.545Z" }, + { url = "https://files.pythonhosted.org/packages/b5/6e/8052e3a28eb6a820c372b2eb4b5e32d195c661e137d3eca94d534a4cfd8a/grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856", size = 4076503, upload-time = "2026-02-06T09:55:21.521Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/f22c98c5265dfad327251fa2f840b591b1df5f5e15d88b19c18c86965b27/grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558", size = 4799767, upload-time = "2026-02-06T09:55:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/cd/89ce482a931b543b92cdd9b2888805518c4620e0094409acb8c81dd4610a/grpcio_status-1.78.0.tar.gz", hash = "sha256:a34cfd28101bfea84b5aa0f936b4b423019e9213882907166af6b3bddc59e189", size = 13808, upload-time = "2026-02-06T10:01:48.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/8a/1241ec22c41028bddd4a052ae9369267b4475265ad0ce7140974548dc3fa/grpcio_status-1.78.0-py3-none-any.whl", hash = "sha256:b492b693d4bf27b47a6c32590701724f1d3b9444b36491878fb71f6208857f34", size = 14523, upload-time = "2026-02-06T10:01:32.584Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isodate" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/db/7a/c0a56c7d56c7fa723988f122fa1f1ccf8c5c4ccc48efad0d214b49e5b1af/isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9", size = 28443, upload-time = "2021-12-13T20:28:31.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/85/7882d311924cbcfc70b1890780763e36ff0b140c7e51c110fc59a532f087/isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96", size = 41722, upload-time = "2021-12-13T20:28:29.073Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +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, 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, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, +] + +[[package]] +name = "jsonpath-python" +version = "1.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/bf/626a72f2d093c5eb4f4de55b443714afa7231beeae40d4a1c69b5c5aa4d1/jsonpath_python-1.1.4.tar.gz", hash = "sha256:bb3e13854e4807c078a1503ae2d87c211b8bff4d9b40b6455ed583b3b50a7fdd", size = 84766, upload-time = "2025-11-25T12:08:39.521Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/bc/52e5bf0d9839e082b976c19afcab7561d0d719c7627483bf5dc251d27eed/jsonpath_python-1.1.4-py3-none-any.whl", hash = "sha256:8700cb8610c44da6e5e9bff50232779c44bf7dc5bc62662d49319ee746898442", size = 12687, upload-time = "2025-11-25T12:08:38.453Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/b4/41315eea8301a5353bca3578792767135b8edbc081b20618a3f0b4d78307/jsonschema_path-0.4.4.tar.gz", hash = "sha256:4c55842890fc384262a59fb63a25c86cc0e2b059e929c18b851c1d19ef612026", size = 14923, upload-time = "2026-02-28T11:58:26.289Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/36/cb2cd6543776d02875de600f12fcd81611daf359544c9ad2abb12d3122a5/jsonschema_path-0.4.4-py3-none-any.whl", hash = "sha256:669bb69cb92cd4c54acf38ee2ff7c3d9ab6b69991698f7a2f17d2bb0e5c9c394", size = 19226, upload-time = "2026-02-28T11:58:25.143Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "lupa" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/15/713cab5d0dfa4858f83b99b3e0329072df33dc14fc3ebbaa017e0f9755c4/lupa-2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6b3dabda836317e63c5ad052826e156610f356a04b3003dfa0dbe66b5d54d671", size = 954828, upload-time = "2025-10-24T07:17:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/2e/71/704740cbc6e587dd6cc8dabf2f04820ac6a671784e57cc3c29db795476db/lupa-2.6-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8726d1c123bbe9fbb974ce29825e94121824e66003038ff4532c14cc2ed0c51c", size = 1919259, upload-time = "2025-10-24T07:17:18.586Z" }, + { url = "https://files.pythonhosted.org/packages/eb/18/f248341c423c5d48837e35584c6c3eb4acab7e722b6057d7b3e28e42dae8/lupa-2.6-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:f4e159e7d814171199b246f9235ca8961f6461ea8c1165ab428afa13c9289a94", size = 984998, upload-time = "2025-10-24T07:17:20.428Z" }, + { url = "https://files.pythonhosted.org/packages/44/1e/8a4bd471e018aad76bcb9455d298c2c96d82eced20f2ae8fcec8cd800948/lupa-2.6-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:202160e80dbfddfb79316692a563d843b767e0f6787bbd1c455f9d54052efa6c", size = 1174871, upload-time = "2025-10-24T07:17:22.755Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5c/3a3f23fd6a91b0986eea1ceaf82ad3f9b958fe3515a9981fb9c4eb046c8b/lupa-2.6-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5deede7c5b36ab64f869dae4831720428b67955b0bb186c8349cf6ea121c852b", size = 1057471, upload-time = "2025-10-24T07:17:24.908Z" }, + { url = "https://files.pythonhosted.org/packages/45/ac/01be1fed778fb0c8f46ee8cbe344e4d782f6806fac12717f08af87aa4355/lupa-2.6-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86f04901f920bbf7c0cac56807dc9597e42347123e6f1f3ca920f15f54188ce5", size = 2100592, upload-time = "2025-10-24T07:17:27.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6c/1a05bb873e30830f8574e10cd0b4cdbc72e9dbad2a09e25810b5e3b1f75d/lupa-2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6deef8f851d6afb965c84849aa5b8c38856942df54597a811ce0369ced678610", size = 1081396, upload-time = "2025-10-24T07:17:29.064Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c2/a19dd80d6dc98b39bbf8135b8198e38aa7ca3360b720eac68d1d7e9286b5/lupa-2.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:21f2b5549681c2a13b1170a26159d30875d367d28f0247b81ca347222c755038", size = 1192007, upload-time = "2025-10-24T07:17:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/4f/43/e1b297225c827f55752e46fdbfb021c8982081b0f24490e42776ea69ae3b/lupa-2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:66eea57630eab5e6f49fdc5d7811c0a2a41f2011be4ea56a087ea76112011eb7", size = 2196661, upload-time = "2025-10-24T07:17:33.484Z" }, + { url = "https://files.pythonhosted.org/packages/2e/8f/2272d429a7fa9dc8dbd6e9c5c9073a03af6007eb22a4c78829fec6a34b80/lupa-2.6-cp310-cp310-win32.whl", hash = "sha256:60a403de8cab262a4fe813085dd77010effa6e2eb1886db2181df803140533b1", size = 1412738, upload-time = "2025-10-24T07:17:35.11Z" }, + { url = "https://files.pythonhosted.org/packages/35/2a/1708911271dd49ad87b4b373b5a4b0e0a0516d3d2af7b76355946c7ee171/lupa-2.6-cp310-cp310-win_amd64.whl", hash = "sha256:e4656a39d93dfa947cf3db56dc16c7916cb0cc8024acd3a952071263f675df64", size = 1656898, upload-time = "2025-10-24T07:17:36.949Z" }, + { url = "https://files.pythonhosted.org/packages/ca/29/1f66907c1ebf1881735afa695e646762c674f00738ebf66d795d59fc0665/lupa-2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d988c0f9331b9f2a5a55186701a25444ab10a1432a1021ee58011499ecbbdd5", size = 962875, upload-time = "2025-10-24T07:17:39.107Z" }, + { url = "https://files.pythonhosted.org/packages/e6/67/4a748604be360eb9c1c215f6a0da921cd1a2b44b2c5951aae6fb83019d3a/lupa-2.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:ebe1bbf48259382c72a6fe363dea61a0fd6fe19eab95e2ae881e20f3654587bf", size = 1935390, upload-time = "2025-10-24T07:17:41.427Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/8ef9ee933a350428b7bdb8335a37ef170ab0bb008bbf9ca8f4f4310116b6/lupa-2.6-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:a8fcee258487cf77cdd41560046843bb38c2e18989cd19671dd1e2596f798306", size = 992193, upload-time = "2025-10-24T07:17:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/65/46/e6c7facebdb438db8a65ed247e56908818389c1a5abbf6a36aab14f1057d/lupa-2.6-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:561a8e3be800827884e767a694727ed8482d066e0d6edfcbf423b05e63b05535", size = 1165844, upload-time = "2025-10-24T07:17:45.437Z" }, + { url = "https://files.pythonhosted.org/packages/1c/26/9f1154c6c95f175ccbf96aa96c8f569c87f64f463b32473e839137601a8b/lupa-2.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af880a62d47991cae78b8e9905c008cbfdc4a3a9723a66310c2634fc7644578c", size = 1048069, upload-time = "2025-10-24T07:17:47.181Z" }, + { url = "https://files.pythonhosted.org/packages/68/67/2cc52ab73d6af81612b2ea24c870d3fa398443af8e2875e5befe142398b1/lupa-2.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b22923aa4023c86c0097b235615f89d469a0c4eee0489699c494d3367c4c85", size = 2079079, upload-time = "2025-10-24T07:17:49.755Z" }, + { url = "https://files.pythonhosted.org/packages/2e/dc/f843f09bbf325f6e5ee61730cf6c3409fc78c010d968c7c78acba3019ca7/lupa-2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:153d2cc6b643f7efb9cfc0c6bb55ec784d5bac1a3660cfc5b958a7b8f38f4a75", size = 1071428, upload-time = "2025-10-24T07:17:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/2e/60/37533a8d85bf004697449acb97ecdacea851acad28f2ad3803662487dd2a/lupa-2.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3fa8777e16f3ded50b72967dc17e23f5a08e4f1e2c9456aff2ebdb57f5b2869f", size = 1181756, upload-time = "2025-10-24T07:17:53.752Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/cf29b20dbb4927b6a3d27c339ac5d73e74306ecc28c8e2c900b2794142ba/lupa-2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8dbdcbe818c02a2f56f5ab5ce2de374dab03e84b25266cfbaef237829bc09b3f", size = 2175687, upload-time = "2025-10-24T07:17:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/94/7c/050e02f80c7131b63db1474bff511e63c545b5a8636a24cbef3fc4da20b6/lupa-2.6-cp311-cp311-win32.whl", hash = "sha256:defaf188fde8f7a1e5ce3a5e6d945e533b8b8d547c11e43b96c9b7fe527f56dc", size = 1412592, upload-time = "2025-10-24T07:17:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/6f2af98aa5d771cea661f66c8eb8f53772ec1ab1dfbce24126cfcd189436/lupa-2.6-cp311-cp311-win_amd64.whl", hash = "sha256:9505ae600b5c14f3e17e70f87f88d333717f60411faca1ddc6f3e61dce85fa9e", size = 1669194, upload-time = "2025-10-24T07:18:01.647Z" }, + { url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" }, + { url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" }, + { url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" }, + { url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" }, + { url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" }, + { url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" }, + { url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" }, + { url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/45/2466d73d79e3940cad4b26761f356f19fd33f4409c96f100e01a5c566909/lz4-4.4.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d221fa421b389ab2345640a508db57da36947a437dfe31aeddb8d5c7b646c22d", size = 207396, upload-time = "2025-11-03T13:01:24.965Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/7da96077a7e8918a5a57a25f1254edaf76aefb457666fcc1066deeecd609/lz4-4.4.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7dc1e1e2dbd872f8fae529acd5e4839efd0b141eaa8ae7ce835a9fe80fbad89f", size = 207154, upload-time = "2025-11-03T13:01:26.922Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0e/0fb54f84fd1890d4af5bc0a3c1fa69678451c1a6bd40de26ec0561bb4ec5/lz4-4.4.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e928ec2d84dc8d13285b4a9288fd6246c5cde4f5f935b479f50d986911f085e3", size = 1291053, upload-time = "2025-11-03T13:01:28.396Z" }, + { url = "https://files.pythonhosted.org/packages/15/45/8ce01cc2715a19c9e72b0e423262072c17d581a8da56e0bd4550f3d76a79/lz4-4.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daffa4807ef54b927451208f5f85750c545a4abbff03d740835fc444cd97f758", size = 1278586, upload-time = "2025-11-03T13:01:29.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/34/7be9b09015e18510a09b8d76c304d505a7cbc66b775ec0b8f61442316818/lz4-4.4.5-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a2b7504d2dffed3fd19d4085fe1cc30cf221263fd01030819bdd8d2bb101cf1", size = 1367315, upload-time = "2025-11-03T13:01:31.054Z" }, + { url = "https://files.pythonhosted.org/packages/2a/94/52cc3ec0d41e8d68c985ec3b2d33631f281d8b748fb44955bc0384c2627b/lz4-4.4.5-cp310-cp310-win32.whl", hash = "sha256:0846e6e78f374156ccf21c631de80967e03cc3c01c373c665789dc0c5431e7fc", size = 88173, upload-time = "2025-11-03T13:01:32.643Z" }, + { url = "https://files.pythonhosted.org/packages/ca/35/c3c0bdc409f551404355aeeabc8da343577d0e53592368062e371a3620e1/lz4-4.4.5-cp310-cp310-win_amd64.whl", hash = "sha256:7c4e7c44b6a31de77d4dc9772b7d2561937c9588a734681f70ec547cfbc51ecd", size = 99492, upload-time = "2025-11-03T13:01:33.813Z" }, + { url = "https://files.pythonhosted.org/packages/1d/02/4d88de2f1e97f9d05fd3d278fe412b08969bc94ff34942f5a3f09318144a/lz4-4.4.5-cp310-cp310-win_arm64.whl", hash = "sha256:15551280f5656d2206b9b43262799c89b25a25460416ec554075a8dc568e4397", size = 91280, upload-time = "2025-11-03T13:01:35.081Z" }, + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "marshmallow" +version = "3.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nltk" +version = "3.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "joblib" }, + { name = "regex" }, + { name = "tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/87/db8be88ad32c2d042420b6fd9ffd4a149f9a0d7f0e86b3f543be2eeeedd2/nltk-3.9.1.tar.gz", hash = "sha256:87d127bd3de4bd89a4f81265e5fa59cb1b199b27440175370f7417d2bc7ae868", size = 2904691, upload-time = "2024-08-18T19:48:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/66/7d9e26593edda06e8cb531874633f7c2372279c3b0f46235539fe546df8b/nltk-3.9.1-py3-none-any.whl", hash = "sha256:4fa26829c5b00715afe3061398a8989dc643b92ce7dd93fb4585a70930d168a1", size = 1505442, upload-time = "2024-08-18T19:48:21.909Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +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/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, + { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, + { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, + { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, + { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, +] + +[[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, 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, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, +] + +[[package]] +name = "orjson" +version = "3.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/45/b268004f745ede84e5798b48ee12b05129d19235d0e15267aa57dcdb400b/orjson-3.11.7.tar.gz", hash = "sha256:9b1a67243945819ce55d24a30b59d6a168e86220452d2c96f4d1f093e71c0c49", size = 6144992, upload-time = "2026-02-02T15:38:49.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1a/a373746fa6d0e116dd9e54371a7b54622c44d12296d5d0f3ad5e3ff33490/orjson-3.11.7-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a02c833f38f36546ba65a452127633afce4cf0dd7296b753d3bb54e55e5c0174", size = 229140, upload-time = "2026-02-02T15:37:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/52/a2/fa129e749d500f9b183e8a3446a193818a25f60261e9ce143ad61e975208/orjson-3.11.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b63c6e6738d7c3470ad01601e23376aa511e50e1f3931395b9f9c722406d1a67", size = 128670, upload-time = "2026-02-02T15:37:08.002Z" }, + { url = "https://files.pythonhosted.org/packages/08/93/1e82011cd1e0bd051ef9d35bed1aa7fb4ea1f0a055dc2c841b46b43a9ebd/orjson-3.11.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:043d3006b7d32c7e233b8cfb1f01c651013ea079e08dcef7189a29abd8befe11", size = 123832, upload-time = "2026-02-02T15:37:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d8/a26b431ef962c7d55736674dddade876822f3e33223c1f47a36879350d04/orjson-3.11.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57036b27ac8a25d81112eb0cc9835cd4833c5b16e1467816adc0015f59e870dc", size = 129171, upload-time = "2026-02-02T15:37:11.112Z" }, + { url = "https://files.pythonhosted.org/packages/a7/19/f47819b84a580f490da260c3ee9ade214cf4cf78ac9ce8c1c758f80fdfc9/orjson-3.11.7-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:733ae23ada68b804b222c44affed76b39e30806d38660bf1eb200520d259cc16", size = 141967, upload-time = "2026-02-02T15:37:12.282Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cd/37ece39a0777ba077fdcdbe4cccae3be8ed00290c14bf8afdc548befc260/orjson-3.11.7-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5fdfad2093bdd08245f2e204d977facd5f871c88c4a71230d5bcbd0e43bf6222", size = 130991, upload-time = "2026-02-02T15:37:13.465Z" }, + { url = "https://files.pythonhosted.org/packages/8f/ed/f2b5d66aa9b6b5c02ff5f120efc7b38c7c4962b21e6be0f00fd99a5c348e/orjson-3.11.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cededd6738e1c153530793998e31c05086582b08315db48ab66649768f326baa", size = 133674, upload-time = "2026-02-02T15:37:14.694Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6e/baa83e68d1aa09fa8c3e5b2c087d01d0a0bd45256de719ed7bc22c07052d/orjson-3.11.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:14f440c7268c8f8633d1b3d443a434bd70cb15686117ea6beff8fdc8f5917a1e", size = 138722, upload-time = "2026-02-02T15:37:16.501Z" }, + { url = "https://files.pythonhosted.org/packages/0c/47/7f8ef4963b772cd56999b535e553f7eb5cd27e9dd6c049baee6f18bfa05d/orjson-3.11.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:3a2479753bbb95b0ebcf7969f562cdb9668e6d12416a35b0dda79febf89cdea2", size = 409056, upload-time = "2026-02-02T15:37:17.895Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/2df104dd2244b3618f25325a656f85cc3277f74bbd91224752410a78f3c7/orjson-3.11.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:71924496986275a737f38e3f22b4e0878882b3f7a310d2ff4dc96e812789120c", size = 144196, upload-time = "2026-02-02T15:37:19.349Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2a/ee41de0aa3a6686598661eae2b4ebdff1340c65bfb17fcff8b87138aab21/orjson-3.11.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4a9eefdc70bf8bf9857f0290f973dec534ac84c35cd6a7f4083be43e7170a8f", size = 134979, upload-time = "2026-02-02T15:37:20.906Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fa/92fc5d3d402b87a8b28277a9ed35386218a6a5287c7fe5ee9b9f02c53fb2/orjson-3.11.7-cp310-cp310-win32.whl", hash = "sha256:ae9e0b37a834cef7ce8f99de6498f8fad4a2c0bf6bfc3d02abd8ed56aa15b2de", size = 127968, upload-time = "2026-02-02T15:37:23.178Z" }, + { url = "https://files.pythonhosted.org/packages/07/29/a576bf36d73d60df06904d3844a9df08e25d59eba64363aaf8ec2f9bff41/orjson-3.11.7-cp310-cp310-win_amd64.whl", hash = "sha256:d772afdb22555f0c58cfc741bdae44180122b3616faa1ecadb595cd526e4c993", size = 125128, upload-time = "2026-02-02T15:37:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/37/02/da6cb01fc6087048d7f61522c327edf4250f1683a58a839fdcc435746dd5/orjson-3.11.7-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9487abc2c2086e7c8eb9a211d2ce8855bae0e92586279d0d27b341d5ad76c85c", size = 228664, upload-time = "2026-02-02T15:37:25.542Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c2/5885e7a5881dba9a9af51bc564e8967225a642b3e03d089289a35054e749/orjson-3.11.7-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:79cacb0b52f6004caf92405a7e1f11e6e2de8bdf9019e4f76b44ba045125cd6b", size = 125344, upload-time = "2026-02-02T15:37:26.92Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/4e7688de0a92d1caf600dfd5fb70b4c5bfff51dfa61ac555072ef2d0d32a/orjson-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c2e85fe4698b6a56d5e2ebf7ae87544d668eb6bde1ad1226c13f44663f20ec9e", size = 128404, upload-time = "2026-02-02T15:37:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/2f/b2/ec04b74ae03a125db7bd69cffd014b227b7f341e3261bf75b5eb88a1aa92/orjson-3.11.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b8d14b71c0b12963fe8a62aac87119f1afdf4cb88a400f61ca5ae581449efcb5", size = 123677, upload-time = "2026-02-02T15:37:30.287Z" }, + { url = "https://files.pythonhosted.org/packages/4c/69/f95bdf960605f08f827f6e3291fe243d8aa9c5c9ff017a8d7232209184c3/orjson-3.11.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:91c81ef070c8f3220054115e1ef468b1c9ce8497b4e526cb9f68ab4dc0a7ac62", size = 128950, upload-time = "2026-02-02T15:37:31.595Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1b/de59c57bae1d148ef298852abd31909ac3089cff370dfd4cd84cc99cbc42/orjson-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:411ebaf34d735e25e358a6d9e7978954a9c9d58cfb47bc6683cdc3964cd2f910", size = 141756, upload-time = "2026-02-02T15:37:32.985Z" }, + { url = "https://files.pythonhosted.org/packages/ee/9e/9decc59f4499f695f65c650f6cfa6cd4c37a3fbe8fa235a0a3614cb54386/orjson-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a16bcd08ab0bcdfc7e8801d9c4a9cc17e58418e4d48ddc6ded4e9e4b1a94062b", size = 130812, upload-time = "2026-02-02T15:37:34.204Z" }, + { url = "https://files.pythonhosted.org/packages/28/e6/59f932bcabd1eac44e334fe8e3281a92eacfcb450586e1f4bde0423728d8/orjson-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c0b51672e466fd7e56230ffbae7f1639e18d0ce023351fb75da21b71bc2c960", size = 133444, upload-time = "2026-02-02T15:37:35.446Z" }, + { url = "https://files.pythonhosted.org/packages/f1/36/b0f05c0eaa7ca30bc965e37e6a2956b0d67adb87a9872942d3568da846ae/orjson-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:136dcd6a2e796dfd9ffca9fc027d778567b0b7c9968d092842d3c323cef88aa8", size = 138609, upload-time = "2026-02-02T15:37:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/b8/03/58ec7d302b8d86944c60c7b4b82975d5161fcce4c9bc8c6cb1d6741b6115/orjson-3.11.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:7ba61079379b0ae29e117db13bda5f28d939766e410d321ec1624afc6a0b0504", size = 408918, upload-time = "2026-02-02T15:37:38.076Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/868d65ef9a8b99be723bd510de491349618abd9f62c826cf206d962db295/orjson-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0527a4510c300e3b406591b0ba69b5dc50031895b0a93743526a3fc45f59d26e", size = 143998, upload-time = "2026-02-02T15:37:39.706Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/1e18e1c83afe3349f4f6dc9e14910f0ae5f82eac756d1412ea4018938535/orjson-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a709e881723c9b18acddcfb8ba357322491ad553e277cf467e1e7e20e2d90561", size = 134802, upload-time = "2026-02-02T15:37:41.002Z" }, + { url = "https://files.pythonhosted.org/packages/d4/0b/ccb7ee1a65b37e8eeb8b267dc953561d72370e85185e459616d4345bab34/orjson-3.11.7-cp311-cp311-win32.whl", hash = "sha256:c43b8b5bab288b6b90dac410cca7e986a4fa747a2e8f94615aea407da706980d", size = 127828, upload-time = "2026-02-02T15:37:42.241Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/55c776dffda3f381e0f07d010a4f5f3902bf48eaba1bb7684d301acd4924/orjson-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:6543001328aa857187f905308a028935864aefe9968af3848401b6fe80dbb471", size = 124941, upload-time = "2026-02-02T15:37:43.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/424a620fa7d263b880162505fb107ef5e0afaa765b5b06a88312ac291560/orjson-3.11.7-cp311-cp311-win_arm64.whl", hash = "sha256:1ee5cc7160a821dfe14f130bc8e63e7611051f964b463d9e2a3a573204446a4d", size = 126245, upload-time = "2026-02-02T15:37:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/80/bf/76f4f1665f6983385938f0e2a5d7efa12a58171b8456c252f3bae8a4cf75/orjson-3.11.7-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bd03ea7606833655048dab1a00734a2875e3e86c276e1d772b2a02556f0d895f", size = 228545, upload-time = "2026-02-02T15:37:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/79/53/6c72c002cb13b5a978a068add59b25a8bdf2800ac1c9c8ecdb26d6d97064/orjson-3.11.7-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:89e440ebc74ce8ab5c7bc4ce6757b4a6b1041becb127df818f6997b5c71aa60b", size = 125224, upload-time = "2026-02-02T15:37:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/2c/83/10e48852865e5dd151bdfe652c06f7da484578ed02c5fca938e3632cb0b8/orjson-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ede977b5fe5ac91b1dffc0a517ca4542d2ec8a6a4ff7b2652d94f640796342a", size = 128154, upload-time = "2026-02-02T15:37:48.954Z" }, + { url = "https://files.pythonhosted.org/packages/6e/52/a66e22a2b9abaa374b4a081d410edab6d1e30024707b87eab7c734afe28d/orjson-3.11.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b7b1dae39230a393df353827c855a5f176271c23434cfd2db74e0e424e693e10", size = 123548, upload-time = "2026-02-02T15:37:50.187Z" }, + { url = "https://files.pythonhosted.org/packages/de/38/605d371417021359f4910c496f764c48ceb8997605f8c25bf1dfe58c0ebe/orjson-3.11.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed46f17096e28fb28d2975834836a639af7278aa87c84f68ab08fbe5b8bd75fa", size = 129000, upload-time = "2026-02-02T15:37:51.426Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/af32e842b0ffd2335c89714d48ca4e3917b42f5d6ee5537832e069a4b3ac/orjson-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3726be79e36e526e3d9c1aceaadbfb4a04ee80a72ab47b3f3c17fefb9812e7b8", size = 141686, upload-time = "2026-02-02T15:37:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/96/0b/fc793858dfa54be6feee940c1463370ece34b3c39c1ca0aa3845f5ba9892/orjson-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0724e265bc548af1dedebd9cb3d24b4e1c1e685a343be43e87ba922a5c5fff2f", size = 130812, upload-time = "2026-02-02T15:37:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/dc/91/98a52415059db3f374757d0b7f0f16e3b5cd5976c90d1c2b56acaea039e6/orjson-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7745312efa9e11c17fbd3cb3097262d079da26930ae9ae7ba28fb738367cbad", size = 133440, upload-time = "2026-02-02T15:37:55.615Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/cb540117bda61791f46381f8c26c8f93e802892830a6055748d3bb1925ab/orjson-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f904c24bdeabd4298f7a977ef14ca2a022ca921ed670b92ecd16ab6f3d01f867", size = 138386, upload-time = "2026-02-02T15:37:56.814Z" }, + { url = "https://files.pythonhosted.org/packages/63/1a/50a3201c334a7f17c231eee5f841342190723794e3b06293f26e7cf87d31/orjson-3.11.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b9fc4d0f81f394689e0814617aadc4f2ea0e8025f38c226cbf22d3b5ddbf025d", size = 408853, upload-time = "2026-02-02T15:37:58.291Z" }, + { url = "https://files.pythonhosted.org/packages/87/cd/8de1c67d0be44fdc22701e5989c0d015a2adf391498ad42c4dc589cd3013/orjson-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:849e38203e5be40b776ed2718e587faf204d184fc9a008ae441f9442320c0cab", size = 144130, upload-time = "2026-02-02T15:38:00.163Z" }, + { url = "https://files.pythonhosted.org/packages/0f/fe/d605d700c35dd55f51710d159fc54516a280923cd1b7e47508982fbb387d/orjson-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4682d1db3bcebd2b64757e0ddf9e87ae5f00d29d16c5cdf3a62f561d08cc3dd2", size = 134818, upload-time = "2026-02-02T15:38:01.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/15ecc67edb3ddb3e2f46ae04475f2d294e8b60c1825fbe28a428b93b3fbd/orjson-3.11.7-cp312-cp312-win32.whl", hash = "sha256:f4f7c956b5215d949a1f65334cf9d7612dde38f20a95f2315deef167def91a6f", size = 127923, upload-time = "2026-02-02T15:38:02.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/70/2e0855361f76198a3965273048c8e50a9695d88cd75811a5b46444895845/orjson-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:bf742e149121dc5648ba0a08ea0871e87b660467ef168a3a5e53bc1fbd64bb74", size = 125007, upload-time = "2026-02-02T15:38:04.032Z" }, + { url = "https://files.pythonhosted.org/packages/68/40/c2051bd19fc467610fed469dc29e43ac65891571138f476834ca192bc290/orjson-3.11.7-cp312-cp312-win_arm64.whl", hash = "sha256:26c3b9132f783b7d7903bf1efb095fed8d4a3a85ec0d334ee8beff3d7a4749d5", size = 126089, upload-time = "2026-02-02T15:38:05.297Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213, upload-time = "2024-09-20T13:10:04.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/70/c853aec59839bceed032d52010ff5f1b8d87dc3114b762e4ba2727661a3b/pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5", size = 12580827, upload-time = "2024-09-20T13:08:42.347Z" }, + { url = "https://files.pythonhosted.org/packages/99/f2/c4527768739ffa4469b2b4fff05aa3768a478aed89a2f271a79a40eee984/pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348", size = 11303897, upload-time = "2024-09-20T13:08:45.807Z" }, + { url = "https://files.pythonhosted.org/packages/ed/12/86c1747ea27989d7a4064f806ce2bae2c6d575b950be087837bdfcabacc9/pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed", size = 66480908, upload-time = "2024-09-20T18:37:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/44/50/7db2cd5e6373ae796f0ddad3675268c8d59fb6076e66f0c339d61cea886b/pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57", size = 13064210, upload-time = "2024-09-20T13:08:48.325Z" }, + { url = "https://files.pythonhosted.org/packages/61/61/a89015a6d5536cb0d6c3ba02cebed51a95538cf83472975275e28ebf7d0c/pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42", size = 16754292, upload-time = "2024-09-20T19:01:54.443Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0d/4cc7b69ce37fac07645a94e1d4b0880b15999494372c1523508511b09e40/pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f", size = 14416379, upload-time = "2024-09-20T13:08:50.882Z" }, + { url = "https://files.pythonhosted.org/packages/31/9e/6ebb433de864a6cd45716af52a4d7a8c3c9aaf3a98368e61db9e69e69a9c/pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645", size = 11598471, upload-time = "2024-09-20T13:08:53.332Z" }, + { url = "https://files.pythonhosted.org/packages/a8/44/d9502bf0ed197ba9bf1103c9867d5904ddcaf869e52329787fc54ed70cc8/pandas-2.2.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66108071e1b935240e74525006034333f98bcdb87ea116de573a6a0dccb6c039", size = 12602222, upload-time = "2024-09-20T13:08:56.254Z" }, + { url = "https://files.pythonhosted.org/packages/52/11/9eac327a38834f162b8250aab32a6781339c69afe7574368fffe46387edf/pandas-2.2.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c2875855b0ff77b2a64a0365e24455d9990730d6431b9e0ee18ad8acee13dbd", size = 11321274, upload-time = "2024-09-20T13:08:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/45/fb/c4beeb084718598ba19aa9f5abbc8aed8b42f90930da861fcb1acdb54c3a/pandas-2.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd8d0c3be0515c12fed0bdbae072551c8b54b7192c7b1fda0ba56059a0179698", size = 15579836, upload-time = "2024-09-20T19:01:57.571Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5f/4dba1d39bb9c38d574a9a22548c540177f78ea47b32f99c0ff2ec499fac5/pandas-2.2.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c124333816c3a9b03fbeef3a9f230ba9a737e9e5bb4060aa2107a86cc0a497fc", size = 13058505, upload-time = "2024-09-20T13:09:01.501Z" }, + { url = "https://files.pythonhosted.org/packages/b9/57/708135b90391995361636634df1f1130d03ba456e95bcf576fada459115a/pandas-2.2.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:63cc132e40a2e084cf01adf0775b15ac515ba905d7dcca47e9a251819c575ef3", size = 16744420, upload-time = "2024-09-20T19:02:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/86/4a/03ed6b7ee323cf30404265c284cee9c65c56a212e0a08d9ee06984ba2240/pandas-2.2.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:29401dbfa9ad77319367d36940cd8a0b3a11aba16063e39632d98b0e931ddf32", size = 14440457, upload-time = "2024-09-20T13:09:04.105Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8c/87ddf1fcb55d11f9f847e3c69bb1c6f8e46e2f40ab1a2d2abadb2401b007/pandas-2.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:3fc6873a41186404dad67245896a6e440baacc92f5b716ccd1bc9ed2995ab2c5", size = 11617166, upload-time = "2024-09-20T13:09:06.917Z" }, + { url = "https://files.pythonhosted.org/packages/17/a3/fb2734118db0af37ea7433f57f722c0a56687e14b14690edff0cdb4b7e58/pandas-2.2.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b1d432e8d08679a40e2a6d8b2f9770a5c21793a6f9f47fdd52c5ce1948a5a8a9", size = 12529893, upload-time = "2024-09-20T13:09:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/e1/0c/ad295fd74bfac85358fd579e271cded3ac969de81f62dd0142c426b9da91/pandas-2.2.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5a1595fe639f5988ba6a8e5bc9649af3baf26df3998a0abe56c02609392e0a4", size = 11363475, upload-time = "2024-09-20T13:09:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2a/4bba3f03f7d07207481fed47f5b35f556c7441acddc368ec43d6643c5777/pandas-2.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5de54125a92bb4d1c051c0659e6fcb75256bf799a732a87184e5ea503965bce3", size = 15188645, upload-time = "2024-09-20T19:02:03.88Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/d8fddee9ed0d0c0f4a2132c1dfcf0e3e53265055da8df952a53e7eaf178c/pandas-2.2.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fffb8ae78d8af97f849404f21411c95062db1496aeb3e56f146f0355c9989319", size = 12739445, upload-time = "2024-09-20T13:09:17.621Z" }, + { url = "https://files.pythonhosted.org/packages/20/e8/45a05d9c39d2cea61ab175dbe6a2de1d05b679e8de2011da4ee190d7e748/pandas-2.2.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dfcb5ee8d4d50c06a51c2fffa6cff6272098ad6540aed1a76d15fb9318194d8", size = 16359235, upload-time = "2024-09-20T19:02:07.094Z" }, + { url = "https://files.pythonhosted.org/packages/1d/99/617d07a6a5e429ff90c90da64d428516605a1ec7d7bea494235e1c3882de/pandas-2.2.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:062309c1b9ea12a50e8ce661145c6aab431b1e99530d3cd60640e255778bd43a", size = 14056756, upload-time = "2024-09-20T13:09:20.474Z" }, + { url = "https://files.pythonhosted.org/packages/29/d4/1244ab8edf173a10fd601f7e13b9566c1b525c4f365d6bee918e68381889/pandas-2.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:59ef3764d0fe818125a5097d2ae867ca3fa64df032331b7e0917cf5d7bf66b13", size = 11504248, upload-time = "2024-09-20T13:09:23.137Z" }, +] + +[[package]] +name = "pathable" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" }, +] + +[[package]] +name = "pathvalidate" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] +pool = [ + { name = "psycopg-pool" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/d8/a763308a41e2ecfb6256ba0877d340c2f2b124c8b2746401863d96fa2c7a/psycopg_binary-3.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3385b58b2fe408a13d084c14b8dcf468cd36cbbe774408250facc128f9fa75c", size = 4609758, upload-time = "2026-02-18T16:46:33.132Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a9/f8a683e85400c1208685e7c895abc049dc13aa0b6ea989e6adf0a3681fe0/psycopg_binary-3.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1bef235a50a80f6aba05147002bc354559657cb6386dbd04d8e1c97d1d7cbe84", size = 4676740, upload-time = "2026-02-18T16:46:42.904Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7d/03512c4aaac8a58fc3b1221f38293aa517a1950d10ef8646c72c49addc7d/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:97c839717bf8c8df3f6d983a20949c4fb22e2a34ee172e3e427ede363feda27b", size = 5496335, upload-time = "2026-02-18T16:46:51.517Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bc/23319b4b1c2c0b810d225e1b6f16efbb16150074fc0ea96bfcabdf59ee09/psycopg_binary-3.3.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:48e500cf1c0984dacf1f28ea482c3cdbb4c2288d51c336c04bc64198ab21fc51", size = 5172032, upload-time = "2026-02-18T16:47:00.878Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c8/6d61dc0a56654c558a37b2d9b2094e470aa12621305cc7935fd769122e32/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb36a08859b9432d94ea6b26ec41a2f98f83f14868c91321d0c1e11f672eeae7", size = 6763107, upload-time = "2026-02-18T16:47:11.784Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b5/e2a3c90aa1059f5b5f593379caad7be3cc3c2ce1ddfc7730e39854e174fe/psycopg_binary-3.3.3-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dde92cfde09293fb63b3f547919ba7d73bd2654573c03502b3263dd0218e44e", size = 5006494, upload-time = "2026-02-18T16:47:17.062Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3e/bf126e0a1f864e191b7f3eeea667ee2ce13d582b036255fb8b12946d1f7a/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:78c9ce98caaf82ac8484d269791c1b403d7598633e0e4e2fa1097baae244e2f1", size = 4533850, upload-time = "2026-02-18T16:47:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d8/bb5e8d395deb945629aa0c65d12ab90ec3bfcbdf56be89e2a84d001864c9/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d593612758d0041cb13cb0003f7f8d3fabb7ad9319e651e78afae49b1cf5860e", size = 4223316, upload-time = "2026-02-18T16:47:25.82Z" }, + { url = "https://files.pythonhosted.org/packages/c2/70/33eef61b0f0fd41ebf93b9699f44067313a45016827f67b3c8cc41f0a7ab/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f24e8e17035200a465c178e9ea945527ad0738118694184c450f1192a452ff25", size = 3954515, upload-time = "2026-02-18T16:47:30.434Z" }, + { url = "https://files.pythonhosted.org/packages/ea/db/27c2b3b9698e713e83e11e8540daa27516f9e90390ec21a41091cb15fcaf/psycopg_binary-3.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e7b607f0e14f2a4cf7e78a05ebd13df6144acfba87cb90842e70d3f125d9f53f", size = 4260274, upload-time = "2026-02-18T16:47:36.128Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3b/71e5d603059bf5474215f573a3e2d357a4e95672b26e04d41674400d4862/psycopg_binary-3.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b27d3a23c79fa59557d2cc63a7e8bb4c7e022c018558eda36f9d7c4e6b99a6e0", size = 3557375, upload-time = "2026-02-18T16:47:42.799Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" }, + { url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" }, + { url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" }, + { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, + { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/9a/9470d013d0d50af0da9c4251614aeb3c1823635cab3edc211e3839db0bcf/psycopg_pool-3.3.0.tar.gz", hash = "sha256:fa115eb2860bd88fce1717d75611f41490dec6135efb619611142b24da3f6db5", size = 31606, upload-time = "2025-12-01T11:34:33.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/f2/8e377d29c2ecf99f6062d35ea606b036e8800720eccfec5fe3dd672c2b24/psycopg2_binary-2.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6fe6b47d0b42ce1c9f1fa3e35bb365011ca22e39db37074458f27921dca40f2", size = 3756506, upload-time = "2025-10-10T11:10:30.144Z" }, + { url = "https://files.pythonhosted.org/packages/24/cc/dc143ea88e4ec9d386106cac05023b69668bd0be20794c613446eaefafe5/psycopg2_binary-2.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6c0e4262e089516603a09474ee13eabf09cb65c332277e39af68f6233911087", size = 3863943, upload-time = "2025-10-10T11:10:34.586Z" }, + { url = "https://files.pythonhosted.org/packages/8c/df/16848771155e7c419c60afeb24950b8aaa3ab09c0a091ec3ccca26a574d0/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c47676e5b485393f069b4d7a811267d3168ce46f988fa602658b8bb901e9e64d", size = 4410873, upload-time = "2025-10-10T11:10:38.951Z" }, + { url = "https://files.pythonhosted.org/packages/43/79/5ef5f32621abd5a541b89b04231fe959a9b327c874a1d41156041c75494b/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a28d8c01a7b27a1e3265b11250ba7557e5f72b5ee9e5f3a2fa8d2949c29bf5d2", size = 4468016, upload-time = "2025-10-10T11:10:43.319Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9b/d7542d0f7ad78f57385971f426704776d7b310f5219ed58da5d605b1892e/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f3f2732cf504a1aa9e9609d02f79bea1067d99edf844ab92c247bbca143303b", size = 4164996, upload-time = "2025-10-10T11:10:46.705Z" }, + { url = "https://files.pythonhosted.org/packages/14/ed/e409388b537fa7414330687936917c522f6a77a13474e4238219fcfd9a84/psycopg2_binary-2.9.11-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:865f9945ed1b3950d968ec4690ce68c55019d79e4497366d36e090327ce7db14", size = 3981881, upload-time = "2025-10-30T02:54:57.182Z" }, + { url = "https://files.pythonhosted.org/packages/bf/30/50e330e63bb05efc6fa7c1447df3e08954894025ca3dcb396ecc6739bc26/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91537a8df2bde69b1c1db01d6d944c831ca793952e4f57892600e96cee95f2cd", size = 3650857, upload-time = "2025-10-10T11:10:50.112Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e0/4026e4c12bb49dd028756c5b0bc4c572319f2d8f1c9008e0dad8cc9addd7/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4dca1f356a67ecb68c81a7bc7809f1569ad9e152ce7fd02c2f2036862ca9f66b", size = 3296063, upload-time = "2025-10-10T11:10:54.089Z" }, + { url = "https://files.pythonhosted.org/packages/2c/34/eb172be293c886fef5299fe5c3fcf180a05478be89856067881007934a7c/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0da4de5c1ac69d94ed4364b6cbe7190c1a70d325f112ba783d83f8440285f152", size = 3043464, upload-time = "2025-10-30T02:55:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/18/1c/532c5d2cb11986372f14b798a95f2eaafe5779334f6a80589a68b5fcf769/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37d8412565a7267f7d79e29ab66876e55cb5e8e7b3bbf94f8206f6795f8f7e7e", size = 3345378, upload-time = "2025-10-10T11:11:01.039Z" }, + { url = "https://files.pythonhosted.org/packages/70/e7/de420e1cf16f838e1fa17b1120e83afff374c7c0130d088dba6286fcf8ea/psycopg2_binary-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:c665f01ec8ab273a61c62beeb8cce3014c214429ced8a308ca1fc410ecac3a39", size = 2713904, upload-time = "2025-10-10T11:11:04.81Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" }, + { url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" }, + { url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" }, + { url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" }, + { url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" }, + { url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" }, + { url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" }, + { url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" }, + { url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" }, + { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, + { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, + { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "py-key-value-shared" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" }, +] + +[package.optional-dependencies] +disk = [ + { name = "diskcache" }, + { name = "pathvalidate" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] +redis = [ + { name = "redis" }, +] + +[[package]] +name = "py-key-value-shared" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, +] + +[[package]] +name = "py4j" +version = "0.10.9.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/31/0b210511177070c8d5d3059556194352e5753602fa64b85b7ab81ec1a009/py4j-0.10.9.9.tar.gz", hash = "sha256:f694cad19efa5bd1dee4f3e5270eb406613c974394035e5bfc4ec1aba870b879", size = 761089, upload-time = "2025-01-15T03:53:18.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/db/ea0203e495be491c85af87b66e37acfd3bf756fd985f87e46fc5e3bf022c/py4j-0.10.9.9-py2.py3-none-any.whl", hash = "sha256:c7c26e4158defb37b0bb124933163641a2ff6e3a3913f7811b0ddbe07ed61533", size = 203008, upload-time = "2025-01-15T03:53:15.648Z" }, +] + +[[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, 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" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +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, 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, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pybreaker" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/89/fbf98e383f1ec6d117af2cd983efdb3eb7018b63834c427025764194cac2/pybreaker-1.4.1.tar.gz", hash = "sha256:8df2d245c73ba40c8242c56ffb4f12138fbadc23e296224740c2028ea9dc1178", size = 15555, upload-time = "2025-09-21T15:12:04.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/75/e64d3d40a741e2be21d69154f4e5c43a66f0c603c5ef11f49e01429a5932/pybreaker-1.4.1-py3-none-any.whl", hash = "sha256:b4dab4a05195b7f2a64a6c1a6c4ba7a96534ef56ea7210e6bcb59f28897160e0", size = 12915, upload-time = "2025-09-21T15:12:02.284Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pydocket" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "croniter" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "fakeredis", extra = ["lua"] }, + { name = "opentelemetry-api" }, + { name = "prometheus-client" }, + { name = "py-key-value-aio", extra = ["memory", "redis"] }, + { name = "python-json-logger" }, + { name = "redis" }, + { name = "rich" }, + { name = "taskgroup", marker = "python_full_version < '3.11'" }, + { name = "typer" }, + { name = "typing-extensions" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, + { name = "uncalled-for" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d2/da/5f76e42214c76402e1a2b4b59610211635c1068cab85509c78f1ca49a385/pydocket-0.18.0.tar.gz", hash = "sha256:cd5b6e7386331ca05a0163401f392b08b07e61342b5333c3ece6a7ca5435f984", size = 354637, upload-time = "2026-03-02T16:22:17.356Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/57/ac0d47cd3550d859138647c2c4fbd53a2db05db8729433eaa6128e9964ba/pydocket-0.18.0-py3-none-any.whl", hash = "sha256:d995d9a3c88af0402fda640c18e1b51561041b9e3af1a92dce2fdc6c8f6c7090", size = 98848, upload-time = "2026-03-02T16:22:15.792Z" }, +] + +[[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, 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, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyopenssl" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions" }, +] +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, upload-time = "2025-05-17T16:28:29.197Z" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + +[[package]] +name = "pyrate-limiter" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/4c/83a2c03f4a169f3d9fdb6570f8f82d85e3903f6f183c21ee7d8cbd6655b4/pyrate_limiter-3.1.1.tar.gz", hash = "sha256:2f57eda712687e6eccddf6afe8f8a15b409b97ed675fe64a626058f12863b7b7", size = 276906, upload-time = "2024-01-02T09:35:24.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/34/089a939b8cda558e1fccada8ba4b422cd8de0eccd4b87f686c185366b78e/pyrate_limiter-3.1.1-py3-none-any.whl", hash = "sha256:c51906f1d51d56dc992ff6c26e8300e32151bc6cfa3e6559792e31971dfd4e2b", size = 23478, upload-time = "2024-01-02T09:35:22.802Z" }, +] + +[[package]] +name = "pyspark" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "py4j" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/bf/58ee13add151469c25825b7125bbf62c3bdcec05eec4d458fcb5c5516066/pyspark-4.1.1.tar.gz", hash = "sha256:77f78984aa84fbe865c717dd37b49913b4e5c97d76ef6824f932f1aefa6621ec", size = 455359625, upload-time = "2026-01-09T09:38:38.28Z" } + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-json-logger" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "python-ulid" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/7e/0d6c82b5ccc71e7c833aed43d9e8468e1f2ff0be1b3f657a6fcafbb8433d/python_ulid-3.1.0.tar.gz", hash = "sha256:ff0410a598bc5f6b01b602851a3296ede6f91389f913a5d5f8c496003836f636", size = 93175, upload-time = "2025-08-18T16:09:26.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577, upload-time = "2025-08-18T16:09:25.047Z" }, +] + +[[package]] +name = "pytz" +version = "2024.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/31/3c70bf7603cc2dca0f19bdc53b4537a797747a58875b552c8c413d963a3f/pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a", size = 319692, upload-time = "2024-09-11T02:24:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/c3/005fcca25ce078d2cc29fd559379817424e94885510568bc1bc53d7d5846/pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725", size = 508002, upload-time = "2024-09-11T02:24:45.8Z" }, +] + +[[package]] +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, 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" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900, upload-time = "2025-11-01T11:54:52.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/d1/0efa42a602ed466d3ca1c462eed5d62015c3fd2a402199e2c4b87aa5aa25/rapidfuzz-3.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b9fcd4d751a4fffa17aed1dde41647923c72c74af02459ad1222e3b0022da3a1", size = 1952376, upload-time = "2025-11-01T11:52:29.175Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/37a169bb28b23850a164e6624b1eb299e1ad73c9e7c218ee15744e68d628/rapidfuzz-3.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ad73afb688b36864a8d9b7344a9cf6da186c471e5790cbf541a635ee0f457f2", size = 1390903, upload-time = "2025-11-01T11:52:31.239Z" }, + { url = "https://files.pythonhosted.org/packages/3c/91/b37207cbbdb6eaafac3da3f55ea85287b27745cb416e75e15769b7d8abe8/rapidfuzz-3.14.3-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5fb2d978a601820d2cfd111e2c221a9a7bfdf84b41a3ccbb96ceef29f2f1ac7", size = 1385655, upload-time = "2025-11-01T11:52:32.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/ca53e518acf43430be61f23b9c5987bd1e01e74fcb7a9ee63e00f597aefb/rapidfuzz-3.14.3-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d83b8b712fa37e06d59f29a4b49e2e9e8635e908fbc21552fe4d1163db9d2a1", size = 3164708, upload-time = "2025-11-01T11:52:34.618Z" }, + { url = "https://files.pythonhosted.org/packages/df/e1/7667bf2db3e52adb13cb933dd4a6a2efc66045d26fa150fc0feb64c26d61/rapidfuzz-3.14.3-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:dc8c07801df5206b81ed6bd6c35cb520cf9b6c64b9b0d19d699f8633dc942897", size = 1221106, upload-time = "2025-11-01T11:52:36.069Z" }, + { url = "https://files.pythonhosted.org/packages/05/8a/84d9f2d46a2c8eb2ccae81747c4901fa10fe4010aade2d57ce7b4b8e02ec/rapidfuzz-3.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c71ce6d4231e5ef2e33caa952bfe671cb9fd42e2afb11952df9fad41d5c821f9", size = 2406048, upload-time = "2025-11-01T11:52:37.936Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a9/a0b7b7a1b81a020c034eb67c8e23b7e49f920004e295378de3046b0d99e1/rapidfuzz-3.14.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0e38828d1381a0cceb8a4831212b2f673d46f5129a1897b0451c883eaf4a1747", size = 2527020, upload-time = "2025-11-01T11:52:39.657Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/416df7d108b99b4942ba04dd4cf73c45c3aadb3ef003d95cad78b1d12eb9/rapidfuzz-3.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da2a007434323904719158e50f3076a4dadb176ce43df28ed14610c773cc9825", size = 4273958, upload-time = "2025-11-01T11:52:41.017Z" }, + { url = "https://files.pythonhosted.org/packages/81/d0/b81e041c17cd475002114e0ab8800e4305e60837882cb376a621e520d70f/rapidfuzz-3.14.3-cp310-cp310-win32.whl", hash = "sha256:fce3152f94afcfd12f3dd8cf51e48fa606e3cb56719bccebe3b401f43d0714f9", size = 1725043, upload-time = "2025-11-01T11:52:42.465Z" }, + { url = "https://files.pythonhosted.org/packages/09/6b/64ad573337d81d64bc78a6a1df53a72a71d54d43d276ce0662c2e95a1f35/rapidfuzz-3.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:37d3c653af15cd88592633e942f5407cb4c64184efab163c40fcebad05f25141", size = 1542273, upload-time = "2025-11-01T11:52:44.005Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5e/faf76e259bc15808bc0b86028f510215c3d755b6c3a3911113079485e561/rapidfuzz-3.14.3-cp310-cp310-win_arm64.whl", hash = "sha256:cc594bbcd3c62f647dfac66800f307beaee56b22aaba1c005e9c4c40ed733923", size = 814875, upload-time = "2025-11-01T11:52:45.405Z" }, + { url = "https://files.pythonhosted.org/packages/76/25/5b0a33ad3332ee1213068c66f7c14e9e221be90bab434f0cb4defa9d6660/rapidfuzz-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:dea2d113e260a5da0c4003e0a5e9fdf24a9dc2bb9eaa43abd030a1e46ce7837d", size = 1953885, upload-time = "2025-11-01T11:52:47.75Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ab/f1181f500c32c8fcf7c966f5920c7e56b9b1d03193386d19c956505c312d/rapidfuzz-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e6c31a4aa68cfa75d7eede8b0ed24b9e458447db604c2db53f358be9843d81d3", size = 1390200, upload-time = "2025-11-01T11:52:49.491Z" }, + { url = "https://files.pythonhosted.org/packages/14/2a/0f2de974ececad873865c6bb3ea3ad07c976ac293d5025b2d73325aac1d4/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02821366d928e68ddcb567fed8723dad7ea3a979fada6283e6914d5858674850", size = 1389319, upload-time = "2025-11-01T11:52:51.224Z" }, + { url = "https://files.pythonhosted.org/packages/ed/69/309d8f3a0bb3031fd9b667174cc4af56000645298af7c2931be5c3d14bb4/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe8df315ab4e6db4e1be72c5170f8e66021acde22cd2f9d04d2058a9fd8162e", size = 3178495, upload-time = "2025-11-01T11:52:53.005Z" }, + { url = "https://files.pythonhosted.org/packages/10/b7/f9c44a99269ea5bf6fd6a40b84e858414b6e241288b9f2b74af470d222b1/rapidfuzz-3.14.3-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:769f31c60cd79420188fcdb3c823227fc4a6deb35cafec9d14045c7f6743acae", size = 1228443, upload-time = "2025-11-01T11:52:54.991Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0a/3b3137abac7f19c9220e14cd7ce993e35071a7655e7ef697785a3edfea1a/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:54fa03062124e73086dae66a3451c553c1e20a39c077fd704dc7154092c34c63", size = 2411998, upload-time = "2025-11-01T11:52:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b6/983805a844d44670eaae63831024cdc97ada4e9c62abc6b20703e81e7f9b/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:834d1e818005ed0d4ae38f6b87b86fad9b0a74085467ece0727d20e15077c094", size = 2530120, upload-time = "2025-11-01T11:52:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/b4/cc/2c97beb2b1be2d7595d805682472f1b1b844111027d5ad89b65e16bdbaaa/rapidfuzz-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:948b00e8476a91f510dd1ec07272efc7d78c275d83b630455559671d4e33b678", size = 4283129, upload-time = "2025-11-01T11:53:00.188Z" }, + { url = "https://files.pythonhosted.org/packages/4d/03/2f0e5e94941045aefe7eafab72320e61285c07b752df9884ce88d6b8b835/rapidfuzz-3.14.3-cp311-cp311-win32.whl", hash = "sha256:43d0305c36f504232f18ea04e55f2059bb89f169d3119c4ea96a0e15b59e2a91", size = 1724224, upload-time = "2025-11-01T11:53:02.149Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/5fa23e204435803875daefda73fd61baeabc3c36b8fc0e34c1705aab8c7b/rapidfuzz-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:ef6bf930b947bd0735c550683939a032090f1d688dfd8861d6b45307b96fd5c5", size = 1544259, upload-time = "2025-11-01T11:53:03.66Z" }, + { url = "https://files.pythonhosted.org/packages/48/35/d657b85fcc615a42661b98ac90ce8e95bd32af474603a105643963749886/rapidfuzz-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:f3eb0ff3b75d6fdccd40b55e7414bb859a1cda77c52762c9c82b85569f5088e7", size = 814734, upload-time = "2025-11-01T11:53:05.008Z" }, + { url = "https://files.pythonhosted.org/packages/fa/8e/3c215e860b458cfbedb3ed73bc72e98eb7e0ed72f6b48099604a7a3260c2/rapidfuzz-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:685c93ea961d135893b5984a5a9851637d23767feabe414ec974f43babbd8226", size = 1945306, upload-time = "2025-11-01T11:53:06.452Z" }, + { url = "https://files.pythonhosted.org/packages/36/d9/31b33512015c899f4a6e6af64df8dfe8acddf4c8b40a4b3e0e6e1bcd00e5/rapidfuzz-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa7c8f26f009f8c673fbfb443792f0cf8cf50c4e18121ff1e285b5e08a94fbdb", size = 1390788, upload-time = "2025-11-01T11:53:08.721Z" }, + { url = "https://files.pythonhosted.org/packages/a9/67/2ee6f8de6e2081ccd560a571d9c9063184fe467f484a17fa90311a7f4a2e/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57f878330c8d361b2ce76cebb8e3e1dc827293b6abf404e67d53260d27b5d941", size = 1374580, upload-time = "2025-11-01T11:53:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c5f545f454871e6af05753a0172849c82feaf0f521c5ca62ba09e1b382d6382", size = 3154947, upload-time = "2025-11-01T11:53:12.093Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cf/9f49831085a16384695f9fb096b99662f589e30b89b4a589a1ebc1a19d34/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:07aa0b5d8863e3151e05026a28e0d924accf0a7a3b605da978f0359bb804df43", size = 1223872, upload-time = "2025-11-01T11:53:13.664Z" }, + { url = "https://files.pythonhosted.org/packages/c8/0f/41ee8034e744b871c2e071ef0d360686f5ccfe5659f4fd96c3ec406b3c8b/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73b07566bc7e010e7b5bd490fb04bb312e820970180df6b5655e9e6224c137db", size = 2392512, upload-time = "2025-11-01T11:53:15.109Z" }, + { url = "https://files.pythonhosted.org/packages/da/86/280038b6b0c2ccec54fb957c732ad6b41cc1fd03b288d76545b9cf98343f/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6de00eb84c71476af7d3110cf25d8fe7c792d7f5fa86764ef0b4ca97e78ca3ed", size = 2521398, upload-time = "2025-11-01T11:53:17.146Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7b/05c26f939607dca0006505e3216248ae2de631e39ef94dd63dbbf0860021/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d7843a1abf0091773a530636fdd2a49a41bcae22f9910b86b4f903e76ddc82dc", size = 4259416, upload-time = "2025-11-01T11:53:19.34Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/9e3af4103d91788f81111af1b54a28de347cdbed8eaa6c91d5e98a889aab/rapidfuzz-3.14.3-cp312-cp312-win32.whl", hash = "sha256:dea97ac3ca18cd3ba8f3d04b5c1fe4aa60e58e8d9b7793d3bd595fdb04128d7a", size = 1709527, upload-time = "2025-11-01T11:53:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/b8/63/d06ecce90e2cf1747e29aeab9f823d21e5877a4c51b79720b2d3be7848f8/rapidfuzz-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:b5100fd6bcee4d27f28f4e0a1c6b5127bc8ba7c2a9959cad9eab0bf4a7ab3329", size = 1538989, upload-time = "2025-11-01T11:53:22.428Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6d/beee32dcda64af8128aab3ace2ccb33d797ed58c434c6419eea015fec779/rapidfuzz-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:4e49c9e992bc5fc873bd0fff7ef16a4405130ec42f2ce3d2b735ba5d3d4eb70f", size = 811161, upload-time = "2025-11-01T11:53:23.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/33/b5bd6475c7c27164b5becc9b0e3eb978f1e3640fea590dd3dced6006ee83/rapidfuzz-3.14.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7cf174b52cb3ef5d49e45d0a1133b7e7d0ecf770ed01f97ae9962c5c91d97d23", size = 1888499, upload-time = "2025-11-01T11:54:42.094Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/89d65d4db4bb931beade9121bc71ad916b5fa9396e807d11b33731494e8e/rapidfuzz-3.14.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:442cba39957a008dfc5bdef21a9c3f4379e30ffb4e41b8555dbaf4887eca9300", size = 1336747, upload-time = "2025-11-01T11:54:43.957Z" }, + { url = "https://files.pythonhosted.org/packages/85/33/cd87d92b23f0b06e8914a61cea6850c6d495ca027f669fab7a379041827a/rapidfuzz-3.14.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1faa0f8f76ba75fd7b142c984947c280ef6558b5067af2ae9b8729b0a0f99ede", size = 1352187, upload-time = "2025-11-01T11:54:45.518Z" }, + { url = "https://files.pythonhosted.org/packages/22/20/9d30b4a1ab26aac22fff17d21dec7e9089ccddfe25151d0a8bb57001dc3d/rapidfuzz-3.14.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e6eefec45625c634926a9fd46c9e4f31118ac8f3156fff9494422cee45207e6", size = 3101472, upload-time = "2025-11-01T11:54:47.255Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ad/fa2d3e5c29a04ead7eaa731c7cd1f30f9ec3c77b3a578fdf90280797cbcb/rapidfuzz-3.14.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56fefb4382bb12250f164250240b9dd7772e41c5c8ae976fd598a32292449cc5", size = 1511361, upload-time = "2025-11-01T11:54:49.057Z" }, +] + +[[package]] +name = "redis" +version = "7.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/31/1476f206482dd9bc53fdbbe9f6fbd5e05d153f18e54667ce839df331f2e6/redis-7.2.1.tar.gz", hash = "sha256:6163c1a47ee2d9d01221d8456bc1c75ab953cbda18cfbc15e7140e9ba16ca3a5", size = 4906735, upload-time = "2026-02-25T20:05:18.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/98/1dd1a5c060916cf21d15e67b7d6a7078e26e2605d5c37cbc9f4f5454c478/redis-7.2.1-py3-none-any.whl", hash = "sha256:49e231fbc8df2001436ae5252b3f0f3dc930430239bfeb6da4c7ee92b16e5d33", size = 396057, upload-time = "2026-02-25T20:05:16.533Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "regex" +version = "2026.2.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/71/41455aa99a5a5ac1eaf311f5d8efd9ce6433c03ac1e0962de163350d0d97/regex-2026.2.28.tar.gz", hash = "sha256:a729e47d418ea11d03469f321aaf67cdee8954cde3ff2cf8403ab87951ad10f2", size = 415184, upload-time = "2026-02-28T02:19:42.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/b8/845a927e078f5e5cc55d29f57becbfde0003d52806544531ab3f2da4503c/regex-2026.2.28-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fc48c500838be6882b32748f60a15229d2dea96e59ef341eaa96ec83538f498d", size = 488461, upload-time = "2026-02-28T02:15:48.405Z" }, + { url = "https://files.pythonhosted.org/packages/32/f9/8a0034716684e38a729210ded6222249f29978b24b684f448162ef21f204/regex-2026.2.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2afa673660928d0b63d84353c6c08a8a476ddfc4a47e11742949d182e6863ce8", size = 290774, upload-time = "2026-02-28T02:15:51.738Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ba/b27feefffbb199528dd32667cd172ed484d9c197618c575f01217fbe6103/regex-2026.2.28-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7ab218076eb0944549e7fe74cf0e2b83a82edb27e81cc87411f76240865e04d5", size = 288737, upload-time = "2026-02-28T02:15:53.534Z" }, + { url = "https://files.pythonhosted.org/packages/18/c5/65379448ca3cbfe774fcc33774dc8295b1ee97dc3237ae3d3c7b27423c9d/regex-2026.2.28-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d63db12e45a9b9f064bfe4800cefefc7e5f182052e4c1b774d46a40ab1d9bb", size = 782675, upload-time = "2026-02-28T02:15:55.488Z" }, + { url = "https://files.pythonhosted.org/packages/aa/30/6fa55bef48090f900fbd4649333791fc3e6467380b9e775e741beeb3231f/regex-2026.2.28-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:195237dc327858a7721bf8b0bbbef797554bc13563c3591e91cd0767bacbe359", size = 850514, upload-time = "2026-02-28T02:15:57.509Z" }, + { url = "https://files.pythonhosted.org/packages/a9/28/9ca180fb3787a54150209754ac06a42409913571fa94994f340b3bba4e1e/regex-2026.2.28-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b387a0d092dac157fb026d737dde35ff3e49ef27f285343e7c6401851239df27", size = 896612, upload-time = "2026-02-28T02:15:59.682Z" }, + { url = "https://files.pythonhosted.org/packages/46/b5/f30d7d3936d6deecc3ea7bea4f7d3c5ee5124e7c8de372226e436b330a55/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3935174fa4d9f70525a4367aaff3cb8bc0548129d114260c29d9dfa4a5b41692", size = 791691, upload-time = "2026-02-28T02:16:01.752Z" }, + { url = "https://files.pythonhosted.org/packages/f5/34/96631bcf446a56ba0b2a7f684358a76855dfe315b7c2f89b35388494ede0/regex-2026.2.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b2b23587b26496ff5fd40df4278becdf386813ec00dc3533fa43a4cf0e2ad3c", size = 783111, upload-time = "2026-02-28T02:16:03.651Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/f95cb7a85fe284d41cd2f3625e0f2ae30172b55dfd2af1d9b4eaef6259d7/regex-2026.2.28-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3b24bd7e9d85dc7c6a8bd2aa14ecd234274a0248335a02adeb25448aecdd420d", size = 767512, upload-time = "2026-02-28T02:16:05.616Z" }, + { url = "https://files.pythonhosted.org/packages/3d/af/a650f64a79c02a97f73f64d4e7fc4cc1984e64affab14075e7c1f9a2db34/regex-2026.2.28-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bd477d5f79920338107f04aa645f094032d9e3030cc55be581df3d1ef61aa318", size = 773920, upload-time = "2026-02-28T02:16:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/72/f8/3f9c2c2af37aedb3f5a1e7227f81bea065028785260d9cacc488e43e6997/regex-2026.2.28-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b49eb78048c6354f49e91e4b77da21257fecb92256b6d599ae44403cab30b05b", size = 846681, upload-time = "2026-02-28T02:16:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/8db04a334571359f4d127d8f89550917ec6561a2fddfd69cd91402b47482/regex-2026.2.28-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a25c7701e4f7a70021db9aaf4a4a0a67033c6318752146e03d1b94d32006217e", size = 755565, upload-time = "2026-02-28T02:16:11.972Z" }, + { url = "https://files.pythonhosted.org/packages/da/bc/91c22f384d79324121b134c267a86ca90d11f8016aafb1dc5bee05890ee3/regex-2026.2.28-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:9dd450db6458387167e033cfa80887a34c99c81d26da1bf8b0b41bf8c9cac88e", size = 835789, upload-time = "2026-02-28T02:16:14.036Z" }, + { url = "https://files.pythonhosted.org/packages/46/a7/4cc94fd3af01dcfdf5a9ed75c8e15fd80fcd62cc46da7592b1749e9c35db/regex-2026.2.28-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2954379dd20752e82d22accf3ff465311cbb2bac6c1f92c4afd400e1757f7451", size = 780094, upload-time = "2026-02-28T02:16:15.468Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/e5a38f420af3c77cab4a65f0c3a55ec02ac9babf04479cfd282d356988a6/regex-2026.2.28-cp310-cp310-win32.whl", hash = "sha256:1f8b17be5c27a684ea6759983c13506bd77bfc7c0347dff41b18ce5ddd2ee09a", size = 266025, upload-time = "2026-02-28T02:16:16.828Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0a/205c4c1466a36e04d90afcd01d8908bac327673050c7fe316b2416d99d3d/regex-2026.2.28-cp310-cp310-win_amd64.whl", hash = "sha256:dd8847c4978bc3c7e6c826fb745f5570e518b8459ac2892151ce6627c7bc00d5", size = 277965, upload-time = "2026-02-28T02:16:18.752Z" }, + { url = "https://files.pythonhosted.org/packages/c3/4d/29b58172f954b6ec2c5ed28529a65e9026ab96b4b7016bcd3858f1c31d3c/regex-2026.2.28-cp310-cp310-win_arm64.whl", hash = "sha256:73cdcdbba8028167ea81490c7f45280113e41db2c7afb65a276f4711fa3bcbff", size = 270336, upload-time = "2026-02-28T02:16:20.735Z" }, + { url = "https://files.pythonhosted.org/packages/04/db/8cbfd0ba3f302f2d09dd0019a9fcab74b63fee77a76c937d0e33161fb8c1/regex-2026.2.28-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e621fb7c8dc147419b28e1702f58a0177ff8308a76fa295c71f3e7827849f5d9", size = 488462, upload-time = "2026-02-28T02:16:22.616Z" }, + { url = "https://files.pythonhosted.org/packages/5d/10/ccc22c52802223f2368731964ddd117799e1390ffc39dbb31634a83022ee/regex-2026.2.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0d5bef2031cbf38757a0b0bc4298bb4824b6332d28edc16b39247228fbdbad97", size = 290774, upload-time = "2026-02-28T02:16:23.993Z" }, + { url = "https://files.pythonhosted.org/packages/62/b9/6796b3bf3101e64117201aaa3a5a030ec677ecf34b3cd6141b5d5c6c67d5/regex-2026.2.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bcb399ed84eabf4282587ba151f2732ad8168e66f1d3f85b1d038868fe547703", size = 288724, upload-time = "2026-02-28T02:16:25.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/02/291c0ae3f3a10cea941d0f5366da1843d8d1fa8a25b0671e20a0e454bb38/regex-2026.2.28-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c1b34dfa72f826f535b20712afa9bb3ba580020e834f3c69866c5bddbf10098", size = 791924, upload-time = "2026-02-28T02:16:26.863Z" }, + { url = "https://files.pythonhosted.org/packages/0f/57/f0235cc520d9672742196c5c15098f8f703f2758d48d5a7465a56333e496/regex-2026.2.28-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:851fa70df44325e1e4cdb79c5e676e91a78147b1b543db2aec8734d2add30ec2", size = 860095, upload-time = "2026-02-28T02:16:28.772Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/393c94cbedda79a0f5f2435ebd01644aba0b338d327eb24b4aa5b8d6c07f/regex-2026.2.28-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:516604edd17b1c2c3e579cf4e9b25a53bf8fa6e7cedddf1127804d3e0140ca64", size = 906583, upload-time = "2026-02-28T02:16:30.977Z" }, + { url = "https://files.pythonhosted.org/packages/2c/73/a72820f47ca5abf2b5d911d0407ba5178fc52cf9780191ed3a54f5f419a2/regex-2026.2.28-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7ce83654d1ab701cb619285a18a8e5a889c1216d746ddc710c914ca5fd71022", size = 800234, upload-time = "2026-02-28T02:16:32.55Z" }, + { url = "https://files.pythonhosted.org/packages/34/b3/6e6a4b7b31fa998c4cf159a12cbeaf356386fbd1a8be743b1e80a3da51e4/regex-2026.2.28-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2791948f7c70bb9335a9102df45e93d428f4b8128020d85920223925d73b9e1", size = 772803, upload-time = "2026-02-28T02:16:34.029Z" }, + { url = "https://files.pythonhosted.org/packages/10/e7/5da0280c765d5a92af5e1cd324b3fe8464303189cbaa449de9a71910e273/regex-2026.2.28-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03a83cc26aa2acda6b8b9dfe748cf9e84cbd390c424a1de34fdcef58961a297a", size = 781117, upload-time = "2026-02-28T02:16:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/76/39/0b8d7efb256ae34e1b8157acc1afd8758048a1cf0196e1aec2e71fd99f4b/regex-2026.2.28-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ec6f5674c5dc836994f50f1186dd1fafde4be0666aae201ae2fcc3d29d8adf27", size = 854224, upload-time = "2026-02-28T02:16:38.119Z" }, + { url = "https://files.pythonhosted.org/packages/21/ff/a96d483ebe8fe6d1c67907729202313895d8de8495569ec319c6f29d0438/regex-2026.2.28-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:50c2fc924749543e0eacc93ada6aeeb3ea5f6715825624baa0dccaec771668ae", size = 761898, upload-time = "2026-02-28T02:16:40.333Z" }, + { url = "https://files.pythonhosted.org/packages/89/bd/d4f2e75cb4a54b484e796017e37c0d09d8a0a837de43d17e238adf163f4e/regex-2026.2.28-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ba55c50f408fb5c346a3a02d2ce0ebc839784e24f7c9684fde328ff063c3cdea", size = 844832, upload-time = "2026-02-28T02:16:41.875Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a7/428a135cf5e15e4e11d1e696eb2bf968362f8ea8a5f237122e96bc2ae950/regex-2026.2.28-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:edb1b1b3a5576c56f08ac46f108c40333f222ebfd5cf63afdfa3aab0791ebe5b", size = 788347, upload-time = "2026-02-28T02:16:43.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/68691428851cf9c9c3707217ab1d9b47cfeec9d153a49919e6c368b9e926/regex-2026.2.28-cp311-cp311-win32.whl", hash = "sha256:948c12ef30ecedb128903c2c2678b339746eb7c689c5c21957c4a23950c96d15", size = 266033, upload-time = "2026-02-28T02:16:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/1483de1c57024e89296cbcceb9cccb3f625d416ddb46e570be185c9b05a9/regex-2026.2.28-cp311-cp311-win_amd64.whl", hash = "sha256:fd63453f10d29097cc3dc62d070746523973fb5aa1c66d25f8558bebd47fed61", size = 277978, upload-time = "2026-02-28T02:16:46.75Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/abec45dc6e7252e3dbc797120496e43bb5730a7abf0d9cb69340696a2f2d/regex-2026.2.28-cp311-cp311-win_arm64.whl", hash = "sha256:00f2b8d9615aa165fdff0a13f1a92049bfad555ee91e20d246a51aa0b556c60a", size = 270340, upload-time = "2026-02-28T02:16:48.626Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/9061b03cf0fc4b5fa2c3984cbbaed54324377e440a5c5a29d29a72518d62/regex-2026.2.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcf26c3c6d0da98fada8ae4ef0aa1c3405a431c0a77eb17306d38a89b02adcd7", size = 489574, upload-time = "2026-02-28T02:16:50.455Z" }, + { url = "https://files.pythonhosted.org/packages/77/83/0c8a5623a233015595e3da499c5a1c13720ac63c107897a6037bb97af248/regex-2026.2.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02473c954af35dd2defeb07e44182f5705b30ea3f351a7cbffa9177beb14da5d", size = 291426, upload-time = "2026-02-28T02:16:52.52Z" }, + { url = "https://files.pythonhosted.org/packages/9e/06/3ef1ac6910dc3295ebd71b1f9bfa737e82cfead211a18b319d45f85ddd09/regex-2026.2.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b65d33a17101569f86d9c5966a8b1d7fbf8afdda5a8aa219301b0a80f58cf7d", size = 289200, upload-time = "2026-02-28T02:16:54.08Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c9/8cc8d850b35ab5650ff6756a1cb85286e2000b66c97520b29c1587455344/regex-2026.2.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71dcecaa113eebcc96622c17692672c2d104b1d71ddf7adeda90da7ddeb26fc", size = 796765, upload-time = "2026-02-28T02:16:55.905Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5d/57702597627fc23278ebf36fbb497ac91c0ce7fec89ac6c81e420ca3e38c/regex-2026.2.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:481df4623fa4969c8b11f3433ed7d5e3dc9cec0f008356c3212b3933fb77e3d8", size = 863093, upload-time = "2026-02-28T02:16:58.094Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/f3ecad537ca2811b4d26b54ca848cf70e04fcfc138667c146a9f3157779c/regex-2026.2.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:64e7c6ad614573e0640f271e811a408d79a9e1fe62a46adb602f598df42a818d", size = 909455, upload-time = "2026-02-28T02:17:00.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4", size = 802037, upload-time = "2026-02-28T02:17:02.842Z" }, + { url = "https://files.pythonhosted.org/packages/44/7c/c6d91d8911ac6803b45ca968e8e500c46934e58c0903cbc6d760ee817a0a/regex-2026.2.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:864cdd1a2ef5716b0ab468af40139e62ede1b3a53386b375ec0786bb6783fc05", size = 775113, upload-time = "2026-02-28T02:17:04.506Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8d/4a9368d168d47abd4158580b8c848709667b1cd293ff0c0c277279543bd0/regex-2026.2.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:511f7419f7afab475fd4d639d4aedfc54205bcb0800066753ef68a59f0f330b5", size = 784194, upload-time = "2026-02-28T02:17:06.888Z" }, + { url = "https://files.pythonhosted.org/packages/cc/bf/2c72ab5d8b7be462cb1651b5cc333da1d0068740342f350fcca3bca31947/regex-2026.2.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b42f7466e32bf15a961cf09f35fa6323cc72e64d3d2c990b10de1274a5da0a59", size = 856846, upload-time = "2026-02-28T02:17:09.11Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f4/6b65c979bb6d09f51bb2d2a7bc85de73c01ec73335d7ddd202dcb8cd1c8f/regex-2026.2.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8710d61737b0c0ce6836b1da7109f20d495e49b3809f30e27e9560be67a257bf", size = 763516, upload-time = "2026-02-28T02:17:11.004Z" }, + { url = "https://files.pythonhosted.org/packages/8e/32/29ea5e27400ee86d2cc2b4e80aa059df04eaf78b4f0c18576ae077aeff68/regex-2026.2.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4390c365fd2d45278f45afd4673cb90f7285f5701607e3ad4274df08e36140ae", size = 849278, upload-time = "2026-02-28T02:17:12.693Z" }, + { url = "https://files.pythonhosted.org/packages/1d/91/3233d03b5f865111cd517e1c95ee8b43e8b428d61fa73764a80c9bb6f537/regex-2026.2.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cb3b1db8ff6c7b8bf838ab05583ea15230cb2f678e569ab0e3a24d1e8320940b", size = 790068, upload-time = "2026-02-28T02:17:14.9Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/abc706c1fb03b4580a09645b206a3fc032f5a9f457bc1a8038ac555658ab/regex-2026.2.28-cp312-cp312-win32.whl", hash = "sha256:f8ed9a5d4612df9d4de15878f0bc6aa7a268afbe5af21a3fdd97fa19516e978c", size = 266416, upload-time = "2026-02-28T02:17:17.15Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/2a6f7dff190e5fa9df9fb4acf2fdf17a1aa0f7f54596cba8de608db56b3a/regex-2026.2.28-cp312-cp312-win_amd64.whl", hash = "sha256:01d65fd24206c8e1e97e2e31b286c59009636c022eb5d003f52760b0f42155d4", size = 277297, upload-time = "2026-02-28T02:17:18.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/58a2484851fadf284458fdbd728f580d55c1abac059ae9f048c63b92f427/regex-2026.2.28-cp312-cp312-win_arm64.whl", hash = "sha256:c0b5ccbb8ffb433939d248707d4a8b31993cb76ab1a0187ca886bf50e96df952", size = 270408, upload-time = "2026-02-28T02:17:20.328Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +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, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-cache" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "cattrs" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "url-normalize" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/6c/deaf1a9462ce8b6a9ac0ee3603d9ba32917be8e48c8f6799770d5418c3cb/requests_cache-1.3.0.tar.gz", hash = "sha256:070e357ccef11a300ccef4294a85de1ab265833c5d9c9538b26cd7ba4085d54a", size = 97720, upload-time = "2026-02-02T23:17:33.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/3f/dfa42bb16be96d53351aa151cb1e39fcaafe6cda01389c530a2ec809ef8a/requests_cache-1.3.0-py3-none-any.whl", hash = "sha256:f09f27bbf100c250886acf13a9db35b53cf2852fddd71977b47c71ea7d90dbba", size = 69626, upload-time = "2026-02-02T23:17:31.718Z" }, +] + +[[package]] +name = "rich" +version = "13.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, +] + +[[package]] +name = "rich-click" +version = "1.9.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/27/091e140ea834272188e63f8dd6faac1f5c687582b687197b3e0ec3c78ebf/rich_click-1.9.7.tar.gz", hash = "sha256:022997c1e30731995bdbc8ec2f82819340d42543237f033a003c7b1f843fc5dc", size = 74838, upload-time = "2026-01-31T04:29:27.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/e5/d708d262b600a352abe01c2ae360d8ff75b0af819b78e9af293191d928e6/rich_click-1.9.7-py3-none-any.whl", hash = "sha256:2f99120fca78f536e07b114d3b60333bc4bb2a0969053b1250869bcdc1b5351b", size = 71491, upload-time = "2026-01-31T04:29:26.777Z" }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +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, 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, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/55/9f485266e6326cab707369601b13e3e72eb90ba3eee2d6779549a00a0d58/ruff-0.6.4.tar.gz", hash = "sha256:ac3b5bfbee99973f80aa1b7cbd1c9cbce200883bdd067300c22a6cc1c7fba212", size = 2469375, upload-time = "2024-09-05T15:51:36.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/78/307591f81d09c8721b5e64539f287c82c81a46f46d16278eb27941ac17f9/ruff-0.6.4-py3-none-linux_armv6l.whl", hash = "sha256:c4b153fc152af51855458e79e835fb6b933032921756cec9af7d0ba2aa01a258", size = 9692673, upload-time = "2024-09-05T15:50:50.469Z" }, + { url = "https://files.pythonhosted.org/packages/69/63/ef398fcacdbd3995618ed30b5a6c809a1ebbf112ba604b3f5b8c3be464cf/ruff-0.6.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bedff9e4f004dad5f7f76a9d39c4ca98af526c9b1695068198b3bda8c085ef60", size = 9481182, upload-time = "2024-09-05T15:50:54.027Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fd/8784e3bbd79bc17de0a62de05fe5165f494ff7d77cb06630d6428c2f10d2/ruff-0.6.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d02a4127a86de23002e694d7ff19f905c51e338c72d8e09b56bfb60e1681724f", size = 9174356, upload-time = "2024-09-05T15:50:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/6d/bc/c69db2d68ac7bfbb222c81dc43a86e0402d0063e20b13e609f7d17d81d3f/ruff-0.6.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7862f42fc1a4aca1ea3ffe8a11f67819d183a5693b228f0bb3a531f5e40336fc", size = 10129365, upload-time = "2024-09-05T15:50:59.674Z" }, + { url = "https://files.pythonhosted.org/packages/3b/10/8ed14ff60a4e5eb08cac0a04a9b4e8590c72d1ce4d29ef22cef97d19536d/ruff-0.6.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eebe4ff1967c838a1a9618a5a59a3b0a00406f8d7eefee97c70411fefc353617", size = 9483351, upload-time = "2024-09-05T15:51:02.296Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/13316b8d64ffd6a43627cf0753339a7f95df413450c301a60904581bee6e/ruff-0.6.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:932063a03bac394866683e15710c25b8690ccdca1cf192b9a98260332ca93408", size = 10301099, upload-time = "2024-09-05T15:51:04.68Z" }, + { url = "https://files.pythonhosted.org/packages/42/00/9623494087272643e8f02187c266638306c6829189a5bf1446968bbe438b/ruff-0.6.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:50e30b437cebef547bd5c3edf9ce81343e5dd7c737cb36ccb4fe83573f3d392e", size = 11033216, upload-time = "2024-09-05T15:51:07.111Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/e0c9d881db42ea1267e075c29aafe0db5a8a3024b131f952747f6234f858/ruff-0.6.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c44536df7b93a587de690e124b89bd47306fddd59398a0fb12afd6133c7b3818", size = 10618140, upload-time = "2024-09-05T15:51:10.005Z" }, + { url = "https://files.pythonhosted.org/packages/5b/35/f1d8b746aedd4c8fde4f83397e940cc4c8fc619860ebbe3073340381a34d/ruff-0.6.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ea086601b22dc5e7693a78f3fcfc460cceabfdf3bdc36dc898792aba48fbad6", size = 11606672, upload-time = "2024-09-05T15:51:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/70/899b03cbb3eb48ed0507d4b32b6f7aee562bc618ef9ffda855ec98c0461a/ruff-0.6.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b52387d3289ccd227b62102c24714ed75fbba0b16ecc69a923a37e3b5e0aaaa", size = 10288013, upload-time = "2024-09-05T15:51:15.487Z" }, + { url = "https://files.pythonhosted.org/packages/17/c6/906bf895640521ca5115ccdd857b2bac42bd61facde6620fdc2efc0a4806/ruff-0.6.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0308610470fcc82969082fc83c76c0d362f562e2f0cdab0586516f03a4e06ec6", size = 10109473, upload-time = "2024-09-05T15:51:17.623Z" }, + { url = "https://files.pythonhosted.org/packages/28/da/1284eb04172f8a5d42eb52fce9d643dd747ac59a4ed6c5d42729f72e934d/ruff-0.6.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:803b96dea21795a6c9d5bfa9e96127cc9c31a1987802ca68f35e5c95aed3fc0d", size = 9568817, upload-time = "2024-09-05T15:51:20.771Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e2/f8250b54edbb2e9222e22806e1bcc35a192ac18d1793ea556fa4977a843a/ruff-0.6.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:66dbfea86b663baab8fcae56c59f190caba9398df1488164e2df53e216248baa", size = 9910840, upload-time = "2024-09-05T15:51:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/9c/7c/dcf2c10562346ecdf6f0e5f6669b2ddc9a74a72956c3f419abd6820c2aff/ruff-0.6.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:34d5efad480193c046c86608dbba2bccdc1c5fd11950fb271f8086e0c763a5d1", size = 10354263, upload-time = "2024-09-05T15:51:26.604Z" }, + { url = "https://files.pythonhosted.org/packages/f1/94/c39d7ac5729e94788110503d928c98c203488664b0fb92c2b801cb832bec/ruff-0.6.4-py3-none-win32.whl", hash = "sha256:f0f8968feea5ce3777c0d8365653d5e91c40c31a81d95824ba61d871a11b8523", size = 7958602, upload-time = "2024-09-05T15:51:29.563Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d2/2dee8c547bee3d4cfdd897f7b8e38510383acaff2c8130ea783b67631d72/ruff-0.6.4-py3-none-win_amd64.whl", hash = "sha256:549daccee5227282289390b0222d0fbee0275d1db6d514550d65420053021a58", size = 8795059, upload-time = "2024-09-05T15:51:31.994Z" }, + { url = "https://files.pythonhosted.org/packages/07/1a/23280818aa4fa89bd0552aab10857154e1d3b90f27b5b745f09ec1ac6ad8/ruff-0.6.4-py3-none-win_arm64.whl", hash = "sha256:ac4b75e898ed189b3708c9ab3fc70b79a433219e1e87193b4f2b77251d058d14", size = 8239636, upload-time = "2024-09-05T15:51:34.17Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "serpyco-rs" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attributes-doc" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/24/a218c68f25152789bd80c40aaa8124b549a06e50c74f742d4d4590e18788/serpyco_rs-1.19.0.tar.gz", hash = "sha256:a9fe01ddbc7ffa4f0f57896dda2759ad676937943f66d3c5af2b0472ca6672e6", size = 84709, upload-time = "2026-02-03T16:58:15.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/f2/8885389eb7b06be6e8ca480f36a9a912b2f56b666575a2596004ea9d683a/serpyco_rs-1.19.0-cp310-cp310-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0aa06769e2a0a165439af070795dff7c272b8510d803005da06b3d5e0e56c186", size = 918522, upload-time = "2026-02-03T16:57:09.726Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bb/4ae9c5e9a6cfea82dbf01225c00e66754c7b2e9e2684d2a148b99b3f8a7b/serpyco_rs-1.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d98ff9749e04a351eccb74cd61cac67ace4ea2a1b1aafecafb7b6a3164241c2c", size = 493183, upload-time = "2026-02-03T16:57:11.33Z" }, + { url = "https://files.pythonhosted.org/packages/83/57/c3df2882fb7e6acb861e0901c70d5c6564f2da52627fe538dae2a5345660/serpyco_rs-1.19.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a2133ebfe1cb773f310d76355819eec99a136c2df638c4e7bd93cc6bd3180b59", size = 516458, upload-time = "2026-02-03T16:57:12.433Z" }, + { url = "https://files.pythonhosted.org/packages/62/8e/0832df8171e93876968db9674fa38cba3415257784c55df75e6d94e0a946/serpyco_rs-1.19.0-cp310-cp310-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0dcf2f44aacce39eec9303d6d6f80245a31c1ae9d6f6f1595b5f523b4883e41a", size = 550060, upload-time = "2026-02-03T16:57:14.097Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d8/82c9dce5133d674cef856a4060536eabfb567319d7a5c6d110db9daf13eb/serpyco_rs-1.19.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a17548844908206fbabda3ad0480edbd880e832cbe7e25ae0aac51ed145f9d39", size = 538512, upload-time = "2026-02-03T16:57:15.202Z" }, + { url = "https://files.pythonhosted.org/packages/a2/8d/83d1d8680fc52349aaba3fc9494f5c7a17ece8ddfa2a21479e9d7fac6996/serpyco_rs-1.19.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e0fd84a4f272b2afa468bd6f39edb6554e9542e5c8edc4bd232f8d4dbb4beff", size = 529017, upload-time = "2026-02-03T16:57:16.364Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d0/2000a2f52ab7960f0cdfda870898cb97e8f820244aafa326afc79c795c94/serpyco_rs-1.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09c2ab08c0da4dabdf12d044e9f441251ee48353b7fcd0a5e712381356fe0d82", size = 492812, upload-time = "2026-02-03T16:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/74/55/471150cbb3bf4b6741afdc41e6a83ae4e414c452c53c291354b1f562fad4/serpyco_rs-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:6d8ea33f71bddc5f2bda1e23aa374966fb3b10d2179b0c92ac3778c2193c4c14", size = 365946, upload-time = "2026-02-03T16:57:18.671Z" }, + { url = "https://files.pythonhosted.org/packages/4a/fd/9fbdb5c22b8b17f2950a58556b4eadac8ee0599ea5292c3704b6b1f7c77b/serpyco_rs-1.19.0-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:03ff7330c499b9b8f48dac7156bdf2557225ec5f0a8773838c077f95460e45a8", size = 926864, upload-time = "2026-02-03T16:57:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/ee/64/e4ca47f328cf6e3ba4ba8904f5843c83a27861c0ddac61b7f9c97a3babba/serpyco_rs-1.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3245f70159cefe26c30e62cc474b9daa094e9282a71c880cfd92730ea8c7242d", size = 493113, upload-time = "2026-02-03T16:57:20.947Z" }, + { url = "https://files.pythonhosted.org/packages/34/13/bf5353ed30ad105a4ac5eaa5322de2df2c1d6705786f90c14fb16f3c7583/serpyco_rs-1.19.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0be7762f3f2cd745fa294efddf141b4d7bac75f3eca9deeaddf7c5d0f7411198", size = 515979, upload-time = "2026-02-03T16:57:22.042Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a2/c107501742adf54cd2d207bace88d27e0facccbd338e3c31073075700e80/serpyco_rs-1.19.0-cp311-cp311-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:63d295a091a5e51fb919987b44bbbf13596609ebe160a18b8ad954ea2346cd2e", size = 550219, upload-time = "2026-02-03T16:57:23.158Z" }, + { url = "https://files.pythonhosted.org/packages/8e/22/e303fbde23c0aad4ff3b7b01b6fae419d61fd821102363381296b2777c67/serpyco_rs-1.19.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b47511401744a927eb0bd502c450571b8adcb470150ed30065cba8c3bc6cb4a", size = 538669, upload-time = "2026-02-03T16:57:24.312Z" }, + { url = "https://files.pythonhosted.org/packages/10/9e/2a92dab92354092779cb58a2b860741c0b0abcdd56f2e5ba728b74ce6406/serpyco_rs-1.19.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a754b12375ddb3efdab72895172d87c7ea8353d3214d3ce09616db4367075f9", size = 528833, upload-time = "2026-02-03T16:57:25.578Z" }, + { url = "https://files.pythonhosted.org/packages/9c/aa/ab577f2bb460a978b1ba32b010215048f94a0a396dacdc6e5cb16967ae59/serpyco_rs-1.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26cfe3331ae75dad1b8ffccb274577396680d78bc76418c89e515f967bc382ab", size = 494817, upload-time = "2026-02-03T16:57:26.631Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/ca99f3e417a4c2a6f402bb3aa8a608dfbde6721751e87ef1a2d3c1fd0d86/serpyco_rs-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:5fce5eaedcca4e427b31377f6b15be6a9ac14303b49c8686a9ca7c8c4c63e467", size = 365019, upload-time = "2026-02-03T16:57:27.793Z" }, + { url = "https://files.pythonhosted.org/packages/e8/78/183403059f653732532899130716596d91af0e6c287f1c7b34b8da177bec/serpyco_rs-1.19.0-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:b5aab95fe9707c40bfdc78cfcc00f93b04de98e1d7e19ed1bb132dd1e6174022", size = 927981, upload-time = "2026-02-03T16:57:29.122Z" }, + { url = "https://files.pythonhosted.org/packages/53/c4/863510c5ebf9b6c9a746de67d5689450a6e5dec5c222b81c15a25a4798be/serpyco_rs-1.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee815d41ac955f0bff87a35e682fa6299cfe138fbdf6f8fe7deaa501a0b69bbf", size = 497449, upload-time = "2026-02-03T16:57:30.523Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f3/e443b85833b023dd4a86ff1023dbb2b64dae633b6a06afcda69c8ae20cf9/serpyco_rs-1.19.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a396fe59f0d4de76b0b005461e36ce4230a0c84aa08e5bfd9465f1463061b11c", size = 518887, upload-time = "2026-02-03T16:57:31.855Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/a0e6cedf8306593464afee2918c3a532851e2f969f3ef6d7165862e51686/serpyco_rs-1.19.0-cp312-cp312-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fae8e4d16de8b145123b052a095e75e257d73305dc440cddcee020d9ae3385ea", size = 549122, upload-time = "2026-02-03T16:57:33.179Z" }, + { url = "https://files.pythonhosted.org/packages/12/a4/3da8515d952f82e85f96dd206b151839ebb65bb4c8d81cb2790773d717f1/serpyco_rs-1.19.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff027e6c066d0fd65f157f026d748fdcb142cef2ff67368ba896665ff6ce0e6b", size = 536491, upload-time = "2026-02-03T16:57:34.363Z" }, + { url = "https://files.pythonhosted.org/packages/6a/24/ad406db5e87e4ae0252b1c753625370fd4e6644442082b000dafadeea2d1/serpyco_rs-1.19.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3785f7fdb2e7b8b4b5b3c73c2d155934cdc8156398ce331f55d2102aa47221f2", size = 537238, upload-time = "2026-02-03T16:57:35.422Z" }, + { url = "https://files.pythonhosted.org/packages/bc/4a/165799cb31719d9267f2b617d34e9950b483f6de4be9a5fb862f1443f8c0/serpyco_rs-1.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66a8aa4547b2ecb03a02f434df3566e29be657f83baac5fe67726729ce9afbc4", size = 492510, upload-time = "2026-02-03T16:57:36.553Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c7/9ad1671d9ec28eb21399f8a2963f841e067524c6b904ae75b012187299d8/serpyco_rs-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:1bb8204cecdd173ddc5650caf5cfbf10646d1aa25bbe3458fe6168708177a583", size = 365760, upload-time = "2026-02-03T16:57:38.28Z" }, +] + +[[package]] +name = "setuptools" +version = "80.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "snowflake-connector-python" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asn1crypto" }, + { name = "boto3" }, + { name = "botocore" }, + { name = "certifi" }, + { name = "cffi" }, + { name = "charset-normalizer" }, + { name = "cryptography" }, + { name = "filelock" }, + { name = "idna" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pyjwt" }, + { name = "pyopenssl" }, + { name = "pytz" }, + { name = "requests" }, + { name = "sortedcontainers" }, + { name = "tomlkit" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/df/41fe26b68801e3d59653a5dc7ce87a92e9d967dcad7b59b035b8c9804815/snowflake_connector_python-3.18.0.tar.gz", hash = "sha256:41a46eb9824574c5f8068e3ed5c02a2dc0a733ed08ee81fa1fb3dd0ebe921728", size = 798019, upload-time = "2025-10-06T12:15:34.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/66/2be9bfebaad12f8b0fbeee68542f14b7a37801b991e3f99adab98ca235ff/snowflake_connector_python-3.18.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e17a9e806823d3a0e578cf9349f6a93810a582b3132903ea9e1683854d08da00", size = 1014396, upload-time = "2025-10-06T12:15:36.069Z" }, + { url = "https://files.pythonhosted.org/packages/8e/38/e00f81687b56a9419c2b0de3adcab449fc1e7d7a5383c29835148b1bb311/snowflake_connector_python-3.18.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:1841b60dc376639493dfc520cf39ad4f4da1f30286bba57e878d57414263d628", size = 1027175, upload-time = "2025-10-06T12:15:37.632Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ae/f45696a00e63fad3e153c01b8fe5c2d55aba954bb69bd09c7d2d0a290cba/snowflake_connector_python-3.18.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65d37263dd288abb649820b7e34af96dc6b2d2115bf5521a2526245f81ddb0cb", size = 2650237, upload-time = "2025-10-06T12:15:14.24Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dd/843ac8067efb061f66c4e186c293270b887103b162a73559660b4fb0d31e/snowflake_connector_python-3.18.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fb9fc9d8c2c7d209ba89282d367a32e75b0688afd4a3f02409e24f153c1a32e", size = 2678195, upload-time = "2025-10-06T12:15:16.975Z" }, + { url = "https://files.pythonhosted.org/packages/e8/b2/035e0e316f875f4410d880e12a2765063c054e12e0184a3d86f2728818e5/snowflake_connector_python-3.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:cfa6b234f53ec624149e21156d0a98e43408d194f2e65bcfaf30acefd35a581e", size = 1161494, upload-time = "2025-10-06T12:15:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/87/7e/b932b9897ea7e83b2184443c5222af2f71526e8bce91ecd64ac20b87527c/snowflake_connector_python-3.18.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a5fcb9a25a9b77b6cd86dfc6a6324b9910e15a493a916983229011ce3509b5f", size = 1014589, upload-time = "2025-10-06T12:15:39.26Z" }, + { url = "https://files.pythonhosted.org/packages/7e/79/97f777d3d26392901910e27f0d41e9a6dc72fba40cb2499febfca7e51e81/snowflake_connector_python-3.18.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:5d89f608fde2fb0597ca5e020c4ac602027dc67f11b61b4d1e5448163bae4edc", size = 1027163, upload-time = "2025-10-06T12:15:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9f/553f9a2ec3ce4ab960c8d3611ecbd2e66f972841ef1e037dcbcd5148abae/snowflake_connector_python-3.18.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1afbd9e21180d2b4a76500ac2978b11865fdb3230609f2a9d80ba459fc27f2e4", size = 2661951, upload-time = "2025-10-06T12:15:18.676Z" }, + { url = "https://files.pythonhosted.org/packages/8a/bb/8213c682ea69cf50ba028db47469455cb7dba31b152b867ac3a468dcca19/snowflake_connector_python-3.18.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c068c8d3cd0c9736cb0679a9f544d34327e64415303bbfe07ec8ce3c5dae800", size = 2692086, upload-time = "2025-10-06T12:15:21.5Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/e651de2061f88e30cd271a023cc79e2e2683ff6aa2cb1e1045b8ba62d365/snowflake_connector_python-3.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:b211b4240596a225b895261a4ced2633e0262e82e2e32f6fb8dfc7d4bfedf8ca", size = 1161558, upload-time = "2025-10-06T12:15:53.091Z" }, + { url = "https://files.pythonhosted.org/packages/da/67/0df7829f295988c121f385c562d60c7a4989bc8f72885d04669ce5cd6516/snowflake_connector_python-3.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fee7035f865088f948510b094101c8a0e5b22501891f2115f7fb1cb555de76a", size = 1013717, upload-time = "2025-10-06T12:15:41.906Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/35353d5311735ebe85f0224f3a6e4f136c29e1b3e4ce6c7466c9b7e7931b/snowflake_connector_python-3.18.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:283366b35df88cd0c71caf0215ba80370ddef4dd37d2adf43b24208c747231ee", size = 1025471, upload-time = "2025-10-06T12:15:43.073Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/d490c00546ca8842d314de689ac718c73c9fe0f9b042e06703449282de7c/snowflake_connector_python-3.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e4c285cc6a7f6431cff98c8f235a0fe9da2262462dd3dfc2b97120574a95cf9", size = 2684000, upload-time = "2025-10-06T12:15:23.411Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cb/4bc697af4138e17cccde506f28233492a6e1919ced7a65aa31b6f1e8bb6c/snowflake_connector_python-3.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94e041e347b5151b66d19d6cfc3b3172dac1f51e44bbf7cf58f3989427dd464a", size = 2715472, upload-time = "2025-10-06T12:15:25.062Z" }, + { url = "https://files.pythonhosted.org/packages/d9/72/815a4b9795ddce224a1392849dd34a408f2dac240bcdcb0539d42cfd31b1/snowflake_connector_python-3.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:7116cfa410d517328fd25fabffb54845b88667586718578c4333ce034fead1ba", size = 1160435, upload-time = "2025-10-06T12:15:55.046Z" }, +] + +[[package]] +name = "snowflake-sqlalchemy" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "snowflake-connector-python" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/0b/5e90eb28191ad6e0318254394c7e2902c4037fd566aa299dc8b5b16238f8/snowflake_sqlalchemy-1.8.2.tar.gz", hash = "sha256:91ca38719e117f94dd195ba94c22dd22f69c585b136ed129ba4e2dd93252b0c2", size = 122603, upload-time = "2025-12-10T08:33:49.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/77/c3af74a84eb00c1004a8e3c8a98627a3eecb2563f4ee01e621326c947bce/snowflake_sqlalchemy-1.8.2-py3-none-any.whl", hash = "sha256:13ad79bf51654cdaaedfbcc60d20bee417c0a128f8710eabbf4aba65b50f6d3d", size = 72726, upload-time = "2025-12-10T08:33:48.106Z" }, +] + +[[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, 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, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload-time = "2025-08-11T14:24:58.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/4e/985f7da36f09592c5ade99321c72c15101d23c0bb7eecfd1daaca5714422/sqlalchemy-2.0.43-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:70322986c0c699dca241418fcf18e637a4369e0ec50540a2b907b184c8bca069", size = 2133162, upload-time = "2025-08-11T15:52:17.854Z" }, + { url = "https://files.pythonhosted.org/packages/37/34/798af8db3cae069461e3bc0898a1610dc469386a97048471d364dc8aae1c/sqlalchemy-2.0.43-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:87accdbba88f33efa7b592dc2e8b2a9c2cdbca73db2f9d5c510790428c09c154", size = 2123082, upload-time = "2025-08-11T15:52:19.181Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0f/79cf4d9dad42f61ec5af1e022c92f66c2d110b93bb1dc9b033892971abfa/sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c00e7845d2f692ebfc7d5e4ec1a3fd87698e4337d09e58d6749a16aedfdf8612", size = 3208871, upload-time = "2025-08-11T15:50:30.656Z" }, + { url = "https://files.pythonhosted.org/packages/56/b3/59befa58fb0e1a9802c87df02344548e6d007e77e87e6084e2131c29e033/sqlalchemy-2.0.43-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:022e436a1cb39b13756cf93b48ecce7aa95382b9cfacceb80a7d263129dfd019", size = 3209583, upload-time = "2025-08-11T15:57:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/29/d2/124b50c0eb8146e8f0fe16d01026c1a073844f0b454436d8544fe9b33bd7/sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5e73ba0d76eefc82ec0219d2301cb33bfe5205ed7a2602523111e2e56ccbd20", size = 3148177, upload-time = "2025-08-11T15:50:32.078Z" }, + { url = "https://files.pythonhosted.org/packages/83/f5/e369cd46aa84278107624617034a5825fedfc5c958b2836310ced4d2eadf/sqlalchemy-2.0.43-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9c2e02f06c68092b875d5cbe4824238ab93a7fa35d9c38052c033f7ca45daa18", size = 3172276, upload-time = "2025-08-11T15:57:49.477Z" }, + { url = "https://files.pythonhosted.org/packages/de/2b/4602bf4c3477fa4c837c9774e6dd22e0389fc52310c4c4dfb7e7ba05e90d/sqlalchemy-2.0.43-cp310-cp310-win32.whl", hash = "sha256:e7a903b5b45b0d9fa03ac6a331e1c1d6b7e0ab41c63b6217b3d10357b83c8b00", size = 2101491, upload-time = "2025-08-11T15:54:59.191Z" }, + { url = "https://files.pythonhosted.org/packages/38/2d/bfc6b6143adef553a08295490ddc52607ee435b9c751c714620c1b3dd44d/sqlalchemy-2.0.43-cp310-cp310-win_amd64.whl", hash = "sha256:4bf0edb24c128b7be0c61cd17eef432e4bef507013292415f3fb7023f02b7d4b", size = 2125148, upload-time = "2025-08-11T15:55:00.593Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/fa7189fe44114658002566c6fe443d3ed0ec1fa782feb72af6ef7fbe98e7/sqlalchemy-2.0.43-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:52d9b73b8fb3e9da34c2b31e6d99d60f5f99fd8c1225c9dad24aeb74a91e1d29", size = 2136472, upload-time = "2025-08-11T15:52:21.789Z" }, + { url = "https://files.pythonhosted.org/packages/99/ea/92ac27f2fbc2e6c1766bb807084ca455265707e041ba027c09c17d697867/sqlalchemy-2.0.43-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f42f23e152e4545157fa367b2435a1ace7571cab016ca26038867eb7df2c3631", size = 2126535, upload-time = "2025-08-11T15:52:23.109Z" }, + { url = "https://files.pythonhosted.org/packages/94/12/536ede80163e295dc57fff69724caf68f91bb40578b6ac6583a293534849/sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fb1a8c5438e0c5ea51afe9c6564f951525795cf432bed0c028c1cb081276685", size = 3297521, upload-time = "2025-08-11T15:50:33.536Z" }, + { url = "https://files.pythonhosted.org/packages/03/b5/cacf432e6f1fc9d156eca0560ac61d4355d2181e751ba8c0cd9cb232c8c1/sqlalchemy-2.0.43-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db691fa174e8f7036afefe3061bc40ac2b770718be2862bfb03aabae09051aca", size = 3297343, upload-time = "2025-08-11T15:57:51.186Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/d4c9b526f18457667de4c024ffbc3a0920c34237b9e9dd298e44c7c00ee5/sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe2b3b4927d0bc03d02ad883f402d5de201dbc8894ac87d2e981e7d87430e60d", size = 3232113, upload-time = "2025-08-11T15:50:34.949Z" }, + { url = "https://files.pythonhosted.org/packages/aa/79/c0121b12b1b114e2c8a10ea297a8a6d5367bc59081b2be896815154b1163/sqlalchemy-2.0.43-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d3d9b904ad4a6b175a2de0738248822f5ac410f52c2fd389ada0b5262d6a1e3", size = 3258240, upload-time = "2025-08-11T15:57:52.983Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/a2f9be96fb382f3ba027ad42f00dbe30fdb6ba28cda5f11412eee346bec5/sqlalchemy-2.0.43-cp311-cp311-win32.whl", hash = "sha256:5cda6b51faff2639296e276591808c1726c4a77929cfaa0f514f30a5f6156921", size = 2101248, upload-time = "2025-08-11T15:55:01.855Z" }, + { url = "https://files.pythonhosted.org/packages/ee/13/744a32ebe3b4a7a9c7ea4e57babae7aa22070d47acf330d8e5a1359607f1/sqlalchemy-2.0.43-cp311-cp311-win_amd64.whl", hash = "sha256:c5d1730b25d9a07727d20ad74bc1039bbbb0a6ca24e6769861c1aa5bf2c4c4a8", size = 2126109, upload-time = "2025-08-11T15:55:04.092Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/20c78f1081446095450bdc6ee6cc10045fce67a8e003a5876b6eaafc5cc4/sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24", size = 2134891, upload-time = "2025-08-11T15:51:13.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/0a/3d89034ae62b200b4396f0f95319f7d86e9945ee64d2343dcad857150fa2/sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83", size = 2123061, upload-time = "2025-08-11T15:51:14.319Z" }, + { url = "https://files.pythonhosted.org/packages/cb/10/2711f7ff1805919221ad5bee205971254845c069ee2e7036847103ca1e4c/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9", size = 3320384, upload-time = "2025-08-11T15:52:35.088Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0e/3d155e264d2ed2778484006ef04647bc63f55b3e2d12e6a4f787747b5900/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48", size = 3329648, upload-time = "2025-08-11T15:56:34.153Z" }, + { url = "https://files.pythonhosted.org/packages/5b/81/635100fb19725c931622c673900da5efb1595c96ff5b441e07e3dd61f2be/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687", size = 3258030, upload-time = "2025-08-11T15:52:36.933Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ed/a99302716d62b4965fded12520c1cbb189f99b17a6d8cf77611d21442e47/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe", size = 3294469, upload-time = "2025-08-11T15:56:35.553Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a2/3a11b06715149bf3310b55a98b5c1e84a42cfb949a7b800bc75cb4e33abc/sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d", size = 2098906, upload-time = "2025-08-11T15:55:00.645Z" }, + { url = "https://files.pythonhosted.org/packages/bc/09/405c915a974814b90aa591280623adc6ad6b322f61fd5cff80aeaef216c9/sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a", size = 2126260, upload-time = "2025-08-11T15:55:02.965Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload-time = "2025-08-11T15:39:53.024Z" }, +] + +[[package]] +name = "sqlalchemy-bigquery" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-bigquery" }, + { name = "packaging" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/39/6d2fb718e61d18c07cfc3de84362c198aa429e3dcf3c1d0a1e476e474196/sqlalchemy_bigquery-1.12.0.tar.gz", hash = "sha256:12783ad83ffad34e8e6e14046cb14bb2f1a3e7fb52676f5a24e940ff5cdeb864", size = 113993, upload-time = "2024-10-02T21:32:50.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ba/684540c3970f70ba68405283671dd23fd42fd7de559bf0aea5bf4117c9e7/sqlalchemy_bigquery-1.12.0-py2.py3-none-any.whl", hash = "sha256:5b2b77bdaefe9c0663db213d9475a5abbae88fa46108c352d19fa6fc51a47a1a", size = 38258, upload-time = "2024-10-02T21:32:48.593Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "structlog" +version = "24.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/a3/e811a94ac3853826805253c906faa99219b79951c7d58605e89c79e65768/structlog-24.4.0.tar.gz", hash = "sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4", size = 1348634, upload-time = "2024-07-17T12:38:43.483Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/65/813fc133609ebcb1299be6a42e5aea99d6344afb35ccb43f67e7daaa3b92/structlog-24.4.0-py3-none-any.whl", hash = "sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610", size = 67180, upload-time = "2024-07-17T12:38:41.043Z" }, +] + +[[package]] +name = "taskgroup" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/b1/74babcc824a57904e919f3af16d86c08b524c0691504baf038ef2d7f655c/taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb", size = 14237, upload-time = "2025-01-03T09:24:11.41Z" }, +] + +[[package]] +name = "thrift" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/2d/8946864f716ac82dcc88d290ed613cba7a80ec75df4f553ec3ff275f486e/thrift-0.20.0.tar.gz", hash = "sha256:4dd662eadf6b8aebe8a41729527bd69adf6ceaa2a8681cbef64d1273b3e8feba", size = 62295, upload-time = "2024-03-22T22:53:08.228Z" } + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[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, 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, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "uncalled-for" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", size = 49488, upload-time = "2026-02-27T17:40:58.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351, upload-time = "2026-02-27T17:40:56.804Z" }, +] + +[[package]] +name = "unidecode" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" }, +] + +[[package]] +name = "url-normalize" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/31/febb777441e5fcdaacb4522316bf2a527c44551430a4873b052d545e3279/url_normalize-2.2.1.tar.gz", hash = "sha256:74a540a3b6eba1d95bdc610c24f2c0141639f3ba903501e61a52a8730247ff37", size = 18846, upload-time = "2025-04-26T20:37:58.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/d9/5ec15501b675f7bc07c5d16aa70d8d778b12375686b6efd47656efdc67cd/url_normalize-2.2.1-py3-none-any.whl", hash = "sha256:3deb687587dc91f7b25c9ae5162ffc0f057ae85d22b1e15cf5698311247f567b", size = 14728, upload-time = "2025-04-26T20:37:57.217Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uuid7" +version = "0.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/19/7472bd526591e2192926247109dbf78692e709d3e56775792fec877a7720/uuid7-0.1.0.tar.gz", hash = "sha256:8c57aa32ee7456d3cc68c95c4530bc571646defac01895cfc73545449894a63c", size = 14052, upload-time = "2021-12-29T01:38:21.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/77/8852f89a91453956582a85024d80ad96f30a41fed4c2b3dce0c9f12ecc7e/uuid7-0.1.0-py2.py3-none-any.whl", hash = "sha256:5e259bb63c8cb4aded5927ff41b444a80d0c7124e8a0ced7cf44efa1f5cccf61", size = 7477, upload-time = "2021-12-29T01:38:20.418Z" }, +] + +[[package]] +name = "uv" +version = "0.8.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/a3/ccb253bb014987c998398b1cc86a4d5a07d091c885b17535e6b00546c0ea/uv-0.8.24.tar.gz", hash = "sha256:34349d22278fff4b5fb37d58fd4fb8c10d75dc7a0cbec80a8cb34bfbf7cb00d5", size = 3668752, upload-time = "2025-10-07T03:34:19.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/fe/29bf6822cab74ef4f636ee7baff542ef60747085e7ccb8f83a3503e1b79d/uv-0.8.24-py3-none-linux_armv6l.whl", hash = "sha256:5a373ee953f341306c70028131a700c42ddef9848829e1b58f4cd62364824546", size = 20578081, upload-time = "2025-10-07T03:33:16.174Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/26702561b06650efe7eb36008e7a93e877cd51a9bb54141cc159113b37b1/uv-0.8.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1cd064933beb3c7392a8dc88d903be809b70612c563e2d659a96d505cac1daf5", size = 19584945, upload-time = "2025-10-07T03:33:21.151Z" }, + { url = "https://files.pythonhosted.org/packages/ea/00/08f4e93989129bb3378f20315dddcac6f8cf26a12bdd90443a340e7ecdb4/uv-0.8.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2bd708a545c1c21d7be8575f4cff00d0cff26be13fc81e3f7e54b8751fb90c0", size = 18187983, upload-time = "2025-10-07T03:33:24.533Z" }, + { url = "https://files.pythonhosted.org/packages/5c/8f/3fce919d6794c6c3ecfc948d875ead078fc407346dd01dbbd5a64b46bf49/uv-0.8.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:8595ca23e4f0b8ea934a29f8080578cd197ada22931b358498e9445ddd2bac5c", size = 19984225, upload-time = "2025-10-07T03:33:29.237Z" }, + { url = "https://files.pythonhosted.org/packages/40/4d/e320ba9573a07942ddfd0c895f9567253edc5eb2b42689dd95ec40e087db/uv-0.8.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f33083111a9cebd1eb2a53225250a51eb9652a79a1cd3bade14a3b52d217bf3", size = 20193175, upload-time = "2025-10-07T03:33:33.045Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/915a605a67d5b00502e28dd8d221f08a1cb3bd006cd6e0485c29a55f2e83/uv-0.8.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4dce6ddc7de84a205ec411a042eaa94324d14fae4f345abf4e4ce74bc804fcf", size = 21051270, upload-time = "2025-10-07T03:33:36.353Z" }, + { url = "https://files.pythonhosted.org/packages/4a/af/0efe560170533fb932239dfc0e2bf3be9e854bc564143a9bf06bd303d43b/uv-0.8.24-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:fa00f8f05468f827f6f7eda1c4b47abbe16147ea04e10ba9cdf39b5d9a6f0227", size = 22550404, upload-time = "2025-10-07T03:33:40.246Z" }, + { url = "https://files.pythonhosted.org/packages/f8/3c/cb3ba8ecbabf83b9fd0d0bf01053d78bf1e759ce462ed0956250e8523424/uv-0.8.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f884f1336f141cab06a85cd24732fe4dc2a577a6c623be2a0209e0f6fba98aca", size = 22175389, upload-time = "2025-10-07T03:33:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0a/e89df282539780742b3621c42b48307cab1a86c9ec7b93fba22ee9b83632/uv-0.8.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3aeac20d6909bcb54d7976ec6b0492e7613cc23dd4509505e08b931cf29ed384", size = 21281276, upload-time = "2025-10-07T03:33:46.919Z" }, + { url = "https://files.pythonhosted.org/packages/15/91/0cb0e416a8b7cfdc7f15e39d700dc06a9689073206fee4c7bf8f1fd68331/uv-0.8.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a75005a146e81ed1bbb0b6e55db18c8ad1e7d714392cdb94a63fa7a3259ad4f", size = 21242977, upload-time = "2025-10-07T03:33:50.231Z" }, + { url = "https://files.pythonhosted.org/packages/01/ea/547fffd3c779fae3e5dfccf64e893eac33126f20454b883b51148e9b8493/uv-0.8.24-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1143a8c6e59f4600dfc1b96b335c7fa47428246be37ac19b6f6dd1535c385ccd", size = 20107480, upload-time = "2025-10-07T03:33:53.407Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/6256cb6b47fed16baa24f85fbc2026cbb24eed4b9bc8c87cd4102bf92c8a/uv-0.8.24-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:17d39a1b32e18ce87ad7038f6d321f1d71ae9e59ac1a10b9d59423d7a7c8c216", size = 21191676, upload-time = "2025-10-07T03:33:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/cba6bf21ab9f0f998a44e3257516c451e55b98256aa585295ee3f1157df6/uv-0.8.24-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:87032d770da97ecba265123aa27f37885a116cc6c3492e9f7b045edf96690ef4", size = 20164806, upload-time = "2025-10-07T03:34:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/23/a8/fd7894621caef02a15283473e9398bcc949232a4c118ed11cfe7600ba969/uv-0.8.24-py3-none-musllinux_1_1_i686.whl", hash = "sha256:17ab3f303d23c04043829b6154f2623a711b76331f199269bcd7df827bb3ea5c", size = 20507984, upload-time = "2025-10-07T03:34:03.337Z" }, + { url = "https://files.pythonhosted.org/packages/2c/b5/e9f1b332c59ea5aac3f1d715700ce670a35bcfe9a92b5c87e2572ce743fe/uv-0.8.24-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:bd1576fe700b064ee0f4f56908dc112b65df4c780ced04fabdf83eb6e3ec7322", size = 21409149, upload-time = "2025-10-07T03:34:06.621Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8e/53d36dbe52c432457307381c5895f0d2f4809decc30991a71b3671f26041/uv-0.8.24-py3-none-win32.whl", hash = "sha256:c0089dacd349d054689da0391f67f655288bb1b4c402a40e6a4599354d22f21d", size = 19343695, upload-time = "2025-10-07T03:34:10.217Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d1/12d251ecd36aea66ddfa1431b61c4782b2905ecbf188bbffd5aee8f5ceef/uv-0.8.24-py3-none-win_amd64.whl", hash = "sha256:59d2527b9afdd89361d057b0c8077fca3212e7335df46532bf9057c6fc5eb9ff", size = 21370118, upload-time = "2025-10-07T03:34:13.72Z" }, + { url = "https://files.pythonhosted.org/packages/65/40/839b2987cf4045c13f4c4946a136797871fd7968f75b7f866978ceea59b8/uv-0.8.24-py3-none-win_arm64.whl", hash = "sha256:712af0dcb2e1522b85e168e10a1dcb9fe5775e81cee632d8a6d7e95054a096f3", size = 19803089, upload-time = "2025-10-07T03:34:17.307Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.29.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/9c/57d19fa093bcf5ac61a48087dd44d00655f85421d1aa9722f8befbf3f40a/virtualenv-20.29.3.tar.gz", hash = "sha256:95e39403fcf3940ac45bc717597dba16110b74506131845d9b687d5e73d947ac", size = 4320280, upload-time = "2025-03-06T19:54:19.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/eb/c6db6e3001d58c6a9e67c74bb7b4206767caa3ccc28c6b9eaf4c23fb4e34/virtualenv-20.29.3-py3-none-any.whl", hash = "sha256:3e3d00f5807e83b234dfb6122bf37cfadf4be216c53a49ac059d02414f819170", size = 4301458, upload-time = "2025-03-06T19:54:16.923Z" }, +] + +[[package]] +name = "wcmatch" +version = "10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/ab/b3a52228538ccb983653c446c1656eddf1d5303b9cb8b9aef6a91299f862/wcmatch-10.0.tar.gz", hash = "sha256:e72f0de09bba6a04e0de70937b0cf06e55f36f37b3deb422dfaf854b867b840a", size = 115578, upload-time = "2024-09-26T18:39:52.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/df/4ee467ab39cc1de4b852c212c1ed3becfec2e486a51ac1ce0091f85f38d7/wcmatch-10.0-py3-none-any.whl", hash = "sha256:0dd927072d03c0a6527a20d2e6ad5ba8d0380e60870c383bc533b71744df7b7a", size = 39347, upload-time = "2024-09-26T18:39:51.002Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "whenever" +version = "0.8.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/67/cfc23dfe54ced1e4388826b29db9b9ab2c70a342b33b7e92cf15866f35a6/whenever-0.8.10.tar.gz", hash = "sha256:5e2a3da71527e299f98eec5bb38c4e79d9527a127107387456125005884fb235", size = 240223, upload-time = "2025-10-16T20:31:23.538Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/22/b7db5ebe8b74f60f8dc2af8264adac158dbd7d199731509299c3345f7874/whenever-0.8.10-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:d9ecb6b649cb7e5c85742f626ddd56d5cf5d276c632a47ec5d72714350300564", size = 390187, upload-time = "2025-10-16T20:30:54.648Z" }, + { url = "https://files.pythonhosted.org/packages/10/65/ed912e437c68e83a3f22477c9f3c67f1810228b8d6566a999f12921025d1/whenever-0.8.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0698cbd2209413f7a0cb84507405587e7b3995ce22504e50477a1a65ec3b65b9", size = 375020, upload-time = "2025-10-16T20:30:45.408Z" }, + { url = "https://files.pythonhosted.org/packages/17/a9/a3e7a9731af8c350e605eff2d2d03d2517a839767efd308337498410d32b/whenever-0.8.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30b2f25ee740f5d201f643982c50f0d6ba2fdbb69704630467d85286e290fdab", size = 397139, upload-time = "2025-10-16T20:29:17.616Z" }, + { url = "https://files.pythonhosted.org/packages/7c/32/8568934b927a122582a4e5551cbc86b9c32c781a5abe32c4697aead88fa4/whenever-0.8.10-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb6abd25e03e1aaa9c4ab949c1b02d755be6ea2f18d6a86e0d024a66705beec6", size = 436865, upload-time = "2025-10-16T20:29:35.073Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3b/c3765b975fb6ceb0ded61d60a815c2045c1ec2f1e70f1afc2df8645175a1/whenever-0.8.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:228860bfc14e63b7c2c6980e41dee7f4efb397accc06eabc51e9dfeaf633ad5a", size = 430843, upload-time = "2025-10-16T20:29:51.467Z" }, + { url = "https://files.pythonhosted.org/packages/dc/76/b809b29be1b11868275c6e50e23f1b387b9a40953e717a87ec33dd83bfa9/whenever-0.8.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0af24862ded1dcb71e096e7570e6e031f934e7cfa57123363ef21049f8f9fdd4", size = 450953, upload-time = "2025-10-16T20:29:59.761Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c9/b9f2655e6715e2314b25e722eff7de92e75795001bdc85fed6cf9edfbd8d/whenever-0.8.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6331ebf85dd234d33fdd627146f20808c6eb39f8056dbd09715055f21cd7c494", size = 412096, upload-time = "2025-10-16T20:30:27.719Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6f/773ad493573fcddd89d5080589ca334335c07e6ce70c7aef743c66a05067/whenever-0.8.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0ce5dfa7769444e12ae8f0fba8bdce05a8081e1829a9de68d4cc02a11ff71131", size = 451054, upload-time = "2025-10-16T20:30:08.951Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/3558f92753e2356558973980c31c6e118330c354caecaa0144d1690a6854/whenever-0.8.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9768562c5a871b2a6377697eb76943fd798c663a4a96b499e4d2fa69c42d7397", size = 575509, upload-time = "2025-10-16T20:29:26.622Z" }, + { url = "https://files.pythonhosted.org/packages/27/b7/f96f250bb9f420b8c42b265046f3623f6a9ffda9d469fe27f9027895c407/whenever-0.8.10-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f88d9ec50f2dfa4981924cb87fb287708ccb5f770fd93dd9c6fc27641e686c1c", size = 701546, upload-time = "2025-10-16T20:29:42.776Z" }, + { url = "https://files.pythonhosted.org/packages/84/8f/351d01a0aca6c579c5253555d999760f351b6c06cfe3e7699f0236aa8e79/whenever-0.8.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:507462b0f02d7d4cdfe90888a0158ee3d6c5d49fa3ddcd1b44901c6778fd7381", size = 625427, upload-time = "2025-10-16T20:30:17.872Z" }, + { url = "https://files.pythonhosted.org/packages/1f/60/f4e5d3ae33cda28dc30b21fdf494372d2eaeec7f72f458524e7dcc08301a/whenever-0.8.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ba2d930b5e428e1b0c01ef6c8af14eb94f84792c37d79352f954cd9ea791838e", size = 583161, upload-time = "2025-10-16T20:30:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/15/72/e47c7a6ceb2743f65e825121b1a3f95267a865b58a6c8eda310a863ef358/whenever-0.8.10-cp310-cp310-win32.whl", hash = "sha256:b598be861fd711d2df683d32dbb15d05279e2e932a4c31f2f7bfd28196985662", size = 328384, upload-time = "2025-10-16T20:31:03.569Z" }, + { url = "https://files.pythonhosted.org/packages/ac/09/12cd9a390780cd9b31891fd37a18014b6d5c3d2416665f3513a87a291ce1/whenever-0.8.10-cp310-cp310-win_amd64.whl", hash = "sha256:66eab892d56685a84a9d933b8252c68794eede39b5105f20d06b000ff17275d4", size = 320937, upload-time = "2025-10-16T20:31:13.019Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/74854e557ae9f57e410bb4b7e8685a8fb0ec301926ca36e71f6e541ce6ae/whenever-0.8.10-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3f03f9bef7e3bfe40461e74c74af0cf8dc90489dacc2360069faccf2997f4bca", size = 390188, upload-time = "2025-10-16T20:30:56.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/fe/dc9d824164824909c230d5c0100332aa62d260fd859cfcfb4f53d119fa79/whenever-0.8.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2f42eb10aaf2818b0e26a5d5230c6cb735ca109882ec4b19cb5cf646c0d28120", size = 375020, upload-time = "2025-10-16T20:30:47.115Z" }, + { url = "https://files.pythonhosted.org/packages/b2/ff/ea8e043b511a18057b5144ad78206198bf4671e241d5ce4b2338613c478b/whenever-0.8.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de0b3ddb300e32b19dd9af391d98ba62b21288d628ec17acf4752d96443a3174", size = 397139, upload-time = "2025-10-16T20:29:20.005Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f5/65dd001e09198d608ff3ceb9ca93f6a7087b4fb4610734840b4980b5f69c/whenever-0.8.10-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:907e7d9fca7dfdaa2fae187320442c1f10d41cadefd1bb58b11b9b30ad36a51f", size = 436865, upload-time = "2025-10-16T20:29:36.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/62/2415de31d92c153f5b6fff0c1218c69d2889882e029935887011cc080a9a/whenever-0.8.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:671380d09a5cf7beae203d4fcb03e4434e41604d8f5832bd67bc060675e7ba93", size = 430844, upload-time = "2025-10-16T20:29:53.131Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a7/b8fad31741a0ba48a28a124b41c11675a460bb140f1ac458eac95f60a932/whenever-0.8.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:816a6ae3b5129afee5ecbac958a828efbad56908db9d6ca4c90cc57133145071", size = 450951, upload-time = "2025-10-16T20:30:00.978Z" }, + { url = "https://files.pythonhosted.org/packages/8c/de/fe07b243f21c071d43974ffc1ee2630383e1ee21b329a39fb61810181eac/whenever-0.8.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f5a51878bdf520655d131a50ca03e7b8a20ec249042e26bf76eeef64e79f3cb", size = 412096, upload-time = "2025-10-16T20:30:29.272Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9d/95468cd837d02a882873a0ef921d6d79960db3b27ac875738d5d138cd577/whenever-0.8.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:071fba23f80a3857db6cbe6c449dd2e0f0cea29d4466c960e52699ef3ed126ae", size = 451052, upload-time = "2025-10-16T20:30:10.533Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c2/5a669c99ff4b470e7beb0990600a6f4df21c6c74b0799b469921fa210c91/whenever-0.8.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c50060b2d3561762dc15d742d03b3c1377778b2896d6c6f3824f15f943d12b62", size = 575510, upload-time = "2025-10-16T20:29:27.734Z" }, + { url = "https://files.pythonhosted.org/packages/79/9d/86a77a04fc42ead5cd06fe7f1c40e59cde943a701ca984e837a9f259eb04/whenever-0.8.10-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2d1b3d00388ce26f450841c34b513fe963ae473a94e6e9c113a534803a70702b", size = 701548, upload-time = "2025-10-16T20:29:43.912Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c3/938c96615593176246debdea9b6567a9ff1bd578da389ec123b25ad03279/whenever-0.8.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e9dc6510beda89e520608459da41b10092e770c58b3b472418fec2633c50857d", size = 625429, upload-time = "2025-10-16T20:30:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/05/0f/b6955e7e79c3d766a372189159018f6f957d0f37f0e287e73e781d115861/whenever-0.8.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:08bae07abb1d2cdc017d38451a3cae5b5577b5b875b65f89847516e6380201dd", size = 583165, upload-time = "2025-10-16T20:30:37.609Z" }, + { url = "https://files.pythonhosted.org/packages/55/7a/06563d2cac97754761488df0c578604fb09e663cb9d7adb7dcefbdeaa5fd/whenever-0.8.10-cp311-cp311-win32.whl", hash = "sha256:96fc39933480786efc074f469157e290414d14bae1a6198bb7e44bc6f6b3531a", size = 328376, upload-time = "2025-10-16T20:31:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b3/b5722d5c033e873970fb616ef4979ed986062250cccf5344bdd964fd45a5/whenever-0.8.10-cp311-cp311-win_amd64.whl", hash = "sha256:a5bad9acce99b46f6dd5dc64c2aab62a0ffba8dcdeeebbd462e37431af0bf243", size = 320938, upload-time = "2025-10-16T20:31:14.386Z" }, + { url = "https://files.pythonhosted.org/packages/26/77/2f76f02e4af09814b2353ba80eefa84472bb586163de792e28df581a9fe0/whenever-0.8.10-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9877982944af2b5055d3aeedcdc3f7af78767f5ce7be8994c3f54b3ffba272e9", size = 391415, upload-time = "2025-10-16T20:30:57.426Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1b/cd64476b64399a5b4f39ee926de721dba38e56ece7ed082c702ea2c401f1/whenever-0.8.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:72db2f4e2511e0c01e63d16a8f539ce82096a08111fa9c63d718c6f49768dce6", size = 375207, upload-time = "2025-10-16T20:30:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/b5/57/2096658c25bec85a804769a786211226ca0929177b3d4becafe2bd0bfad2/whenever-0.8.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da0e929bcc4aa807a68aa766bf040ae314bb4ad291dcc9e75d9e472b5eccec0f", size = 396804, upload-time = "2025-10-16T20:29:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/62afb72eb0e1caae28f6b2899069431249a0cc79c8aa70b3599f1b6faa39/whenever-0.8.10-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11c9bea3260edc9018d0c08d20d836fb9d69fdd2dfb25f8f71896de70e1d88c1", size = 437439, upload-time = "2025-10-16T20:29:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/60/48/8deb4bd33e4bf5536f1e12096eaab59b47561c4ca2db1a8b5d0363345cb9/whenever-0.8.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e8c14d7c5418db4e3e52bb4e33138334f86d1c4e6059aa2642325bf5270cc06", size = 432826, upload-time = "2025-10-16T20:29:54.432Z" }, + { url = "https://files.pythonhosted.org/packages/63/32/604450523cb9eca3cd5039a6fe91862c10841dd684a687e64a23ae068bf4/whenever-0.8.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be8156fd0b84b57b52f43f0df41e5bf775df6fce8323f2d69bc0b0a36b08836b", size = 451699, upload-time = "2025-10-16T20:30:02.242Z" }, + { url = "https://files.pythonhosted.org/packages/5c/11/0d5a236b6a0502e0c809d105bdc63d9a370f7b482b05cf514a08a570b128/whenever-0.8.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3381092c1944baff5b80b1e81f63684e365a84274f80145cbd6f07f505725ae2", size = 412902, upload-time = "2025-10-16T20:30:30.673Z" }, + { url = "https://files.pythonhosted.org/packages/77/f2/01497ff97563d0b5b3b2baaa008699d7c2d50acdfd1f59ec82358c6f8a6e/whenever-0.8.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0792c5f0f5bea0749fccd3f1612594305ba1e7c3a5173ff096f32895bb3de0d", size = 452110, upload-time = "2025-10-16T20:30:11.98Z" }, + { url = "https://files.pythonhosted.org/packages/91/6f/6c736d2eae3012d126f9c3a5cf6bc176f3eef63536aeacef8ef3250b21ba/whenever-0.8.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:49cca1b92b1dd7da33b7f4f5f699d6c3a376ad8ea293f67c23b2b00df218a3ea", size = 575306, upload-time = "2025-10-16T20:29:28.955Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/9935bb4891e88152e3b520ef7ebf243a53955b9b9c79673588da140881df/whenever-0.8.10-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1791288d70931319910860ac4e941d944da3a7c189199dc37a877a9844f8af01", size = 702203, upload-time = "2025-10-16T20:29:45.609Z" }, + { url = "https://files.pythonhosted.org/packages/16/1d/e59c731844b587fbdef79fdd8a1bb71488c83891d9da8f7ed3d01bbde157/whenever-0.8.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:162da8253584608100e35b8b6b95a1fe7edced64b13ceac70351d30459425d67", size = 626912, upload-time = "2025-10-16T20:30:20.924Z" }, + { url = "https://files.pythonhosted.org/packages/7f/25/72463deca11ba6ba846905e72c14b21f0f3c60654d6e9f6b1c2ab662f905/whenever-0.8.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ce5529a859321c88b25bee659f761447281fe3fbe52352c7c9aa49f0ee8d7ff", size = 584097, upload-time = "2025-10-16T20:30:39.144Z" }, + { url = "https://files.pythonhosted.org/packages/24/6b/a2b711c849d114d65273ec3a9671b5434bd6371a65db17d2e76a6821a122/whenever-0.8.10-cp312-cp312-win32.whl", hash = "sha256:7e756ea4c89995e702ca6cfb061c9536fac3395667e1737c23ca7eb7462e6ce7", size = 329993, upload-time = "2025-10-16T20:31:06.34Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5d/50cff202663587bca2659042c1d049a91d78ce86d0aa2440eaf1d8e2d6dd/whenever-0.8.10-cp312-cp312-win_amd64.whl", hash = "sha256:19c4279bc5907881cbfe310cfe32ba58163ce1c515c056962d121875231be03f", size = 322358, upload-time = "2025-10-16T20:31:16.25Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b6/a485fb8bd32d29b81949bf863ae673955458bbeb28bd852d1c210ccbe75d/whenever-0.8.10-py3-none-any.whl", hash = "sha256:5393187037cff776fe1f5e0fe6094cb52f4509945459d239b9fcc09d95696f43", size = 53506, upload-time = "2025-10-16T20:31:22.043Z" }, +] + +[[package]] +name = "xmltodict" +version = "0.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/05/51dcca9a9bf5e1bce52582683ce50980bcadbc4fa5143b9f2b19ab99958f/xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553", size = 51942, upload-time = "2024-10-16T06:10:29.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/45/fc303eb433e8a2a271739c98e953728422fa61a3c1f36077a49e395c972e/xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac", size = 9981, upload-time = "2024-10-16T06:10:27.649Z" }, +] + +[[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, 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, upload-time = "2025-06-08T17:06:38.034Z" }, +] From 8b3402409f4719a78279e5b3307cc99cea0fc5a7 Mon Sep 17 00:00:00 2001 From: park-peter Date: Tue, 3 Mar 2026 18:02:34 +0900 Subject: [PATCH 4/7] fix bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dedup: - List[str] dedup_keys (__all__ sentinel) now expanded to per-stream dict after selected streams are known, fixing silent no-op. - Dedup runs per-stream with the owning writer as executor, fixing crash in parallel + SQL mode where executor was None. - _execute_sql(None) raises immediately instead of silently falling back to Spark. - Key column identifiers validated through validate_identifier() at both normalization and MERGE-build time; unsafe chars rejected early. - _run_dedup_for_stream always routes through positional _dk_N columns in both flatten and raw modes. Overwrite: - SQL _atomic_overwrite_sql now checks types: rejects incompatible changes, CASTs safe narrower→wider, ALTERs target column when staging is wider. - Spark _atomic_overwrite uses raw dataType objects (not str()) with _type_name()/_sql_type() helpers to normalize parenthesized type strings (e.g. IntegerType()) and emit correct SQL type names for CAST and ALTER COLUMN TYPE. Reverse-widening now widens the target instead of narrowing the staging data. Preview: - get_table_name() sanitizes and backtick-quotes stream names, matching writer table naming. - get_target_schema() strips trailing "()" from Spark type strings so _PYTHON_TO_SPARK comparisons work correctly. Incremental: - StateManager supports SQL connector (not just Spark) via _get_connection() with staging_volume/warehouse_id params. - State loaded before sync via _apply_incremental_state(), which probes set_stream_state / set_state_for_stream / set_state on the source. Raises NotImplementedError when saved state exists but the connector lacks a state API. - State saved per-stream on success via _save_incremental_state(), which extracts connector-emitted state when available and falls back to a run_id+records dict. Progress: - record_processed() called every 5 000 records in both sequential and parallel-oversized paths. - stream_completed() called in parallel future collection. - ProgressReporter.close() called in finally block; errors suppressed. Enrichment: - enrich_table() in sync() now passes sanitized stream name. --- src/brickbyte/_client.py | 395 +++++++++++++----- src/brickbyte/_dedup.py | 41 +- src/brickbyte/_state.py | 124 +++++- src/brickbyte/preview.py | 24 +- .../writers/spark_streaming_writer.py | 97 +++-- src/brickbyte/writers/sql_streaming_writer.py | 54 ++- 6 files changed, 573 insertions(+), 162 deletions(-) diff --git a/src/brickbyte/_client.py b/src/brickbyte/_client.py index 7faf2a2..e058404 100644 --- a/src/brickbyte/_client.py +++ b/src/brickbyte/_client.py @@ -8,7 +8,7 @@ import threading import uuid from pathlib import Path -from typing import Callable, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Union from brickbyte import SyncResult @@ -208,7 +208,8 @@ def sync( timeout_seconds: Optional timeout in seconds for the sync operation incremental: If True, use incremental sync with state management deduplicate: If True, deduplicate records after sync - dedup_keys: Column(s) to use as dedup keys (required when deduplicate=True) + dedup_keys: Column(s) to use as dedup keys (required when deduplicate=True; + ignored when deduplicate=False) max_parallel_streams: Max number of streams to write in parallel (default: 1) progress_callback: Optional callback for progress reporting @@ -229,6 +230,8 @@ def sync( normalized_dedup_keys = None if deduplicate: normalized_dedup_keys = self._normalize_dedup_keys(dedup_keys, streams) + elif dedup_keys is not None: + logger.info("dedup_keys provided but deduplicate=False; ignoring dedup_keys") # Set up timeout cancel_event = None @@ -240,6 +243,7 @@ def sync( timer.start() writer = None + progress_reporter = None try: logger.info(f"Setting up {source}...") self._setup_source(source, source_install) @@ -272,14 +276,13 @@ def sync( ) sanitized_map[sanitized] = stream - # If dedup_keys is a dict, validate keys match selected streams (original names) + if normalized_dedup_keys is not None and "__all__" in normalized_dedup_keys: + all_keys = normalized_dedup_keys["__all__"] + normalized_dedup_keys = {s: all_keys for s in selected} + if deduplicate and isinstance(normalized_dedup_keys, dict): for dk_stream in normalized_dedup_keys: - if dk_stream == "__all__": - continue if dk_stream not in selected: - # Check if user used sanitized name by mistake - # sanitized_map: sanitized_name -> original_name if dk_stream in sanitized_map: orig = sanitized_map[dk_stream] raise ValueError( @@ -291,21 +294,31 @@ def sync( f"Selected streams: {selected}" ) - # Load incremental state if needed state_manager = None + stream_states: Dict[str, dict] = {} if incremental: from brickbyte._state import StateManager - state_manager = StateManager(catalog=catalog, schema=schema) - # TODO: load and pass state to PyAirbyte + state_manager = StateManager( + catalog=catalog, + schema=schema, + staging_volume=staging_volume, + warehouse_id=warehouse_id, + ) + for stream_name in selected: + saved = state_manager.get_state(source, stream_name) + if saved is not None: + stream_states[stream_name] = saved + logger.info( + f" Loaded incremental state for {stream_name}" + ) + self._apply_incremental_state(ab_source, stream_states) via_msg = f" via {staging_volume}" if staging_volume else " (Native Spark)" logger.info( f"Streaming {len(selected)} streams to {catalog}.{schema}{via_msg}..." ) - # Set up progress reporter - progress_reporter = None if progress_callback is not None: from brickbyte._progress import ProgressReporter @@ -319,6 +332,19 @@ def sync( successful_streams: List[str] = [] lock = threading.Lock() + # Common writer-creation kwargs used by both paths + _writer_kwargs = dict( + catalog=catalog, + schema=schema, + staging_volume=staging_volume, + warehouse_id=warehouse_id, + buffer_size_records=buffer_size_records, + buffer_size_mb=buffer_size_mb, + flatten=flatten, + run_id=run_id, + dedup_keys=normalized_dedup_keys, + ) + if max_parallel_streams > 1: import concurrent.futures @@ -331,17 +357,7 @@ def sync( def _write_stream_records(stream_name, records_list, _run_id, _mode): """Write a list of records in a thread-owned writer.""" - thread_writer = create_streaming_writer( - catalog=catalog, - schema=schema, - staging_volume=staging_volume, - warehouse_id=warehouse_id, - buffer_size_records=buffer_size_records, - buffer_size_mb=buffer_size_mb, - flatten=flatten, - run_id=_run_id, - dedup_keys=normalized_dedup_keys, - ) + thread_writer = create_streaming_writer(**_writer_kwargs) try: if _mode == "overwrite": thread_writer.safe_overwrite_begin(stream_name, _run_id) @@ -353,12 +369,13 @@ def _write_stream_records(stream_name, records_list, _run_id, _mode): if _mode == "overwrite": thread_writer.safe_overwrite_finish(stream_name, _run_id) - return len(records_list) - finally: + return stream_name, len(records_list), thread_writer + except Exception: thread_writer.close() with in_flight_lock: nonlocal in_flight in_flight -= 1 + raise for stream_name in selected: logger.info(f" Streaming: {stream_name}") @@ -382,30 +399,17 @@ def _write_stream_records(stream_name, records_list, _run_id, _mode): records_list.append(record) if accumulated_size >= buffer_size_mb * 1024 * 1024: - # Oversized: switch to synchronous mode oversized = True break if oversized: - # Process synchronously in main thread - sync_writer = create_streaming_writer( - catalog=catalog, - schema=schema, - staging_volume=staging_volume, - warehouse_id=warehouse_id, - buffer_size_records=buffer_size_records, - buffer_size_mb=buffer_size_mb, - flatten=flatten, - run_id=run_id, - dedup_keys=normalized_dedup_keys, - ) + sync_writer = create_streaming_writer(**_writer_kwargs) try: if mode == "overwrite": sync_writer.safe_overwrite_begin(stream_name, run_id) for rec in records_list: sync_writer.write_record(stream_name, rec) - # Continue consuming remaining records count = len(records_list) for record in records_generator: if cancel_event and cancel_event.is_set(): @@ -414,19 +418,39 @@ def _write_stream_records(stream_name, records_list, _run_id, _mode): ) sync_writer.write_record(stream_name, record) count += 1 + if progress_reporter and count % 5000 == 0: + progress_reporter.record_processed(stream_name, count) sync_writer.flush_stream(stream_name) if mode == "overwrite": sync_writer.safe_overwrite_finish(stream_name, run_id) + self._run_dedup_for_stream( + stream_name, deduplicate, normalized_dedup_keys, + flatten, catalog, schema, sync_writer, + ) + with lock: total_records += count successful_streams.append(stream_name) + + if progress_reporter: + progress_reporter.stream_completed(stream_name, count) + + self._save_incremental_state( + state_manager=state_manager, + incremental=incremental, + ab_source=ab_source, + source=source, + stream_name=stream_name, + run_id=run_id, + records_written=count, + ) + logger.info(f" {count} records streamed (sync)") finally: sync_writer.close() else: - # Wait until in-flight count is below limit import time while True: @@ -452,7 +476,6 @@ def _write_stream_records(stream_name, records_list, _run_id, _mode): is_fatal = "ConnectorFailed" in error_name if is_fatal or not continue_on_error: - # Cancel remaining futures for _, f in futures: f.cancel() raise @@ -460,10 +483,34 @@ def _write_stream_records(stream_name, records_list, _run_id, _mode): # Collect results from futures for stream_name, future in futures: try: - count = future.result() + _sname, count, thread_writer = future.result() + try: + self._run_dedup_for_stream( + _sname, deduplicate, normalized_dedup_keys, + flatten, catalog, schema, thread_writer, + ) + finally: + thread_writer.close() + with in_flight_lock: + in_flight -= 1 + with lock: total_records += count - successful_streams.append(stream_name) + successful_streams.append(_sname) + + if progress_reporter: + progress_reporter.stream_completed(_sname, count) + + self._save_incremental_state( + state_manager=state_manager, + incremental=incremental, + ab_source=ab_source, + source=source, + stream_name=_sname, + run_id=run_id, + records_written=count, + ) + logger.info(f" {count} records streamed") except Exception as e: logger.error(f" Failed to stream {stream_name}: {e}") @@ -478,17 +525,7 @@ def _write_stream_records(stream_name, records_list, _run_id, _mode): else: # Sequential processing (default) - writer = create_streaming_writer( - catalog=catalog, - schema=schema, - staging_volume=staging_volume, - warehouse_id=warehouse_id, - buffer_size_records=buffer_size_records, - buffer_size_mb=buffer_size_mb, - flatten=flatten, - run_id=run_id, - dedup_keys=normalized_dedup_keys, - ) + writer = create_streaming_writer(**_writer_kwargs) for stream_name in selected: logger.info(f" Streaming: {stream_name}") @@ -507,6 +544,10 @@ def _write_stream_records(stream_name, records_list, _run_id, _mode): writer.write_record(stream_name, record) count += 1 + + if progress_reporter and count % 5000 == 0: + progress_reporter.record_processed(stream_name, count) + if count % 10000 == 0: logger.info(f" ...streamed {count} records") @@ -524,6 +565,11 @@ def _write_stream_records(stream_name, records_list, _run_id, _mode): if mode == "overwrite": writer.safe_overwrite_finish(stream_name, run_id) + self._run_dedup_for_stream( + stream_name, deduplicate, normalized_dedup_keys, + flatten, catalog, schema, writer, + ) + logger.info(f" {count} records streamed") total_records += count successful_streams.append(stream_name) @@ -531,10 +577,15 @@ def _write_stream_records(stream_name, records_list, _run_id, _mode): if progress_reporter: progress_reporter.stream_completed(stream_name, count) - # Save incremental state on success - if state_manager and incremental: - # TODO: save state from PyAirbyte - pass + self._save_incremental_state( + state_manager=state_manager, + incremental=incremental, + ab_source=ab_source, + source=source, + stream_name=stream_name, + run_id=run_id, + records_written=count, + ) except Exception as e: error_name = type(e).__name__ @@ -558,52 +609,20 @@ def _write_stream_records(stream_name, records_list, _run_id, _mode): f"Sync failed. Failed streams: {failed_streams}" ) - # Run deduplication if enabled - if deduplicate and normalized_dedup_keys and successful_streams: - from brickbyte._dedup import deduplicate_stream - from brickbyte._sanitize import sanitize_stream_name as _sanitize - - for stream_name in successful_streams: - sanitized = _sanitize(stream_name) - stream_keys = normalized_dedup_keys.get(stream_name) - if stream_keys is None: - continue - - table_name = f"`{catalog}`.`{schema}`.`{sanitized}`" - if flatten: - deduplicate_stream( - executor=writer if writer else None, - table_name=table_name, - key_columns=stream_keys, - run_id_col="_run_id", - extracted_at_col="_extracted_at", - record_id_col="_record_id", - flatten=True, - ) - else: - dk_cols = [f"_dk_{i}" for i in range(len(stream_keys))] - deduplicate_stream( - executor=writer if writer else None, - table_name=table_name, - key_columns=dk_cols, - run_id_col="run_id", - extracted_at_col="extracted_at", - record_id_col="record_id", - flatten=False, - ) - enriched_tables = [] if enrich_metadata and successful_streams: logger.info("Enriching metadata with AI...") + from brickbyte._sanitize import sanitize_stream_name as _sanitize from brickbyte.enrichment import enrich_table model = enrich_model or "databricks-meta-llama-3-3-70b-instruct" for stream_name in successful_streams: try: + sanitized = _sanitize(stream_name) enrich_table( catalog=catalog, schema=schema, - table=stream_name, + table=sanitized, apply_to_catalog=True, model_name=model, ) @@ -623,11 +642,62 @@ def _write_stream_records(stream_name, records_list, _run_id, _mode): finally: if timer is not None: timer.cancel() + if progress_reporter is not None: + try: + progress_reporter.close() + except Exception as e: + logger.debug(f"Failed to close progress reporter: {e}") if writer is not None: writer.close() if cleanup: self.cleanup() + def _run_dedup_for_stream( + self, + stream_name: str, + deduplicate: bool, + normalized_dedup_keys: Optional[Dict[str, List[str]]], + flatten: bool, + catalog: str, + schema: str, + executor_writer, + ): + """Run dedup for a single stream using the provided writer as executor.""" + if not deduplicate or not normalized_dedup_keys: + return + + stream_keys = normalized_dedup_keys.get(stream_name) + if stream_keys is None: + return + + from brickbyte._dedup import deduplicate_stream + from brickbyte._sanitize import sanitize_stream_name + + sanitized = sanitize_stream_name(stream_name) + table_name = f"`{catalog}`.`{schema}`.`{sanitized}`" + dk_cols = [f"_dk_{i}" for i in range(len(stream_keys))] + + if flatten: + deduplicate_stream( + executor=executor_writer, + table_name=table_name, + key_columns=dk_cols, + run_id_col="_run_id", + extracted_at_col="_extracted_at", + record_id_col="_record_id", + flatten=True, + ) + else: + deduplicate_stream( + executor=executor_writer, + table_name=table_name, + key_columns=dk_cols, + run_id_col="run_id", + extracted_at_col="extracted_at", + record_id_col="record_id", + flatten=False, + ) + def _normalize_dedup_keys( self, dedup_keys: Optional[Union[List[str], Dict[str, List[str]]]], @@ -643,7 +713,7 @@ def _normalize_dedup_keys( if isinstance(dedup_keys, list): if len(dedup_keys) == 0: raise ValueError("dedup_keys must be non-empty") - # Will be expanded to all selected streams later + self._validate_dedup_key_list(dedup_keys, context="dedup_keys") return {"__all__": dedup_keys} if isinstance(dedup_keys, dict): @@ -652,10 +722,147 @@ def _normalize_dedup_keys( raise ValueError( f"dedup_keys for stream '{stream_name}' must be non-empty" ) + self._validate_dedup_key_list( + keys, + context=f"dedup_keys for stream '{stream_name}'", + ) return dedup_keys raise ValueError("dedup_keys must be a list or dict") + def _validate_dedup_key_list(self, keys: List[str], context: str) -> None: + """Validate dedup key identifier safety.""" + from brickbyte._sanitize import validate_identifier + + for key in keys: + if not isinstance(key, str) or not key: + raise ValueError(f"{context} must contain non-empty string keys") + try: + validate_identifier(key) + except ValueError as e: + raise ValueError(f"{context} contains invalid key '{key}': {e}") from e + + def _apply_incremental_state( + self, + ab_source: Any, + stream_states: Dict[str, dict], + ) -> None: + """Apply previously saved stream states to the source before reading.""" + if not stream_states: + return + + for method_name in ("set_stream_state", "set_state_for_stream"): + method = getattr(ab_source, method_name, None) + if not callable(method): + continue + try: + for stream_name, state in stream_states.items(): + method(stream_name, state) + logger.info( + f"Applied incremental state for {len(stream_states)} stream(s)" + ) + return + except TypeError: + continue + + set_state = getattr(ab_source, "set_state", None) + if callable(set_state): + state_payload = { + "streams": [ + { + "stream": {"name": stream_name}, + "stream_state": state, + } + for stream_name, state in stream_states.items() + ] + } + for payload in (state_payload, stream_states): + try: + set_state(payload) + logger.info( + f"Applied incremental state for {len(stream_states)} stream(s)" + ) + return + except TypeError: + continue + + raise NotImplementedError( + "incremental=True requires source state injection support " + "(set_stream_state/set_state_for_stream/set_state)." + ) + + def _extract_incremental_state( + self, + ab_source: Any, + stream_name: str, + run_id: str, + records_written: int, + ) -> dict: + """Extract connector-emitted stream state when available.""" + fallback_state = {"run_id": run_id, "records": records_written} + + for method_name in ("get_stream_state", "stream_state"): + method = getattr(ab_source, method_name, None) + if not callable(method): + continue + try: + state = method(stream_name) + if state is not None: + return state + except TypeError: + continue + except Exception as e: + logger.debug(f"Could not read stream state via {method_name}: {e}") + + get_state = getattr(ab_source, "get_state", None) + if callable(get_state): + for args in ((stream_name,), tuple()): + try: + state = get_state(*args) + except TypeError: + continue + except Exception as e: + logger.debug(f"Could not read state via get_state: {e}") + break + if state is None: + continue + if isinstance(state, dict): + if stream_name in state: + return state[stream_name] + streams_state = state.get("streams") + if isinstance(streams_state, dict) and stream_name in streams_state: + return streams_state[stream_name] + return state + + return fallback_state + + def _save_incremental_state( + self, + state_manager, + incremental: bool, + ab_source: Any, + source: str, + stream_name: str, + run_id: str, + records_written: int, + ) -> None: + """Persist state for a successfully synced stream.""" + if not incremental or state_manager is None: + return + + state = self._extract_incremental_state( + ab_source=ab_source, + stream_name=stream_name, + run_id=run_id, + records_written=records_written, + ) + state_manager.save_state( + source=source, + stream_name=stream_name, + state=state, + run_id=run_id, + ) + def cleanup(self): """Remove virtual environments.""" for manager in self._source_env_managers.values(): diff --git a/src/brickbyte/_dedup.py b/src/brickbyte/_dedup.py index fc79ec4..58438e0 100644 --- a/src/brickbyte/_dedup.py +++ b/src/brickbyte/_dedup.py @@ -39,8 +39,16 @@ def deduplicate_stream( if not key_columns: return + from brickbyte._sanitize import validate_identifier + + validated_keys = [validate_identifier(col) for col in key_columns] + validate_identifier(run_id_col) + validated_extracted_at_col = validate_identifier(extracted_at_col) + validated_record_id_col = validate_identifier(record_id_col) + validated_dk_missing_col = validate_identifier(dk_missing_col) + key_match = " AND ".join( - f"t.`{col}` <=> s.`{col}`" for col in key_columns + f"t.`{col}` <=> s.`{col}`" for col in validated_keys ) # Build the dedup MERGE statement @@ -49,14 +57,14 @@ def deduplicate_stream( MERGE INTO {table_name} t USING ( SELECT *, ROW_NUMBER() OVER ( - PARTITION BY {', '.join(f'`{c}`' for c in key_columns)} - ORDER BY `{extracted_at_col}` DESC, `{record_id_col}` DESC + PARTITION BY {', '.join(f'`{c}`' for c in validated_keys)} + ORDER BY `{validated_extracted_at_col}` DESC, `{validated_record_id_col}` DESC ) AS _rn FROM {table_name} - WHERE `{dk_missing_col}` = false + WHERE `{validated_dk_missing_col}` = false ) s ON {key_match} - AND t.`{record_id_col}` = s.`{record_id_col}` + AND t.`{validated_record_id_col}` = s.`{validated_record_id_col}` WHEN MATCHED AND s._rn > 1 THEN DELETE """ @@ -64,19 +72,18 @@ def deduplicate_stream( def _execute_sql(executor, sql: str): - """Execute SQL via whatever executor is available.""" - if executor is None: - # Try Spark - try: - from pyspark.sql import SparkSession + """Execute SQL via the provided executor. - spark = SparkSession.getActiveSession() - if spark: - spark.sql(sql) - return - except ImportError: - pass - raise RuntimeError("No executor available for dedup SQL") + The executor should be a writer instance that has either a ``spark`` + attribute (SparkStreamingWriter) or an ``_execute`` method + (SQLStreamingWriter). Passing ``None`` is a programming error — + callers must always supply the writer that owns the table. + """ + if executor is None: + raise RuntimeError( + "No executor provided for dedup SQL. " + "This is a bug — the writer that wrote the table must be passed." + ) if hasattr(executor, "spark"): executor.spark.sql(sql) diff --git a/src/brickbyte/_state.py b/src/brickbyte/_state.py index 82f3981..623e2bc 100644 --- a/src/brickbyte/_state.py +++ b/src/brickbyte/_state.py @@ -21,6 +21,19 @@ ) """ +UPSERT_STATE_SPARK_SQL = """ +MERGE INTO {table_name} t +USING (SELECT :source AS source, :stream_name AS stream_name, + :state AS state, :run_id AS run_id, + current_timestamp() AS updated_at) s +ON t.source = s.source AND t.stream_name = s.stream_name +WHEN MATCHED THEN UPDATE SET + t.state = s.state, t.run_id = s.run_id, t.updated_at = s.updated_at +WHEN NOT MATCHED THEN INSERT (source, stream_name, state, run_id, updated_at) + VALUES (s.source, s.stream_name, s.state, s.run_id, s.updated_at) +""" + +# SQL connector uses named params with :name syntax UPSERT_STATE_SQL = """ MERGE INTO {table_name} t USING (SELECT :source AS source, :stream_name AS stream_name, @@ -35,29 +48,39 @@ class StateManager: - """Manages incremental sync state in a Delta table.""" - - def __init__(self, catalog: str, schema: str): + """Manages incremental sync state in a Delta table. + + Works with both Spark (when active) and the SQL connector (when + staging_volume / warehouse_id are provided, i.e. remote mode). + """ + + def __init__( + self, + catalog: str, + schema: str, + staging_volume: Optional[str] = None, + warehouse_id: Optional[str] = None, + ): self.catalog = catalog self.schema = schema self._state_table = f"`{catalog}`.`{schema}`.`{STATE_TABLE_SUFFIX}`" self._spark = None self._connection = None self._initialized = False + self._staging_volume = staging_volume + self._warehouse_id = warehouse_id def _ensure_table(self): """Create the state table if it doesn't exist.""" if self._initialized: return + ddl = STATE_TABLE_DDL.format(table_name=self._state_table) spark = self._get_spark() if spark: - spark.sql(STATE_TABLE_DDL.format(table_name=self._state_table)) + spark.sql(ddl) else: - raise RuntimeError( - "StateManager requires either an active SparkSession or " - "a SQL connection to manage state." - ) + self._sql_execute(ddl) self._initialized = True def _get_spark(self): @@ -71,24 +94,78 @@ def _get_spark(self): pass return self._spark + def _get_connection(self): + """Get or create a SQL connector connection for remote mode.""" + if self._connection is not None: + return self._connection + + from databricks.sdk import WorkspaceClient + + w = WorkspaceClient() + server_hostname = w.config.host.replace("https://", "").rstrip("/") + access_token = w.config.token + + wh_id = self._warehouse_id + if not wh_id: + warehouses = list(w.warehouses.list()) + running = [ + wh for wh in warehouses if wh.state and wh.state.value == "RUNNING" + ] + if running: + wh_id = running[0].id + else: + raise RuntimeError( + "No running SQL warehouse found for state management. " + "Provide warehouse_id or start a warehouse." + ) + + from databricks import sql + + self._connection = sql.connect( + server_hostname=server_hostname, + http_path=f"/sql/1.0/warehouses/{wh_id}", + access_token=access_token, + catalog=self.catalog, + schema=self.schema, + ) + return self._connection + + def _sql_execute(self, query: str, params: Optional[dict] = None): + """Execute a query via SQL connector.""" + conn = self._get_connection() + cursor = conn.cursor() + try: + if params: + cursor.execute(query, params) + else: + cursor.execute(query) + return cursor.fetchall() if cursor.description else [] + finally: + cursor.close() + def save_state(self, source: str, stream_name: str, state: dict, run_id: str): """Save state for a (source, stream) pair via MERGE upsert.""" self._ensure_table() state_json = json.dumps(state, default=str) + params = { + "source": source, + "stream_name": stream_name, + "state": state_json, + "run_id": run_id, + } + spark = self._get_spark() if spark: spark.sql( - UPSERT_STATE_SQL.format(table_name=self._state_table), - args={ - "source": source, - "stream_name": stream_name, - "state": state_json, - "run_id": run_id, - }, + UPSERT_STATE_SPARK_SQL.format(table_name=self._state_table), + args=params, ) else: - raise RuntimeError("StateManager requires Spark for state management.") + self._sql_execute( + UPSERT_STATE_SQL.format(table_name=self._state_table), + params, + ) def get_state(self, source: str, stream_name: str) -> Optional[dict]: """Load state for a (source, stream) pair. Returns None if no state exists.""" @@ -111,7 +188,14 @@ def get_state(self, source: str, stream_name: str) -> Optional[dict]: return json.loads(rows[0]["state"]) return None - raise RuntimeError("StateManager requires Spark for state management.") + rows = self._sql_execute( + f"SELECT state FROM {self._state_table} " + f"WHERE source = :source AND stream_name = :stream_name LIMIT 1", + {"source": source, "stream_name": stream_name}, + ) + if rows: + return json.loads(rows[0][0]) + return None def clear_state(self, source: str, stream_name: str): """Delete state for a (source, stream) pair.""" @@ -123,3 +207,9 @@ def clear_state(self, source: str, stream_name: str): f"DELETE FROM {self._state_table} " f"WHERE source = '{source}' AND stream_name = '{stream_name}'" ) + else: + self._sql_execute( + f"DELETE FROM {self._state_table} " + f"WHERE source = :source AND stream_name = :stream_name", + {"source": source, "stream_name": stream_name}, + ) diff --git a/src/brickbyte/preview.py b/src/brickbyte/preview.py index 96fdb58..60073ce 100644 --- a/src/brickbyte/preview.py +++ b/src/brickbyte/preview.py @@ -143,8 +143,11 @@ def spark(self): return self._spark def get_table_name(self, stream_name: str) -> str: - """Get fully qualified table name.""" - return f"{self.catalog}.{self.schema}.{stream_name}" + """Get fully qualified table name using sanitized stream name.""" + from brickbyte._sanitize import sanitize_stream_name + + sanitized = sanitize_stream_name(stream_name) + return f"`{self.catalog}`.`{self.schema}`.`{sanitized}`" def table_exists(self, stream_name: str) -> bool: """Check if target table exists.""" @@ -163,13 +166,26 @@ def get_target_count(self, stream_name: str) -> int: return self.spark.table(table_name).count() def get_target_schema(self, stream_name: str) -> Dict[str, str]: - """Get schema of target table.""" + """Get schema of target table. + + Returns type names stripped of trailing ``()`` so that + ``StringType()`` becomes ``StringType``, matching the values + produced by ``_PYTHON_TO_SPARK``. + """ if not self.table_exists(stream_name): return {} table_name = self.get_table_name(stream_name) df = self.spark.table(table_name) - return {f.name: str(f.dataType) for f in df.schema.fields} + schema = {} + for f in df.schema.fields: + type_str = str(f.dataType) + # Spark str() on simple types yields e.g. "StringType()" — + # strip the trailing "()" for consistent comparison. + if type_str.endswith("()"): + type_str = type_str[:-2] + schema[f.name] = type_str + return schema def get_source_schema(self, sample_records: List[dict]) -> Dict[str, str]: """Infer schema from sample records.""" diff --git a/src/brickbyte/writers/spark_streaming_writer.py b/src/brickbyte/writers/spark_streaming_writer.py index a1b45ba..b99b630 100644 --- a/src/brickbyte/writers/spark_streaming_writer.py +++ b/src/brickbyte/writers/spark_streaming_writer.py @@ -23,6 +23,18 @@ class SparkStreamingWriter(BaseWriter): Writes data to Databricks using micro-batch streaming. """ + _SAFE_WIDENINGS = { + ("IntegerType", "LongType"), + ("IntegerType", "DoubleType"), + ("LongType", "DoubleType"), + ("FloatType", "DoubleType"), + ("ShortType", "IntegerType"), + ("ShortType", "LongType"), + ("ByteType", "ShortType"), + ("ByteType", "IntegerType"), + ("ByteType", "LongType"), + } + def __init__( self, catalog: str, @@ -218,34 +230,20 @@ def _atomic_overwrite(self, target_name: str, staging_name: str): target_df = self.spark.table(target_name) staging_df = self.spark.table(staging_name) - target_schema = {f.name: str(f.dataType) for f in target_df.schema.fields} - staging_schema = {f.name: str(f.dataType) for f in staging_df.schema.fields} + target_schema = {f.name: f.dataType for f in target_df.schema.fields} + staging_schema = {f.name: f.dataType for f in staging_df.schema.fields} target_cols = set(target_schema.keys()) staging_cols = set(staging_schema.keys()) - # Check for incompatible type changes - _SAFE_WIDENINGS = { - ("IntegerType", "LongType"), - ("IntegerType", "DoubleType"), - ("LongType", "DoubleType"), - ("FloatType", "DoubleType"), - ("ShortType", "IntegerType"), - ("ShortType", "LongType"), - ("ByteType", "ShortType"), - ("ByteType", "IntegerType"), - ("ByteType", "LongType"), - } - for col in target_cols & staging_cols: - t_type = target_schema[col] - s_type = staging_schema[col] + t_type = self._type_name(target_schema[col]) + s_type = self._type_name(staging_schema[col]) if t_type != s_type: - if (s_type, t_type) not in _SAFE_WIDENINGS and ( + if (s_type, t_type) not in self._SAFE_WIDENINGS and ( t_type, s_type, - ) not in _SAFE_WIDENINGS: - # Check if one can be cast to the other + ) not in self._SAFE_WIDENINGS: if t_type != "StringType" and s_type != "StringType": raise ValueError( f"Incompatible type change for column '{col}': " @@ -256,22 +254,29 @@ def _atomic_overwrite(self, target_name: str, staging_name: str): # Add new columns from staging to target new_cols = staging_cols - target_cols for col in new_cols: - col_type = staging_schema[col] + col_type = self._sql_type(staging_schema[col]) self.spark.sql( f"ALTER TABLE {target_name} ADD COLUMNS (`{col}` {col_type})" ) - # Build SELECT for INSERT OVERWRITE with all columns all_cols = target_cols | staging_cols select_parts = [] for col in sorted(all_cols): if col in staging_cols and col in target_cols: - s_type = staging_schema[col] - t_type = target_schema[col] - if s_type != t_type and (s_type, t_type) in _SAFE_WIDENINGS: - select_parts.append(f"CAST(`{col}` AS {t_type}) AS `{col}`") - elif s_type != t_type and (t_type, s_type) in _SAFE_WIDENINGS: - select_parts.append(f"CAST(`{col}` AS {s_type}) AS `{col}`") + s_type = self._type_name(staging_schema[col]) + t_type = self._type_name(target_schema[col]) + if s_type != t_type and (s_type, t_type) in self._SAFE_WIDENINGS: + target_sql_type = self._sql_type(target_schema[col]) + select_parts.append( + f"CAST(`{col}` AS {target_sql_type}) AS `{col}`" + ) + elif s_type != t_type and (t_type, s_type) in self._SAFE_WIDENINGS: + staging_sql_type = self._sql_type(staging_schema[col]) + self.spark.sql( + f"ALTER TABLE {target_name} " + f"ALTER COLUMN `{col}` TYPE {staging_sql_type}" + ) + select_parts.append(f"`{col}`") else: select_parts.append(f"`{col}`") elif col in staging_cols: @@ -286,3 +291,39 @@ def _atomic_overwrite(self, target_name: str, staging_name: str): f"INSERT OVERWRITE {target_name} ({col_list}) " f"SELECT {select_expr} FROM {staging_name}" ) + + @staticmethod + def _type_name(data_type) -> str: + """Get normalized Spark class-based type name (e.g., IntegerType).""" + if isinstance(data_type, str): + if data_type.endswith("()"): + return data_type[:-2] + return data_type + return type(data_type).__name__ + + @staticmethod + def _sql_type(data_type) -> str: + """Get SQL type string for DDL/CAST clauses.""" + if hasattr(data_type, "simpleString"): + return data_type.simpleString() + + as_str = str(data_type) + if as_str.endswith("()"): + as_str = as_str[:-2] + if as_str.startswith("DecimalType(") and as_str.endswith(")"): + precision_scale = as_str[len("DecimalType("):-1] + return f"DECIMAL({precision_scale})" + mapping = { + "ByteType": "TINYINT", + "ShortType": "SMALLINT", + "IntegerType": "INT", + "LongType": "BIGINT", + "FloatType": "FLOAT", + "DoubleType": "DOUBLE", + "StringType": "STRING", + "BooleanType": "BOOLEAN", + "TimestampType": "TIMESTAMP", + "DateType": "DATE", + "BinaryType": "BINARY", + } + return mapping.get(as_str, as_str) diff --git a/src/brickbyte/writers/sql_streaming_writer.py b/src/brickbyte/writers/sql_streaming_writer.py index 4645515..055cc33 100644 --- a/src/brickbyte/writers/sql_streaming_writer.py +++ b/src/brickbyte/writers/sql_streaming_writer.py @@ -333,8 +333,21 @@ def safe_overwrite_finish(self, stream_name: str, run_id: str): finally: self._overwrite_streams.pop(stream_name, None) + # Safe widening pairs: (narrower, wider) — SQL type names (lowercase) + _SAFE_WIDENINGS_SQL = { + ("int", "bigint"), + ("int", "double"), + ("bigint", "double"), + ("float", "double"), + ("smallint", "int"), + ("smallint", "bigint"), + ("tinyint", "smallint"), + ("tinyint", "int"), + ("tinyint", "bigint"), + } + def _atomic_overwrite_sql(self, target_name: str, staging_name: str): - """Perform atomic INSERT OVERWRITE via SQL with schema alignment.""" + """Perform atomic INSERT OVERWRITE via SQL with schema alignment and type checks.""" conn = self._get_connection() cursor = conn.cursor() try: @@ -349,6 +362,23 @@ def _atomic_overwrite_sql(self, target_name: str, staging_name: str): target_cols = set(target_schema.keys()) staging_cols = set(staging_schema.keys()) + # Check for incompatible type changes + for col in target_cols & staging_cols: + t_type = target_schema[col].lower() + s_type = staging_schema[col].lower() + if t_type != s_type: + pair = (s_type, t_type) + reverse = (t_type, s_type) + is_safe = pair in self._SAFE_WIDENINGS_SQL + is_reverse_safe = reverse in self._SAFE_WIDENINGS_SQL + if not is_safe and not is_reverse_safe: + if t_type != "string" and s_type != "string": + raise ValueError( + f"Incompatible type change for column '{col}': " + f"{target_schema[col]} -> {staging_schema[col]}. " + f"Drop the table manually to reset schema." + ) + # Add new columns from staging to target new_cols = staging_cols - target_cols for col in new_cols: @@ -360,7 +390,27 @@ def _atomic_overwrite_sql(self, target_name: str, staging_name: str): all_cols = target_cols | staging_cols select_parts = [] for col in sorted(all_cols): - if col in staging_cols: + if col in staging_cols and col in target_cols: + s_type = staging_schema[col].lower() + t_type = target_schema[col].lower() + if s_type != t_type: + # Always widen to the wider type + if (s_type, t_type) in self._SAFE_WIDENINGS_SQL: + select_parts.append( + f"CAST(`{col}` AS {target_schema[col]}) AS `{col}`" + ) + elif (t_type, s_type) in self._SAFE_WIDENINGS_SQL: + # Staging is wider — widen target to match + self._execute( + f"ALTER TABLE {target_name} " + f"ALTER COLUMN `{col}` TYPE {staging_schema[col]}" + ) + select_parts.append(f"`{col}`") + else: + select_parts.append(f"`{col}`") + else: + select_parts.append(f"`{col}`") + elif col in staging_cols: select_parts.append(f"`{col}`") else: select_parts.append(f"NULL AS `{col}`") From 610361808441549a58f89f6dd41c4ff866a0efc7 Mon Sep 17 00:00:00 2001 From: park-peter Date: Tue, 3 Mar 2026 18:25:04 +0900 Subject: [PATCH 5/7] modify ci to publish only when tagged --- .github/workflows/publish-pypi.yml | 2 ++ .github/workflows/publish-testpypi.yml | 5 ++++- pyproject.toml | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 6cb06bf..323a740 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -8,6 +8,7 @@ on: jobs: build: name: Build distribution + if: ${{ github.event_name == 'workflow_dispatch' || (!contains(github.event.release.tag_name, '-alpha') && !contains(github.event.release.tag_name, '-beta') && !contains(github.event.release.tag_name, '-rc')) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -31,6 +32,7 @@ jobs: publish-pypi: name: Publish to PyPI + if: ${{ github.event_name == 'workflow_dispatch' || (!contains(github.event.release.tag_name, '-alpha') && !contains(github.event.release.tag_name, '-beta') && !contains(github.event.release.tag_name, '-rc')) }} needs: build runs-on: ubuntu-latest environment: diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index 1758649..c4de5bd 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -2,7 +2,10 @@ name: Publish to TestPyPI on: push: - branches: [main] + tags: + - "v*-alpha*" + - "v*-beta*" + - "v*-rc*" workflow_dispatch: jobs: diff --git a/pyproject.toml b/pyproject.toml index 958a104..c9bb78b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "brickbyte" -version = "0.1.0" +version = "0.1.0rc1" description = "Sync data from Airbyte sources to Databricks Unity Catalog with streaming architecture" authors = [ { name="Sri Tikkireddy", email="sri.tikkireddy@databricks.com" }, From 44b28e772e48f68f69d3222431a0f8f2b7115331 Mon Sep 17 00:00:00 2001 From: park-peter Date: Thu, 12 Mar 2026 22:47:19 +0900 Subject: [PATCH 6/7] Fix review findings and remove enrichment --- README.md | 19 +- notebooks/brickbyte-azure-blob.py | 4 - notebooks/brickbyte-example.py | 3 - notebooks/brickbyte-gcs.py | 4 - notebooks/brickbyte-google-drive.py | 4 - notebooks/brickbyte-microsoft-teams.py | 3 - notebooks/brickbyte-s3.py | 4 - src/brickbyte/__init__.py | 1 - src/brickbyte/_client.py | 295 +++------- src/brickbyte/_progress.py | 16 +- src/brickbyte/enrichment/__init__.py | 15 - src/brickbyte/enrichment/semantic.py | 556 ------------------ src/brickbyte/preview.py | 51 +- src/brickbyte/writers/sql_streaming_writer.py | 40 +- tests/test_concurrent.py | 140 ++++- tests/test_enrichment_sql.py | 110 ---- tests/test_functional.py | 1 - tests/test_preview.py | 39 +- tests/test_streaming.py | 40 +- uv.lock | 2 +- 20 files changed, 299 insertions(+), 1048 deletions(-) delete mode 100644 src/brickbyte/enrichment/__init__.py delete mode 100644 src/brickbyte/enrichment/semantic.py delete mode 100644 tests/test_enrichment_sql.py diff --git a/README.md b/README.md index 5098fb3..2a6123a 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,7 @@ brickbyte wraps [PyAirbyte](https://github.com/airbytehq/PyAirbyte) to extract d - **Concurrent Streams** - Parallel writes with isolated per-thread writers - **Progress Reporting** - Callback events every 5000 records plus per-stream completion - **Timeout Control** - Cooperative timeout for long-running syncs -- **AI Enrichment** - Column descriptions, PII detection via Foundation Models -- **Preview** - Schema comparison before committing +- **Preview** - Sample-based schema comparison before committing ## Quick Start @@ -167,18 +166,6 @@ result = bb.sync( ) ``` -### AI Metadata Enrichment - -```python -result = bb.sync( - source="source-faker", - source_config={"count": 100}, - catalog="main", - schema="bronze", - enrich_metadata=True, -) -``` - ### Preview ```python @@ -191,6 +178,8 @@ preview = bb.preview( print(preview) ``` +Preview reports sampled source records, current target counts, and inferred schema changes. + ## Credential Management brickbyte auto-discovers credentials from Databricks Secrets: @@ -222,7 +211,7 @@ Supports dotted keys for nested config (`source-x/credentials.client_id` maps to ## Development ```bash -uv pip install -e ".[dev]" +uv sync --extra dev uv run pytest tests/ -v -m "not integration" uv run ruff check src/ ``` diff --git a/notebooks/brickbyte-azure-blob.py b/notebooks/brickbyte-azure-blob.py index 1d9543e..8afe9a7 100644 --- a/notebooks/brickbyte-azure-blob.py +++ b/notebooks/brickbyte-azure-blob.py @@ -219,8 +219,4 @@ # }, # catalog="", # TODO: Set your Unity Catalog name # schema="", # TODO: Set your target schema -# enrich_metadata=True, -# enrich_model="databricks-meta-llama-3-3-70b-instruct", # ) - -# print(f"Enriched tables: {result.enriched_tables}") diff --git a/notebooks/brickbyte-example.py b/notebooks/brickbyte-example.py index 161f524..be67ef1 100644 --- a/notebooks/brickbyte-example.py +++ b/notebooks/brickbyte-example.py @@ -153,7 +153,4 @@ # source_config={"count": 100}, # catalog="main", # schema="bronze", -# enrich_metadata=True, -# enrich_model="databricks-meta-llama-3-3-70b-instruct", # ) -# print(f"Enriched tables: {result.enriched_tables}") diff --git a/notebooks/brickbyte-gcs.py b/notebooks/brickbyte-gcs.py index 66e8845..7a66b61 100644 --- a/notebooks/brickbyte-gcs.py +++ b/notebooks/brickbyte-gcs.py @@ -245,8 +245,4 @@ # }, # catalog="", # TODO: Set your Unity Catalog name # schema="", # TODO: Set your target schema -# enrich_metadata=True, -# enrich_model="databricks-meta-llama-3-3-70b-instruct", # ) - -# print(f"Enriched tables: {result.enriched_tables}") diff --git a/notebooks/brickbyte-google-drive.py b/notebooks/brickbyte-google-drive.py index 629a228..e3c3864 100644 --- a/notebooks/brickbyte-google-drive.py +++ b/notebooks/brickbyte-google-drive.py @@ -199,8 +199,4 @@ # }, # catalog="", # TODO: Set your Unity Catalog name # schema="", # TODO: Set your target schema -# enrich_metadata=True, -# enrich_model="databricks-meta-llama-3-3-70b-instruct", # ) - -# print(f"Enriched tables: {result.enriched_tables}") diff --git a/notebooks/brickbyte-microsoft-teams.py b/notebooks/brickbyte-microsoft-teams.py index 887fa85..7881c29 100644 --- a/notebooks/brickbyte-microsoft-teams.py +++ b/notebooks/brickbyte-microsoft-teams.py @@ -265,11 +265,8 @@ # streams=["users", "teams", "channels"], # catalog="", # TODO: Set your Unity Catalog name # schema="", # TODO: Set your target schema -# enrich_metadata=True, -# enrich_model="databricks-meta-llama-3-3-70b-instruct", # ) -# print(f"Enriched tables: {result.enriched_tables}") # COMMAND ---------- diff --git a/notebooks/brickbyte-s3.py b/notebooks/brickbyte-s3.py index 82e5ef4..13bb551 100644 --- a/notebooks/brickbyte-s3.py +++ b/notebooks/brickbyte-s3.py @@ -357,8 +357,4 @@ # }, # catalog="", # TODO: Set your Unity Catalog name # schema="", # TODO: Set your target schema -# enrich_metadata=True, -# enrich_model="databricks-meta-llama-3-3-70b-instruct", # ) - -# print(f"Enriched tables: {result.enriched_tables}") diff --git a/src/brickbyte/__init__.py b/src/brickbyte/__init__.py index 4155bb4..1da932b 100644 --- a/src/brickbyte/__init__.py +++ b/src/brickbyte/__init__.py @@ -17,7 +17,6 @@ class SyncResult: records_written: int streams_synced: List[str] failed_streams: List[str] = field(default_factory=list) - enriched_tables: List[str] = field(default_factory=list) def client( diff --git a/src/brickbyte/_client.py b/src/brickbyte/_client.py index e058404..3cc481c 100644 --- a/src/brickbyte/_client.py +++ b/src/brickbyte/_client.py @@ -97,6 +97,27 @@ def _validate_sync_params(self, mode: str): f"Invalid mode '{mode}'. Must be one of: {', '.join(valid_modes)}" ) + def _create_source_instance( + self, + ab_module: Any, + source: str, + source_config: dict, + ) -> Any: + """Create a configured Airbyte source instance.""" + return ab_module.get_source( + source, + config=source_config, + local_executable=self._get_source_exec_path(source), + ) + + @staticmethod + def _select_streams(ab_source: Any, streams: Optional[List[str]]) -> None: + """Select requested streams on a source instance.""" + if streams: + ab_source.select_streams(streams) + else: + ab_source.select_all_streams() + def preview( self, source: str, @@ -120,7 +141,7 @@ def preview( sample_size: Number of sample records per stream Returns: - PreviewResult with detailed comparison + PreviewResult with sampled source records, target counts, and schema changes """ import airbyte as ab @@ -132,17 +153,10 @@ def preview( logger.info(f"Setting up {source}...") self._setup_source(source, source_install) - ab_source = ab.get_source( - source, - config=merged_config, - local_executable=self._get_source_exec_path(source), - ) + ab_source = self._create_source_instance(ab, source, merged_config) ab_source.check() - if streams: - ab_source.select_streams(streams) - else: - ab_source.select_all_streams() + self._select_streams(ab_source, streams) selected = list(ab_source.get_selected_streams()) @@ -169,8 +183,6 @@ def sync( streams: Optional[List[str]] = None, mode: str = "overwrite", flatten: bool = False, - enrich_metadata: bool = False, - enrich_model: Optional[str] = None, warehouse_id: Optional[str] = None, source_install: Optional[str] = None, cleanup: bool = False, @@ -197,8 +209,6 @@ def sync( mode: Write mode ("overwrite" or "append") flatten: If True, flatten record fields into columns. If False (default), store as JSON in 'data' column. - enrich_metadata: If True, use AI to generate column descriptions - enrich_model: Foundation Model endpoint for enrichment warehouse_id: SQL warehouse ID (optional, auto-discovered) source_install: Override source installation (e.g., custom git URL) cleanup: Whether to cleanup venvs after sync (default: False) @@ -214,7 +224,7 @@ def sync( progress_callback: Optional callback for progress reporting Returns: - SyncResult with records_written, streams_synced, failed_streams, enriched_tables + SyncResult with records_written, streams_synced, failed_streams """ import airbyte as ab @@ -249,19 +259,12 @@ def sync( self._setup_source(source, source_install) logger.info(f"Configuring {source}...") - ab_source = ab.get_source( - source, - config=merged_config, - local_executable=self._get_source_exec_path(source), - ) + ab_source = self._create_source_instance(ab, source, merged_config) logger.info("Validating source connection...") ab_source.check() - if streams: - ab_source.select_streams(streams) - else: - ab_source.select_all_streams() + self._select_streams(ab_source, streams) selected = list(ab_source.get_selected_streams()) @@ -312,7 +315,8 @@ def sync( logger.info( f" Loaded incremental state for {stream_name}" ) - self._apply_incremental_state(ab_source, stream_states) + if max_parallel_streams == 1: + self._apply_incremental_state(ab_source, stream_states) via_msg = f" via {staging_volume}" if staging_volume else " (Native Spark)" logger.info( @@ -330,7 +334,6 @@ def sync( total_records = 0 failed_streams: List[str] = [] successful_streams: List[str] = [] - lock = threading.Lock() # Common writer-creation kwargs used by both paths _writer_kwargs = dict( @@ -348,180 +351,102 @@ def sync( if max_parallel_streams > 1: import concurrent.futures - executor = concurrent.futures.ThreadPoolExecutor( - max_workers=max_parallel_streams - ) - futures = [] - in_flight = 0 - in_flight_lock = threading.Lock() - - def _write_stream_records(stream_name, records_list, _run_id, _mode): - """Write a list of records in a thread-owned writer.""" - thread_writer = create_streaming_writer(**_writer_kwargs) - try: - if _mode == "overwrite": - thread_writer.safe_overwrite_begin(stream_name, _run_id) - - for record in records_list: - thread_writer.write_record(stream_name, record) - thread_writer.flush_stream(stream_name) - - if _mode == "overwrite": - thread_writer.safe_overwrite_finish(stream_name, _run_id) - - return stream_name, len(records_list), thread_writer - except Exception: - thread_writer.close() - with in_flight_lock: - nonlocal in_flight - in_flight -= 1 - raise - - for stream_name in selected: + def _sync_stream_parallel(stream_name: str): + """Sync a single stream in an isolated worker.""" logger.info(f" Streaming: {stream_name}") + stream_source = self._create_source_instance(ab, source, merged_config) + self._select_streams(stream_source, [stream_name]) + if incremental and stream_name in stream_states: + self._apply_incremental_state( + stream_source, + {stream_name: stream_states[stream_name]}, + ) + thread_writer = create_streaming_writer(**_writer_kwargs) try: - records_generator = ab_source.get_records(stream_name) - records_list = [] - accumulated_size = 0 - oversized = False + if mode == "overwrite": + thread_writer.safe_overwrite_begin(stream_name, run_id) - for record in records_generator: + count = 0 + for record in stream_source.get_records(stream_name): if cancel_event and cancel_event.is_set(): raise TimeoutError( f"Sync timed out after {timeout_seconds} seconds" ) - record_size = sum( - len(str(v).encode("utf-8")) for v in record.values() - ) - accumulated_size += record_size - records_list.append(record) - - if accumulated_size >= buffer_size_mb * 1024 * 1024: - oversized = True - break - - if oversized: - sync_writer = create_streaming_writer(**_writer_kwargs) - try: - if mode == "overwrite": - sync_writer.safe_overwrite_begin(stream_name, run_id) - - for rec in records_list: - sync_writer.write_record(stream_name, rec) - count = len(records_list) - for record in records_generator: - if cancel_event and cancel_event.is_set(): - raise TimeoutError( - f"Sync timed out after {timeout_seconds} seconds" - ) - sync_writer.write_record(stream_name, record) - count += 1 - if progress_reporter and count % 5000 == 0: - progress_reporter.record_processed(stream_name, count) - sync_writer.flush_stream(stream_name) - - if mode == "overwrite": - sync_writer.safe_overwrite_finish(stream_name, run_id) - - self._run_dedup_for_stream( - stream_name, deduplicate, normalized_dedup_keys, - flatten, catalog, schema, sync_writer, - ) + thread_writer.write_record(stream_name, record) + count += 1 + if progress_reporter and count % 5000 == 0: + progress_reporter.record_processed(stream_name, count) - with lock: - total_records += count - successful_streams.append(stream_name) + thread_writer.flush_stream(stream_name) - if progress_reporter: - progress_reporter.stream_completed(stream_name, count) + if mode == "overwrite": + thread_writer.safe_overwrite_finish(stream_name, run_id) - self._save_incremental_state( - state_manager=state_manager, - incremental=incremental, - ab_source=ab_source, - source=source, - stream_name=stream_name, - run_id=run_id, - records_written=count, - ) + self._run_dedup_for_stream( + stream_name, + deduplicate, + normalized_dedup_keys, + flatten, + catalog, + schema, + thread_writer, + ) - logger.info(f" {count} records streamed (sync)") - finally: - sync_writer.close() - else: - import time - - while True: - with in_flight_lock: - if in_flight < max_parallel_streams: - in_flight += 1 - break - time.sleep(0.01) - - future = executor.submit( - _write_stream_records, - stream_name, - records_list, - run_id, - mode, + state = None + if incremental: + state = self._extract_incremental_state( + ab_source=stream_source, + stream_name=stream_name, + run_id=run_id, + records_written=count, ) - futures.append((stream_name, future)) + return stream_name, count, state except Exception as e: - error_name = type(e).__name__ logger.error(f" Failed to stream {stream_name}: {e}") - failed_streams.append(stream_name) + raise + finally: + thread_writer.close() - is_fatal = "ConnectorFailed" in error_name - if is_fatal or not continue_on_error: - for _, f in futures: - f.cancel() - raise + with concurrent.futures.ThreadPoolExecutor( + max_workers=max_parallel_streams + ) as executor: + future_to_stream = { + executor.submit(_sync_stream_parallel, stream_name): stream_name + for stream_name in selected + } - # Collect results from futures - for stream_name, future in futures: - try: - _sname, count, thread_writer = future.result() + for future in concurrent.futures.as_completed(future_to_stream): + stream_name = future_to_stream[future] try: - self._run_dedup_for_stream( - _sname, deduplicate, normalized_dedup_keys, - flatten, catalog, schema, thread_writer, - ) - finally: - thread_writer.close() - with in_flight_lock: - in_flight -= 1 - - with lock: + _sname, count, state = future.result() total_records += count successful_streams.append(_sname) - if progress_reporter: - progress_reporter.stream_completed(_sname, count) + if progress_reporter: + progress_reporter.stream_completed(_sname, count) - self._save_incremental_state( - state_manager=state_manager, - incremental=incremental, - ab_source=ab_source, - source=source, - stream_name=_sname, - run_id=run_id, - records_written=count, - ) + if incremental and state_manager is not None: + state_manager.save_state( + source=source, + stream_name=_sname, + state=state, + run_id=run_id, + ) - logger.info(f" {count} records streamed") - except Exception as e: - logger.error(f" Failed to stream {stream_name}: {e}") - with lock: + logger.info(f" {count} records streamed") + except Exception as e: + error_name = type(e).__name__ failed_streams.append(stream_name) - if not continue_on_error: - for _, f in futures: - f.cancel() - raise - executor.shutdown(wait=True) + is_fatal = "ConnectorFailed" in error_name + if is_fatal or not continue_on_error: + for pending in future_to_stream: + if pending is not future: + pending.cancel() + raise else: # Sequential processing (default) @@ -609,34 +534,10 @@ def _write_stream_records(stream_name, records_list, _run_id, _mode): f"Sync failed. Failed streams: {failed_streams}" ) - enriched_tables = [] - if enrich_metadata and successful_streams: - logger.info("Enriching metadata with AI...") - from brickbyte._sanitize import sanitize_stream_name as _sanitize - from brickbyte.enrichment import enrich_table - - model = enrich_model or "databricks-meta-llama-3-3-70b-instruct" - for stream_name in successful_streams: - try: - sanitized = _sanitize(stream_name) - enrich_table( - catalog=catalog, - schema=schema, - table=sanitized, - apply_to_catalog=True, - model_name=model, - ) - enriched_tables.append(stream_name) - except Exception as e: - logger.warning( - f" Warning: Could not enrich {stream_name}: {e}" - ) - return SyncResult( records_written=total_records, streams_synced=successful_streams, failed_streams=failed_streams, - enriched_tables=enriched_tables, ) finally: diff --git a/src/brickbyte/_progress.py b/src/brickbyte/_progress.py index 1c74291..c0a3f37 100644 --- a/src/brickbyte/_progress.py +++ b/src/brickbyte/_progress.py @@ -1,6 +1,7 @@ """ Progress reporting for brickbyte sync operations. """ +import threading import time from dataclasses import dataclass from typing import Callable, Optional @@ -34,6 +35,7 @@ def __init__( self._start_time = time.monotonic() self._tqdm_bar = None self._records_by_stream: dict = {} + self._lock = threading.Lock() if use_tqdm or self._is_notebook(): try: @@ -61,22 +63,26 @@ def _is_notebook(self) -> bool: def record_processed(self, stream_name: str, count: int): """Called periodically during record processing.""" - self._records_by_stream[stream_name] = count + with self._lock: + self._records_by_stream[stream_name] = count + streams_completed = self.streams_completed if count % 5000 == 0 and self.callback: event = ProgressEvent( stream_name=stream_name, records_processed=count, total_streams=self.total_streams, - streams_completed=self.streams_completed, + streams_completed=streams_completed, elapsed_seconds=time.monotonic() - self._start_time, ) self.callback(event) def stream_completed(self, stream_name: str, records: int): """Called when a stream finishes.""" - self.streams_completed += 1 - self._records_by_stream[stream_name] = records + with self._lock: + self.streams_completed += 1 + self._records_by_stream[stream_name] = records + streams_completed = self.streams_completed if self._tqdm_bar: self._tqdm_bar.update(1) @@ -89,7 +95,7 @@ def stream_completed(self, stream_name: str, records: int): stream_name=stream_name, records_processed=records, total_streams=self.total_streams, - streams_completed=self.streams_completed, + streams_completed=streams_completed, elapsed_seconds=time.monotonic() - self._start_time, ) self.callback(event) diff --git a/src/brickbyte/enrichment/__init__.py b/src/brickbyte/enrichment/__init__.py deleted file mode 100644 index 6956605..0000000 --- a/src/brickbyte/enrichment/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -brickbyte Enrichment Module. - -Provides AI-powered metadata enrichment for tables: -- Column descriptions via Foundation Models -- PII detection -- Data classification -""" -from brickbyte.enrichment.semantic import ( - SemanticEnricher, - SQLSemanticEnricher, - enrich_table, -) - -__all__ = ["SemanticEnricher", "SQLSemanticEnricher", "enrich_table"] diff --git a/src/brickbyte/enrichment/semantic.py b/src/brickbyte/enrichment/semantic.py deleted file mode 100644 index 3790dc5..0000000 --- a/src/brickbyte/enrichment/semantic.py +++ /dev/null @@ -1,556 +0,0 @@ -""" -AI-powered semantic enrichment for brickbyte. -Uses Databricks Foundation Models to generate metadata. -""" -import json -import logging -import re -from dataclasses import dataclass, field -from typing import Dict, List, Optional - -logger = logging.getLogger("brickbyte") - - -@dataclass -class ColumnEnrichment: - """Enrichment results for a single column.""" - - column_name: str - description: Optional[str] = None - is_pii: bool = False - pii_type: Optional[str] = None - data_classification: Optional[str] = None - - def __str__(self) -> str: - parts = [f"{self.column_name}:"] - if self.description: - parts.append(f' "{self.description}"') - if self.is_pii: - parts.append(f" PII detected: {self.pii_type}") - if self.data_classification: - parts.append(f" Classification: {self.data_classification}") - return "\n".join(parts) - - -@dataclass -class TableEnrichment: - """Enrichment results for a table.""" - - table_name: str - columns: List[ColumnEnrichment] = field(default_factory=list) - table_description: Optional[str] = None - - def __str__(self) -> str: - lines = [f"Table: {self.table_name}"] - if self.table_description: - lines.append(f"Description: {self.table_description}") - lines.append("") - for col in self.columns: - lines.append(str(col)) - return "\n".join(lines) - - -ENRICHMENT_PROMPT = """Analyze this database table and provide metadata enrichment. - -Table: {table_name} -Columns and sample data: -{column_samples} - -For each column, provide: -1. A brief description (1-2 sentences) -2. Whether it contains PII (personally identifiable information) -3. If PII, what type (email, phone, ssn, name, address, etc.) -4. Data classification (public, internal, confidential, restricted) - -Also provide a brief description of the table's purpose. - -Respond in JSON format: -{{ - "table_description": "Brief description of the table", - "columns": [ - {{ - "name": "column_name", - "description": "Description of the column", - "is_pii": true/false, - "pii_type": "type or null", - "classification": "public/internal/confidential/restricted" - }} - ] -}} -""" - - -class SemanticEnricher: - """ - AI-powered semantic enrichment using Databricks Foundation Models. - Requires an active Spark session. - """ - - def __init__( - self, - model_name: str = "databricks-meta-llama-3-3-70b-instruct", - sample_rows: int = 50, - ): - self.model_name = model_name - self.sample_rows = sample_rows - self._spark = None - self._client = None - - @property - def spark(self): - """Get active Spark session, raising if unavailable.""" - if self._spark is None: - from pyspark.sql import SparkSession - - session = SparkSession.getActiveSession() - if session is None: - raise RuntimeError( - "SemanticEnricher requires an active SparkSession. " - "Use SQLSemanticEnricher for SQL-based enrichment, " - "or ensure you are running in a Databricks notebook." - ) - self._spark = session - return self._spark - - @property - def client(self): - """Get or create Databricks SDK client.""" - if self._client is None: - from databricks.sdk import WorkspaceClient - - self._client = WorkspaceClient() - return self._client - - def _get_column_samples(self, table_name: str) -> Dict[str, List[str]]: - """Get sample values for each column.""" - schema = self.spark.table(table_name).schema - col_names = [f.name for f in schema.fields] - - if "data" in col_names: - data_col = "data" - elif "_airbyte_data" in col_names: - data_col = "_airbyte_data" - else: - # Flattened mode - sample all columns directly - df = self.spark.sql( - f"SELECT * FROM {table_name} LIMIT {self.sample_rows}" - ).toPandas() - samples = {} - for col in df.columns: - if not col.startswith("_"): - vals = df[col].dropna().astype(str).head(5).tolist() - samples[col] = [v[:100] for v in vals] - return samples - - df = self.spark.sql( - f"SELECT {data_col} FROM {table_name} LIMIT {self.sample_rows}" - ).toPandas() - - samples = {} - for _, row in df.iterrows(): - try: - record = json.loads(row[data_col]) - for col, value in record.items(): - if col not in samples: - samples[col] = [] - if value is not None and len(samples[col]) < 5: - samples[col].append(str(value)[:100]) - except (json.JSONDecodeError, KeyError, TypeError) as e: - logger.debug(f"Error parsing JSON row: {e}") - continue - - return samples - - def _format_samples_for_prompt(self, samples: Dict[str, List[str]]) -> str: - """Format column samples for the prompt.""" - lines = [] - for col, values in samples.items(): - values_str = ", ".join(f'"{v}"' for v in values[:3]) - lines.append(f"- {col}: {values_str}") - return "\n".join(lines) - - def _call_foundation_model(self, prompt: str) -> str: - """Call the Foundation Model API.""" - try: - from databricks.sdk.service.serving import ( - ChatMessage, - ChatMessageRole, - ) - - response = self.client.serving_endpoints.query( - name=self.model_name, - messages=[ChatMessage(role=ChatMessageRole.USER, content=prompt)], - ) - return response.choices[0].message.content - except Exception as e: - logger.warning(f"Foundation Model call failed: {e}") - return "{}" - - def _parse_enrichment_response( - self, - response: str, - table_name: str, - ) -> TableEnrichment: - """Parse the Foundation Model response into structured enrichment.""" - enrichment = TableEnrichment(table_name=table_name) - - try: - json_match = re.search(r"\{[\s\S]*\}", response) - if json_match: - data = json.loads(json_match.group()) - else: - data = {} - except json.JSONDecodeError: - data = {} - - enrichment.table_description = data.get("table_description") - - for col_data in data.get("columns", []): - col = ColumnEnrichment( - column_name=col_data.get("name", ""), - description=col_data.get("description"), - is_pii=col_data.get("is_pii", False), - pii_type=col_data.get("pii_type"), - data_classification=col_data.get("classification"), - ) - if col.column_name: - enrichment.columns.append(col) - - return enrichment - - def enrich(self, table_name: str) -> TableEnrichment: - """Generate semantic enrichment for a table.""" - logger.info(f" Analyzing table: {table_name}") - - samples = self._get_column_samples(table_name) - - if not samples: - logger.info(" No data columns found to analyze") - return TableEnrichment(table_name=table_name) - - samples_str = self._format_samples_for_prompt(samples) - prompt = ENRICHMENT_PROMPT.format( - table_name=table_name, - column_samples=samples_str, - ) - - logger.info(" Calling Foundation Model...") - response = self._call_foundation_model(prompt) - - enrichment = self._parse_enrichment_response(response, table_name) - - logger.info( - f" Generated descriptions for {len(enrichment.columns)} columns" - ) - - return enrichment - - def apply_to_catalog(self, enrichment: TableEnrichment): - """Apply enrichment metadata to Unity Catalog.""" - logger.info(f" Applying metadata to {enrichment.table_name}") - - # Set table comment - if enrichment.table_description: - try: - escaped_desc = enrichment.table_description.replace("'", "''") - self.spark.sql( - f"COMMENT ON TABLE {enrichment.table_name} IS '{escaped_desc}'" - ) - logger.info(" Set table description") - except Exception as e: - logger.warning(f" Could not set table comment: {e}") - - # Set PII tags - pii_fields = [] - for col in enrichment.columns: - if col.is_pii: - pii_fields.append(f"{col.column_name}:{col.pii_type or 'pii'}") - - if pii_fields: - try: - pii_value = ",".join(pii_fields) - self.spark.sql( - f"ALTER TABLE {enrichment.table_name} " - f"SET TAGS ('pii_fields' = '{pii_value}')" - ) - logger.info(f" Tagged PII fields: {pii_fields}") - except Exception as e: - logger.warning(f" Could not set PII tags: {e}") - - # In flatten mode, use COMMENT ON COLUMN for each column - # In raw mode, use TBLPROPERTIES - schema = self.spark.table(enrichment.table_name).schema - col_names = {f.name for f in schema.fields} - - flatten_mode = "data" not in col_names and "_airbyte_data" not in col_names - - if flatten_mode and enrichment.columns: - for col in enrichment.columns: - if col.description and col.column_name in col_names: - try: - escaped = col.description.replace("'", "''") - self.spark.sql( - f"COMMENT ON COLUMN {enrichment.table_name}." - f"`{col.column_name}` IS '{escaped}'" - ) - except Exception as e: - logger.warning( - f" Could not set comment on {col.column_name}: {e}" - ) - logger.info(" Set column-level comments") - elif enrichment.columns: - try: - desc_summary = "; ".join( - f"{c.column_name}: {c.description}" - for c in enrichment.columns[:10] - if c.description - ) - if desc_summary: - escaped = desc_summary.replace("'", "''")[:1000] - self.spark.sql( - f"ALTER TABLE {enrichment.table_name} " - f"SET TBLPROPERTIES ('brickbyte.field_descriptions' = '{escaped}')" - ) - logger.info(" Stored field descriptions in table properties") - except Exception as e: - logger.warning(f" Could not set field descriptions: {e}") - - logger.info(" Applied metadata to catalog") - - -class SQLSemanticEnricher: - """ - SQL-based semantic enrichment using SQL connector. - Does not require Spark. - """ - - def __init__( - self, - server_hostname: str, - http_path: str, - access_token: str, - catalog: str, - schema: str, - model_name: str = "databricks-meta-llama-3-3-70b-instruct", - sample_rows: int = 50, - ): - self.server_hostname = server_hostname - self.http_path = http_path - self._access_token = access_token - self.catalog = catalog - self.schema = schema - self.model_name = model_name - self.sample_rows = sample_rows - self._connection = None - self._client = None - - def _get_connection(self): - """Get or create database connection.""" - if self._connection is None: - from databricks import sql - - self._connection = sql.connect( - server_hostname=self.server_hostname, - http_path=self.http_path, - access_token=self._access_token, - catalog=self.catalog, - schema=self.schema, - ) - return self._connection - - def _execute(self, query: str): - """Execute a SQL query and return results.""" - conn = self._get_connection() - cursor = conn.cursor() - try: - cursor.execute(query) - return cursor.fetchall() - finally: - cursor.close() - - @property - def client(self): - """Get Databricks SDK client.""" - if self._client is None: - from databricks.sdk import WorkspaceClient - - self._client = WorkspaceClient() - return self._client - - def _get_column_samples(self, table_name: str) -> Dict[str, List[str]]: - """Get sample values via SQL.""" - # Get columns - columns = self._execute(f"DESCRIBE TABLE {table_name}") - col_names = [row[0] for row in columns] - - if "data" in col_names: - # Raw mode - rows = self._execute( - f"SELECT data FROM {table_name} LIMIT {self.sample_rows}" - ) - samples = {} - for row in rows: - try: - record = json.loads(row[0]) - for col, value in record.items(): - if col not in samples: - samples[col] = [] - if value is not None and len(samples[col]) < 5: - samples[col].append(str(value)[:100]) - except (json.JSONDecodeError, KeyError, TypeError) as e: - logger.debug(f"Error parsing JSON row: {e}") - continue - return samples - else: - # Flatten mode - sample_cols = [c for c in col_names if not c.startswith("_")][:20] - if not sample_cols: - return {} - - cols_str = ", ".join(f"`{c}`" for c in sample_cols) - rows = self._execute( - f"SELECT {cols_str} FROM {table_name} LIMIT {self.sample_rows}" - ) - samples = {} - for col_idx, col in enumerate(sample_cols): - vals = [] - for row in rows: - if row[col_idx] is not None and len(vals) < 5: - vals.append(str(row[col_idx])[:100]) - if vals: - samples[col] = vals - return samples - - def _call_foundation_model(self, prompt: str) -> str: - """Call the Foundation Model API.""" - try: - from databricks.sdk.service.serving import ( - ChatMessage, - ChatMessageRole, - ) - - response = self.client.serving_endpoints.query( - name=self.model_name, - messages=[ChatMessage(role=ChatMessageRole.USER, content=prompt)], - ) - return response.choices[0].message.content - except Exception as e: - logger.warning(f"Foundation Model call failed: {e}") - return "{}" - - def enrich(self, table_name: str) -> TableEnrichment: - """Generate semantic enrichment for a table.""" - logger.info(f" Analyzing table: {table_name}") - - samples = self._get_column_samples(table_name) - if not samples: - return TableEnrichment(table_name=table_name) - - lines = [] - for col, values in samples.items(): - values_str = ", ".join(f'"{v}"' for v in values[:3]) - lines.append(f"- {col}: {values_str}") - samples_str = "\n".join(lines) - - prompt = ENRICHMENT_PROMPT.format( - table_name=table_name, - column_samples=samples_str, - ) - - response = self._call_foundation_model(prompt) - - enrichment = TableEnrichment(table_name=table_name) - try: - json_match = re.search(r"\{[\s\S]*\}", response) - if json_match: - data = json.loads(json_match.group()) - else: - data = {} - except json.JSONDecodeError: - data = {} - - enrichment.table_description = data.get("table_description") - for col_data in data.get("columns", []): - col = ColumnEnrichment( - column_name=col_data.get("name", ""), - description=col_data.get("description"), - is_pii=col_data.get("is_pii", False), - pii_type=col_data.get("pii_type"), - data_classification=col_data.get("classification"), - ) - if col.column_name: - enrichment.columns.append(col) - - return enrichment - - def apply_to_catalog(self, enrichment: TableEnrichment): - """Apply enrichment metadata via SQL.""" - logger.info(f" Applying metadata to {enrichment.table_name}") - - if enrichment.table_description: - try: - escaped = enrichment.table_description.replace("'", "''") - self._execute( - f"COMMENT ON TABLE {enrichment.table_name} IS '{escaped}'" - ) - except Exception as e: - logger.warning(f" Could not set table comment: {e}") - - pii_fields = [] - for col in enrichment.columns: - if col.is_pii: - pii_fields.append(f"{col.column_name}:{col.pii_type or 'pii'}") - - if pii_fields: - try: - pii_value = ",".join(pii_fields) - self._execute( - f"ALTER TABLE {enrichment.table_name} " - f"SET TAGS ('pii_fields' = '{pii_value}')" - ) - except Exception as e: - logger.warning(f" Could not set PII tags: {e}") - - # Use COMMENT ON COLUMN in flatten mode - columns = self._execute(f"DESCRIBE TABLE {enrichment.table_name}") - col_names = {row[0] for row in columns} - flatten_mode = "data" not in col_names - - if flatten_mode: - for col in enrichment.columns: - if col.description and col.column_name in col_names: - try: - escaped = col.description.replace("'", "''") - self._execute( - f"COMMENT ON COLUMN {enrichment.table_name}." - f"`{col.column_name}` IS '{escaped}'" - ) - except Exception as e: - logger.warning( - f" Could not set comment on {col.column_name}: {e}" - ) - - def close(self): - """Close the SQL connection.""" - if self._connection: - self._connection.close() - self._connection = None - - -def enrich_table( - catalog: str, - schema: str, - table: str, - apply_to_catalog: bool = True, - model_name: str = "databricks-meta-llama-3-3-70b-instruct", -) -> TableEnrichment: - """Convenience function to enrich a single table using Spark.""" - table_name = f"{catalog}.{schema}.{table}" - - enricher = SemanticEnricher(model_name=model_name) - enrichment = enricher.enrich(table_name) - - if apply_to_catalog: - enricher.apply_to_catalog(enrichment) - - return enrichment diff --git a/src/brickbyte/preview.py b/src/brickbyte/preview.py index 60073ce..3279013 100644 --- a/src/brickbyte/preview.py +++ b/src/brickbyte/preview.py @@ -1,6 +1,6 @@ """ Preview engine for brickbyte. -Provides diff calculation and schema comparison before syncing. +Provides sample-based schema comparison before syncing. """ import logging from dataclasses import dataclass, field @@ -47,31 +47,16 @@ class StreamPreview: """Preview information for a single stream.""" stream_name: str - source_count: int + sampled_records: int target_count: int - new_records: int = 0 - modified_records: int = 0 - deleted_records: int = 0 schema_changes: List[SchemaChange] = field(default_factory=list) sample_records: List[dict] = field(default_factory=list) def __str__(self) -> str: - parts = [] - - if self.new_records > 0: - parts.append(f"+{self.new_records} new") - if self.modified_records > 0: - parts.append(f"~{self.modified_records} modified") - if self.deleted_records > 0: - parts.append(f"-{self.deleted_records} deleted") - - if not parts: - if self.source_count >= 0: - parts.append(f"{self.source_count} records") - else: - parts.append("Unknown records (Streaming)") - - line = f"{self.stream_name}: {' | '.join(parts)}" + line = ( + f"{self.stream_name}: sampled {self.sampled_records} records" + f" | target has {self.target_count} records" + ) if self.schema_changes: line += "\n Schema changes:" @@ -86,10 +71,8 @@ class PreviewResult: """Complete preview result for all streams.""" streams: List[StreamPreview] = field(default_factory=list) - total_source_records: int = 0 - total_new_records: int = 0 - total_modified_records: int = 0 - total_deleted_records: int = 0 + total_sampled_records: int = 0 + total_target_records: int = 0 has_schema_changes: bool = False def __str__(self) -> str: @@ -101,10 +84,9 @@ def __str__(self) -> str: lines.append("") lines.append("-" * 60) lines.append( - f"Total: {self.total_source_records} records " - f"(+{self.total_new_records} new, " - f"~{self.total_modified_records} modified, " - f"-{self.total_deleted_records} deleted)" + f"Total: sampled {self.total_sampled_records} records " + f"across {len(self.streams)} streams " + f"| target has {self.total_target_records} records" ) if self.has_schema_changes: @@ -119,9 +101,9 @@ class PreviewEngine: """ Generates previews of sync operations. - Compares source data (sampled) with existing target tables to show: + Samples source data and compares it with existing target tables to show: - Target record counts - - Schema changes (inferred from samples) + - Schema changes inferred from samples - Sample records """ @@ -269,11 +251,8 @@ def preview_stream( return StreamPreview( stream_name=stream_name, - source_count=-1, + sampled_records=len(sample_records), target_count=target_count, - new_records=-1, - modified_records=-1, - deleted_records=-1, schema_changes=schema_changes, sample_records=sample_records, ) @@ -290,6 +269,8 @@ def preview( for stream_name in streams: stream_preview = self.preview_stream(ab_source, stream_name, sample_size) result.streams.append(stream_preview) + result.total_sampled_records += stream_preview.sampled_records + result.total_target_records += stream_preview.target_count if stream_preview.schema_changes: result.has_schema_changes = True diff --git a/src/brickbyte/writers/sql_streaming_writer.py b/src/brickbyte/writers/sql_streaming_writer.py index 055cc33..0f19bfa 100644 --- a/src/brickbyte/writers/sql_streaming_writer.py +++ b/src/brickbyte/writers/sql_streaming_writer.py @@ -10,6 +10,7 @@ import logging import os import shutil +import tempfile from datetime import datetime, timezone from typing import Dict, List, Optional from uuid import uuid4 @@ -58,6 +59,7 @@ def __init__( self._buffer_sizes: Dict[str, int] = {} self._batch_index: int = 0 self._overwrite_streams: Dict[str, str] = {} + self._local_staging_root = tempfile.mkdtemp(prefix="brickbyte-sql-") parts = self.staging_volume.split(".") if len(parts) != 3: @@ -67,15 +69,6 @@ def __init__( ) self._vol_subpath = os.path.join(parts[0], parts[1], parts[2]) - # Validate Volume path exists - vol_base = f"/Volumes/{self._vol_subpath}" - if not os.path.exists(vol_base): - raise EnvironmentError( - f"Volume path '{vol_base}' does not exist. " - f"Ensure you are running on Databricks and the Volume " - f"'{self.staging_volume}' has been created in Unity Catalog." - ) - def _get_connection(self): """Get or create database connection.""" if self._connection is None: @@ -87,6 +80,7 @@ def _get_connection(self): access_token=self._access_token, catalog=self.catalog, schema=self.schema, + staging_allowed_local_path=self._local_staging_root, ) return self._connection @@ -100,15 +94,21 @@ def _execute(self, query: str): cursor.close() def _get_staging_dir(self, stream_name: str) -> str: - """Get staging directory path in Volume.""" + """Get local staging directory path for parquet generation.""" from brickbyte._sanitize import sanitize_stream_name sanitized = sanitize_stream_name(stream_name) - base_path = f"/Volumes/{self._vol_subpath}" - stream_dir = os.path.join(base_path, "brickbyte_streaming", sanitized) + stream_dir = os.path.join(self._local_staging_root, sanitized) os.makedirs(stream_dir, exist_ok=True) return stream_dir + def _get_volume_dir(self, stream_name: str) -> str: + """Get destination directory path inside the Unity Catalog Volume.""" + from brickbyte._sanitize import sanitize_stream_name + + sanitized = sanitize_stream_name(stream_name) + return f"/Volumes/{self._vol_subpath}/brickbyte_streaming/{self.run_id}/{sanitized}" + def table_exists(self, stream_name: str) -> bool: table_name = self.get_table_name(stream_name) try: @@ -218,9 +218,11 @@ def flush_stream(self, stream_name: str): records = self._buffers[stream_name] table_name = self._get_write_table_name(stream_name) - staging_dir = self._get_staging_dir(stream_name) + local_staging_dir = self._get_staging_dir(stream_name) + volume_staging_dir = self._get_volume_dir(stream_name) filename = f"{self.run_id}_{self._batch_index:06d}.parquet" - file_path = os.path.join(staging_dir, filename) + file_path = os.path.join(local_staging_dir, filename) + volume_file_path = f"{volume_staging_dir}/{filename}" self._batch_index += 1 try: @@ -236,9 +238,10 @@ def flush_stream(self, stream_name: str): ddl = self._infer_ddl_from_arrow(table.schema, table_name) self._execute(ddl) + self._execute(f"PUT '{file_path}' INTO '{volume_file_path}' OVERWRITE") copy_query = f""" COPY INTO {table_name} - FROM '{file_path}' + FROM '{volume_file_path}' FILEFORMAT = PARQUET FORMAT_OPTIONS ('mergeSchema' = 'true') """ @@ -248,6 +251,10 @@ def flush_stream(self, stream_name: str): logger.error(f"Error flushing stream {stream_name}: {e}") raise finally: + try: + self._execute(f"REMOVE '{volume_file_path}'") + except Exception: + pass # Always clean up the parquet file if os.path.exists(file_path): os.remove(file_path) @@ -303,6 +310,9 @@ def close(self): self._connection.close() self._connection = None + if os.path.exists(self._local_staging_root): + shutil.rmtree(self._local_staging_root, ignore_errors=True) + def safe_overwrite_begin(self, stream_name: str, run_id: str): """Begin safe overwrite — redirect writes to staging table.""" staging_name = self.get_staging_table_name(stream_name, run_id) diff --git a/tests/test_concurrent.py b/tests/test_concurrent.py index eef116c..fe9c57b 100644 --- a/tests/test_concurrent.py +++ b/tests/test_concurrent.py @@ -1,6 +1,7 @@ """ Tests for concurrent stream processing. """ +import threading from unittest.mock import MagicMock, patch import pytest @@ -13,19 +14,52 @@ class TestConcurrentStreams: def bb(self, tmp_path): return brickbyte.client(base_venv_directory=str(tmp_path)) + def _set_up_mock_sources(self, mock_airbyte, stream_records, stream_states=None): + stream_names = list(stream_records) + + def make_source(): + mock_source = MagicMock() + selected = {"streams": list(stream_names)} + + def select_all_streams(): + selected["streams"] = list(stream_names) + + def select_streams(streams): + selected["streams"] = list(streams) + + def get_selected_streams(): + return list(selected["streams"]) + + def get_records(stream_name): + behavior = stream_records[stream_name] + if isinstance(behavior, Exception): + raise behavior + return iter(behavior) + + mock_source.select_all_streams.side_effect = select_all_streams + mock_source.select_streams.side_effect = select_streams + mock_source.get_selected_streams.side_effect = get_selected_streams + mock_source.get_records.side_effect = get_records + mock_source.check.return_value = None + + if stream_states is not None: + mock_source.get_stream_state.side_effect = ( + lambda stream_name: stream_states[stream_name] + ) + + return mock_source + + mock_airbyte.get_source.side_effect = lambda *args, **kwargs: make_source() + def test_parallel_streams_each_get_own_writer(self, bb, mock_airbyte): - mock_source = MagicMock() - mock_airbyte.get_source.return_value = mock_source - mock_source.get_selected_streams.return_value = [ - "stream1", - "stream2", - "stream3", - ] - mock_source.get_records.side_effect = [ - [{"id": 1}], - [{"id": 2}], - [{"id": 3}], - ] + self._set_up_mock_sources( + mock_airbyte, + { + "stream1": [{"id": 1}], + "stream2": [{"id": 2}], + "stream3": [{"id": 3}], + }, + ) writers_created = [] @@ -51,14 +85,52 @@ def mock_create_writer(**kwargs): # Each stream gets its own writer (in thread pool) + no sequential writer assert len(writers_created) == 3 + def test_parallel_mode_completes_when_streams_exceed_workers(self, bb, mock_airbyte): + self._set_up_mock_sources( + mock_airbyte, + { + "stream1": [{"id": 1}], + "stream2": [{"id": 2}], + "stream3": [{"id": 3}], + }, + ) + + result_holder = {} + error_holder = {} + + with patch("brickbyte.writers.create_streaming_writer") as mock_factory: + mock_factory.return_value = MagicMock() + + def run_sync(): + try: + result_holder["result"] = bb.sync( + source="source-faker", + source_config={}, + catalog="main", + schema="test", + staging_volume="main.staging.vol", + mode="append", + max_parallel_streams=2, + ) + except Exception as e: # pragma: no cover - assertion below + error_holder["error"] = e + + thread = threading.Thread(target=run_sync, daemon=True) + thread.start() + thread.join(1) + + assert thread.is_alive() is False + assert "error" not in error_holder + assert result_holder["result"].records_written == 3 + def test_error_propagation_with_continue_on_error_false(self, bb, mock_airbyte): - mock_source = MagicMock() - mock_airbyte.get_source.return_value = mock_source - mock_source.get_selected_streams.return_value = ["stream1", "stream2"] - mock_source.get_records.side_effect = [ - RuntimeError("connection failed"), - [{"id": 1}], - ] + self._set_up_mock_sources( + mock_airbyte, + { + "stream1": RuntimeError("connection failed"), + "stream2": [{"id": 1}], + }, + ) with patch("brickbyte.writers.create_streaming_writer") as mock_factory: mock_writer = MagicMock() @@ -77,10 +149,13 @@ def test_error_propagation_with_continue_on_error_false(self, bb, mock_airbyte): ) def test_sequential_mode_uses_single_writer(self, bb, mock_airbyte): - mock_source = MagicMock() - mock_airbyte.get_source.return_value = mock_source - mock_source.get_selected_streams.return_value = ["stream1", "stream2"] - mock_source.get_records.side_effect = [[{"id": 1}], [{"id": 2}]] + self._set_up_mock_sources( + mock_airbyte, + { + "stream1": [{"id": 1}], + "stream2": [{"id": 2}], + }, + ) with patch( "brickbyte.writers.create_streaming_writer" @@ -103,14 +178,17 @@ def test_sequential_mode_uses_single_writer(self, bb, mock_airbyte): mock_factory.assert_called_once() def test_parallel_incremental_saves_state_per_stream(self, bb, mock_airbyte): - mock_source = MagicMock() - mock_airbyte.get_source.return_value = mock_source - mock_source.get_selected_streams.return_value = ["users", "orders"] - mock_source.get_records.side_effect = [ - [{"id": 1}], - [{"id": 2}], - ] - mock_source.get_state.return_value = None + self._set_up_mock_sources( + mock_airbyte, + { + "users": [{"id": 1}], + "orders": [{"id": 2}], + }, + stream_states={ + "users": {"cursor": "users"}, + "orders": {"cursor": "orders"}, + }, + ) with patch("brickbyte._state.StateManager") as mock_state_manager_cls: mock_state_manager = MagicMock() diff --git a/tests/test_enrichment_sql.py b/tests/test_enrichment_sql.py deleted file mode 100644 index f25ea88..0000000 --- a/tests/test_enrichment_sql.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -Tests for SQL-mode enrichment. -""" -from unittest.mock import MagicMock, patch - -import pytest - -from brickbyte.enrichment.semantic import ( - ColumnEnrichment, - SQLSemanticEnricher, - TableEnrichment, -) - - -class TestSQLSemanticEnricher: - @pytest.fixture - def enricher(self): - with patch("databricks.sql.connect") as mock_connect: - e = SQLSemanticEnricher( - server_hostname="host", - http_path="/sql", - access_token="token", - catalog="main", - schema="test", - ) - e._connection = mock_connect.return_value - return e - - def test_get_column_samples_raw_mode(self, enricher): - cursor = MagicMock() - enricher._connection.cursor.return_value = cursor - - # DESCRIBE returns data column - cursor.fetchall.side_effect = [ - [("record_id",), ("extracted_at",), ("data",), ("run_id",)], - [('{"name": "Alice", "email": "alice@example.com"}',)], - ] - - samples = enricher._get_column_samples("`main`.`test`.`users`") - - assert "name" in samples - assert "email" in samples - assert samples["name"] == ["Alice"] - - def test_get_column_samples_flatten_mode(self, enricher): - cursor = MagicMock() - enricher._connection.cursor.return_value = cursor - - # DESCRIBE returns no data column (flatten mode) - cursor.fetchall.side_effect = [ - [("name",), ("email",), ("_record_id",), ("_extracted_at",)], - [("Alice", "alice@example.com"), ("Bob", "bob@example.com")], - ] - - samples = enricher._get_column_samples("`main`.`test`.`users`") - - assert "name" in samples - assert "email" in samples - assert "_record_id" not in samples # underscore-prefixed excluded - - def test_apply_to_catalog_column_comments_in_flatten(self, enricher): - cursor = MagicMock() - enricher._connection.cursor.return_value = cursor - - # DESCRIBE returns flatten columns (no data column) - cursor.fetchall.return_value = [ - ("name",), - ("email",), - ("_record_id",), - ] - - enrichment = TableEnrichment( - table_name="`main`.`test`.`users`", - columns=[ - ColumnEnrichment( - column_name="name", - description="User full name", - ), - ColumnEnrichment( - column_name="email", - description="User email address", - is_pii=True, - pii_type="email", - ), - ], - ) - - enricher.apply_to_catalog(enrichment) - - # Should have COMMENT ON COLUMN calls - execute_calls = [str(c) for c in cursor.execute.call_args_list] - comment_calls = [c for c in execute_calls if "COMMENT ON COLUMN" in c] - assert len(comment_calls) >= 1 - - def test_close_connection(self): - with patch("databricks.sql.connect") as mock_connect: - mock_conn = MagicMock() - mock_connect.return_value = mock_conn - - e = SQLSemanticEnricher( - server_hostname="host", - http_path="/sql", - access_token="token", - catalog="main", - schema="test", - ) - # Force connection creation - e._connection = mock_conn - e.close() - mock_conn.close.assert_called_once() diff --git a/tests/test_functional.py b/tests/test_functional.py index 70ae10c..d01360e 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -139,7 +139,6 @@ def test_sync_result_dataclass(self): records_written=100, streams_synced=["a", "b"], failed_streams=["c"], - enriched_tables=["a"], ) assert result.records_written == 100 assert len(result.streams_synced) == 2 diff --git a/tests/test_preview.py b/tests/test_preview.py index 82d3562..e41798f 100644 --- a/tests/test_preview.py +++ b/tests/test_preview.py @@ -44,30 +44,22 @@ def test_type_changed_str(self): class TestStreamPreview: def test_str_with_counts(self): - preview = StreamPreview( - stream_name="users", - source_count=100, - target_count=50, - new_records=30, - modified_records=10, - deleted_records=5, - ) + preview = StreamPreview(stream_name="users", sampled_records=5, target_count=50) result = str(preview) assert "users" in result - assert "+30 new" in result - assert "~10 modified" in result - assert "-5 deleted" in result + assert "sampled 5 records" in result + assert "target has 50 records" in result def test_str_streaming_unknown_count(self): - preview = StreamPreview(stream_name="events", source_count=-1, target_count=0) + preview = StreamPreview(stream_name="events", sampled_records=0, target_count=0) result = str(preview) assert "events" in result - assert "Unknown" in result or "Streaming" in result + assert "sampled 0 records" in result def test_str_with_schema_changes(self): preview = StreamPreview( stream_name="orders", - source_count=50, + sampled_records=2, target_count=40, schema_changes=[ SchemaChange(column="new_field", change_type="added", source_type="str"), @@ -82,22 +74,23 @@ class TestPreviewResult: def test_str_output(self): result = PreviewResult( streams=[ - StreamPreview("users", 100, 50, new_records=30), - StreamPreview("orders", 200, 150, new_records=20), + StreamPreview("users", 5, 50), + StreamPreview("orders", 3, 150), ], - total_source_records=300, - total_new_records=50, + total_sampled_records=8, + total_target_records=200, has_schema_changes=True, ) output = str(result) assert "Sync Preview" in output assert "users" in output assert "orders" in output + assert "sampled 8 records" in output assert "Schema changes detected" in output def test_str_no_schema_changes(self): result = PreviewResult( - streams=[StreamPreview("data", 10, 10)], has_schema_changes=False + streams=[StreamPreview("data", 1, 10)], has_schema_changes=False ) output = str(result) assert "Schema changes detected" not in output @@ -191,13 +184,19 @@ def test_preview_stream(self, engine): preview = engine.preview_stream(mock_source, "users", sample_size=5) assert preview.stream_name == "users" assert preview.target_count == 100 + assert preview.sampled_records == 2 assert len(preview.sample_records) == 2 def test_preview_all_streams(self, engine): engine._spark.catalog.tableExists.return_value = False mock_source = MagicMock() - mock_source.get_records.return_value = iter([{"id": 1}]) + mock_source.get_records.side_effect = [ + iter([{"id": 1}]), + iter([{"id": 2}]), + ] result = engine.preview(mock_source, ["stream1", "stream2"]) assert len(result.streams) == 2 + assert result.total_sampled_records == 2 + assert result.total_target_records == 0 assert result.streams[0].stream_name == "stream1" assert result.streams[1].stream_name == "stream2" diff --git a/tests/test_streaming.py b/tests/test_streaming.py index d62ef24..5e18c35 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -27,27 +27,14 @@ def writer(self): def test_init_validation(self): with pytest.raises(ValueError): - with patch("os.path.exists", return_value=True): - SQLStreamingWriter( - catalog="main", - schema="test", - staging_volume="invalid_format", - server_hostname="h", - http_path="p", - access_token="t", - ) - - def test_volume_path_guard(self): - with patch("os.path.exists", return_value=False): - with pytest.raises(EnvironmentError, match="Volume path"): - SQLStreamingWriter( - catalog="main", - schema="test", - staging_volume="main.staging.vol", - server_hostname="h", - http_path="p", - access_token="t", - ) + SQLStreamingWriter( + catalog="main", + schema="test", + staging_volume="invalid_format", + server_hostname="h", + http_path="p", + access_token="t", + ) @patch("pyarrow.parquet.write_table") @patch("os.path.exists", return_value=True) @@ -62,16 +49,21 @@ def test_flush_logic(self, mock_makedirs, mock_remove, mock_exists, mock_pq_writ writer.write_record("stream1", {"id": 2}) assert len(writer._buffers["stream1"]) == 0 - assert writer._execute.call_count == 2 # CREATE + COPY INTO + assert writer._execute.call_count == 4 # CREATE + PUT + COPY INTO + REMOVE mock_pq_write.assert_called_once() mock_remove.assert_called_once() - copy_call = writer._execute.call_args_list[1] + put_call = writer._execute.call_args_list[1] + assert "PUT" in put_call[0][0] + + copy_call = writer._execute.call_args_list[2] query = copy_call[0][0] assert "COPY INTO" in query - # force=true should NOT be in the query assert "force" not in query.lower() + remove_call = writer._execute.call_args_list[3] + assert "REMOVE" in remove_call[0][0] + @patch("pyarrow.parquet.write_table") @patch("os.remove") @patch("os.makedirs") diff --git a/uv.lock b/uv.lock index 9231a9b..150b497 100644 --- a/uv.lock +++ b/uv.lock @@ -314,7 +314,7 @@ wheels = [ [[package]] name = "brickbyte" -version = "0.1.0" +version = "0.1.0rc1" source = { editable = "." } dependencies = [ { name = "airbyte" }, From 777844da7093df5194fd9312848ade58c0cb581a Mon Sep 17 00:00:00 2001 From: park-peter Date: Thu, 12 Mar 2026 22:54:08 +0900 Subject: [PATCH 7/7] Format source with Ruff --- src/brickbyte/__init__.py | 1 + src/brickbyte/_client.py | 47 +++++++------------ src/brickbyte/_dedup.py | 5 +- src/brickbyte/_progress.py | 5 +- src/brickbyte/_sanitize.py | 4 +- src/brickbyte/_state.py | 9 ++-- src/brickbyte/credentials.py | 13 ++--- src/brickbyte/preview.py | 1 + src/brickbyte/types.py | 4 +- src/brickbyte/writers/__init__.py | 4 +- src/brickbyte/writers/base.py | 1 + .../writers/spark_streaming_writer.py | 19 +++----- src/brickbyte/writers/sql_streaming_writer.py | 17 ++----- 13 files changed, 48 insertions(+), 82 deletions(-) diff --git a/src/brickbyte/__init__.py b/src/brickbyte/__init__.py index 1da932b..27ec6f7 100644 --- a/src/brickbyte/__init__.py +++ b/src/brickbyte/__init__.py @@ -1,6 +1,7 @@ """ brickbyte - Sync data from 600+ sources directly into Databricks. """ + import logging from dataclasses import dataclass, field from typing import List diff --git a/src/brickbyte/_client.py b/src/brickbyte/_client.py index 3cc481c..43b8f7c 100644 --- a/src/brickbyte/_client.py +++ b/src/brickbyte/_client.py @@ -1,6 +1,7 @@ """ Internal Client class for brickbyte. """ + import logging import os import shutil @@ -93,9 +94,7 @@ def _validate_sync_params(self, mode: str): if mode not in valid_modes: if mode == "merge": raise NotImplementedError("Merge mode is not yet supported.") - raise ValueError( - f"Invalid mode '{mode}'. Must be one of: {', '.join(valid_modes)}" - ) + raise ValueError(f"Invalid mode '{mode}'. Must be one of: {', '.join(valid_modes)}") def _create_source_instance( self, @@ -312,16 +311,12 @@ def sync( saved = state_manager.get_state(source, stream_name) if saved is not None: stream_states[stream_name] = saved - logger.info( - f" Loaded incremental state for {stream_name}" - ) + logger.info(f" Loaded incremental state for {stream_name}") if max_parallel_streams == 1: self._apply_incremental_state(ab_source, stream_states) via_msg = f" via {staging_volume}" if staging_volume else " (Native Spark)" - logger.info( - f"Streaming {len(selected)} streams to {catalog}.{schema}{via_msg}..." - ) + logger.info(f"Streaming {len(selected)} streams to {catalog}.{schema}{via_msg}...") if progress_callback is not None: from brickbyte._progress import ProgressReporter @@ -476,11 +471,7 @@ def _sync_stream_parallel(stream_name: str): if count % 10000 == 0: logger.info(f" ...streamed {count} records") - if ( - cancel_event - and count % 1000 == 0 - and cancel_event.is_set() - ): + if cancel_event and count % 1000 == 0 and cancel_event.is_set(): raise TimeoutError( f"Sync timed out after {timeout_seconds} seconds" ) @@ -491,8 +482,13 @@ def _sync_stream_parallel(stream_name: str): writer.safe_overwrite_finish(stream_name, run_id) self._run_dedup_for_stream( - stream_name, deduplicate, normalized_dedup_keys, - flatten, catalog, schema, writer, + stream_name, + deduplicate, + normalized_dedup_keys, + flatten, + catalog, + schema, + writer, ) logger.info(f" {count} records streamed") @@ -526,13 +522,10 @@ def _sync_stream_parallel(stream_name: str): if failed_streams: if continue_on_error: logger.warning( - f"Completed with {len(failed_streams)} failed streams: " - f"{failed_streams}" + f"Completed with {len(failed_streams)} failed streams: " f"{failed_streams}" ) else: - raise RuntimeError( - f"Sync failed. Failed streams: {failed_streams}" - ) + raise RuntimeError(f"Sync failed. Failed streams: {failed_streams}") return SyncResult( records_written=total_records, @@ -620,9 +613,7 @@ def _normalize_dedup_keys( if isinstance(dedup_keys, dict): for stream_name, keys in dedup_keys.items(): if not isinstance(keys, list) or len(keys) == 0: - raise ValueError( - f"dedup_keys for stream '{stream_name}' must be non-empty" - ) + raise ValueError(f"dedup_keys for stream '{stream_name}' must be non-empty") self._validate_dedup_key_list( keys, context=f"dedup_keys for stream '{stream_name}'", @@ -659,9 +650,7 @@ def _apply_incremental_state( try: for stream_name, state in stream_states.items(): method(stream_name, state) - logger.info( - f"Applied incremental state for {len(stream_states)} stream(s)" - ) + logger.info(f"Applied incremental state for {len(stream_states)} stream(s)") return except TypeError: continue @@ -680,9 +669,7 @@ def _apply_incremental_state( for payload in (state_payload, stream_states): try: set_state(payload) - logger.info( - f"Applied incremental state for {len(stream_states)} stream(s)" - ) + logger.info(f"Applied incremental state for {len(stream_states)} stream(s)") return except TypeError: continue diff --git a/src/brickbyte/_dedup.py b/src/brickbyte/_dedup.py index 58438e0..1baa154 100644 --- a/src/brickbyte/_dedup.py +++ b/src/brickbyte/_dedup.py @@ -3,6 +3,7 @@ Uses MERGE to remove duplicate records based on user-specified keys. """ + import logging from typing import List @@ -47,9 +48,7 @@ def deduplicate_stream( validated_record_id_col = validate_identifier(record_id_col) validated_dk_missing_col = validate_identifier(dk_missing_col) - key_match = " AND ".join( - f"t.`{col}` <=> s.`{col}`" for col in validated_keys - ) + key_match = " AND ".join(f"t.`{col}` <=> s.`{col}`" for col in validated_keys) # Build the dedup MERGE statement # This keeps only the latest record per key combo diff --git a/src/brickbyte/_progress.py b/src/brickbyte/_progress.py index c0a3f37..92c605f 100644 --- a/src/brickbyte/_progress.py +++ b/src/brickbyte/_progress.py @@ -1,6 +1,7 @@ """ Progress reporting for brickbyte sync operations. """ + import threading import time from dataclasses import dataclass @@ -86,9 +87,7 @@ def stream_completed(self, stream_name: str, records: int): if self._tqdm_bar: self._tqdm_bar.update(1) - self._tqdm_bar.set_postfix( - stream=stream_name, records=records - ) + self._tqdm_bar.set_postfix(stream=stream_name, records=records) if self.callback: event = ProgressEvent( diff --git a/src/brickbyte/_sanitize.py b/src/brickbyte/_sanitize.py index b938114..8df82d1 100644 --- a/src/brickbyte/_sanitize.py +++ b/src/brickbyte/_sanitize.py @@ -1,6 +1,7 @@ """ Stream name sanitization and SQL identifier validation for brickbyte. """ + import re @@ -42,8 +43,7 @@ def validate_identifier(name: str) -> str: dangerous = re.search(r"[\x00`;]", name) if dangerous: raise ValueError( - f"Identifier '{name}' contains unsafe character: " - f"{repr(dangerous.group())}" + f"Identifier '{name}' contains unsafe character: " f"{repr(dangerous.group())}" ) return name diff --git a/src/brickbyte/_state.py b/src/brickbyte/_state.py index 623e2bc..9caa0ed 100644 --- a/src/brickbyte/_state.py +++ b/src/brickbyte/_state.py @@ -3,6 +3,7 @@ Manages the `__brickbyte_state` table to track sync state per (source, stream) pair. """ + import json import logging from typing import Optional @@ -108,9 +109,7 @@ def _get_connection(self): wh_id = self._warehouse_id if not wh_id: warehouses = list(w.warehouses.list()) - running = [ - wh for wh in warehouses if wh.state and wh.state.value == "RUNNING" - ] + running = [wh for wh in warehouses if wh.state and wh.state.value == "RUNNING"] if running: wh_id = running[0].id else: @@ -177,9 +176,7 @@ def get_state(self, source: str, stream_name: str) -> Optional[dict]: df = ( spark.table(self._state_table) - .filter( - (col("source") == source) & (col("stream_name") == stream_name) - ) + .filter((col("source") == source) & (col("stream_name") == stream_name)) .select("state") .limit(1) ) diff --git a/src/brickbyte/credentials.py b/src/brickbyte/credentials.py index c133b63..4302e7e 100644 --- a/src/brickbyte/credentials.py +++ b/src/brickbyte/credentials.py @@ -4,6 +4,7 @@ Provides automatic credential resolution from Databricks Secrets with optional YAML profiles for advanced use cases. """ + import logging import re from typing import Any, Dict, List, Optional @@ -75,9 +76,7 @@ def _list_secrets_for_source(self, source: str) -> List[str]: # Filter keys that start with the source name prefix = f"{source}/" - return [ - key[len(prefix) :] for key in self._available_keys if key.startswith(prefix) - ] + return [key[len(prefix) :] for key in self._available_keys if key.startswith(prefix)] except Exception as e: logger.debug(f"Could not list secrets: {e}") return [] @@ -201,9 +200,7 @@ def get_credentials(self, source: str) -> Dict[str, Any]: profile_name = self._mappings[source] credentials = self._resolve_profile(profile_name) if credentials: - logger.debug( - f"Resolved credentials for {source} from profile '{profile_name}'" - ) + logger.debug(f"Resolved credentials for {source} from profile '{profile_name}'") # Fall back to convention-based discovery if not credentials: @@ -216,9 +213,7 @@ def get_credentials(self, source: str) -> Dict[str, Any]: self._set_nested(credentials, key, value) if credentials: - logger.debug( - f"Discovered {len(credentials)} credentials for {source} from secrets" - ) + logger.debug(f"Discovered {len(credentials)} credentials for {source} from secrets") # Cache the result self._cache[source] = credentials diff --git a/src/brickbyte/preview.py b/src/brickbyte/preview.py index 3279013..0446741 100644 --- a/src/brickbyte/preview.py +++ b/src/brickbyte/preview.py @@ -2,6 +2,7 @@ Preview engine for brickbyte. Provides sample-based schema comparison before syncing. """ + import logging from dataclasses import dataclass, field from typing import Any, Dict, List, Optional diff --git a/src/brickbyte/types.py b/src/brickbyte/types.py index b5ce350..eb17295 100644 --- a/src/brickbyte/types.py +++ b/src/brickbyte/types.py @@ -103,5 +103,5 @@ "source-youtube-analytics", "source-zendesk-chat", "source-zendesk-support", - "source-zenloop" -] \ No newline at end of file + "source-zenloop", +] diff --git a/src/brickbyte/writers/__init__.py b/src/brickbyte/writers/__init__.py index 1c8bb00..ef38aa5 100644 --- a/src/brickbyte/writers/__init__.py +++ b/src/brickbyte/writers/__init__.py @@ -1,6 +1,7 @@ """ brickbyte Writers Module. """ + import logging from typing import Dict, List, Optional @@ -81,8 +82,7 @@ def create_streaming_writer( warehouse_id = running[0].id else: raise ValueError( - "No running SQL warehouse found. " - "Specify warehouse_id or start a warehouse." + "No running SQL warehouse found. " "Specify warehouse_id or start a warehouse." ) http_path = f"/sql/1.0/warehouses/{warehouse_id}" diff --git a/src/brickbyte/writers/base.py b/src/brickbyte/writers/base.py index b5add61..c19c5b5 100644 --- a/src/brickbyte/writers/base.py +++ b/src/brickbyte/writers/base.py @@ -2,6 +2,7 @@ Abstract base writer for brickbyte. Defines the interface all writers must implement. """ + from abc import ABC, abstractmethod from typing import Dict, List, Optional diff --git a/src/brickbyte/writers/spark_streaming_writer.py b/src/brickbyte/writers/spark_streaming_writer.py index b99b630..ceb5741 100644 --- a/src/brickbyte/writers/spark_streaming_writer.py +++ b/src/brickbyte/writers/spark_streaming_writer.py @@ -6,6 +6,7 @@ - Fault tolerance (each flush = implicit checkpoint) - Databricks auto-optimize handles small file compaction """ + import json import logging from datetime import datetime, timezone @@ -141,9 +142,7 @@ def write_record(self, stream_name: str, record: dict): len(str(v).encode("utf-8")) for v in transformed.values() ) else: - self._buffer_sizes[stream_name] += len( - transformed["data"].encode("utf-8") - ) + self._buffer_sizes[stream_name] += len(transformed["data"].encode("utf-8")) # Flush micro-batch when thresholds hit if ( @@ -215,9 +214,7 @@ def safe_overwrite_finish(self, stream_name: str, run_id: str): self._atomic_overwrite(target_name, staging_name) self.spark.sql(f"DROP TABLE IF EXISTS {staging_name}") else: - self.spark.sql( - f"ALTER TABLE {staging_name} RENAME TO {target_name}" - ) + self.spark.sql(f"ALTER TABLE {staging_name} RENAME TO {target_name}") except Exception: # On failure, drop staging table, target untouched self.spark.sql(f"DROP TABLE IF EXISTS {staging_name}") @@ -255,9 +252,7 @@ def _atomic_overwrite(self, target_name: str, staging_name: str): new_cols = staging_cols - target_cols for col in new_cols: col_type = self._sql_type(staging_schema[col]) - self.spark.sql( - f"ALTER TABLE {target_name} ADD COLUMNS (`{col}` {col_type})" - ) + self.spark.sql(f"ALTER TABLE {target_name} ADD COLUMNS (`{col}` {col_type})") all_cols = target_cols | staging_cols select_parts = [] @@ -267,9 +262,7 @@ def _atomic_overwrite(self, target_name: str, staging_name: str): t_type = self._type_name(target_schema[col]) if s_type != t_type and (s_type, t_type) in self._SAFE_WIDENINGS: target_sql_type = self._sql_type(target_schema[col]) - select_parts.append( - f"CAST(`{col}` AS {target_sql_type}) AS `{col}`" - ) + select_parts.append(f"CAST(`{col}` AS {target_sql_type}) AS `{col}`") elif s_type != t_type and (t_type, s_type) in self._SAFE_WIDENINGS: staging_sql_type = self._sql_type(staging_schema[col]) self.spark.sql( @@ -311,7 +304,7 @@ def _sql_type(data_type) -> str: if as_str.endswith("()"): as_str = as_str[:-2] if as_str.startswith("DecimalType(") and as_str.endswith(")"): - precision_scale = as_str[len("DecimalType("):-1] + precision_scale = as_str[len("DecimalType(") : -1] return f"DECIMAL({precision_scale})" mapping = { "ByteType": "TINYINT", diff --git a/src/brickbyte/writers/sql_streaming_writer.py b/src/brickbyte/writers/sql_streaming_writer.py index 0f19bfa..041174e 100644 --- a/src/brickbyte/writers/sql_streaming_writer.py +++ b/src/brickbyte/writers/sql_streaming_writer.py @@ -6,6 +6,7 @@ - Fault tolerance (each flush = implicit checkpoint) - Databricks auto-optimize handles small file compaction """ + import json import logging import os @@ -193,9 +194,7 @@ def write_record(self, stream_name: str, record: dict): len(str(v).encode("utf-8")) for v in transformed.values() ) else: - self._buffer_sizes[stream_name] += len( - transformed["data"].encode("utf-8") - ) + self._buffer_sizes[stream_name] += len(transformed["data"].encode("utf-8")) # Check both thresholds if ( @@ -334,9 +333,7 @@ def safe_overwrite_finish(self, stream_name: str, run_id: str): self._atomic_overwrite_sql(target_name, staging_name) self._execute(f"DROP TABLE IF EXISTS {staging_name}") else: - self._execute( - f"ALTER TABLE {staging_name} RENAME TO {target_name}" - ) + self._execute(f"ALTER TABLE {staging_name} RENAME TO {target_name}") except Exception: self._execute(f"DROP TABLE IF EXISTS {staging_name}") raise @@ -393,9 +390,7 @@ def _atomic_overwrite_sql(self, target_name: str, staging_name: str): new_cols = staging_cols - target_cols for col in new_cols: col_type = staging_schema[col] - self._execute( - f"ALTER TABLE {target_name} ADD COLUMNS (`{col}` {col_type})" - ) + self._execute(f"ALTER TABLE {target_name} ADD COLUMNS (`{col}` {col_type})") all_cols = target_cols | staging_cols select_parts = [] @@ -406,9 +401,7 @@ def _atomic_overwrite_sql(self, target_name: str, staging_name: str): if s_type != t_type: # Always widen to the wider type if (s_type, t_type) in self._SAFE_WIDENINGS_SQL: - select_parts.append( - f"CAST(`{col}` AS {target_schema[col]}) AS `{col}`" - ) + select_parts.append(f"CAST(`{col}` AS {target_schema[col]}) AS `{col}`") elif (t_type, s_type) in self._SAFE_WIDENINGS_SQL: # Staging is wider — widen target to match self._execute(