diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 276a37c..0000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-name: Bug report
-about: Create a report to help us improve
-title: "[Bug]:"
-labels: ''
-assignees: ''
-
----
-
-**Describe the bug**
-A clear and concise description of what the bug is.
-
-**To Reproduce**
-Steps to reproduce the behavior:
-1. Go to '...'
-2. Click on '....'
-3. Scroll down to '....'
-4. See error
-
-**Expected behavior**
-A clear and concise description of what you expected to happen.
-
-**Screenshots**
-If applicable, add screenshots to help explain your problem.
-
-**Additional context**
-Add any other context about the problem here.
-
-Thanks for taking the time to fill out this bug report!
diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md
deleted file mode 100644
index 1cb77ff..0000000
--- a/.github/ISSUE_TEMPLATE/documentation.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-name: Documentation Issue Report
-about: Issues related to documentation.
-title: "[Docs]:"
-labels: 'docs'
-assignees: ''
-
----
-
-## Current State
-A clear and concise description of what the issue is about:
-
-## Desired State
-Describe the desired state the documentation should be:
diff --git a/.github/ISSUE_TEMPLATE/request-new-library.md b/.github/ISSUE_TEMPLATE/request-new-library.md
deleted file mode 100644
index 517a8d8..0000000
--- a/.github/ISSUE_TEMPLATE/request-new-library.md
+++ /dev/null
@@ -1,28 +0,0 @@
----
-name: New library support request
-about: New library support request for the transformation function
-title: "[New library]:"
-labels: ''
-assignees: ''
-
----
-
-**Python package(library) name**
-The name of the library you are requesting.
-
-**Version**
-The version of the library (if specific version compatibility is required).
-
-**Description**
-A brief explanation of why this library is necessary for your transformation function.
-
-**Use cases**
-Example use cases or sample code that demonstrates the library's use in your context.
-
-**Additional context**
-Add any other context about the problem here.
-
-Thanks for taking the time to fill out this request!
-
-Our team will review library requests on a case-by-case basis, considering factors such as the library's relevance to data processing, security implications, and overall demand.
-While we aim to accommodate as many reasonable requests as possible, please note that not all library requests may be approved.
diff --git a/.github/ISSUE_TEMPLATE/request-new-source-or-destination-feature.md b/.github/ISSUE_TEMPLATE/request-new-source-or-destination-feature.md
deleted file mode 100644
index 187fcff..0000000
--- a/.github/ISSUE_TEMPLATE/request-new-source-or-destination-feature.md
+++ /dev/null
@@ -1,26 +0,0 @@
----
-name: Request new source or destination feature
-about: Submit a new source or destination feature request.
-title: "[Feature]:"
-labels: ''
-assignees: ''
-
----
-
-* [ ] I checked the integrations page and my source/destination is not listed there.
-
-# What is the data source/destination?
-Could you describe the data source/destination? Is it an API? Or database? Link relevant information.
-
-# Your plans for the source/destination
-* [ ] I want to run it myself
-* [ ] I need the data for my hobby project
-* [ ] I need the data for my work
-* [ ] I want to run it in production
-* I want to load data to: [e.g. BigQuery, Redshift]
-
-# What data would you like loaded/sent?
-Describe how it should be loaded/consumed
-
-# Besides you who else will benefit?
-Please tell us who else could use this source/destination.
diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml
new file mode 100644
index 0000000..759cc1e
--- /dev/null
+++ b/.github/workflows/copilot-setup-steps.yml
@@ -0,0 +1,41 @@
+name: "Copilot Setup Steps"
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - .github/workflows/copilot-setup-steps.yml
+ pull_request:
+ paths:
+ - .github/workflows/copilot-setup-steps.yml
+
+jobs:
+ copilot-setup-steps:
+ runs-on: ubuntu-latest
+
+ permissions:
+ contents: read
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ id: setup-python
+ with:
+ python-version-file: "pyproject.toml"
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ enable-cache: true
+ cache-dependency-glob: "uv.lock"
+
+ - name: Set up venv
+ run: |
+ uv venv --python ${{ steps.setup-python.outputs.python-path }}
+
+ - name: Install dependencies
+ run: |
+ uv pip install -e .[test]
diff --git a/.github/workflows/on_pr.yaml b/.github/workflows/on_pr.yaml
deleted file mode 100644
index 9f475e8..0000000
--- a/.github/workflows/on_pr.yaml
+++ /dev/null
@@ -1,104 +0,0 @@
-name: Test GlassFlow Python SDK
-
-on:
- pull_request:
- branches:
- - main
- - release/*
-
-permissions:
- contents: write
- checks: write
- pull-requests: write
-
-jobs:
- tests:
- name: Run pytest tests
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup Python environment
- uses: actions/setup-python@v5
- with:
- python-version: "3.x"
- cache: 'pip'
- cache-dependency-path: setup.py
-
- - name: Install dependencies
- run: pip install -e .[dev]
-
- - name: Run Tests
- run: pytest
- env:
- PERSONAL_ACCESS_TOKEN: ${{ secrets.INTEGRATION_PERSONAL_ACCESS_TOKEN }}
-
- - name: Upload coverage report
- uses: actions/upload-artifact@v4
- with:
- name: coverageReport
- path: tests/reports/
-
- checks:
- name: Run code checks
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup Python environment
- uses: actions/setup-python@v5
- with:
- python-version: "3.x"
- cache: 'pip'
- cache-dependency-path: setup.py
-
- - name: Install dependencies
- run: pip install -e .[dev]
-
- - name: Run ruff linter checks
- run: ruff check .
-
- - name: Run ruff formatting checks
- run: ruff format --check .
-
- coverage:
- runs-on: ubuntu-latest
- needs: [tests]
- steps:
- - uses: actions/checkout@v4
- with:
- persist-credentials: false # otherwise, the token used is the GITHUB_TOKEN, instead of your personal token
- fetch-depth: 0 # otherwise, you will fail to push refs to dest repo
-
- - name: Download coverage report
- uses: actions/download-artifact@v4
- with:
- name: coverageReport
-
- - name: Pytest coverage comment
- id: coverageComment
- uses: MishaKav/pytest-coverage-comment@main
- with:
- pytest-xml-coverage-path: ./coverage.xml
- junitxml-path: ./pytest.xml
-
- - name: Update Readme with Coverage Html
- if: ${{ github.ref == 'refs/heads/main' }}
- run: |
- sed -i '//,//c\\n\${{ steps.coverageComment.outputs.coverageHtml }}\n' ./README.md
-
- - name: Commit & Push changes to Readme
- id: commit
- if: ${{ github.ref == 'refs/heads/main' }}
- run: |
- git config --global user.name 'GitHub Actions Workflow glassflow-python-sdk/.github/workflows/on_pr.yaml'
- git config --global user.email 'glassflow-actions-workflow@users.noreply.github.com'
-
- git add README.md
- git commit -m "Update coverage on Readme"
-
- git push origin master
diff --git a/.github/workflows/publish_pypi.yaml b/.github/workflows/publish_pypi.yaml
deleted file mode 100644
index 6a8adeb..0000000
--- a/.github/workflows/publish_pypi.yaml
+++ /dev/null
@@ -1,123 +0,0 @@
-name: Publish Python š distribution š¦ to PyPI and TestPyPI
-
-on:
- push:
- branches:
- - release/*
- tags:
- - v*
-
-jobs:
- build:
- name: Build distribution š¦
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v4
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: "3.x"
- - name: Install pypa/build
- run: >-
- python3 -m
- pip install
- build
- --user
- - name: Build a binary wheel and a source tarball
- run: python3 -m build
- - name: Store the distribution packages
- uses: actions/upload-artifact@v4
- with:
- name: python-package-distributions
- path: dist/
-
- publish-to-pypi:
- name: >-
- Publish Python š distribution š¦ to PyPI
- if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes
- needs:
- - build
- runs-on: ubuntu-latest
- environment:
- name: pypi
- url: https://pypi.org/p/glassflow
- permissions:
- id-token: write
-
- steps:
- - name: Download all the dists
- uses: actions/download-artifact@v4
- with:
- name: python-package-distributions
- path: dist/
- - name: Publish distribution š¦ to PyPI
- uses: pypa/gh-action-pypi-publish@release/v1
-
- github-release:
- name: >-
- Sign the Python š distribution š¦ with Sigstore
- and upload them to GitHub Release
- needs:
- - publish-to-pypi
- runs-on: ubuntu-latest
-
- permissions:
- contents: write
- id-token: write
-
- steps:
- - name: Download all the dists
- uses: actions/download-artifact@v4
- with:
- name: python-package-distributions
- path: dist/
- - name: Sign the dists with Sigstore
- uses: sigstore/gh-action-sigstore-python@v3.0.0
- with:
- inputs: >-
- ./dist/*.tar.gz
- ./dist/*.whl
- - name: Create GitHub Release
- env:
- GITHUB_TOKEN: ${{ github.token }}
- run: >-
- gh release create
- '${{ github.ref_name }}'
- --repo '${{ github.repository }}'
- --notes ""
- - name: Upload artifact signatures to GitHub Release
- env:
- GITHUB_TOKEN: ${{ github.token }}
- # Upload to GitHub Release using the `gh` CLI.
- # `dist/` contains the built packages, and the
- # sigstore-produced signatures and certificates.
- run: >-
- gh release upload
- '${{ github.ref_name }}' dist/**
- --repo '${{ github.repository }}'
-
- publish-to-testpypi:
- name: Publish Python š distribution š¦ to TestPyPI
- if: ${{ ! startsWith(github.ref, 'refs/tags/') }} # do not publish to TestPyPyPI on tag pushes
- needs:
- - build
- runs-on: ubuntu-latest
-
- environment:
- name: testpypi
- url: https://test.pypi.org/p/glassflow
-
- permissions:
- id-token: write # IMPORTANT: mandatory for trusted publishing
-
- steps:
- - name: Download all the dists
- uses: actions/download-artifact@v4
- with:
- name: python-package-distributions
- path: dist/
- - name: Publish distribution š¦ to TestPyPI
- uses: pypa/gh-action-pypi-publish@release/v1
- with:
- repository-url: https://test.pypi.org/legacy/
\ No newline at end of file
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..e474109
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,76 @@
+name: Release
+
+on:
+ pull_request:
+ types: [closed]
+ branches:
+ - main
+
+jobs:
+ release:
+ if: |
+ github.event.pull_request.merged == true &&
+ (contains(github.event.pull_request.labels.*.name, 'bump:major') ||
+ contains(github.event.pull_request.labels.*.name, 'bump:minor') ||
+ contains(github.event.pull_request.labels.*.name, 'bump:patch'))
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ enable-cache: true
+ cache-dependency-glob: "uv.lock"
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ id: setup-python
+ with:
+ python-version-file: "pyproject.toml"
+
+ - name: Set up venv
+ run: |
+ uv venv --python ${{ steps.setup-python.outputs.python-path }}
+
+ - name: Install dependencies
+ run: |
+ uv pip install -e .[build]
+
+ - name: Get version
+ id: current_version
+ run: |
+ echo "VERSION=$(cat VERSION)" >> $GITHUB_OUTPUT
+
+ - name: Create and push tag
+ run: |
+ git tag "v${{ steps.current_version.outputs.version }}"
+ git push origin "v${{ steps.current_version.outputs.version }}"
+ # Wait for tag to be available
+ sleep 2
+
+ - name: Build package
+ run: |
+ uv build
+
+ - name: Publish to PyPI
+ env:
+ TWINE_USERNAME: __token__
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
+ run: |
+ uv run twine upload dist/*
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v1
+ with:
+ tag_name: "v${{ steps.current_version.outputs.version }}"
+ files: |
+ dist/*.whl
+ dist/*.tar.gz
+ generate_release_notes: true
+ draft: false
+ prerelease: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..1b89b23
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,128 @@
+name: Test
+
+on:
+ pull_request:
+ branches:
+ - main
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ id: setup-python
+ with:
+ python-version-file: "pyproject.toml"
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ enable-cache: true
+ cache-dependency-glob: "uv.lock"
+
+ - name: Set up venv
+ run: |
+ uv venv --python ${{ steps.setup-python.outputs.python-path }}
+
+ - name: Install dependencies
+ run: |
+ uv pip install -e .[test]
+
+ - name: Run Ruff checks
+ run: |
+ uv run ruff check .
+ uv run ruff format --check .
+
+ test:
+ needs: lint
+ strategy:
+ matrix:
+ python-version: ["3.9", "3.10", "3.11"]
+ runs-on: ubuntu-latest
+ permissions:
+ pull-requests: write
+ checks: write
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@v5
+ id: setup-python
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ enable-cache: true
+ cache-dependency-glob: "uv.lock"
+
+ - name: Set up venv
+ run: |
+ uv venv --python ${{ steps.setup-python.outputs.python-path }}
+
+ - name: Install dependencies
+ run: |
+ uv pip install -e .[test]
+
+ - name: Run tests with coverage
+ run: |
+ uv run pytest --cov=src --cov-report=term-missing
+
+ - name: Generate coverage report
+ if: matrix.python-version == '3.11'
+ run: |
+ uv run pytest --cov=src --cov-report=xml:coverage.xml
+
+ - name: Pytest coverage comment
+ if: matrix.python-version == '3.11'
+ id: coverageComment
+ uses: MishaKav/pytest-coverage-comment@main
+ with:
+ pytest-xml-coverage-path: ./coverage.xml
+ title: "Test Coverage Report"
+ badge-title: "Coverage"
+ hide-badge: false
+ hide-report: false
+ create-new-comment: true
+ hide-comment: false
+ report-only-changed-files: false
+ remove-link-from-badge: false
+ unique-id-for-comment: "python-coverage"
+
+ - name: Update README with coverage badge
+ if: matrix.python-version == '3.11'
+ run: |
+ # Extract coverage percentage and color from the coverageComment step
+ COVERAGE_PERCENTAGE=$(echo "${{ steps.coverageComment.outputs.coverage }}" | grep -o '[0-9]*%' | tr -d '%')
+ BADGE_COLOR=$(echo "${{ steps.coverageComment.outputs.color }}" | tr -d '#')
+
+ # Create the badge URL
+ BADGE_URL="https://img.shields.io/badge/coverage-${COVERAGE_PERCENTAGE}%25-${BADGE_COLOR}"
+
+ # Update README with the badge
+ sed -i "//,//c\\
+ \\
+
\\
+ " README.md
+
+ - name: Clean up coverage file
+ if: matrix.python-version == '3.11'
+ run: |
+ rm -f coverage.xml
+
+ - name: Commit and push README changes
+ if: matrix.python-version == '3.11'
+ uses: actions-js/push@master
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ branch: ${{ github.head_ref }}
+ message: "docs: update coverage badge"
\ No newline at end of file
diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml
new file mode 100644
index 0000000..2bb5a09
--- /dev/null
+++ b/.github/workflows/version-bump.yml
@@ -0,0 +1,107 @@
+name: Version Bump
+
+on:
+ pull_request:
+ types: [labeled]
+ branches:
+ - main
+
+jobs:
+ determine-bump:
+ runs-on: ubuntu-latest
+ outputs:
+ bump_type: ${{ steps.bump_type.outputs.bump_type }}
+ steps:
+ - name: Determine highest bump type
+ id: bump_type
+ run: |
+ LABEL="${{ github.event.label.name }}"
+ BUMP_TYPE="patch" # Default to lowest impact
+
+ if [[ "$LABEL" == "bump:major" ]]; then
+ BUMP_TYPE="major"
+ elif [[ "$LABEL" == "bump:minor" ]]; then
+ BUMP_TYPE="minor"
+ fi
+
+ echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT
+ echo "Using $BUMP_TYPE version bump (highest impact label found)"
+
+ bump-version:
+ needs: determine-bump
+ runs-on: ubuntu-latest
+ if: |
+ ${{ github.event.label.name == 'bump:major' }} ||
+ ${{ github.event.label.name == 'bump:minor' }} ||
+ ${{ github.event.label.name == 'bump:patch' }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ id: setup-python
+ with:
+ python-version-file: "pyproject.toml"
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v5
+ with:
+ enable-cache: true
+ cache-dependency-glob: "uv.lock"
+
+ - name: Set up venv
+ run: |
+ uv venv --python ${{ steps.setup-python.outputs.python-path }}
+
+ - name: Install dependencies
+ run: |
+ uv pip install -e .[build]
+
+ - name: Get current version
+ id: current_version
+ run: |
+ CURRENT_VERSION=$(cat VERSION)
+ echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
+
+ - name: Calculate new version
+ id: new_version
+ run: |
+ CURRENT=${{ steps.current_version.outputs.current_version }}
+ BUMP_TYPE=${{ needs.determine-bump.outputs.bump_type }}
+
+ # Split version into parts
+ IFS='.' read -r major minor patch <<< "$CURRENT"
+
+ # Bump version based on type
+ case "$BUMP_TYPE" in
+ "major")
+ major=$((major + 1))
+ minor=0
+ patch=0
+ ;;
+ "minor")
+ minor=$((minor + 1))
+ patch=0
+ ;;
+ "patch")
+ patch=$((patch + 1))
+ ;;
+ esac
+
+ NEW_VERSION="${major}.${minor}.${patch}"
+ echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
+
+ - name: Update VERSION file
+ run: |
+ echo "${{ steps.new_version.outputs.new_version }}" > VERSION
+
+ - name: Commit and push version changes
+ uses: actions-js/push@master
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ branch: ${{ github.head_ref }}
+ message: "chore: bump version to ${{ steps.new_version.outputs.new_version }}"
\ No newline at end of file
diff --git a/README.md b/README.md
index 128094f..d4103ee 100644
--- a/README.md
+++ b/README.md
@@ -1,248 +1,153 @@
-
-

-
-
-
-
-
-
-
+# GlassFlow ETL Python SDK
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+A Python SDK for creating and managing data pipelines between Kafka and ClickHouse.
-# GlassFlow Python SDK
+## Features
-The [GlassFlow](https://www.glassflow.dev/) Python SDK provides a convenient way to interact with the GlassFlow API in your Python applications. The SDK is used to publish and consume events to your [GlassFlow pipelines](https://www.glassflow.dev/docs/concepts/pipeline).
+- Create and manage data pipelines between Kafka and ClickHouse
+- Deduplication of events during a time window based on a key
+- Temporal joins between topics based on a common key with a given time window
+- Schema validation and configuration management
## Installation
-You can install the GlassFlow Python SDK using pip:
-
-```shell
+```bash
pip install glassflow
```
-## Data Operations
-
-* [publish](#publish) - Publish a new event into the pipeline
-* [consume](#consume) - Consume the transformed event from the pipeline
-* [consume failed](#consume-failed) - Consume the events that failed from the pipeline
-* [validate credentials](#validate-credentials) - Validate pipeline credentials
-
-
-## publish
-
-Publish a new event into the pipeline
-
-### Example Usage
-
-```python
-from glassflow import PipelineDataSource
-
-source = PipelineDataSource(pipeline_id="")
+from glassflow.etl import Pipeline
+
+
+pipeline_config = {
+ "pipeline_id": "test-pipeline",
+ "source": {
+ "type": "kafka",
+ "provider": "aiven",
+ "connection_params": {
+ "brokers": ["localhoust:9092"],
+ "protocol": "SASL_SSL",
+ "mechanism": "SCRAM-SHA-256",
+ "username": "user",
+ "password": "pass"
+ }
+ "topics": [
+ {
+ "consumer_group_initial_offset": "earliest",
+ "id": "test-topic",
+ "name": "test-topic",
+ "schema": {
+ "type": "json",
+ "fields": [
+ {"name": "id", "type": "string" },
+ {"name": "email", "type": "string"}
+ ]
+ },
+ "deduplication": {
+ "id_field": "id",
+ "id_field_type": "string",
+ "time_window": "1h",
+ "enabled": True
+ }
+ }
+ ],
+ },
+ "sink": {
+ "type": "clickhouse",
+ "host": "localhost:8443",
+ "port": 8443,
+ "database": "test",
+ "username": "default",
+ "password": "pass",
+ "table_mapping": [
+ {
+ "source_id": "test_table",
+ "field_name": "id",
+ "column_name": "user_id",
+ "column_type": "UUID"
+ },
+ {
+ "source_id": "test_table",
+ "field_name": "email",
+ "column_name": "email",
+ "column_type": "String"
+ }
+ ]
+ }
+}
+
+# Create a pipeline from a JSON configuration
+pipeline = Pipeline(pipeline_config)
+
+# Create the pipeline
+pipeline.create()
```
-Now you can perform CRUD operations on your pipelines:
-
-* [list_pipelines](#list_pipelines) - Returns the list of pipelines available
-* [get_pipeline](#get_pipeline) - Returns a pipeline object from a given pipeline ID
-* [create](#create) - Create a new pipeline
-* [delete](#delete) - Delete an existing pipeline
-
-## list_pipelines
+## Pipeline Configuration
-Returns information about the available pipelines. It can be restricted to a
-specific space by passing the `space_id`.
+For detailed information about the pipeline configuration, see [GlassFlow docs](https://docs.glassflow.dev/pipeline/pipeline-configuration).
-### Example Usage
+## Tracking
-```python
-from glassflow import GlassFlowClient
+The SDK includes anonymous usage tracking to help improve the product. Tracking is enabled by default but can be disabled in two ways:
-client = GlassFlowClient(personal_access_token="")
-res = client.list_pipelines()
+1. Using an environment variable:
+```bash
+export GF_TRACKING_ENABLED=false
```
-## get_pipeline
-
-Gets information about a pipeline from a given pipeline ID. It returns a Pipeline object
-which can be used manage the Pipeline.
-
-### Example Usage
-
+2. Programmatically using the `disable_tracking` method:
```python
-from glassflow import GlassFlowClient
-
-client = GlassFlowClient(personal_access_token="")
-pipeline = client.get_pipeline(pipeline_id="")
-
-print("Name:", pipeline.name)
+pipeline = Pipeline(pipeline_config)
+pipeline.disable_tracking()
```
-## create
+The tracking collects anonymous information about:
+- SDK version
+- Platform (operating system)
+- Python version
+- Pipeline ID
+- Whether joins or deduplication are enabled
+- Kafka security protocol, auth mechanism used and whether authentication is disabled
+- Errors during pipeline creation and deletion
-The Pipeline object has a create method that creates a new GlassFlow pipeline.
+## Development
-### Example Usage
+### Setup
-```python
-from glassflow import Pipeline
-
-pipeline = Pipeline(
- name="",
- transformation_file="path/to/transformation.py",
- space_id="",
- personal_access_token=""
-).create()
-```
+1. Clone the repository
+2. Create a virtual environment
+3. Install dependencies:
-In the next example we create a pipeline with Google PubSub source
-and a webhook sink:
-
-```python
-from glassflow import Pipeline
-
-pipeline = Pipeline(
- name="",
- transformation_file="path/to/transformation.py",
- space_id="",
- personal_access_token="",
- source_kind="google_pubsub",
- source_config={
- "project_id": "",
- "subscription_id": "",
- "credentials_json": ""
- },
- sink_kind="webhook",
- sink_config={
- "url": "",
- "method": "",
- "headers": [{"header1": "header1_value"}]
- }
-).create()
+```bash
+uv venv
+source .venv/bin/activate
+uv pip install -e .[dev]
```
-## delete
-
-The Pipeline object has a delete method to delete a pipeline
+### Testing
-### Example Usage
-
-```python
-from glassflow import Pipeline
-
-pipeline = Pipeline(
- name="",
- transformation_file="path/to/transformation.py",
- space_id="",
- personal_access_token=""
-).create()
-
-pipeline.delete()
+```bash
+pytest
```
-
-## Quickstart
-
-Follow the quickstart guide [here](https://www.glassflow.dev/docs)
-
-## Code Samples
-
-[GlassFlow Examples](https://github.com/glassflow/glassflow-examples)
-
-## SDK Maturity
-
-Please note that the GlassFlow Python SDK is currently in beta and is subject to potential breaking changes. We recommend keeping an eye on the official documentation and updating your code accordingly to ensure compatibility with future versions of the SDK.
-
-
-## Contributing
-
-Anyone who wishes to contribute to this project, whether documentation, features, bug fixes, code cleanup, testing, or code reviews, is very much encouraged to do so.
-
-1. Join the [Slack channel](https://join.slack.com/t/glassflowhub/shared_invite/zt-2g3s6nhci-bb8cXP9g9jAQ942gHP5tqg).
-
-2. Just raise your hand on the GitHub [discussion](https://github.com/glassflow/glassflow-python-sdk/discussions) board.
-
-If you are unfamiliar with how to contribute to GitHub projects, here is a [Get Started Guide](https://docs.github.com/en/get-started/quickstart/contributing-to-projects). A full set of contribution guidelines, along with templates, are in progress.
-
-## Troubleshooting
-
-For any questions, comments, or additional help, please reach out to us via email at [help@glassflow.dev](mailto:help@glassflow.dev).
-Please check out our [Q&A](https://github.com/glassflow/glassflow-python-sdk/discussions/categories/q-a) to get solutions for common installation problems and other issues.
-
-### Raise an issue
-
-To provide feedback or report a bug, please [raise an issue on our issue tracker](https://github.com/glassflow/glassflow-python-sdk/issues).
diff --git a/USAGE.md b/USAGE.md
deleted file mode 100644
index 75634e0..0000000
--- a/USAGE.md
+++ /dev/null
@@ -1,13 +0,0 @@
-```python
-import glassflow
-
-source = glassflow.PipelineDataSource(pipeline_id="", pipeline_access_token="")
-data = {
- "name": "Hello World",
- "id": 1
-}
-source_res = source.publish(request_body=data)
-
-sink = glassflow.PipelineDataSink(pipeline_id="", pipeline_access_token="")
-sink_res = sink.consume()
-```
\ No newline at end of file
diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..4a36342
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+3.0.0
diff --git a/assets/GlassFlow quickstart.gif b/assets/GlassFlow quickstart.gif
deleted file mode 100644
index 6cb109d..0000000
Binary files a/assets/GlassFlow quickstart.gif and /dev/null differ
diff --git a/docs/assets/favicon.ico b/docs/assets/favicon.ico
deleted file mode 100644
index d236b63..0000000
Binary files a/docs/assets/favicon.ico and /dev/null differ
diff --git a/docs/assets/favicon.png b/docs/assets/favicon.png
deleted file mode 100644
index 8da3eec..0000000
Binary files a/docs/assets/favicon.png and /dev/null differ
diff --git a/docs/assets/logo.png b/docs/assets/logo.png
deleted file mode 100644
index 3c077f6..0000000
Binary files a/docs/assets/logo.png and /dev/null differ
diff --git a/docs/index.md b/docs/index.md
deleted file mode 100644
index 05e638b..0000000
--- a/docs/index.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# Welcome to GlassFlow Python SDK Docs
-
-The [GlassFlow](https://www.glassflow.dev/) Python SDK provides a convenient way to interact with the GlassFlow API in your Python applications. The SDK is used to publish and consume events to your [GlassFlow pipelines](https://www.glassflow.dev/docs/concepts/pipeline).
-
-!!! warning "SDK Maturity"
- Please note that the GlassFlow Python SDK is currently in beta and is subject to potential breaking changes. We recommend keeping an eye on the official documentation and updating your code accordingly to ensure compatibility with future versions of the SDK.
-
-
-## Installation
-
-You can install the GlassFlow Python SDK using pip:
-
-```shell
-pip install glassflow
-```
-
-## Contributing
-
-Anyone who wishes to contribute to this project, whether documentation, features, bug fixes, code cleanup, testing, or code reviews, is very much encouraged to do so.
-
-Just raise your hand on the GitHub [discussion](https://github.com/glassflow/glassflow-python-sdk/discussions) board.
-
-If you are unfamiliar with how to contribute to GitHub projects, here is a [Get Started Guide](https://docs.github.com/en/get-started/quickstart/contributing-to-projects). A full set of contribution guidelines, along with templates, are in progress.
-
-## Troubleshooting
-
-For any questions, comments, or additional help, please reach out to us via email at [help@glassflow.dev](mailto:help@glassflow.dev).
-Please check out our [Q&A](https://github.com/glassflow/glassflow-python-sdk/discussions/categories/q-a) to get solutions for common installation problems and other issues.
-
-### Raise an issue
-
-To provide feedback or report a bug, please [raise an issue on our issue tracker](https://github.com/glassflow/glassflow-python-sdk/issues).
diff --git a/docs/pipeline_management.md b/docs/pipeline_management.md
deleted file mode 100644
index 94be1a2..0000000
--- a/docs/pipeline_management.md
+++ /dev/null
@@ -1,176 +0,0 @@
-# Pipeline and Space Management
-
-In order to manage your pipelines and spaces with this SDK, you need to provide the `PERSONAL_ACCESS_TOKEN`
-to the GlassFlow client.
-
-```python
-from glassflow import GlassFlowClient
-
-client = GlassFlowClient(personal_access_token="")
-```
-
-Here is a list of operations you can do with the `GlassFlowClient`:
-
-* [List Pipelines](#list-pipelines) - Returns a list with all your pipelines
-* [Get Pipeline](#get-pipeline) - Returns a pipeline object from a given pipeline ID
-* [Create Pipeline](#create-pipeline) - Create a new pipeline
-* [List Spaces](#list-spaces) - Returns a list with all your spaces
-* [Create Space](#create-space) - Create a new space
-
-You can also interact directly with the `Pipeline` or `Space` objects. They
-allow for some extra functionalities like delete or update.
-
-* [Update Pipeline](#update-pipeline) - Update an existing pipeline
-* [Delete Pipeline](#delete-pipeline) - Delete an existing pipeline
-* [Delete Space](#delete-space) - Delete an existing pipeline
-
-## List Pipelines
-
-Returns information about the available pipelines. It can be restricted to a
-specific space by passing the `space_id`.
-
-???+ example "Usage"
- ```python
- from glassflow import GlassFlowClient
-
- client = GlassFlowClient(personal_access_token="")
- res = client.list_pipelines()
- ```
-
-## Get Pipeline
-
-Gets information about a pipeline from a given pipeline ID. It returns a Pipeline object
-which can be used manage the Pipeline.
-
-???+ example "Usage"
- ```python
- from glassflow import GlassFlowClient
-
- client = GlassFlowClient(personal_access_token="")
- pipeline = client.get_pipeline(pipeline_id="")
-
- print("Name:", pipeline.name)
- ```
-
-## Create Pipeline
-
-Creates a new pipeline and returns a `Pipeline` object.
-
-???+ example "Usage"
- ```python
- from glassflow import GlassFlowClient
-
- client = GlassFlowClient(personal_access_token="")
- pipeline = client.create_pipeline(
- name="MyFirstPipeline",
- space_id="",
- transformation_file="path/to/transformation.py"
- )
-
- print("Pipeline ID:", pipeline.id)
- ```
-
-???+ example "Usage"
- In the next example we create a pipeline with Google PubSub source
- and a webhook sink:
-
- ```python
- from glassflow import GlassFlowClient
-
- client = GlassFlowClient(personal_access_token="")
-
- pipeline = client.create_pipeline(
- name="MyFirstPipeline",
- space_id="",
- transformation_file="path/to/transformation.py",
- source_kind="google_pubsub",
- source_config={
- "project_id": "",
- "subscription_id": "",
- "credentials_json": ""
- },
- sink_kind="webhook",
- sink_config={
- "url": "www.my-webhook-url.com",
- "method": "POST",
- "headers": [{"header1": "header1_value"}]
- }
- )
- ```
-
-## Update Pipeline
-
-The Pipeline object has an update method.
-
-???+ example "Usage"
- ```python
- from glassflow import Pipeline
-
- pipeline = Pipeline(
- id="",
- personal_access_token="",
- )
-
- pipeline.update(
- transformation_file="path/to/new/transformation.py",
- name="NewPipelineName",
- )
- ```
-
-## Delete Pipeline
-
-The Pipeline object has a delete method to delete a pipeline
-
-???+ example "Usage"
- ```python
- from glassflow import Pipeline
-
- pipeline = Pipeline(
- id="",
- personal_access_token=""
- )
- pipeline.delete()
- ```
-
-## List Spaces
-
-Returns information about the available spaces.
-
-### Example Usage
-
-???+ example "Usage"
- ```python
- from glassflow import GlassFlowClient
-
- client = GlassFlowClient(personal_access_token="")
- res = client.list_spaces()
- ```
-
-
-## Create Space
-
-Creates a new space and returns a `Space` object.
-
-???+ example "Usage"
- ```python
- from glassflow import GlassFlowClient
-
- client = GlassFlowClient(personal_access_token="")
- space = client.create_space(name="MyFirstSpace")
- ```
-
-
-## Delete Space
-
-The Space object has a delete method to delete a space
-
-???+ example "Usage"
- ```python
- from glassflow import Space
-
- space = Space(
- id="",
- personal_access_token=""
- )
- space.delete()
- ```
\ No newline at end of file
diff --git a/docs/publish_and_consume.md b/docs/publish_and_consume.md
deleted file mode 100644
index 8d93203..0000000
--- a/docs/publish_and_consume.md
+++ /dev/null
@@ -1,76 +0,0 @@
-# Publish and Consume Events
-
-
-* [Publish](#publish) - Publish a new event into the pipeline from a data source
-* [Consume](#consume) - Consume the transformed event from the pipeline in a data sink
-* [Consume Failed](#consume-failed) - Consume the events that failed from the pipeline in a
-* [Validate Credentials](#validate-credentials) - Validate pipeline credentials
-
-
-## Publish
-
-Publish a new event into the pipeline
-
-???+ example "Usage"
- ```python
- from glassflow import PipelineDataSource
-
- source = PipelineDataSource(pipeline_id="=3.9"
+readme = "README.md"
+license = {text = "MIT"}
+keywords = ["clickhouse", "etl", "kafka", "data-pipeline", "glassflow", "streaming", "data-engineering"]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+]
+dependencies = [
+ "pydantic>=2.0.0",
+ "requests>=2.31.0",
+ "python-dotenv>=1.0.0",
+ "httpx>=0.26.0",
+ "mixpanel>=4.10.0",
+ "pyyaml>=6.0.2",
+]
+
+[project.optional-dependencies]
+test = [
+ "pytest>=7.0.0",
+ "pytest-cov>=4.0.0",
+ "ruff>=0.1.0",
+]
+build = [
+ "build>=1.0.0",
+ "hatch>=1.0.0",
+ "twine>=4.0.0",
+]
+
+[project.urls]
+Homepage = "https://github.com/glassflow/glassflow-python-sdk"
+Documentation = "https://glassflow.github.io/glassflow-python-sdk"
+Repository = "https://github.com/glassflow/glassflow-python-sdk.git"
+Issues = "https://github.com/glassflow/glassflow-python-sdk/issues"
+
[build-system]
-requires = ["setuptools>=61.0"]
-build-backend = "setuptools.build_meta"
+requires = ["hatchling"]
+build-backend = "hatchling.build"
-[tool.pytest.ini_options]
-addopts = """
- --import-mode=importlib \
- --junitxml=tests/reports/pytest.xml \
- --cov=src/glassflow \
- --cov-report xml:tests/reports/coverage.xml \
- -ra -q
-"""
-testpaths = [
+[tool.hatch.version]
+source = "regex"
+path = "VERSION"
+pattern = "(?P.+)"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/glassflow"]
+include-paths = [
+ "src/glassflow/",
+ "README.md",
+ "LICENSE"
+]
+
+[tool.hatch.build]
+exclude = [
"tests",
+ "secrets",
+ ".venv",
+ "*.json",
+ "*.yml",
+ "*.yaml"
]
-[tool.coverage.html]
-skip_covered = true
+[tool.ruff]
+line-length = 88
+target-version = "py39"
[tool.ruff.lint]
select = [
- # pycodestyle
- "E",
- # Pyflakes
- "F",
- # pyupgrade
- "UP",
- # flake8-bugbear
- "B",
- # flake8-simplify
- "SIM",
- # isort
- "I",
-]
-
-[tool.ruff.lint.pydocstyle]
-convention = "google"
-
-[tool.datamodel-codegen]
-field-constraints = true
-snake-case-field = true
-strip-default-none = false
-target-python-version = "3.8"
-use-title-as-name = true
-disable-timestamp = true
-enable-version-header = true
-use-double-quotes = true
-use-subclass-enum=true
-use-standard-collections=true
-input-file-type = "openapi"
-output-model-type = "pydantic_v2.BaseModel"
\ No newline at end of file
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # pyflakes
+ "I", # isort
+ "B", # flake8-bugbear
+]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+addopts = "-v --cov=src/glassflow --cov-report=term-missing -p no:warnings"
+
+[tool.coverage.run]
+source = ["src/glassflow"]
+data_file = ".coverage"
+
+[tool.coverage.report]
+exclude_lines = [
+ "pragma: no cover",
+ "def __repr__",
+ "if self.debug:",
+ "if settings.DEBUG",
+ "raise AssertionError",
+ "raise NotImplementedError",
+ "if 0:",
+ "if __name__ == .__main__.:",
+ "class .*\\bProtocol\\):",
+ "@(abc\\.)?abstractmethod",
+]
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 106cc1f..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,4 +0,0 @@
-mkdocs-material==9.5.11
-mkdocs-material-extensions==1.3.1
-mkdocstrings==0.26.1
-mkdocstrings-python==1.11.1
\ No newline at end of file
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 8af30f6..0000000
--- a/setup.py
+++ /dev/null
@@ -1,58 +0,0 @@
-import setuptools
-
-try:
- with open("README.md") as fh:
- long_description = fh.read()
-except FileNotFoundError:
- long_description = ""
-
-setuptools.setup(
- name="glassflow",
- version="2.1.0",
- author="glassflow",
- description="GlassFlow Python Client SDK",
- url="https://www.glassflow.dev/docs",
- project_urls={
- "Source": "https://github.com/glassflow/glassflow-python-sdk",
- },
- long_description=long_description,
- long_description_content_type="text/markdown",
- packages=setuptools.find_packages(where="src"),
- install_requires=[
- "urllib3==1.26.15",
- "certifi>=2023.7.22",
- "charset-normalizer>=3.2.0",
- "pydantic>=2.10.6",
- "idna>=3.4",
- "jsonpath-python>=1.0.6 ",
- "marshmallow>=3.19.0",
- "mypy-extensions>=1.0.0",
- "packaging>=23.1",
- "python-dateutil>=2.8.2",
- "requests>=2.31.0",
- "six>=1.16.0",
- "typing-inspect>=0.9.0",
- "typing_extensions>=4.7.1",
- "python-dotenv==1.0.1",
- "eval_type_backport>=0.2.0",
- ],
- extras_require={
- "dev": [
- "pylint>=2.16.2",
- "pytest>=8.3.2",
- "pytest-cov>=5.0.0",
- "datamodel-code-generator[http]>=0.27.0",
- "requests-mock>=1.12.1",
- "isort>=5.13.2",
- "ruff>=0.9.0",
- ]
- },
- package_dir={"": "src"},
- python_requires=">=3.8",
- package_data={"glassflow": ["py.typed"]},
- entry_points={
- "console_scripts": [
- "glassflow = cli.cli:glassflow",
- ],
- },
-)
diff --git a/src/cli/cli.py b/src/cli/cli.py
deleted file mode 100644
index 1dae9eb..0000000
--- a/src/cli/cli.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import click
-
-from .commands import get_started
-
-
-@click.group()
-def glassflow():
- """Glassflow CLI - Manage and control Glassflow SDK"""
- pass
-
-
-@click.command()
-@click.argument("command", required=False)
-def help(command):
- """Displays help information about Glassflow CLI and its commands."""
-
- commands = {
- "get-started": "Initialize Glassflow with an access token.\nUsage: "
- "glassflow get-started --token YOUR_TOKEN",
- "help": "Shows help information.\nUsage: glassflow help [command]",
- }
-
- if command:
- if command in commands:
- click.echo(f"ā¹ļø Help for `{command}`:\n{commands[command]}")
- else:
- click.echo(
- f"ā Unknown command: `{command}`. Run `glassflow help` for a "
- f"list of commands."
- )
- else:
- click.echo("š Glassflow CLI Help:")
- for cmd, desc in commands.items():
- click.echo(f" ā {cmd}: {desc.splitlines()[0]}")
- click.echo("\nRun `glassflow help ` for more details.")
-
-
-# Add commands to CLI group
-glassflow.add_command(get_started)
-glassflow.add_command(help)
-
-if __name__ == "__main__":
- glassflow()
diff --git a/src/cli/commands/__init__.py b/src/cli/commands/__init__.py
deleted file mode 100644
index 09e9bb2..0000000
--- a/src/cli/commands/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .get_started import get_started as get_started
diff --git a/src/cli/commands/get_started.py b/src/cli/commands/get_started.py
deleted file mode 100644
index b43e1de..0000000
--- a/src/cli/commands/get_started.py
+++ /dev/null
@@ -1,107 +0,0 @@
-import os
-
-import click
-from dotenv import load_dotenv
-
-
-@click.command()
-@click.option(
- "--personal-access-token", "-pat", default=None, help="Personal access token."
-)
-@click.option(
- "--env-file",
- "-e",
- default=".env",
- help="Path to the .env file (default: .env in current directory).",
-)
-def get_started(personal_access_token, env_file):
- """Displays a welcome message and setup instructions."""
-
- # Load token from .env if not provided in CLI
- if personal_access_token is None:
- if os.path.exists(env_file):
- load_dotenv(env_file) # Load environment variables
- personal_access_token = os.getenv("PERSONAL_ACCESS_TOKEN")
- else:
- click.echo("ā ļø No token provided and .env file not found!", err=True)
- return
-
- if not personal_access_token:
- click.echo("ā Error: Personal access token is required.", err=True)
- return
-
- click.echo("š Welcome to Glassflow! \n")
- click.echo(
- f"š Using Personal Access Token: {personal_access_token[:4]}... "
- f"(hidden for security)"
- )
- click.echo("\nš In this getting started guide, we will do the following:")
- click.echo("1. Define a data transformation function in Python.\n")
- click.echo("2. Create a pipeline with the function.\n")
- click.echo("3. Send events to the pipeline.\n")
- click.echo("4. Consume transformed events in real-time from the pipeline\n")
- click.echo("5. Monitor the pipeline and view logs.\n")
-
- filename = create_transformation_function()
- pipeline = create_space_pipeline(personal_access_token, filename)
- send_consume_events(pipeline)
-
- click.echo(
- "\nš Congratulations! You have successfully created a pipeline and sent"
- " events to it.\n"
- )
- click.echo(
- "š» View the logs and monitor the Pipeline in the "
- f"Glassflow Web App at https://app.glassflow.dev/pipelines/{pipeline.id}"
- )
-
-
-def create_transformation_function(filename="transform_getting_started.py"):
- file_content = """import json
-import logging
-
-def handler(data: dict, log: logging.Logger):
- log.info("Echo: " + json.dumps(data))
- data['transformed_by'] = "glassflow"
-
- return data
-"""
- with open(filename, "w") as f:
- f.write(file_content)
- click.echo(f"ā
Transformation function created in {filename}")
- click.echo("The transformation function is:\n")
- click.echo(file_content)
- click.echo("š You can modify the transformation function in the file.")
- return filename
-
-
-def create_space_pipeline(personal_access_token, transform_filename):
- import glassflow
-
- # create glassflow client to interact with GlassFlow
- client = glassflow.GlassFlowClient(personal_access_token=personal_access_token)
- example_space = client.create_space(name="getting-started")
- pipeline = client.create_pipeline(
- name="getting-started-pipeline",
- transformation_file=transform_filename,
- space_id=example_space.id,
- )
- click.echo(f"ā
Created a pipeline with pipeline_id {pipeline.id}")
- return pipeline
-
-
-def send_consume_events(pipeline):
- click.echo("š Sending some generated events to pipeline .....")
- data_source = pipeline.get_source()
- for i in range(10):
- event = {"data": f"hello GF {i}"}
- res = data_source.publish(event)
- if res.status_code == 200:
- click.echo(f"Sent event: {event}")
-
- click.echo("š” Consuming transformed events from the pipeline")
- data_sink = pipeline.get_sink()
- for _ in range(10):
- resp = data_sink.consume()
- if resp.status_code == 200:
- click.echo(f"Consumed event: {resp.event()} ")
diff --git a/src/glassflow/__init__.py b/src/glassflow/__init__.py
deleted file mode 100644
index 45473e8..0000000
--- a/src/glassflow/__init__.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from .client import GlassFlowClient as GlassFlowClient
-from .config import GlassFlowConfig as GlassFlowConfig
-from .models import api as internal # noqa: F401
-from .models import errors as errors
-from .models import responses as responses
-from .pipeline import Pipeline as Pipeline
-from .pipeline_data import PipelineDataSink as PipelineDataSink
-from .pipeline_data import PipelineDataSource as PipelineDataSource
-from .secret import Secret as Secret
-from .space import Space as Space
diff --git a/src/glassflow/api_client.py b/src/glassflow/api_client.py
deleted file mode 100644
index 9188678..0000000
--- a/src/glassflow/api_client.py
+++ /dev/null
@@ -1,61 +0,0 @@
-from __future__ import annotations
-
-import sys
-
-import requests as requests_http
-
-from .config import GlassFlowConfig
-from .models import errors
-
-
-class APIClient:
- glassflow_config = GlassFlowConfig()
-
- def __init__(self):
- """API client constructor"""
- super().__init__()
- self.client = requests_http.Session()
-
- def _get_core_headers(self) -> dict:
- headers = {
- "Accept": "application/json",
- "Gf-Client": self.glassflow_config.glassflow_client,
- "User-Agent": self.glassflow_config.user_agent,
- "Gf-Python-Version": (
- f"{sys.version_info.major}."
- f"{sys.version_info.minor}."
- f"{sys.version_info.micro}"
- ),
- }
- return headers
-
- def _request(
- self,
- method,
- endpoint,
- request_headers=None,
- json=None,
- request_query_params=None,
- files=None,
- data=None,
- ):
- headers = self._get_core_headers()
- if request_headers:
- headers.update(request_headers)
-
- url = self.glassflow_config.server_url + endpoint
-
- try:
- http_res = self.client.request(
- method,
- url=url,
- params=request_query_params,
- headers=headers,
- json=json,
- files=files,
- data=data,
- )
- http_res.raise_for_status()
- except requests_http.HTTPError as http_err:
- raise errors.UnknownError(http_err.response) from http_err
- return http_res
diff --git a/src/glassflow/client.py b/src/glassflow/client.py
deleted file mode 100644
index 5f2e95b..0000000
--- a/src/glassflow/client.py
+++ /dev/null
@@ -1,244 +0,0 @@
-"""GlassFlow Python Client to interact with GlassFlow API"""
-
-from .api_client import APIClient
-from .models import errors, responses
-from .pipeline import Pipeline
-from .secret import Secret
-from .space import Space
-
-
-class GlassFlowClient(APIClient):
- """
- GlassFlow Client to interact with GlassFlow API and manage pipelines
- and other resources
-
- Attributes:
- client (requests.Session): Session object to make HTTP requests to GlassFlow API
- glassflow_config (GlassFlowConfig): GlassFlow config object to store
- configuration
- organization_id (str): Organization ID of the user. If not provided,
- the default organization will be used
-
- """
-
- def __init__(
- self, personal_access_token: str = None, organization_id: str = None
- ) -> None:
- """Create a new GlassFlowClient object
-
- Args:
- personal_access_token: GlassFlow Personal Access Token
- organization_id: Organization ID of the user. If not provided,
- the default organization will be used
- """
- super().__init__()
- self.personal_access_token = personal_access_token
- self.organization_id = organization_id
- self.request_headers = {"Personal-Access-Token": self.personal_access_token}
- self.request_query_params = {"organization_id": self.organization_id}
-
- def get_pipeline(self, pipeline_id: str) -> Pipeline:
- """Gets a Pipeline object from the GlassFlow API
-
- Args:
- pipeline_id: UUID of the pipeline
-
- Returns:
- Pipeline: Pipeline object from the GlassFlow API
-
- Raises:
- errors.PipelineNotFoundError: Pipeline does not exist
- errors.PipelineUnauthorizedError: User does not have permission to
- perform the requested operation
- errors.ClientError: GlassFlow Client Error
- """
- return Pipeline(
- personal_access_token=self.personal_access_token,
- id=pipeline_id,
- organization_id=self.organization_id,
- ).fetch()
-
- def create_pipeline(
- self,
- name: str,
- space_id: str,
- transformation_file: str = None,
- requirements: str = None,
- source_kind: str = None,
- source_config: dict = None,
- sink_kind: str = None,
- sink_config: dict = None,
- env_vars: list[dict[str, str]] = None,
- state: str = "running",
- metadata: dict = None,
- ) -> Pipeline:
- """Creates a new GlassFlow pipeline
-
- Args:
- name: Name of the pipeline
- space_id: ID of the GlassFlow Space you want to create the pipeline in
- transformation_file: Path to file with transformation function of
- the pipeline.
- requirements: Requirements.txt of the pipeline
- source_kind: Kind of source for the pipeline. If no source is
- provided, the default source will be SDK
- source_config: Configuration of the pipeline's source
- sink_kind: Kind of sink for the pipeline. If no sink is provided,
- the default sink will be SDK
- sink_config: Configuration of the pipeline's sink
- env_vars: Environment variables to pass to the pipeline
- state: State of the pipeline after creation.
- It can be either "running" or "paused"
- metadata: Metadata of the pipeline
-
- Returns:
- Pipeline: New pipeline
-
- Raises:
- errors.PipelineUnauthorizedError: User does not have permission to perform
- the requested operation
- """
- return Pipeline(
- name=name,
- space_id=space_id,
- transformation_file=transformation_file,
- requirements=requirements,
- source_kind=source_kind,
- source_config=source_config,
- sink_kind=sink_kind,
- sink_config=sink_config,
- env_vars=env_vars,
- state=state,
- metadata=metadata,
- organization_id=self.organization_id,
- personal_access_token=self.personal_access_token,
- ).create()
-
- def list_pipelines(
- self, space_ids: list[str] = None
- ) -> responses.ListPipelinesResponse:
- """
- Lists all pipelines in the GlassFlow API
-
- Args:
- space_ids: List of Space IDs of the pipelines to list.
- If not specified, all the pipelines will be listed.
-
- Returns:
- responses.ListPipelinesResponse: Response object with the pipelines listed
-
- Raises:
- errors.PipelineUnauthorizedError: User does not have permission to
- perform the requested operation
- """
-
- endpoint = "/pipelines"
- query_params = {"space_id": space_ids} if space_ids else {}
- http_res = self._request(
- method="GET", endpoint=endpoint, request_query_params=query_params
- )
- return responses.ListPipelinesResponse(**http_res.json())
-
- def create_space(
- self,
- name: str,
- ) -> Space:
- """Creates a new Space
-
- Args:
- name: Name of the Space
-
- Returns:
- Space: New space
-
- Raises:
- errors.SpaceUnauthorizedError: User does not have permission to perform
- the requested operation
- """
- return Space(
- name=name,
- personal_access_token=self.personal_access_token,
- organization_id=self.organization_id,
- ).create()
-
- def list_spaces(self) -> responses.ListSpacesResponse:
- """
- Lists all GlassFlow spaces in the GlassFlow API
-
- Returns:
- response.ListSpacesResponse: Response object with the spaces listed
-
- Raises:
- errors.SpaceUnauthorizedError: User does not have permission to perform the
- requested operation
- """
-
- endpoint = "/spaces"
- http_res = self._request(method="GET", endpoint=endpoint)
- return responses.ListSpacesResponse(**http_res.json())
-
- def create_secret(self, key: str, value: str) -> Secret:
- """
- Creates a new secret
-
- Args:
- key: Secret key (must be unique in your organization)
- value: Secret value
-
- Returns:
- Secret: New secret
-
- Raises:
- errors.SecretUnauthorizedError: User does not have permission to perform the
- requested operation
- """
- return Secret(
- key=key,
- value=value,
- personal_access_token=self.personal_access_token,
- organization_id=self.organization_id,
- ).create()
-
- def list_secrets(self) -> responses.ListSecretsResponse:
- """
- Lists all GlassFlow secrets in the GlassFlow API
-
- Returns:
- responses.ListSecretsResponse: Response object with the secrets listed
-
- Raises:
- errors.SecretUnauthorizedError: User does not have permission to perform the
- requested operation
- """
- endpoint = "/secrets"
- http_res = self._request(method="GET", endpoint=endpoint)
- return responses.ListSecretsResponse(**http_res.json())
-
- def _request(
- self,
- method,
- endpoint,
- request_headers=None,
- json=None,
- request_query_params=None,
- files=None,
- data=None,
- ):
- headers = {**self.request_headers, **(request_headers or {})}
- query_params = {**self.request_query_params, **(request_query_params or {})}
-
- try:
- http_res = super()._request(
- method=method,
- endpoint=endpoint,
- request_headers=headers,
- json=json,
- request_query_params=query_params,
- files=files,
- data=data,
- )
- return http_res
- except errors.UnknownError as e:
- if e.status_code == 401:
- raise errors.UnauthorizedError(e.raw_response) from e
- raise e
diff --git a/src/glassflow/config.py b/src/glassflow/config.py
deleted file mode 100644
index 8683fab..0000000
--- a/src/glassflow/config.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from importlib.metadata import version
-
-from pydantic import BaseModel
-
-
-class GlassFlowConfig(BaseModel):
- """Configuration object for GlassFlowClient
-
- Attributes:
- server_url: The base URL of the GlassFlow API
- sdk_version: The version of the GlassFlow Python SDK
- user_agent: The user agent to be used in the requests
-
- """
-
- server_url: str = "https://api.glassflow.dev/v1"
- sdk_version: str = version("glassflow")
- user_agent: str = f"glassflow-python-sdk/{sdk_version}"
- glassflow_client: str = f"python-sdk/{sdk_version}"
diff --git a/src/glassflow/etl/__init__.py b/src/glassflow/etl/__init__.py
new file mode 100644
index 0000000..ecfd926
--- /dev/null
+++ b/src/glassflow/etl/__init__.py
@@ -0,0 +1,23 @@
+"""
+GlassFlow SDK for creating data pipelines between Kafka and ClickHouse.
+"""
+
+from .client import Client
+from .dlq import DLQ
+from .models import (
+ JoinConfig,
+ PipelineConfig,
+ SinkConfig,
+ SourceConfig,
+)
+from .pipeline import Pipeline
+
+__all__ = [
+ "Pipeline",
+ "Client",
+ "DLQ",
+ "PipelineConfig",
+ "SourceConfig",
+ "SinkConfig",
+ "JoinConfig",
+]
diff --git a/src/glassflow/etl/api_client.py b/src/glassflow/etl/api_client.py
new file mode 100644
index 0000000..e71038a
--- /dev/null
+++ b/src/glassflow/etl/api_client.py
@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import httpx
+
+from . import errors
+from .models import GlassFlowConfig
+from .tracking import Tracking
+
+
+class APIClient:
+ """
+ API client
+ """
+
+ version = "1"
+ glassflow_config = GlassFlowConfig()
+ _tracking = Tracking(glassflow_config.analytics.distinct_id)
+
+ def __init__(self, host: str | None = None):
+ """Initialize the API Client class.
+
+ Args:
+ host: Host URL of the GlassFlow Clickhouse ETL service
+ """
+ self.host = host if host else self.glassflow_config.glassflow.host
+ self.http_client = httpx.Client(base_url=self.host)
+
+ def _request(
+ self, method: str, endpoint: str, **kwargs: Any
+ ) -> httpx.Response | None:
+ """
+ Generic request method with centralized error handling.
+
+ Args:
+ method: HTTP method (GET, POST, DELETE, etc.)
+ endpoint: API endpoint
+ **kwargs: Additional arguments to pass to httpx
+
+ Returns:
+ httpx.Response: The response object
+
+ Raises:
+ httpx.HTTPStatusError: If the API request fails with HTTP errors
+ (to be handled by subclasses)
+ RequestError: If there is a network error
+ """
+ try:
+ response = self.http_client.request(method, endpoint, **kwargs)
+ response.raise_for_status()
+ return response
+ except httpx.HTTPStatusError as e:
+ self._raise_api_error(e.response)
+ except httpx.RequestError as e:
+ self._track_event("RequestError", error_type="ConnectionError")
+ raise errors.ConnectionError(
+ "Failed to connect to GlassFlow ETL API"
+ ) from e
+
+ @staticmethod
+ def _raise_api_error(response: httpx.Response) -> None:
+ """Raise an APIError based on the response."""
+ status_code = response.status_code
+ try:
+ message = response.json().get("message", None)
+ except json.JSONDecodeError:
+ message = f"{status_code} {response.reason_phrase}"
+ if status_code == 400:
+ raise errors.ValidationError(status_code, message, response=response)
+ elif status_code == 403:
+ raise errors.ForbiddenError(status_code, message, response=response)
+ elif status_code == 404:
+ raise errors.NotFoundError(status_code, message, response=response)
+ elif status_code == 422:
+ raise errors.UnprocessableContentError(
+ status_code, message, response=response
+ )
+ elif status_code == 500:
+ raise errors.ServerError(status_code, message, response=response)
+ else:
+ raise errors.APIError(
+ status_code,
+ message="An error occurred: "
+ f"({status_code} {response.reason_phrase}) {message}",
+ response=response,
+ )
+
+ def _track_event(self, event_name: str, **kwargs: Any) -> None:
+ """Track an event with the given name and properties."""
+ self._tracking.track_event(event_name, kwargs)
diff --git a/src/glassflow/etl/client.py b/src/glassflow/etl/client.py
new file mode 100644
index 0000000..e210751
--- /dev/null
+++ b/src/glassflow/etl/client.py
@@ -0,0 +1,125 @@
+from __future__ import annotations
+
+from typing import Any, List
+
+from . import errors, models
+from .api_client import APIClient
+from .pipeline import Pipeline
+
+
+class Client(APIClient):
+ """
+ Manager class for handling multiple Pipeline instances.
+ """
+
+ ENDPOINT = "/api/v1/pipeline"
+
+ def __init__(self, host: str | None = None) -> None:
+ """Initialize the PipelineManager class.
+
+ Args:
+ host: GlassFlow API host
+ """
+ super().__init__(host=host)
+
+ def get_pipeline(self, pipeline_id: str):
+ """Fetch a pipeline by its ID.
+
+ Args:
+ pipeline_id: The ID of the pipeline to fetch
+
+ Returns:
+ Pipeline: A Pipeline instance for the given ID
+
+ Raises:
+ PipelineNotFoundError: If pipeline is not found
+ APIError: If the API request fails
+ """
+ return Pipeline(host=self.host, pipeline_id=pipeline_id).get()
+
+ def list_pipelines(self) -> List[dict]:
+ """Returns a list of available pipelines.
+
+ Returns:
+ List[dict]: List of pipeline items with details as dictionaries
+
+ Raises:
+ APIError: If the API request fails
+ """
+ try:
+ response = self._request("GET", self.ENDPOINT)
+ data = response.json()
+
+ # API always returns a list of pipelines
+ return data if isinstance(data, list) else []
+
+ except errors.NotFoundError:
+ # No pipelines found, return empty list
+ return []
+ except errors.APIError as e:
+ self._track_event("PipelineListError", error_type="InternalServerError")
+ raise e
+
+ def create_pipeline(
+ self,
+ pipeline_config: dict[str, Any] | models.PipelineConfig | None = None,
+ pipeline_config_yaml_path: str | None = None,
+ pipeline_config_json_path: str | None = None,
+ ):
+ """Creates a new pipeline with the given config.
+
+ Args:
+ pipeline_config: Dictionary or PipelineConfig object containing
+ the pipeline configuration
+ pipeline_config_yaml_path: Path to the YAML file containing
+ the pipeline configuration
+ pipeline_config_json_path: Path to the JSON file containing
+ the pipeline configuration
+
+ Returns:
+ Pipeline: A Pipeline instance for the created pipeline
+
+ Raises:
+ PipelineAlreadyExistsError: If pipeline already exists
+ PipelineInvalidConfigurationError: If configuration is invalid
+ APIError: If the API request fails
+ """
+ if pipeline_config is None:
+ if pipeline_config_yaml_path is None and pipeline_config_json_path is None:
+ raise ValueError(
+ "Either pipeline_config or pipeline_config_yaml_path or "
+ "pipeline_config_json_path must be provided"
+ )
+ if pipeline_config_yaml_path is not None:
+ pipeline = Pipeline.from_yaml(pipeline_config_yaml_path, host=self.host)
+ elif pipeline_config_json_path is not None:
+ pipeline = Pipeline.from_json(pipeline_config_json_path, host=self.host)
+ else:
+ if (
+ pipeline_config_yaml_path is not None
+ or pipeline_config_json_path is not None
+ ):
+ raise ValueError(
+ "Either pipeline_config or pipeline_config_yaml_path or "
+ "pipeline_config_json_path must be provided"
+ )
+ pipeline = Pipeline(config=pipeline_config, host=self.host)
+
+ return pipeline.create()
+
+ def delete_pipeline(self, pipeline_id: str, terminate: bool = True) -> None:
+ """Deletes the pipeline with the given ID.
+
+ Args:
+ pipeline_id: The ID of the pipeline to delete
+ terminate: Whether to terminate the pipeline (i.e. delete all the pipeline
+ components and potentially all the events in the pipeline)
+ Raises:
+ PipelineNotFoundError: If pipeline is not found
+ APIError: If the API request fails
+ """
+ Pipeline(host=self.host, pipeline_id=pipeline_id).delete(terminate=terminate)
+
+ def disable_tracking(self) -> None:
+ """Disable tracking of pipeline events."""
+ self._tracking.enabled = False
diff --git a/src/glassflow/etl/dlq.py b/src/glassflow/etl/dlq.py
new file mode 100644
index 0000000..803e7a9
--- /dev/null
+++ b/src/glassflow/etl/dlq.py
@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List
+
+from . import errors
+from .api_client import APIClient
+from .errors import InvalidBatchSizeError
+
+
+class DLQ(APIClient):
+ """
+ Dead Letter Queue client for managing failed messages.
+ """
+
+ def __init__(self, pipeline_id: str, host: str | None = None):
+ super().__init__(host)
+ self.pipeline_id = pipeline_id
+ self.endpoint = f"/api/v1/pipeline/{self.pipeline_id}/dlq"
+ self._max_batch_size = 100
+
+ def consume(self, batch_size: int = 100) -> List[Dict[str, Any]]:
+ """
+ Consume messages from the Dead Letter Queue.
+
+ Args:
+ batch_size: Number of messages to consume (between 1 and 1000)
+
+ Returns:
+ List of messages from the DLQ
+ """
+ if (
+ not isinstance(batch_size, int)
+ or batch_size < 1
+ or batch_size > self._max_batch_size
+ ):
+ raise ValueError("batch_size must be an integer between 1 and 100")
+
+ try:
+ response = self._request(
+ "GET", f"{self.endpoint}/consume", params={"batch_size": batch_size}
+ )
+ response.raise_for_status()
+ if response.status_code != 204:
+ return response.json()
+ return []
+ except errors.UnprocessableContentError as e:
+ raise InvalidBatchSizeError(
+ f"Invalid batch size: batch size should be larger than 1 "
+ f"and smaller than {self._max_batch_size}"
+ ) from e
+ except errors.APIError as e:
+ raise e
+
+ def state(self) -> Dict[str, Any]:
+ """
+ Get the current state of the Dead Letter Queue.
+
+ Returns:
+ Dictionary containing DLQ state information
+
+ Raises:
+ ConnectionError: If there is a network error
+ InternalServerError: If the API request fails
+ """
+ try:
+ response = self._request("GET", f"{self.endpoint}/state")
+ response.raise_for_status()
+ return response.json()
+ except errors.NotFoundError as e:
+ raise errors.PipelineNotFoundError(
+ status_code=e.status_code,
+ message=f"Pipeline with id '{self.pipeline_id}' not found",
+ response=e.response,
+ ) from e
+ except errors.APIError as e:
+ raise e
diff --git a/src/glassflow/etl/errors.py b/src/glassflow/etl/errors.py
new file mode 100644
index 0000000..95b1115
--- /dev/null
+++ b/src/glassflow/etl/errors.py
@@ -0,0 +1,62 @@
+class GlassFlowError(Exception):
+ """Base exception for all GlassFlow SDK errors."""
+
+
+# Generic client-side errors
+class RequestError(GlassFlowError):
+ """A low-level HTTP or network error."""
+
+
+class ConnectionError(RequestError):
+ """Raised when a connection to the server fails."""
+
+
+# Server/API-level errors
+class APIError(GlassFlowError):
+ """Base for API response errors."""
+
+ def __init__(self, status_code, message=None, response=None):
+ self.status_code = status_code
+ self.response = response
+ self.message = message
+ super().__init__(self.message)
+
+
+class NotFoundError(APIError):
+ """Raised when a resource is not found (404)."""
+
+
+class ValidationError(APIError):
+ """Raised on 400 Bad Request errors due to bad input."""
+
+
+class ForbiddenError(APIError):
+ """Raised on 403 Forbidden errors."""
+
+
+class UnprocessableContentError(APIError):
+ """Raised on 422 Unprocessable Content errors."""
+
+
+class ServerError(APIError):
+ """Raised on 500 Server Error errors."""
+
+
+class PipelineAlreadyExistsError(APIError):
+ """Raised when a pipeline already exists."""
+
+
+class PipelineNotFoundError(APIError):
+ """Raised on 404 when a pipeline is not found."""
+
+
+class PipelineInvalidConfigurationError(APIError):
+ """Raised when a pipeline configuration is invalid."""
+
+
+class InvalidDataTypeMappingError(GlassFlowError):
+ """Exception raised when a data type mapping is invalid."""
+
+
+class InvalidBatchSizeError(GlassFlowError):
+ """Exception raised when a batch size is invalid."""
diff --git a/src/glassflow/etl/models/__init__.py b/src/glassflow/etl/models/__init__.py
new file mode 100644
index 0000000..3b5ae08
--- /dev/null
+++ b/src/glassflow/etl/models/__init__.py
@@ -0,0 +1,60 @@
+from .config import GlassFlowConfig
+from .data_types import ClickhouseDataType, KafkaDataType
+from .join import (
+ JoinConfig,
+ JoinConfigPatch,
+ JoinOrientation,
+ JoinSourceConfig,
+ JoinSourceConfigPatch,
+ JoinType,
+)
+from .pipeline import PipelineConfig, PipelineConfigPatch
+from .sink import SinkConfig, SinkConfigPatch, SinkType, TableMapping
+from .source import (
+ ConsumerGroupOffset,
+ DeduplicationConfig,
+ DeduplicationConfigPatch,
+ KafkaConnectionParams,
+ KafkaConnectionParamsPatch,
+ KafkaMechanism,
+ Schema,
+ SchemaField,
+ SchemaType,
+ SourceConfig,
+ SourceConfigPatch,
+ SourceType,
+ TopicConfig,
+ TopicConfigPatch,
+)
+
+__all__ = [
+ "ClickhouseDataType",
+ "ConsumerGroupOffset",
+ "DeduplicationConfig",
+ "KafkaConnectionParams",
+ "KafkaDataType",
+ "KafkaMechanism",
+ "JoinConfig",
+ "JoinOrientation",
+ "JoinSourceConfig",
+ "JoinType",
+ "PipelineConfig",
+ "PipelineConfigPatch",
+ "SinkConfig",
+ "SinkType",
+ "TableMapping",
+ "Schema",
+ "SchemaField",
+ "SchemaType",
+ "SourceConfig",
+ "SourceType",
+ "TopicConfig",
+ "GlassFlowConfig",
+ "SourceConfigPatch",
+ "TopicConfigPatch",
+ "KafkaConnectionParamsPatch",
+ "DeduplicationConfigPatch",
+ "JoinConfigPatch",
+ "JoinSourceConfigPatch",
+ "SinkConfigPatch",
+]
diff --git a/src/glassflow/etl/models/base.py b/src/glassflow/etl/models/base.py
new file mode 100644
index 0000000..96eb813
--- /dev/null
+++ b/src/glassflow/etl/models/base.py
@@ -0,0 +1,13 @@
+from enum import Enum
+
+
+class CaseInsensitiveStrEnum(str, Enum):
+ @classmethod
+ def _missing_(cls, value):
+ for member in cls:
+ if member.value.lower() == value.lower():
+ return member
+ raise ValueError(f"Invalid value: {value}")
+
+ def __str__(self):
+ return str(self.value)
diff --git a/src/glassflow/etl/models/config.py b/src/glassflow/etl/models/config.py
new file mode 100644
index 0000000..274695d
--- /dev/null
+++ b/src/glassflow/etl/models/config.py
@@ -0,0 +1,48 @@
+import configparser
+import os
+import uuid
+from pathlib import Path
+
+from pydantic import BaseModel
+
+
+class GlassFlowSettings(BaseModel):
+ host: str = "http://localhost:8080"
+
+
+class AnalyticsSettings(BaseModel):
+ enabled: bool = True
+ distinct_id: str = str(uuid.uuid4())
+
+
+class GlassFlowConfig(BaseModel):
+ config_file: Path = Path.home() / ".glassflow" / "clickhouse.conf"
+ glassflow: GlassFlowSettings = GlassFlowSettings()
+ analytics: AnalyticsSettings = AnalyticsSettings()
+
+ def model_post_init(self, ctx):
+ self.load()
+
+ def load(self):
+ config = configparser.ConfigParser()
+
+ if os.path.exists(self.config_file):
+ with open(self.config_file, "r") as f:
+ config.read_file(f)
+ if "glassflow" in config:
+ self.glassflow = GlassFlowSettings(**config["glassflow"])
+ if "analytics" in config:
+ self.analytics = AnalyticsSettings(**config["analytics"])
+ else:
+ self.write()
+
+ def write(self):
+ if not os.path.exists(self.config_file.parent):
+ os.makedirs(self.config_file.parent)
+
+ config = configparser.ConfigParser()
+ config["glassflow"] = self.glassflow.model_dump()
+ config["analytics"] = self.analytics.model_dump()
+
+ with open(self.config_file, "w") as f:
+ config.write(f)
diff --git a/src/glassflow/etl/models/data_types.py b/src/glassflow/etl/models/data_types.py
new file mode 100644
index 0000000..15367c1
--- /dev/null
+++ b/src/glassflow/etl/models/data_types.py
@@ -0,0 +1,93 @@
+from .base import CaseInsensitiveStrEnum
+
+
+class KafkaDataType(CaseInsensitiveStrEnum):
+ STRING = "string"
+ INT8 = "int8"
+ INT16 = "int16"
+ INT32 = "int32"
+ INT64 = "int64"
+ FLOAT32 = "float32"
+ FLOAT64 = "float64"
+ BOOL = "bool"
+ BYTES = "bytes"
+ ARRAY = "array"
+
+
+class ClickhouseDataType(CaseInsensitiveStrEnum):
+ INT8 = "Int8"
+ INT16 = "Int16"
+ INT32 = "Int32"
+ INT64 = "Int64"
+ FLOAT32 = "Float32"
+ FLOAT64 = "Float64"
+ STRING = "String"
+ FIXEDSTRING = "FixedString"
+ DATETIME = "DateTime"
+ DATETIME64 = "DateTime64"
+ BOOL = "Bool"
+ UUID = "UUID"
+ ENUM8 = "Enum8"
+ ENUM16 = "Enum16"
+ LC_INT8 = "LowCardinality(Int8)"
+ LC_INT16 = "LowCardinality(Int16)"
+ LC_INT32 = "LowCardinality(Int32)"
+ LC_INT64 = "LowCardinality(Int64)"
+ LC_UINT8 = "LowCardinality(UInt8)"
+ LC_UINT16 = "LowCardinality(UInt16)"
+ LC_UINT32 = "LowCardinality(UInt32)"
+ LC_UINT64 = "LowCardinality(UInt64)"
+ LC_FLOAT32 = "LowCardinality(Float32)"
+ LC_FLOAT64 = "LowCardinality(Float64)"
+ LC_STRING = "LowCardinality(String)"
+ LC_FIXEDSTRING = "LowCardinality(FixedString)"
+ LC_DATETIME = "LowCardinality(DateTime)"
+ ARRAY_STRING = "Array(String)"
+ ARRAY_INT8 = "Array(Int8)"
+ ARRAY_INT16 = "Array(Int16)"
+ ARRAY_INT32 = "Array(Int32)"
+ ARRAY_INT64 = "Array(Int64)"
+
+
+kafka_to_clickhouse_data_type_mappings = {
+ KafkaDataType.STRING: [
+ ClickhouseDataType.STRING,
+ ClickhouseDataType.FIXEDSTRING,
+ ClickhouseDataType.DATETIME,
+ ClickhouseDataType.DATETIME64,
+ ClickhouseDataType.UUID,
+ ClickhouseDataType.ENUM8,
+ ClickhouseDataType.ENUM16,
+ ClickhouseDataType.LC_STRING,
+ ClickhouseDataType.LC_FIXEDSTRING,
+ ClickhouseDataType.LC_DATETIME,
+ ],
+ KafkaDataType.INT8: [ClickhouseDataType.INT8, ClickhouseDataType.LC_INT8],
+ KafkaDataType.INT16: [ClickhouseDataType.INT16, ClickhouseDataType.LC_INT16],
+ KafkaDataType.INT32: [ClickhouseDataType.INT32, ClickhouseDataType.LC_INT32],
+ KafkaDataType.INT64: [
+ ClickhouseDataType.INT64,
+ ClickhouseDataType.DATETIME,
+ ClickhouseDataType.DATETIME64,
+ ClickhouseDataType.LC_INT64,
+ ClickhouseDataType.LC_DATETIME,
+ ],
+ KafkaDataType.FLOAT32: [ClickhouseDataType.FLOAT32, ClickhouseDataType.LC_FLOAT32],
+ KafkaDataType.FLOAT64: [
+ ClickhouseDataType.FLOAT64,
+ ClickhouseDataType.DATETIME,
+ ClickhouseDataType.DATETIME64,
+ ClickhouseDataType.LC_FLOAT64,
+ ClickhouseDataType.LC_DATETIME,
+ ],
+ KafkaDataType.BOOL: [ClickhouseDataType.BOOL],
+ KafkaDataType.BYTES: [ClickhouseDataType.STRING],
+ KafkaDataType.ARRAY: [
+ ClickhouseDataType.STRING,
+ ClickhouseDataType.ARRAY_STRING,
+ ClickhouseDataType.ARRAY_INT8,
+ ClickhouseDataType.ARRAY_INT16,
+ ClickhouseDataType.ARRAY_INT32,
+ ClickhouseDataType.ARRAY_INT64,
+ ],
+}
diff --git a/src/glassflow/etl/models/join.py b/src/glassflow/etl/models/join.py
new file mode 100644
index 0000000..66a44a5
--- /dev/null
+++ b/src/glassflow/etl/models/join.py
@@ -0,0 +1,79 @@
+from typing import List, Optional
+
+from pydantic import BaseModel, Field, ValidationInfo, field_validator
+
+from .base import CaseInsensitiveStrEnum
+
+
+class JoinOrientation(CaseInsensitiveStrEnum):
+ LEFT = "left"
+ RIGHT = "right"
+
+
+class JoinSourceConfig(BaseModel):
+ source_id: str
+ join_key: str
+ time_window: str
+ orientation: JoinOrientation
+
+
+class JoinType(CaseInsensitiveStrEnum):
+ TEMPORAL = "temporal"
+
+
+class JoinConfig(BaseModel):
+ """Configuration for joining multiple sources."""
+
+ enabled: bool = False
+ type: Optional[JoinType] = None
+ sources: Optional[List[JoinSourceConfig]] = None
+
+ @field_validator("sources")
+ @classmethod
+ def validate_sources(
+ cls, v: Optional[List[JoinSourceConfig]], info: ValidationInfo
+ ) -> Optional[List[JoinSourceConfig]]:
+ """
+ Validate that when join is enabled, there are exactly two sources
+ with opposite orientations.
+ """
+ if not info.data.get("enabled", False):
+ return v
+
+ if not v:
+ raise ValueError("sources are required when join is enabled")
+
+ if len(v) != 2:
+ raise ValueError("join must have exactly two sources when enabled")
+
+ orientations = {source.orientation for source in v}
+ if orientations != {JoinOrientation.LEFT, JoinOrientation.RIGHT}:
+ raise ValueError(
+ "join sources must have opposite orientations (one LEFT and one RIGHT)"
+ )
+
+ return v
+
+ @field_validator("type")
+ @classmethod
+ def validate_type(
+ cls, v: Optional[JoinType], info: ValidationInfo
+ ) -> Optional[JoinType]:
+ """Validate that type is required when join is enabled."""
+ if info.data.get("enabled", False) and not v:
+ raise ValueError("type is required when join is enabled")
+ return v
+
+
+class JoinSourceConfigPatch(BaseModel):
+ source_id: Optional[str] = Field(default=None)
+ join_key: Optional[str] = Field(default=None)
+ time_window: Optional[str] = Field(default=None)
+ orientation: Optional[JoinOrientation] = Field(default=None)
+
+
+class JoinConfigPatch(BaseModel):
+ enabled: Optional[bool] = Field(default=None)
+ type: Optional[JoinType] = Field(default=None)
+ # TODO: How to patch an element in a list?
+ sources: Optional[List[JoinSourceConfig]] = Field(default=None)
diff --git a/src/glassflow/etl/models/pipeline.py b/src/glassflow/etl/models/pipeline.py
new file mode 100644
index 0000000..8d79905
--- /dev/null
+++ b/src/glassflow/etl/models/pipeline.py
@@ -0,0 +1,185 @@
+import re
+from typing import Any, Optional
+
+from pydantic import BaseModel, Field, field_validator, model_validator
+
+from ..errors import InvalidDataTypeMappingError
+from .data_types import kafka_to_clickhouse_data_type_mappings
+from .join import JoinConfig, JoinConfigPatch
+from .sink import SinkConfig, SinkConfigPatch
+from .source import SourceConfig, SourceConfigPatch
+
+
+class PipelineConfig(BaseModel):
+ pipeline_id: str
+ name: Optional[str] = Field(default=None)
+ source: SourceConfig
+ join: Optional[JoinConfig] = Field(default=JoinConfig())
+ sink: SinkConfig
+
+ @field_validator("pipeline_id")
+ @classmethod
+ def validate_pipeline_id(cls, v: str) -> str:
+ if not v:
+ raise ValueError("pipeline_id cannot be empty")
+ if len(v) > 40:
+ raise ValueError("pipeline_id cannot be longer than 40 characters")
+ if not re.match(r"^[a-z0-9-]+$", v):
+ raise ValueError(
+ "pipeline_id can only contain lowercase letters, numbers, and hyphens"
+ )
+ if not re.match(r"^[a-z0-9]", v):
+ raise ValueError("pipeline_id must start with a lowercase alphanumeric")
+ if not re.match(r".*[a-z0-9]$", v):
+ raise ValueError("pipeline_id must end with a lowercase alphanumeric")
+ return v
+
+ @model_validator(mode="after")
+ def set_pipeline_name(self) -> "PipelineConfig":
+ """
+ If name is not provided, use the pipeline_id and replace hyphens
+ with spaces.
+ """
+ if self.name is None:
+ self.name = self.pipeline_id.replace("-", " ").title()
+ return self
+
+ @field_validator("join")
+ @classmethod
+ def validate_join_config(
+ cls,
+ v: Optional[JoinConfig],
+ info: Any,
+ ) -> Optional[JoinConfig]:
+ if not v or not v.enabled:
+ return v
+
+ # Get the source topics from the parent model's data
+ source = info.data.get("source", {})
+ if isinstance(source, dict):
+ source_topics = source.get("topics", [])
+ else:
+ source_topics = source.topics
+ if not source_topics:
+ return v
+
+ # Validate each source in the join config
+ for source in v.sources:
+ # Check if source_id exists in any topic
+ source_exists = any(
+ topic.name == source.source_id for topic in source_topics
+ )
+ if not source_exists:
+ raise ValueError(
+ f"Source ID '{source.source_id}' does not exist in any topic"
+ )
+
+ # Find the topic and check if join_key exists in its schema
+ topic = next((t for t in source_topics if t.name == source.source_id), None)
+ if not topic:
+ continue
+
+ field_exists = any(
+ field.name == source.join_key for field in topic.event_schema.fields
+ )
+ if not field_exists:
+ raise ValueError(
+ f"Join key '{source.join_key}' does not exist in source "
+ f"'{source.source_id}' schema"
+ )
+
+ return v
+
+ @field_validator("sink")
+ @classmethod
+ def validate_sink_config(cls, v: SinkConfig, info: Any) -> SinkConfig:
+ # Get the source topics from the parent model's data
+ source = info.data.get("source", {})
+ if isinstance(source, dict):
+ source_topics = source.get("topics", [])
+ else:
+ source_topics = source.topics
+ if not source_topics:
+ return v
+
+ # Validate each table mapping
+ for mapping in v.table_mapping:
+ # Check if source_id exists in any topic
+ source_exists = any(
+ topic.name == mapping.source_id for topic in source_topics
+ )
+ if not source_exists:
+ raise ValueError(
+ f"Source ID '{mapping.source_id}' does not exist in any topic"
+ )
+
+ # Find the topic and check if field_name exists in its schema
+ topic = next(
+ (t for t in source_topics if t.name == mapping.source_id), None
+ )
+ if not topic:
+ continue
+
+ field_exists = any(
+ field.name == mapping.field_name for field in topic.event_schema.fields
+ )
+ if not field_exists:
+ raise ValueError(
+ f"Field '{mapping.field_name}' does not exist in source "
+ f"'{mapping.source_id}' event schema"
+ )
+
+ return v
+
+ @field_validator("sink")
+ @classmethod
+ def validate_data_type_compatibility(cls, v: SinkConfig, info: Any) -> SinkConfig:
+ # Get the source topics from the parent model's data
+ source = info.data.get("source", {})
+ if isinstance(source, dict):
+ source_topics = source.get("topics", [])
+ else:
+ source_topics = source.topics
+ if not source_topics:
+ return v
+
+ # Validate each table mapping
+ for mapping in v.table_mapping:
+ # Find the topic
+ topic = next(
+ (t for t in source_topics if t.name == mapping.source_id), None
+ )
+ if not topic:
+ continue
+
+ # Find the source field
+ source_field = next(
+ (f for f in topic.event_schema.fields if f.name == mapping.field_name),
+ None,
+ )
+ if not source_field:
+ continue
+
+ # Get the source and target data types
+ source_type = source_field.type
+ target_type = mapping.column_type
+
+ # Check if the target type is compatible with the source type
+ compatible_types = kafka_to_clickhouse_data_type_mappings.get(
+ source_type, []
+ )
+ if target_type not in compatible_types:
+ raise InvalidDataTypeMappingError(
+ f"Data type '{target_type}' is not compatible with source type "
+ f"'{source_type}' for field '{mapping.field_name}' in source "
+ f"'{mapping.source_id}'"
+ )
+
+ return v
+
+
+class PipelineConfigPatch(BaseModel):
+ name: Optional[str] = Field(default=None)
+ source: Optional[SourceConfigPatch] = Field(default=None)
+ join: Optional[JoinConfigPatch] = Field(default=None)
+ sink: Optional[SinkConfigPatch] = Field(default=None)
diff --git a/src/glassflow/etl/models/sink.py b/src/glassflow/etl/models/sink.py
new file mode 100644
index 0000000..f7a36e8
--- /dev/null
+++ b/src/glassflow/etl/models/sink.py
@@ -0,0 +1,49 @@
+from typing import List, Optional
+
+from pydantic import BaseModel, Field
+
+from .base import CaseInsensitiveStrEnum
+from .data_types import ClickhouseDataType
+
+
+class TableMapping(BaseModel):
+ source_id: str
+ field_name: str
+ column_name: str
+ column_type: ClickhouseDataType
+
+
+class SinkType(CaseInsensitiveStrEnum):
+ CLICKHOUSE = "clickhouse"
+
+
+class SinkConfig(BaseModel):
+ type: SinkType = SinkType.CLICKHOUSE
+ provider: Optional[str] = Field(default=None)
+ host: str
+ port: str
+ http_port: Optional[str] = Field(default=None)
+ database: str
+ username: str
+ password: str
+ secure: bool = Field(default=False)
+ skip_certificate_verification: bool = Field(default=False)
+ max_batch_size: int = Field(default=1000)
+ max_delay_time: str = Field(default="10m")
+ table: str
+ table_mapping: List[TableMapping]
+
+
+class SinkConfigPatch(BaseModel):
+ provider: Optional[str] = Field(default=None)
+ host: Optional[str] = Field(default=None)
+ port: Optional[str] = Field(default=None)
+ http_port: Optional[str] = Field(default=None)
+ database: Optional[str] = Field(default=None)
+ username: Optional[str] = Field(default=None)
+ password: Optional[str] = Field(default=None)
+ secure: Optional[bool] = Field(default=None)
+ max_batch_size: Optional[int] = Field(default=None)
+ max_delay_time: Optional[str] = Field(default=None)
+ table: Optional[str] = Field(default=None)
+ table_mapping: Optional[List[TableMapping]] = Field(default=None)
diff --git a/src/glassflow/etl/models/source.py b/src/glassflow/etl/models/source.py
new file mode 100644
index 0000000..f2c8ff2
--- /dev/null
+++ b/src/glassflow/etl/models/source.py
@@ -0,0 +1,188 @@
+from typing import List, Optional
+
+from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
+
+from .base import CaseInsensitiveStrEnum
+from .data_types import KafkaDataType
+
+
+class KafkaProtocol(CaseInsensitiveStrEnum):
+ SSL = "SSL"
+ SASL_SSL = "SASL_SSL"
+ SASL_PLAINTEXT = "SASL_PLAINTEXT"
+ PLAINTEXT = "PLAINTEXT"
+
+
+class KafkaMechanism(CaseInsensitiveStrEnum):
+ SCRAM_SHA_256 = "SCRAM-SHA-256"
+ SCRAM_SHA_512 = "SCRAM-SHA-512"
+ PLAIN = "PLAIN"
+
+
+class SchemaField(BaseModel):
+ name: str
+ type: KafkaDataType
+
+
+class SchemaType(CaseInsensitiveStrEnum):
+ JSON = "json"
+
+
+class Schema(BaseModel):
+ type: SchemaType = SchemaType.JSON
+ fields: List[SchemaField]
+
+
+class DeduplicationConfig(BaseModel):
+ enabled: bool = False
+ id_field: Optional[str] = Field(default=None)
+ id_field_type: Optional[KafkaDataType] = Field(default=None)
+ time_window: Optional[str] = Field(default=None)
+
+ @model_validator(mode="before")
+ @classmethod
+ def validate_deduplication_fields(cls, values):
+ """Validate deduplication fields based on enabled status."""
+ if isinstance(values, dict):
+ enabled = values.get("enabled", False)
+
+ # If deduplication is disabled, allow empty strings
+ if not enabled:
+ # Convert empty strings to None for enum fields
+ if values.get("id_field_type") == "":
+ values["id_field_type"] = None
+ if values.get("id_field") == "":
+ values["id_field"] = None
+ if values.get("time_window") == "":
+ values["time_window"] = None
+ else:
+ # If enabled, ensure required fields are present and not empty
+ for field_name in ["id_field", "id_field_type", "time_window"]:
+ field_value = values.get(field_name)
+ if field_value is None or field_value == "":
+ raise ValueError(
+ f"{field_name} is required when deduplication is enabled"
+ )
+
+ # Validate id_field_type is a valid type when enabled
+ id_field_type = values.get("id_field_type")
+ if id_field_type not in [
+ KafkaDataType.STRING,
+ KafkaDataType.INT32,
+ KafkaDataType.INT64,
+ ]:
+ raise ValueError(
+ "id_field_type must be a string, int32, or int64 when "
+ "deduplication is enabled"
+ )
+
+ return values
+
+
+class ConsumerGroupOffset(CaseInsensitiveStrEnum):
+ LATEST = "latest"
+ EARLIEST = "earliest"
+
+
+class TopicConfig(BaseModel):
+ consumer_group_initial_offset: ConsumerGroupOffset = ConsumerGroupOffset.EARLIEST
+ name: str
+ event_schema: Schema = Field(alias="schema")
+ deduplication: Optional[DeduplicationConfig] = Field(default=DeduplicationConfig())
+
+ @field_validator("deduplication")
+ @classmethod
+ def validate_deduplication_id_field(
+ cls, v: DeduplicationConfig, info: ValidationInfo
+ ) -> DeduplicationConfig:
+ """
+ Validate that the deduplication ID field exists in the
+ schema and has matching type.
+ """
+ if v is None or not v.enabled:
+ return v
+
+ # Skip validation if id_field is empty when deduplication is disabled
+ if not v.id_field or v.id_field == "":
+ return v
+
+ # Get the schema from the parent model's data
+ schema = info.data.get("event_schema", {})
+ if isinstance(schema, dict):
+ fields = schema.get("fields", [])
+ else:
+ fields = schema.fields
+
+ # Find the field in the schema
+ field = next((f for f in fields if f.name == v.id_field), None)
+ if not field:
+ raise ValueError(
+ f"Deduplication ID field '{v.id_field}' does not exist in "
+ "the event schema"
+ )
+
+ # Check if the field type matches the deduplication ID field type
+ if field.type.value != v.id_field_type.value:
+ raise ValueError(
+ f"Deduplication ID field type '{v.id_field_type.value}' does not match "
+ f"schema field type '{field.type.value}' for field '{v.id_field}'"
+ )
+
+ return v
+
+
+class KafkaConnectionParams(BaseModel):
+ brokers: List[str]
+ protocol: KafkaProtocol
+ mechanism: Optional[KafkaMechanism] = Field(default=None)
+ username: Optional[str] = Field(default=None)
+ password: Optional[str] = Field(default=None)
+ root_ca: Optional[str] = Field(default=None)
+ skip_auth: bool = Field(default=False)
+
+ @model_validator(mode="before")
+ def empty_str_to_none(values):
+ if values.get("mechanism", None) == "":
+ values["mechanism"] = None
+ return values
+
+
+class SourceType(CaseInsensitiveStrEnum):
+ KAFKA = "kafka"
+
+
+class SourceConfig(BaseModel):
+ type: SourceType = SourceType.KAFKA
+ provider: Optional[str] = Field(default=None)
+ connection_params: KafkaConnectionParams
+ topics: List[TopicConfig]
+
+
+class DeduplicationConfigPatch(BaseModel):
+ enabled: Optional[bool] = Field(default=None)
+ id_field: Optional[str] = Field(default=None)
+ id_field_type: Optional[KafkaDataType] = Field(default=None)
+ time_window: Optional[str] = Field(default=None)
+
+
+class TopicConfigPatch(BaseModel):
+ consumer_group_initial_offset: Optional[ConsumerGroupOffset] = Field(default=None)
+ name: Optional[str] = Field(default=None)
+ event_schema: Optional[Schema] = Field(default=None)
+ deduplication: Optional[DeduplicationConfigPatch] = Field(default=None)
+
+
+class KafkaConnectionParamsPatch(BaseModel):
+ brokers: Optional[List[str]] = Field(default=None)
+ protocol: Optional[KafkaProtocol] = Field(default=None)
+ mechanism: Optional[KafkaMechanism] = Field(default=None)
+ username: Optional[str] = Field(default=None)
+ password: Optional[str] = Field(default=None)
+ root_ca: Optional[str] = Field(default=None)
+ skip_auth: Optional[bool] = Field(default=None)
+
+
+class SourceConfigPatch(BaseModel):
+ provider: Optional[str] = Field(default=None)
+ connection_params: Optional[KafkaConnectionParamsPatch] = Field(default=None)
+ topics: Optional[List[TopicConfigPatch]] = Field(default=None)
diff --git a/src/glassflow/etl/pipeline.py b/src/glassflow/etl/pipeline.py
new file mode 100644
index 0000000..ef8406d
--- /dev/null
+++ b/src/glassflow/etl/pipeline.py
@@ -0,0 +1,359 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import yaml
+from httpx._models import Response
+from pydantic import ValidationError
+
+from . import errors, models
+from .api_client import APIClient
+from .dlq import DLQ
+
+
+class Pipeline(APIClient):
+ """
+ Main class for managing Kafka to ClickHouse pipelines.
+ """
+
+ ENDPOINT = "/api/v1/pipeline"
+
+ def __init__(
+ self,
+ host: str | None = None,
+ pipeline_id: str | None = None,
+ config: models.PipelineConfig | dict[str, Any] | None = None,
+ ):
+ """Initialize the Pipeline class.
+
+ Args:
+ host: GlassFlow API host
+ pipeline_id: ID of the pipeline to create
+ config: Pipeline configuration
+ """
+ super().__init__(host=host)
+
+ if not config and not pipeline_id:
+ raise ValueError("Either config or pipeline_id must be provided")
+ elif config and pipeline_id:
+ raise ValueError("Only one of config or pipeline_id can be provided")
+
+ if pipeline_id is not None:
+ self.pipeline_id = pipeline_id
+
+ if config is not None:
+ if isinstance(config, dict):
+ self.config = models.PipelineConfig.model_validate(config)
+ else:
+ self.config = config
+ self.pipeline_id = self.config.pipeline_id
+ else:
+ self.config = None
+
+ self._dlq = DLQ(pipeline_id=self.pipeline_id, host=host)
+
+ def get(self) -> Pipeline:
+ """Fetch a pipeline by its ID.
+
+ Returns:
+ Pipeline: A Pipeline instance for the given ID
+
+ Raises:
+ PipelineNotFoundError: If pipeline is not found
+ APIError: If the API request fails
+ """
+ response = self._request(
+ "GET", f"{self.ENDPOINT}/{self.pipeline_id}", event_name="PipelineGet"
+ )
+ self.config = models.PipelineConfig.model_validate(response.json())
+ self._dlq = DLQ(pipeline_id=self.pipeline_id, host=self.host)
+ return self
+
+ def create(self) -> Pipeline:
+ """Creates a new pipeline with the given config.
+
+ Returns:
+ Pipeline: A Pipeline instance for the created pipeline
+
+ Raises:
+ PipelineAlreadyExistsError: If pipeline already exists
+ PipelineInvalidConfigurationError: If configuration is invalid
+ APIError: If the API request fails
+ """
+ if self.config is None:
+ raise ValueError("Pipeline configuration must be provided in constructor")
+ try:
+ self._request(
+ "POST",
+ self.ENDPOINT,
+ json=self.config.model_dump(
+ mode="json",
+ by_alias=True,
+ exclude_none=True,
+ ),
+ event_name="PipelineCreated",
+ )
+ return self
+
+ except errors.ForbiddenError as e:
+ self._track_event("PipelineCreated", error_type="PipelineAlreadyExists")
+ raise errors.PipelineAlreadyExistsError(
+ status_code=e.status_code,
+ message=f"Pipeline with ID {self.config.pipeline_id} already exists;"
+ "delete it first before creating new pipeline or use a"
+ "different pipeline ID",
+ response=e.response,
+ ) from e
+
+ def rename(self, name: str) -> Pipeline:
+ """Renames the pipeline with the given name.
+
+ Returns:
+ Pipeline: A Pipeline instance for the renamed pipeline
+
+ Raises:
+ PipelineNotFoundError: If pipeline is not found
+ APIError: If the API request fails
+ """
+ self._request(
+ "PATCH",
+ f"{self.ENDPOINT}/{self.pipeline_id}",
+ json={"name": name},
+ event_name="PipelineRenamed",
+ )
+ self.config.name = name
+ return self
+
+ def update(
+ self, config_patch: models.PipelineConfigPatch | dict[str, Any]
+ ) -> Pipeline:
+ """Updates the pipeline with the given config.
+
+ Args:
+ config_patch: Pipeline configuration patch
+
+ Returns:
+ Pipeline: A Pipeline instance for the updated pipeline
+
+ Raises:
+ PipelineNotFoundError: If pipeline is not found
+ APIError: If the API request fails
+ """
+ raise NotImplementedError("Updating is not implemented")
+
+ def delete(self, terminate: bool = True) -> None:
+ """Deletes the pipeline with the given ID.
+
+ Args:
+ terminate: Whether to terminate the pipeline (i.e. delete all the pipeline
+ components and potentially all the events in the pipeline)
+
+ Raises:
+ PipelineNotFoundError: If pipeline is not found
+ APIError: If the API request fails
+ """
+ if not terminate:
+ raise NotImplementedError("Graceful deletion is not implemented")
+
+ if self.config is None:
+ self.get()
+ endpoint = f"{self.ENDPOINT}/{self.pipeline_id}/terminate"
+ self._request("DELETE", endpoint, event_name="PipelineDeleted")
+
+ def pause(self) -> Pipeline:
+ """Pauses the pipeline with the given ID.
+
+ Returns:
+ Pipeline: A Pipeline instance for the paused pipeline
+
+ Raises:
+ PipelineNotFoundError: If pipeline is not found
+ APIError: If the API request fails
+ """
+ raise NotImplementedError("Pausing is not implemented")
+
+ def resume(self) -> Pipeline:
+ """Resumes the pipeline with the given ID.
+
+ Returns:
+ Pipeline: A Pipeline instance for the resumed pipeline
+
+ Raises:
+ PipelineNotFoundError: If pipeline is not found
+ APIError: If the API request fails
+ """
+ raise NotImplementedError("Resuming is not implemented")
+
+ def health(self) -> dict[str, Any]:
+ """Get the health of the pipeline.
+
+ Returns:
+ dict: Pipeline health
+ """
+ return self._request(
+ "GET",
+ f"{self.ENDPOINT}/{self.pipeline_id}/health",
+ event_name="PipelineHealth",
+ ).json()
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert the pipeline configuration to a dictionary.
+
+ Returns:
+ dict: Pipeline configuration as a dictionary
+ """
+ if not hasattr(self, "config") or self.config is None:
+ return {"pipeline_id": self.pipeline_id}
+
+ return self.config.model_dump(
+ mode="json",
+ by_alias=True,
+ exclude_none=True,
+ )
+
+ def to_yaml(self, yaml_path: str) -> None:
+ """Save the pipeline configuration to a YAML file.
+
+ Args:
+ yaml_path: Path to the YAML file
+ """
+ with open(yaml_path, "w") as f:
+ yaml.dump(self.to_dict(), f, default_flow_style=False)
+
+ def to_json(self, json_path: str) -> None:
+ """Save the pipeline configuration to a JSON file.
+
+ Args:
+ json_path: Path to the JSON file
+ """
+ with open(json_path, "w") as f:
+ json.dump(self.to_dict(), f, indent=4)
+
+ @classmethod
+ def from_yaml(cls, yaml_path: str, host: str | None = None) -> Pipeline:
+ """Create a pipeline from a YAML file.
+
+ Args:
+ yaml_path: Path to the YAML file
+ host: GlassFlow API host
+
+ Returns:
+ Pipeline: A Pipeline instance for the created pipeline
+ """
+ with open(yaml_path, "r") as f:
+ config = yaml.safe_load(f)
+ return cls(config=config, host=host)
+
+ @classmethod
+ def from_json(cls, json_path: str, host: str | None = None) -> Pipeline:
+ """Create a pipeline from a JSON file.
+
+ Args:
+ json_path: Path to the JSON file
+ host: GlassFlow API host
+
+ Returns:
+ Pipeline: A Pipeline instance for the created pipeline
+ """
+ with open(json_path, "r") as f:
+ config = json.load(f)
+ return cls(config=config, host=host)
+
+ @staticmethod
+ def validate_config(config: dict[str, Any]) -> bool:
+ """
+ Validate a pipeline configuration.
+
+ Args:
+ config: Dictionary containing the pipeline configuration
+
+ Returns:
+ True if the configuration is valid
+
+ Raises:
+ ValueError: If the configuration is invalid
+ ValidationError: If the configuration fails Pydantic validation
+ """
+ try:
+ models.PipelineConfig.model_validate(config)
+ return True
+ except ValidationError as e:
+ raise e
+ except ValueError as e:
+ raise e
+
+ @property
+ def dlq(self) -> DLQ:
+ """Get the DLQ (Dead Letter Queue) client for this pipeline.
+
+ Returns:
+ DLQ: The DLQ client instance
+ """
+ return self._dlq
+
+ @dlq.setter
+ def dlq(self, dlq: DLQ) -> None:
+ self._dlq = dlq
+
+ def _tracking_info(self) -> dict[str, Any]:
+ """Get information about the active pipeline."""
+ # If config is not set, return minimal info
+ if self.config is None:
+ return {"pipeline_id": self.pipeline_id}
+
+ # Extract join info
+ join_enabled = getattr(self.config.join, "enabled", False)
+
+ # Extract deduplication info
+ deduplication_enabled = any(
+ t.deduplication and t.deduplication.enabled
+ for t in self.config.source.topics
+ )
+
+ # Extract connection params
+ conn_params = self.config.source.connection_params
+
+ root_ca_provided = conn_params.root_ca is not None
+ skip_auth = conn_params.skip_auth
+ protocol = str(conn_params.protocol)
+ mechanism = str(conn_params.mechanism)
+
+ return {
+ "pipeline_id": self.config.pipeline_id,
+ "join_enabled": join_enabled,
+ "deduplication_enabled": deduplication_enabled,
+ "source_auth_method": mechanism,
+ "source_security_protocol": protocol,
+ "source_root_ca_provided": root_ca_provided,
+ "source_skip_auth": skip_auth,
+ }
+
+ def _track_event(self, event_name: str, **kwargs: Any) -> None:
+ pipeline_properties = self._tracking_info()
+ properties = {**pipeline_properties, **kwargs}
+ super()._track_event(event_name, **properties)
+
+ def _request(
+ self, method: str, endpoint: str, event_name: str, **kwargs: Any
+ ) -> Response:
+ try:
+ response = super()._request(method, endpoint, **kwargs)
+ self._track_event(event_name)
+ return response
+ except errors.NotFoundError as e:
+ self._track_event(event_name, error_type="PipelineNotFound")
+ raise errors.PipelineNotFoundError(
+ status_code=e.status_code,
+ message=f"Pipeline with id '{self.pipeline_id}' not found",
+ response=e.response,
+ ) from e
+ except errors.UnprocessableContentError as e:
+ self._track_event(event_name, error_type="InvalidPipelineConfig")
+ raise errors.PipelineInvalidConfigurationError(
+ status_code=e.status_code,
+ message=e.message or "Invalid pipeline configuration",
+ ) from e
+ except errors.APIError as e:
+ self._track_event(event_name, error_type="InternalServerError")
+ raise e
diff --git a/src/glassflow/etl/tracking.py b/src/glassflow/etl/tracking.py
new file mode 100644
index 0000000..2d8146c
--- /dev/null
+++ b/src/glassflow/etl/tracking.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import os
+import platform
+from importlib.metadata import version
+from typing import Any, Dict
+
+import mixpanel
+
+
+class Tracking:
+ """Mixpanel tracking implementation for GlassFlow Clickhouse ETL."""
+
+ def __init__(self, distinct_id: str) -> None:
+ """Initialize the tracking client"""
+ self.enabled = os.getenv("GF_TRACKING_ENABLED", "true").lower() == "true"
+ self._project_token = "209670ec9b352915013a5dfdb169dd25"
+ self._distinct_id = distinct_id
+ self.client = mixpanel.Mixpanel(self._project_token)
+
+ self.sdk_version = version("glassflow")
+ self.platform = platform.system()
+ self.python_version = platform.python_version()
+
+ def track_event(
+ self, event_name: str, properties: Dict[str, Any] | None = None
+ ) -> None:
+ """Track an event in Mixpanel.
+
+ Args:
+ event_name: Name of the event to track
+ properties: Additional properties to include with the event
+ """
+ if not self.enabled:
+ return
+
+ base_properties = {
+ "sdk_version": self.sdk_version,
+ "platform": self.platform,
+ "python_version": self.python_version,
+ }
+ if properties is None:
+ properties = {}
+ properties = {**base_properties, **properties}
+
+ try:
+ self.client.track(
+ distinct_id=self._distinct_id,
+ event_name=event_name,
+ properties=properties,
+ )
+ except Exception:
+ pass
diff --git a/src/glassflow/models/__init__.py b/src/glassflow/models/__init__.py
deleted file mode 100644
index 5bb534f..0000000
--- a/src/glassflow/models/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-# package
diff --git a/src/glassflow/models/api/__init__.py b/src/glassflow/models/api/__init__.py
deleted file mode 100644
index dc2a47f..0000000
--- a/src/glassflow/models/api/__init__.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from .api import (
- ConsumeOutputEvent,
- CreatePipeline,
- CreateSecret,
- CreateSpace,
- FunctionEnvironments,
- GetDetailedSpacePipeline,
- ListAccessTokens,
- ListPipelines,
- ListSpaceScopes,
- Pipeline,
- PipelineFunctionOutput,
- PipelineState,
- SinkConnector,
- SourceConnector,
- Space,
-)
-
-__all__ = [
- "CreateSpace",
- "Space",
- "ConsumeOutputEvent",
- "CreateSecret",
- "Pipeline",
- "GetDetailedSpacePipeline",
- "ListAccessTokens",
- "SourceConnector",
- "SinkConnector",
- "CreatePipeline",
- "PipelineState",
- "PipelineFunctionOutput",
- "ListSpaceScopes",
- "ListPipelines",
- "FunctionEnvironments",
-]
diff --git a/src/glassflow/models/api/api.py b/src/glassflow/models/api/api.py
deleted file mode 100644
index e0e876a..0000000
--- a/src/glassflow/models/api/api.py
+++ /dev/null
@@ -1,608 +0,0 @@
-# ruff: noqa
-# generated by datamodel-codegen:
-# filename: https://api.glassflow.dev/v1/openapi.yaml
-# version: 0.27.2
-
-from __future__ import annotations
-
-from datetime import datetime
-from enum import Enum
-from typing import Any, Literal, Optional, Union
-
-from pydantic import BaseModel, ConfigDict, Field, RootModel
-
-
-class Error(BaseModel):
- detail: str
-
-
-class CreateOrganization(BaseModel):
- name: str
-
-
-class Organization(CreateOrganization):
- id: str
-
-
-class OrganizationScope(Organization):
- role: str
-
-
-class OrganizationScopes(RootModel[list[OrganizationScope]]):
- root: list[OrganizationScope]
-
-
-class SecretKey(RootModel[str]):
- root: str
-
-
-class Type(str, Enum):
- organization = "organization"
-
-
-class SecretRef(BaseModel):
- type: Type
- key: SecretKey
-
-
-class CreateSecret(BaseModel):
- key: SecretKey
- value: str
-
-
-class SignUp(BaseModel):
- access_token: str
- id_token: str
-
-
-class BasePipeline(BaseModel):
- name: str
- space_id: str
- metadata: dict[str, Any]
-
-
-class PipelineState(str, Enum):
- running = "running"
- paused = "paused"
-
-
-class FunctionEnvironment(BaseModel):
- name: str
- value: str
-
-
-class FunctionEnvironments(RootModel[Optional[list[FunctionEnvironment]]]):
- root: Optional[list[FunctionEnvironment]] = None
-
-
-class Kind(str, Enum):
- google_pubsub = "google_pubsub"
-
-
-class Config(BaseModel):
- project_id: str
- subscription_id: str
- credentials_json: str
-
-
-class Kind1(str, Enum):
- amazon_sqs = "amazon_sqs"
-
-
-class Config1(BaseModel):
- queue_url: str
- aws_region: str
- aws_access_key: str
- aws_secret_key: str
-
-
-class Kind2(str, Enum):
- postgres = "postgres"
-
-
-class Config2(BaseModel):
- db_host: str
- db_port: Optional[str] = "5432"
- db_user: str
- db_pass: str
- db_name: str
- db_sslmode: Optional[str] = None
- replication_slot: str
- publication: Optional[str] = None
- replication_output_plugin_name: Optional[str] = "wal2json"
- replication_output_plugin_args: Optional[list[str]] = None
-
-
-class Kind3(str, Enum):
- webhook = "webhook"
-
-
-class Method(str, Enum):
- get = "GET"
- post = "POST"
- put = "PUT"
- patch = "PATCH"
- delete = "DELETE"
-
-
-class Header(BaseModel):
- name: str
- value: str
-
-
-class Config3(BaseModel):
- url: str
- method: Method
- headers: list[Header]
-
-
-class SinkConnectorWebhookConfigHeadersListItem(BaseModel):
- name: str
- value: str
-
-
-class SinkConnectorWebhookConfigHeadersList(
- RootModel[list[SinkConnectorWebhookConfigHeadersListItem]]
-):
- root: list[SinkConnectorWebhookConfigHeadersListItem]
-
-
-class Kind4(str, Enum):
- clickhouse = "clickhouse"
-
-
-class Config4(BaseModel):
- addr: str
- database: str
- username: str
- password: str
- table: str
-
-
-class Kind5(str, Enum):
- amazon_s3 = "amazon_s3"
-
-
-class Config5(BaseModel):
- s3_bucket: str
- s3_key: str
- aws_region: str
- aws_access_key: str
- aws_secret_key: str
-
-
-class Kind6(str, Enum):
- snowflake_cdc_json = "snowflake_cdc_json"
-
-
-class Config6(BaseModel):
- account: str
- warehouse: str
- db_user: str
- db_pass: str
- db_name: str
- db_schema: str
- db_host: Optional[str] = None
- db_port: Optional[str] = "443"
- db_role: Optional[str] = None
-
-
-class Kind7(str, Enum):
- pinecone_json = "pinecone_json"
-
-
-class ClientHeader(BaseModel):
- name: str
- value: str
-
-
-class Config7(BaseModel):
- api_key: str
- api_host: str
- api_source_tag: Optional[str] = None
- index_host: str
- client_headers: Optional[list[ClientHeader]] = None
-
-
-class Kind8(str, Enum):
- mongodb_json = "mongodb_json"
-
-
-class Config8(BaseModel):
- connection_uri: str
- db_name: str
-
-
-class Pipeline(BasePipeline):
- id: str
- created_at: datetime
- state: PipelineState
-
-
-class SpacePipeline(Pipeline):
- space_name: str
-
-
-class PipelineFunctionOutput(BaseModel):
- environments: FunctionEnvironments
-
-
-class SpacePipelines(RootModel[list[SpacePipeline]]):
- root: list[SpacePipeline]
-
-
-class CreateSpace(BaseModel):
- name: str
-
-
-class UpdateSpace(BaseModel):
- name: str
-
-
-class Space(CreateSpace):
- id: str
- created_at: datetime
-
-
-class SpaceScope(Space):
- permission: str
-
-
-class SpaceScopes(RootModel[list[SpaceScope]]):
- root: list[SpaceScope]
-
-
-class Payload(BaseModel):
- model_config = ConfigDict(
- extra="allow",
- )
- message: str
-
-
-class SeverityCodeInput(int, Enum):
- integer_100 = 100
- integer_200 = 200
- integer_400 = 400
- integer_500 = 500
-
-
-class SeverityCode(RootModel[int]):
- root: int
-
-
-class CreateAccessToken(BaseModel):
- name: str
-
-
-class AccessToken(CreateAccessToken):
- id: str
- token: str
- created_at: datetime
-
-
-class AccessTokens(RootModel[list[AccessToken]]):
- root: list[AccessToken]
-
-
-class PaginationResponse(BaseModel):
- total_amount: int
-
-
-class SourceFile(BaseModel):
- name: str
- content: str
-
-
-class SourceFiles(RootModel[list[SourceFile]]):
- root: list[SourceFile]
-
-
-class EventContext(BaseModel):
- request_id: str
- external_id: Optional[str] = None
- receive_time: datetime
-
-
-class PersonalAccessToken(RootModel[str]):
- root: str
-
-
-class QueryRangeMatrix(RootModel[Optional[Any]]):
- root: Optional[Any] = None
-
-
-class ConnectorValueValue(BaseModel):
- value: str
-
-
-class ConnectorValueSecretRef(BaseModel):
- secret_ref: SecretRef
-
-
-class ConnectorValueList(RootModel[list[str]]):
- root: list[str]
-
-
-class Profile(BaseModel):
- id: str
- home_organization: Organization
- name: str
- email: str
- provider: str
- external_settings: Any
- subscriber_id: str
-
-
-class ListOrganizationScopes(PaginationResponse):
- organizations: OrganizationScopes
-
-
-class Secret(BaseModel):
- key: SecretKey
-
-
-class ListPipelines(PaginationResponse):
- pipelines: SpacePipelines
-
-
-class ListSpaceScopes(PaginationResponse):
- spaces: SpaceScopes
-
-
-class FunctionLogEntry(BaseModel):
- level: str
- severity_code: SeverityCode
- timestamp: datetime
- payload: Payload
-
-
-class ListAccessTokens(PaginationResponse):
- access_tokens: AccessTokens
-
-
-class ConsumeInputEvent(BaseModel):
- req_id: Optional[str] = Field(None, description="DEPRECATED")
- receive_time: Optional[datetime] = Field(None, description="DEPRECATED")
- payload: Any
- event_context: EventContext
-
-
-class ConsumeOutputEvent(BaseModel):
- req_id: Optional[str] = Field(None, description="DEPRECATED")
- receive_time: Optional[datetime] = Field(None, description="DEPRECATED")
- payload: Any
- event_context: EventContext
- status: str
- response: Optional[Any] = None
- error_details: Optional[str] = None
- stack_trace: Optional[str] = None
-
-
-class ListPersonalAccessTokens(BaseModel):
- tokens: list[PersonalAccessToken]
-
-
-class PipelineInputQueueRelativeLatencyMetricsResponse(BaseModel):
- input_queue_total_push_events: QueryRangeMatrix
- input_queue_latency: QueryRangeMatrix
-
-
-class ConnectorValue(RootModel[Union[ConnectorValueValue, ConnectorValueSecretRef]]):
- root: Union[ConnectorValueValue, ConnectorValueSecretRef]
-
-
-class Secrets(RootModel[list[Secret]]):
- root: list[Secret]
-
-
-class Configuration(BaseModel):
- project_id: ConnectorValue
- subscription_id: ConnectorValue
- credentials_json: ConnectorValue
-
-
-class SourceConnectorGooglePubSub(BaseModel):
- kind: Literal["google_pubsub"]
- config: Optional[Config] = None
- configuration: Optional[Configuration] = None
-
-
-class Configuration1(BaseModel):
- queue_url: ConnectorValue
- aws_region: ConnectorValue
- aws_access_key: ConnectorValue
- aws_secret_key: ConnectorValue
-
-
-class SourceConnectorAmazonSQS(BaseModel):
- kind: Literal["amazon_sqs"]
- config: Optional[Config1] = None
- configuration: Optional[Configuration1] = None
-
-
-class Configuration2(BaseModel):
- db_host: ConnectorValue
- db_port: Optional[ConnectorValue] = None
- db_user: ConnectorValue
- db_pass: ConnectorValue
- db_name: ConnectorValue
- db_sslmode: Optional[ConnectorValue] = None
- replication_slot: ConnectorValue
- publication: Optional[ConnectorValue] = None
- replication_output_plugin_name: Optional[ConnectorValue] = None
- replication_output_plugin_args: Optional[ConnectorValueList] = None
-
-
-class SourceConnectorPostgres(BaseModel):
- kind: Literal["postgres"]
- config: Optional[Config2] = None
- configuration: Optional[Configuration2] = None
-
-
-class Configuration3(BaseModel):
- url: ConnectorValue
- method: ConnectorValue
- headers: SinkConnectorWebhookConfigHeadersList
-
-
-class SinkConnectorWebhook(BaseModel):
- kind: Literal["webhook"]
- config: Optional[Config3] = None
- configuration: Optional[Configuration3] = None
-
-
-class Configuration4(BaseModel):
- addr: ConnectorValue
- database: ConnectorValue
- username: ConnectorValue
- password: ConnectorValue
- table: ConnectorValue
-
-
-class SinkConnectorClickhouse(BaseModel):
- kind: Literal["clickhouse"]
- config: Optional[Config4] = None
- configuration: Optional[Configuration4] = None
-
-
-class Configuration5(BaseModel):
- s3_bucket: ConnectorValue
- s3_key: ConnectorValue
- aws_region: ConnectorValue
- aws_access_key: ConnectorValue
- aws_secret_key: ConnectorValue
-
-
-class SinkConnectorAmazonS3(BaseModel):
- kind: Literal["amazon_s3"]
- config: Optional[Config5] = None
- configuration: Optional[Configuration5] = None
-
-
-class Configuration6(BaseModel):
- account: ConnectorValue
- warehouse: ConnectorValue
- db_user: ConnectorValue
- db_pass: ConnectorValue
- db_name: ConnectorValue
- db_schema: ConnectorValue
- db_host: Optional[ConnectorValue] = None
- db_port: Optional[ConnectorValue] = None
- db_role: Optional[ConnectorValue] = None
-
-
-class SinkConnectorSnowflakeCDCJSON(BaseModel):
- kind: Literal["snowflake_cdc_json"]
- config: Optional[Config6] = None
- configuration: Optional[Configuration6] = None
-
-
-class ClientHeader1(BaseModel):
- name: str
- value: ConnectorValue
-
-
-class Configuration7(BaseModel):
- api_key: ConnectorValue
- api_host: ConnectorValue
- api_source_tag: Optional[ConnectorValue] = None
- index_host: ConnectorValue
- client_headers: Optional[list[ClientHeader1]] = None
-
-
-class SinkConnectorPineconeJSON(BaseModel):
- kind: Literal["pinecone_json"]
- config: Optional[Config7] = None
- configuration: Optional[Configuration7] = None
-
-
-class Configuration8(BaseModel):
- connection_uri: ConnectorValue
- db_name: ConnectorValue
-
-
-class SinkConnectorMongoDBJSON(BaseModel):
- kind: Literal["mongodb_json"]
- config: Optional[Config8] = None
- configuration: Optional[Configuration8] = None
-
-
-class FunctionLogs(RootModel[list[FunctionLogEntry]]):
- root: list[FunctionLogEntry]
-
-
-class ListOrganizationSecrets(PaginationResponse):
- secrets: Secrets
-
-
-class SourceConnector(
- RootModel[
- Optional[
- Union[
- SourceConnectorGooglePubSub,
- SourceConnectorAmazonSQS,
- SourceConnectorPostgres,
- ]
- ]
- ]
-):
- root: Optional[
- Union[
- SourceConnectorGooglePubSub,
- SourceConnectorAmazonSQS,
- SourceConnectorPostgres,
- ]
- ] = Field(None, discriminator="kind")
-
-
-class SinkConnector(
- RootModel[
- Optional[
- Union[
- SinkConnectorWebhook,
- SinkConnectorClickhouse,
- SinkConnectorAmazonS3,
- SinkConnectorSnowflakeCDCJSON,
- SinkConnectorPineconeJSON,
- SinkConnectorMongoDBJSON,
- ]
- ]
- ]
-):
- root: Optional[
- Union[
- SinkConnectorWebhook,
- SinkConnectorClickhouse,
- SinkConnectorAmazonS3,
- SinkConnectorSnowflakeCDCJSON,
- SinkConnectorPineconeJSON,
- SinkConnectorMongoDBJSON,
- ]
- ] = Field(None, discriminator="kind")
-
-
-class GetDetailedSpacePipeline(SpacePipeline):
- source_connector: SourceConnector
- sink_connector: SinkConnector
- environments: FunctionEnvironments
-
-
-class UpdatePipeline(BaseModel):
- name: str
- transformation_function: Optional[str] = None
- transformation_requirements: Optional[list[str]] = None
- requirements_txt: Optional[str] = None
- metadata: Optional[dict[str, Any]] = None
- source_connector: Optional[SourceConnector] = None
- sink_connector: Optional[SinkConnector] = None
- environments: Optional[FunctionEnvironments] = None
-
-
-class CreatePipeline(BasePipeline):
- transformation_function: Optional[str] = None
- transformation_requirements: Optional[list[str]] = None
- requirements_txt: Optional[str] = None
- source_connector: Optional[SourceConnector] = None
- sink_connector: Optional[SinkConnector] = None
- environments: Optional[FunctionEnvironments] = None
- state: Optional[PipelineState] = None
diff --git a/src/glassflow/models/errors/__init__.py b/src/glassflow/models/errors/__init__.py
deleted file mode 100644
index 895edc2..0000000
--- a/src/glassflow/models/errors/__init__.py
+++ /dev/null
@@ -1,46 +0,0 @@
-from .clienterror import (
- ClientError,
- UnauthorizedError,
- UnknownContentTypeError,
- UnknownError,
-)
-from .error import Error
-from .pipeline import (
- ConnectorConfigValueError,
- PipelineAccessTokenInvalidError,
- PipelineArtifactStillInProgressError,
- PipelineNotFoundError,
- PipelineTooManyRequestsError,
- PipelineUnauthorizedError,
-)
-from .secret import (
- SecretInvalidKeyError,
- SecretNotFoundError,
- SecretUnauthorizedError,
-)
-from .space import (
- SpaceIsNotEmptyError,
- SpaceNotFoundError,
- SpaceUnauthorizedError,
-)
-
-__all__ = [
- "Error",
- "ClientError",
- "UnknownContentTypeError",
- "UnauthorizedError",
- "ConnectorConfigValueError",
- "SecretInvalidKeyError",
- "SecretNotFoundError",
- "SecretUnauthorizedError",
- "SpaceNotFoundError",
- "SpaceIsNotEmptyError",
- "SpaceUnauthorizedError",
- "PipelineArtifactStillInProgressError",
- "PipelineNotFoundError",
- "PipelineAccessTokenInvalidError",
- "PipelineAccessTokenInvalidError",
- "PipelineTooManyRequestsError",
- "PipelineUnauthorizedError",
- "UnknownError",
-]
diff --git a/src/glassflow/models/errors/clienterror.py b/src/glassflow/models/errors/clienterror.py
deleted file mode 100644
index 1a4f02b..0000000
--- a/src/glassflow/models/errors/clienterror.py
+++ /dev/null
@@ -1,88 +0,0 @@
-import requests as requests_http
-
-
-class ClientError(Exception):
- """Represents an error returned by the API.
-
- Attributes:
- detail: A message describing the error
- status_code: The status code of the response
- body: The response body
- raw_response: The raw response object
-
- """
-
- detail: str
- status_code: int
- body: str
- raw_response: requests_http.Response
-
- def __init__(
- self,
- detail: str,
- status_code: int,
- body: str,
- raw_response: requests_http.Response,
- ):
- """Create a new ClientError object
-
- Args:
- detail: A message describing the error
- status_code: The status code of the response
- body: The response body
- raw_response: The raw response object
- """
- self.detail = detail
- self.status_code = status_code
- self.body = body
- self.raw_response = raw_response
-
- def __str__(self) -> str:
- """Return a string representation of the error
-
- Returns:
- str: The string representation of the error
-
- """
- body = ""
- if len(self.body) > 0:
- body = f"\n{self.body}"
-
- return f"{self.detail}: Status {self.status_code}{body}"
-
-
-class UnauthorizedError(ClientError):
- """Error caused by a user not authorized."""
-
- def __init__(self, raw_response: requests_http.Response):
- super().__init__(
- detail="Unauthorized request, Personal Access Token used is invalid",
- status_code=401,
- body=raw_response.text,
- raw_response=raw_response,
- )
-
-
-class UnknownContentTypeError(ClientError):
- """Error caused by an unknown content type response."""
-
- def __init__(self, raw_response: requests_http.Response):
- content_type = raw_response.headers.get("Content-Type")
- super().__init__(
- detail=f"unknown content-type received: {content_type}",
- status_code=raw_response.status_code,
- body=raw_response.text,
- raw_response=raw_response,
- )
-
-
-class UnknownError(ClientError):
- """Error caused by an unknown error."""
-
- def __init__(self, raw_response: requests_http.Response):
- super().__init__(
- detail="Error in getting response from GlassFlow",
- status_code=raw_response.status_code,
- body=raw_response.text,
- raw_response=raw_response,
- )
diff --git a/src/glassflow/models/errors/error.py b/src/glassflow/models/errors/error.py
deleted file mode 100644
index c38e600..0000000
--- a/src/glassflow/models/errors/error.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from pydantic import BaseModel
-
-
-class Error(BaseModel):
- """Bad request error response
-
- Attributes:
- detail: A message describing the error
- """
-
- detail: str
-
- def __str__(self) -> str:
- return self.model_dump_json()
diff --git a/src/glassflow/models/errors/pipeline.py b/src/glassflow/models/errors/pipeline.py
deleted file mode 100644
index 1f02c01..0000000
--- a/src/glassflow/models/errors/pipeline.py
+++ /dev/null
@@ -1,73 +0,0 @@
-from .clienterror import ClientError, requests_http
-
-
-class ConnectorConfigValueError(Exception):
- """Value error for missing connector settings."""
-
- def __init__(self, connector_type: str):
- super().__init__(
- f"{connector_type}_kind and {connector_type}_config "
- f"or {connector_type}_config_secret_refs must be provided"
- )
-
-
-class PipelineNotFoundError(ClientError):
- """Error caused by a pipeline ID not found."""
-
- def __init__(self, pipeline_id: str, raw_response: requests_http.Response):
- super().__init__(
- detail=f"Pipeline ID {pipeline_id} does not exist",
- status_code=raw_response.status_code,
- body=raw_response.text,
- raw_response=raw_response,
- )
-
-
-class PipelineUnauthorizedError(ClientError):
- """Pipeline operation not authorized, invalid Personal Access Token"""
-
- def __init__(self, pipeline_id: str, raw_response: requests_http.Response):
- super().__init__(
- detail=f"Unauthorized request on pipeline {pipeline_id}, "
- f"Personal Access Token used is invalid",
- status_code=raw_response.status_code,
- body=raw_response.text,
- raw_response=raw_response,
- )
-
-
-class PipelineArtifactStillInProgressError(ClientError):
- """Error returned when the pipeline artifact is still being processed."""
-
- def __init__(self, pipeline_id: str, raw_response: requests_http.Response):
- super().__init__(
- detail=f"Artifact from pipeline {pipeline_id} "
- f"is still in process, try again later.",
- status_code=raw_response.status_code,
- body=raw_response.text,
- raw_response=raw_response,
- )
-
-
-class PipelineTooManyRequestsError(ClientError):
- """Error caused by too many requests to a pipeline."""
-
- def __init__(self, raw_response: requests_http.Response):
- super().__init__(
- detail="Too many requests",
- status_code=raw_response.status_code,
- body=raw_response.text,
- raw_response=raw_response,
- )
-
-
-class PipelineAccessTokenInvalidError(ClientError):
- """Error caused by invalid access token."""
-
- def __init__(self, raw_response: requests_http.Response):
- super().__init__(
- detail="The Pipeline Access Token used is invalid",
- status_code=raw_response.status_code,
- body=raw_response.text,
- raw_response=raw_response,
- )
diff --git a/src/glassflow/models/errors/secret.py b/src/glassflow/models/errors/secret.py
deleted file mode 100644
index 65371e8..0000000
--- a/src/glassflow/models/errors/secret.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from .clienterror import ClientError, requests_http
-
-
-class SecretNotFoundError(ClientError):
- """Error caused by a Secret Key not found."""
-
- def __init__(self, secret_key: str, raw_response: requests_http.Response):
- super().__init__(
- detail=f"Secret Key {secret_key} does not exist",
- status_code=404,
- body=raw_response.text,
- raw_response=raw_response,
- )
-
-
-class SecretUnauthorizedError(ClientError):
- """Secret operation not authorized, invalid Personal Access Token"""
-
- def __init__(self, secret_key: str, raw_response: requests_http.Response):
- super().__init__(
- detail=f"Unauthorized request on Secret {secret_key}, "
- f"Personal Access Token used is invalid",
- status_code=raw_response.status_code,
- body=raw_response.text,
- raw_response=raw_response,
- )
-
-
-class SecretInvalidKeyError(Exception):
- """Error caused by a Secret Key has invalid format."""
-
- def __init__(self, secret_key: str):
- super().__init__(
- f"Secret key {secret_key} has invalid format, it must start with a letter, "
- f"and it can only contain characters in a-zA-Z0-9_"
- )
diff --git a/src/glassflow/models/errors/space.py b/src/glassflow/models/errors/space.py
deleted file mode 100644
index c5519f0..0000000
--- a/src/glassflow/models/errors/space.py
+++ /dev/null
@@ -1,38 +0,0 @@
-from .clienterror import ClientError, requests_http
-
-
-class SpaceNotFoundError(ClientError):
- """Error caused by a space ID not found."""
-
- def __init__(self, space_id: str, raw_response: requests_http.Response):
- super().__init__(
- detail=f"Space ID {space_id} does not exist",
- status_code=404,
- body=raw_response.text,
- raw_response=raw_response,
- )
-
-
-class SpaceIsNotEmptyError(ClientError):
- """Error caused by trying to delete a space that is not empty."""
-
- def __init__(self, raw_response: requests_http.Response):
- super().__init__(
- detail=raw_response.json()["msg"],
- status_code=409,
- body=raw_response.text,
- raw_response=raw_response,
- )
-
-
-class SpaceUnauthorizedError(ClientError):
- """Space operation not authorized, invalid Personal Access Token"""
-
- def __init__(self, space_id: str, raw_response: requests_http.Response):
- super().__init__(
- detail=f"Unauthorized request on Space {space_id}, "
- f"Personal Access Token used is invalid",
- status_code=raw_response.status_code,
- body=raw_response.text,
- raw_response=raw_response,
- )
diff --git a/src/glassflow/models/operations/__init__.py b/src/glassflow/models/operations/__init__.py
deleted file mode 100644
index 49cf330..0000000
--- a/src/glassflow/models/operations/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from glassflow.models.operations.pipeline import (
- CreatePipeline,
- UpdatePipelineRequest,
-)
-
-__all__ = [
- "CreatePipeline",
- "UpdatePipelineRequest",
-]
diff --git a/src/glassflow/models/operations/pipeline.py b/src/glassflow/models/operations/pipeline.py
deleted file mode 100644
index 2bde155..0000000
--- a/src/glassflow/models/operations/pipeline.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from __future__ import annotations
-
-from pydantic import AwareDatetime, BaseModel
-
-from glassflow.models.api import (
- PipelineState,
- SinkConnector,
- SourceConnector,
-)
-
-
-class CreatePipeline(BaseModel):
- name: str
- space_id: str
- metadata: dict | None = None
- id: str
- created_at: AwareDatetime
- state: PipelineState
- access_token: str
-
-
-class UpdatePipelineRequest(BaseModel):
- name: str | None = None
- state: str | None = None
- metadata: dict | None = None
- source_connector: SourceConnector | None = None
- sink_connector: SinkConnector | None = None
diff --git a/src/glassflow/models/responses/__init__.py b/src/glassflow/models/responses/__init__.py
deleted file mode 100644
index 1a8d9a7..0000000
--- a/src/glassflow/models/responses/__init__.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from .pipeline import (
- AccessToken,
- ConsumeEventResponse,
- ConsumeFailedResponse,
- ConsumeOutputEvent,
- FunctionLogEntry,
- FunctionLogsResponse,
- ListAccessTokensResponse,
- ListPipelinesResponse,
- PublishEventResponse,
- TestFunctionResponse,
-)
-from .secret import ListSecretsResponse, Secret
-from .space import ListSpacesResponse, Space
-
-__all__ = [
- "ListSpacesResponse",
- "Space",
- "FunctionLogsResponse",
- "FunctionLogEntry",
- "TestFunctionResponse",
- "ListPipelinesResponse",
- "ConsumeEventResponse",
- "ConsumeOutputEvent",
- "PublishEventResponse",
- "ConsumeFailedResponse",
- "ListAccessTokensResponse",
- "AccessToken",
- "Secret",
- "ListSecretsResponse",
-]
diff --git a/src/glassflow/models/responses/pipeline.py b/src/glassflow/models/responses/pipeline.py
deleted file mode 100644
index 10c289f..0000000
--- a/src/glassflow/models/responses/pipeline.py
+++ /dev/null
@@ -1,240 +0,0 @@
-from __future__ import annotations
-
-from enum import Enum
-from typing import Any
-
-from pydantic import AwareDatetime, BaseModel, ConfigDict
-
-
-class Payload(BaseModel):
- """
- Logs payload response object.
-
- Attributes:
- message (str): log message
- """
-
- model_config = ConfigDict(
- extra="allow",
- )
- message: str
-
-
-class FunctionLogEntry(BaseModel):
- """
- Logs entry response object.
-
- Attributes:
- level (int): Log level.
- severity_code (int): Log severity code.
- timestamp (AwareDatetime): Log timestamp.
- payload (Payload): Log payload.
- """
-
- level: str
- severity_code: int
- timestamp: AwareDatetime
- payload: Payload
-
-
-class FunctionLogsResponse(BaseModel):
- """
- Response for a function's logs endpoint.
-
- Attributes:
- logs (list[FunctionLogEntry]): list of logs
- next (str): ID used to retrieve next page of logs
- """
-
- logs: list[FunctionLogEntry]
- next: str
-
-
-class EventContext(BaseModel):
- """
- Event context response object.
-
- Attributes:
- request_id (str): Request ID.
- external_id (str): External ID.
- receive_time (AwareDatetime): Receive time.
- """
-
- request_id: str
- external_id: str | None = None
- receive_time: AwareDatetime
-
-
-class ConsumeOutputEvent(BaseModel):
- """
- Consume output event
-
- Attributes:
- payload (Any): Payload
- event_context (EventContext): Event context
- status (str): Status
- response (Any): request response
- error_details (str): Error details
- stack_trace (str): Error Stack trace
- """
-
- payload: Any
- event_context: EventContext
- status: str
- response: Any | None = None
- error_details: str | None = None
- stack_trace: str | None = None
-
-
-class TestFunctionResponse(ConsumeOutputEvent):
- """Response for Test function endpoint."""
-
- pass
-
-
-class BasePipeline(BaseModel):
- """
- Base pipeline response object.
-
- Attributes:
- name (str): Pipeline name.
- space_id (str): Space ID.
- metadata (dict[str, Any]): Pipeline metadata.
- """
-
- name: str
- space_id: str
- metadata: dict[str, Any]
-
-
-class PipelineState(str, Enum):
- """
- Pipeline state
- """
-
- running = "running"
- paused = "paused"
-
-
-class Pipeline(BasePipeline):
- """
- Pipeline response object.
-
- Attributes:
- id (str): Pipeline id
- created_at (AwareDatetime): Pipeline creation time
- state (PipelineState): Pipeline state
- """
-
- id: str
- created_at: AwareDatetime
- state: PipelineState
-
-
-class SpacePipeline(Pipeline):
- """
- Pipeline with space response object.
-
- Attributes:
- space_name (str): Space name
- """
-
- space_name: str
-
-
-class ListPipelinesResponse(BaseModel):
- """
- Response for list pipelines endpoint
-
- Attributes:
- total_amount (int): Total amount of pipelines.
- pipelines (list[SpacePipeline]): List of pipelines.
- """
-
- total_amount: int
- pipelines: list[SpacePipeline]
-
-
-class ConsumeEventResponse(BaseModel):
- """
- Response from consume event
-
- Attributes:
- status_code (int): HTTP status code
- body (ConsumeOutputEvent): Body of the response
- """
-
- body: ConsumeOutputEvent | None = None
- status_code: int | None = None
-
- def event(self) -> Any:
- """Return event response."""
- if self.body:
- return self.body.response
- return None
-
-
-class PublishEventResponseBody(BaseModel):
- """Message pushed to the pipeline."""
-
- pass
-
-
-class PublishEventResponse(BaseModel):
- """
- Response from publishing event
-
- Attributes:
- status_code (int): HTTP status code
- """
-
- status_code: int | None = None
-
-
-class ConsumeFailedResponse(BaseModel):
- """
- Response from consuming failed event
-
- Attributes:
- status_code (int): HTTP status code
- body (ConsumeOutputEvent | None): ConsumeOutputEvent
- """
-
- body: ConsumeOutputEvent | None = None
- status_code: int | None = None
-
- def event(self) -> Any:
- """Return failed event response."""
- if self.body:
- return self.body.response
- return None
-
-
-class AccessToken(BaseModel):
- """
- Access Token response object.
-
- Attributes:
- id (str): The access token id.
- name (str): The access token name.
- token (str): The access token string.
- created_at (AwareDatetime): The access token creation date.
- """
-
- id: str
- name: str
- token: str
- created_at: AwareDatetime
-
-
-class ListAccessTokensResponse(BaseModel):
- """
- Response for listing access tokens endpoint.
-
- Attributes:
- total_amount (int): Total amount of access tokens.
- access_tokens (list[AccessToken]): List of access tokens.
- """
-
- access_tokens: list[AccessToken]
- total_amount: int
diff --git a/src/glassflow/models/responses/secret.py b/src/glassflow/models/responses/secret.py
deleted file mode 100644
index cbecc8c..0000000
--- a/src/glassflow/models/responses/secret.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from pydantic import BaseModel
-
-
-class Secret(BaseModel):
- """
- Secret response object
-
- Attributes:
- key (str): Secret key
- """
-
- key: str
-
-
-class ListSecretsResponse(BaseModel):
- """
- Response from the list secrets endpoint.
-
- Attributes:
- total_amount (int): Total amount of the secrets.
- secrets (list[Secret]): List of secrets.
- """
-
- total_amount: int
- secrets: list[Secret]
diff --git a/src/glassflow/models/responses/space.py b/src/glassflow/models/responses/space.py
deleted file mode 100644
index 3a556ae..0000000
--- a/src/glassflow/models/responses/space.py
+++ /dev/null
@@ -1,33 +0,0 @@
-from datetime import datetime
-
-from pydantic import BaseModel
-
-
-class Space(BaseModel):
- """
- Space response object.
-
- Attributes:
- name (str): Space name.
- id (int): Space id.
- created_at (datetime): Space creation date.
- permission (str): Space permission.
- """
-
- name: str
- id: str
- created_at: datetime
- permission: str
-
-
-class ListSpacesResponse(BaseModel):
- """
- Response from list spaces endpoint.
-
- Attributes:
- total_amount (int): Total amount of spaces.
- spaces (list[Space]): List of spaces.
- """
-
- total_amount: int
- spaces: list[Space]
diff --git a/src/glassflow/pipeline.py b/src/glassflow/pipeline.py
deleted file mode 100644
index 23f7b52..0000000
--- a/src/glassflow/pipeline.py
+++ /dev/null
@@ -1,535 +0,0 @@
-from __future__ import annotations
-
-from .client import APIClient
-from .models import api, errors, operations, responses
-from .models.responses.pipeline import AccessToken
-from .pipeline_data import PipelineDataSink, PipelineDataSource
-
-
-class Pipeline(APIClient):
- def __init__(
- self,
- personal_access_token: str,
- name: str | None = None,
- space_id: str | None = None,
- id: str | None = None,
- source_kind: str | None = None,
- source_config: dict | None = None,
- sink_kind: str | None = None,
- sink_config: dict | None = None,
- requirements: str | None = None,
- transformation_file: str | None = None,
- env_vars: list[dict[str, str]] | None = None,
- state: str = "running",
- organization_id: str | None = None,
- metadata: dict | None = None,
- created_at: str | None = None,
- ):
- """Creates a new GlassFlow pipeline object
-
- Args:
- personal_access_token: The personal access token to authenticate
- against GlassFlow
- id: Pipeline ID
- name: Name of the pipeline
- space_id: ID of the GlassFlow Space you want to create the pipeline in
- transformation_file: Path to file with transformation function of
- the pipeline.
- requirements: Requirements.txt of the pipeline
- source_kind: Kind of source for the pipeline. If no source is
- provided, the default source will be SDK
- source_config: Configuration of the pipeline's source
- sink_kind: Kind of sink for the pipeline. If no sink is provided,
- the default sink will be SDK
- sink_config: Configuration of the pipeline's sink
- env_vars: Environment variables to pass to the pipeline
- state: State of the pipeline after creation.
- It can be either "running" or "paused"
- metadata: Metadata of the pipeline
- created_at: Timestamp when the pipeline was created
-
- Raises:
- FileNotFoundError: If the transformation file is provided and
- does not exist
- """
- super().__init__()
- self.id = id
- self.name = name
- self.space_id = space_id
- self.personal_access_token = personal_access_token
- self.source_kind = source_kind
- self.source_config = source_config
- self.sink_kind = sink_kind
- self.sink_config = sink_config
- self.requirements = requirements
- self.transformation_code = None
- self.transformation_file = transformation_file
- self.env_vars = env_vars
- self.state = state
- self.organization_id = organization_id
- self.metadata = metadata if metadata is not None else {}
- self.created_at = created_at
- self.access_tokens: list[AccessToken] = []
-
- self.headers = {"Personal-Access-Token": self.personal_access_token}
- self.query_params = {"organization_id": self.organization_id}
- if self.transformation_file is not None:
- self._read_transformation_file()
-
- self.source_connector = self._fill_connector(
- "source",
- self.source_kind,
- self.source_config,
- )
- self.sink_connector = self._fill_connector(
- "sink", self.sink_kind, self.sink_config
- )
-
- def fetch(self) -> Pipeline:
- """
- Fetches pipeline information from the GlassFlow API
-
- Returns:
- Pipeline object
-
- Raises:
- ValueError: If ID is not provided in the constructor
- errors.PipelineNotFoundError: If ID provided does not match any
- existing pipeline in GlassFlow
- errors.PipelineUnauthorizedError: If the Personal Access Token is not
- provider or is invalid
- """
- if self.id is None:
- raise ValueError(
- "Pipeline id must be provided in order to fetch it's details"
- )
-
- endpoint = f"/pipelines/{self.id}"
- http_res = self._request(method="GET", endpoint=endpoint)
- fetched_pipeline = api.GetDetailedSpacePipeline(**http_res.json())
- self._fill_pipeline_details(fetched_pipeline)
- # Fetch Pipeline Access Tokens
- self._list_access_tokens()
- # Fetch function source
- self._get_function_artifact()
- return self
-
- def create(self) -> Pipeline:
- """
- Creates a new GlassFlow pipeline
-
- Returns:
- Pipeline object
-
- Raises:
- ValueError: If name is not provided in the constructor
- ValueError: If space_id is not provided in the constructor
- ValueError: If transformation_file is not provided
- in the constructor
- """
-
- if self.name is None:
- raise ValueError("Name must be provided in order to create the pipeline")
- if self.space_id is None:
- raise ValueError("Argument space_id must be provided in the constructor")
- if self.transformation_file is None:
- raise ValueError(
- "Argument transformation_file must be provided in the constructor"
- )
- else:
- self._read_transformation_file()
-
- create_pipeline = api.CreatePipeline(
- name=self.name,
- space_id=self.space_id,
- transformation_function=self.transformation_code,
- requirements_txt=self.requirements,
- source_connector=self.source_connector,
- sink_connector=self.sink_connector,
- environments=self.env_vars,
- state=api.PipelineState(self.state),
- metadata=self.metadata,
- )
- endpoint = "/pipelines"
- http_res = self._request(
- method="POST",
- endpoint=endpoint,
- json=create_pipeline.model_dump(exclude_none=True),
- )
- res_json = http_res.json()
- # using custom operations model because api model does not exist
- res = operations.CreatePipeline(
- **res_json,
- )
- self.id = res.id
- self.created_at = res.created_at
- self.space_id = res.space_id
- self.access_tokens.append(
- AccessToken(
- name="default",
- token=res.access_token,
- id="default",
- created_at=res.created_at,
- )
- )
- return self
-
- def update(
- self,
- name: str | None = None,
- state: str | None = None,
- transformation_file: str | None = None,
- requirements: str | None = None,
- metadata: dict | None = None,
- source_kind: str | None = None,
- source_config: dict | None = None,
- sink_kind: str | None = None,
- sink_config: dict | None = None,
- env_vars: list[dict[str, str]] | None = None,
- ) -> Pipeline:
- """
- Updates a GlassFlow pipeline
-
- Args:
- name: Name of the pipeline
- state: State of the pipeline after creation.
- It can be either "running" or "paused"
- transformation_file: Path to file with transformation function of
- the pipeline.
- requirements: Requirements.txt of the pipeline
- source_kind: Kind of source for the pipeline. If no source is
- provided, the default source will be SDK
- source_config: Configuration of the pipeline's source
- sink_kind: Kind of sink for the pipeline. If no sink is provided,
- the default sink will be SDK
- sink_config: Configuration of the pipeline's sink
- env_vars: Environment variables to pass to the pipeline
- metadata: Metadata of the pipeline
-
- Returns:
- Updated pipeline
-
- """
- self.fetch()
- if transformation_file is not None or requirements is not None:
- if transformation_file is not None:
- with open(transformation_file) as f:
- file = f.read()
- else:
- file = self.transformation_code
-
- if requirements is None:
- requirements = self.requirements
-
- self._upload_function_artifact(file, requirements)
- self.requirements = requirements
- self.transformation_code = file
-
- if source_kind is not None:
- source_connector = self._fill_connector(
- "source",
- source_kind,
- source_config,
- )
- else:
- source_connector = self.source_connector
-
- if sink_kind is not None:
- sink_connector = self._fill_connector("sink", sink_kind, sink_config)
- else:
- sink_connector = self.sink_connector
-
- if env_vars is not None:
- self._update_function(env_vars)
-
- # using custom model because api model does not exist
- pipeline_req = operations.UpdatePipelineRequest(
- name=name if name is not None else self.name,
- state=state if state is not None else self.state,
- metadata=metadata if metadata is not None else self.metadata,
- source_connector=source_connector,
- sink_connector=sink_connector,
- )
-
- endpoint = f"/pipelines/{self.id}"
- body = pipeline_req.model_dump_json(exclude_none=True)
- http_res = self._request(method="PATCH", endpoint=endpoint, data=body)
- # Fetch updated pipeline details and validate
- updated_pipeline = api.GetDetailedSpacePipeline(**http_res.json())
- self._fill_pipeline_details(updated_pipeline)
- return self
-
- def delete(self) -> None:
- """
- Deletes a GlassFlow pipeline
-
- Raises:
- ValueError: If ID is not provided in the constructor
- error.PipelineNotFoundError: If ID provided does not match any
- existing pipeline in GlassFlow
- errors.PipelineUnauthorizedError: If the Personal Access Token is not
- provided or is invalid
- """
- if self.id is None:
- raise ValueError("Pipeline id must be provided")
-
- endpoint = f"/pipelines/{self.id}"
- self._request(method="DELETE", endpoint=endpoint)
-
- def get_logs(
- self,
- page_size: int = 50,
- page_token: str | None = None,
- severity_code: int = 100,
- start_time: str | None = None,
- end_time: str | None = None,
- ) -> responses.FunctionLogsResponse:
- """
- Get the pipeline's logs
-
- Args:
- page_size: Pagination size
- page_token: Page token filter (use for pagination)
- severity_code: Severity code filter (100, 200, 300, 400, 500)
- start_time: Start time filter
- end_time: End time filter
-
- Returns:
- Response with the logs
- """
-
- query_params = {
- "page_size": page_size,
- "page_token": page_token,
- "severity_code": severity_code,
- "start_time": start_time,
- "end_time": end_time,
- }
- endpoint = f"/pipelines/{self.id}/functions/main/logs"
- http_res = self._request(
- method="GET", endpoint=endpoint, request_query_params=query_params
- )
- base_res_json = http_res.json()
- logs = [responses.FunctionLogEntry(**entry) for entry in base_res_json["logs"]]
- return responses.FunctionLogsResponse(
- logs=logs,
- next=base_res_json["next"],
- )
-
- def get_source(
- self, pipeline_access_token_name: str | None = None
- ) -> PipelineDataSource:
- """
- Get source client to publish data to the pipeline
-
- Args:
- pipeline_access_token_name (str | None): Name of the pipeline
- access token to use. If not specified, the default token
- will be used
-
- Returns:
- Source client to publish data to the pipeline
-
- Raises:
- ValueError: If pipeline id is not provided in the constructor
- """
- return self._get_data_client("source", pipeline_access_token_name)
-
- def get_sink(
- self, pipeline_access_token_name: str | None = None
- ) -> PipelineDataSink:
- """
- Get sink client to consume data from the pipeline
-
- Args:
- pipeline_access_token_name (str | None): Name of the pipeline
- access token to use. If not specified, the default token
- will be used
-
- Returns:
- Sink client to consume data from the pipeline
-
- Raises:
- ValueError: If pipeline id is not provided in the constructor
- """
- return self._get_data_client("sink", pipeline_access_token_name)
-
- def test(self, data: dict) -> responses.TestFunctionResponse:
- """
- Test a pipeline's function with a sample input JSON
-
- Args:
- data: Input JSON
-
- Returns:
- Test function response
- """
- endpoint = f"/pipelines/{self.id}/functions/main/test"
- request_body = data
- http_res = self._request(method="POST", endpoint=endpoint, json=request_body)
- base_res_json = http_res.json()
- return responses.TestFunctionResponse(
- **base_res_json,
- )
-
- def _request(
- self,
- method,
- endpoint,
- request_headers=None,
- json=None,
- request_query_params=None,
- files=None,
- data=None,
- ):
- headers = {**self.headers, **(request_headers or {})}
- query_params = {**self.query_params, **(request_query_params or {})}
- try:
- return super()._request(
- method=method,
- endpoint=endpoint,
- request_headers=headers,
- json=json,
- request_query_params=query_params,
- files=files,
- data=data,
- )
- except errors.UnknownError as e:
- if e.status_code == 401:
- raise errors.PipelineUnauthorizedError(self.id, e.raw_response) from e
- if e.status_code == 404:
- raise errors.PipelineNotFoundError(self.id, e.raw_response) from e
- if e.status_code == 425:
- raise errors.PipelineArtifactStillInProgressError(
- self.id, e.raw_response
- ) from e
- raise e
-
- @staticmethod
- def _fill_connector(
- connector_type: str, kind: str, config: dict
- ) -> api.SourceConnector | api.SinkConnector:
- """Format connector input"""
- if not kind and not config:
- connector = None
- elif kind and config:
- connector = dict(kind=kind, config=config)
- else:
- raise errors.ConnectorConfigValueError(connector_type)
-
- if connector_type == "source":
- return api.SourceConnector(root=connector)
- elif connector_type == "sink":
- return api.SinkConnector(root=connector)
- else:
- raise ValueError("connector_type must be 'source' or 'sink'")
-
- def _list_access_tokens(self) -> Pipeline:
- endpoint = f"/pipelines/{self.id}/access_tokens"
- http_res = self._request(method="GET", endpoint=endpoint)
- tokens = responses.ListAccessTokensResponse(**http_res.json())
- self.access_tokens = tokens.access_tokens
- return self
-
- def _get_function_artifact(self) -> Pipeline:
- """
- Fetch pipeline function source
-
- Returns:
- Pipeline with function source details
- """
- endpoint = f"/pipelines/{self.id}/functions/main/artifacts/latest"
- http_res = self._request(method="GET", endpoint=endpoint)
- res_json = http_res.json()
- self.transformation_code = res_json["transformation_function"]
- # you would never know what else was changed
-
- if "requirements_txt" in res_json:
- self.requirements = res_json["requirements_txt"]
- return self
-
- def _upload_function_artifact(self, file: str, requirements: str) -> None:
- files = {"file": file}
- data = {
- "requirementsTxt": requirements,
- }
- endpoint = f"/pipelines/{self.id}/functions/main/artifacts"
- self._request(method="POST", endpoint=endpoint, files=files, data=data)
-
- def _update_function(self, env_vars):
- """
- Patch pipeline function
-
- Args:
- env_vars: Environment variables to update
-
- Returns:
- Pipeline with updated function
- """
- endpoint = f"/pipelines/{self.id}/functions/main"
- body = api.PipelineFunctionOutput(environments=env_vars)
- http_res = self._request(
- method="PATCH", endpoint=endpoint, json=body.model_dump()
- )
- self.env_vars = http_res.json()["environments"]
- return self
-
- def _get_data_client(
- self, client_type: str, pipeline_access_token_name: str | None = None
- ) -> PipelineDataSource | PipelineDataSink:
- if self.id is None:
- raise ValueError("Pipeline id must be provided in the constructor")
- elif len(self.access_tokens) == 0:
- self._list_access_tokens()
-
- if pipeline_access_token_name is not None:
- for t in self.access_tokens:
- if t.name == pipeline_access_token_name:
- token = t.token
- break
- else:
- raise ValueError(
- f"Token with name {pipeline_access_token_name} was not found"
- )
- else:
- token = self.access_tokens[0].token
- if client_type == "source":
- client = PipelineDataSource(
- pipeline_id=self.id,
- pipeline_access_token=token,
- )
- elif client_type == "sink":
- client = PipelineDataSink(
- pipeline_id=self.id,
- pipeline_access_token=token,
- )
- else:
- raise ValueError("client_type must be either source or sink")
- return client
-
- def _read_transformation_file(self):
- try:
- with open(self.transformation_file) as f:
- self.transformation_code = f.read()
- except FileNotFoundError:
- raise
-
- def _fill_pipeline_details(
- self, pipeline_details: api.GetDetailedSpacePipeline
- ) -> Pipeline:
- self.id = pipeline_details.id
- self.name = pipeline_details.name
- self.space_id = pipeline_details.space_id
- self.state = pipeline_details.state
- source_connector = pipeline_details.source_connector
- if source_connector.root:
- self.source_kind = source_connector.root.kind
- self.source_config = source_connector.root.config
- sink_connector = pipeline_details.sink_connector
- if sink_connector.root:
- self.sink_kind = sink_connector.root.kind
- self.sink_config = sink_connector.root.config
- self.created_at = pipeline_details.created_at
- self.env_vars = pipeline_details.environments
-
- return self
diff --git a/src/glassflow/pipeline_data.py b/src/glassflow/pipeline_data.py
deleted file mode 100644
index cd4dafa..0000000
--- a/src/glassflow/pipeline_data.py
+++ /dev/null
@@ -1,159 +0,0 @@
-import random
-import time
-
-from .api_client import APIClient
-from .models import errors, responses
-
-
-class PipelineDataClient(APIClient):
- """Base Client object to publish and consume events from the given pipeline.
-
- Attributes:
- glassflow_config (GlassFlowConfig): GlassFlowConfig object to interact
- with GlassFlow API
- pipeline_id (str): The pipeline id to interact with
- pipeline_access_token (str): The access token to access the pipeline
- """
-
- def __init__(self, pipeline_id: str, pipeline_access_token: str):
- super().__init__()
- self.pipeline_id = pipeline_id
- self.pipeline_access_token = pipeline_access_token
- self.headers = {"X-PIPELINE-ACCESS-TOKEN": self.pipeline_access_token}
- self.query_params = {}
-
- def validate_credentials(self) -> None:
- """
- Check if the pipeline credentials are valid and raise an error if not
- """
-
- endpoint = f"/pipelines/{self.pipeline_id}/status/access_token"
- return self._request(method="GET", endpoint=endpoint)
-
- def _request(
- self,
- method,
- endpoint,
- request_headers=None,
- json=None,
- request_query_params=None,
- files=None,
- data=None,
- ):
- headers = {**self.headers, **(request_headers or {})}
- query_params = {**self.query_params, **(request_query_params or {})}
- try:
- return super()._request(
- method=method,
- endpoint=endpoint,
- request_headers=headers,
- json=json,
- request_query_params=query_params,
- files=files,
- data=data,
- )
- except errors.UnknownError as e:
- if e.status_code == 401:
- raise errors.PipelineAccessTokenInvalidError(e.raw_response) from e
- if e.status_code == 404:
- raise errors.PipelineNotFoundError(
- self.pipeline_id, e.raw_response
- ) from e
- if e.status_code == 429:
- return errors.PipelineTooManyRequestsError(e.raw_response)
- raise e
-
-
-class PipelineDataSource(PipelineDataClient):
- def publish(self, request_body: dict) -> responses.PublishEventResponse:
- """Push a new message into the pipeline
-
- Args:
- request_body: The message to be published into the pipeline
-
- Returns:
- Response object containing the status code and the raw response
-
- Raises:
- errors.ClientError: If an error occurred while publishing the event
- """
- endpoint = f"/pipelines/{self.pipeline_id}/topics/input/events"
- http_res = self._request(method="POST", endpoint=endpoint, json=request_body)
- return responses.PublishEventResponse(
- status_code=http_res.status_code,
- )
-
-
-class PipelineDataSink(PipelineDataClient):
- def __init__(self, pipeline_id: str, pipeline_access_token: str):
- super().__init__(pipeline_id, pipeline_access_token)
-
- # retry delay for consuming messages (in seconds)
- self._consume_retry_delay_minimum = 1
- self._consume_retry_delay_current = 1
- self._consume_retry_delay_max = 60
-
- def consume(self) -> responses.ConsumeEventResponse:
- """Consume the last message from the pipeline
-
- Returns:
- Response object containing the status code and the raw response
-
- Raises:
- errors.ClientError: If an error occurred while consuming the event
-
- """
-
- endpoint = f"/pipelines/{self.pipeline_id}/topics/output/events/consume"
- self._respect_retry_delay()
- http_res = self._request(method="POST", endpoint=endpoint)
- self._update_retry_delay(http_res.status_code)
-
- body = None
- if http_res.status_code == 200:
- body = http_res.json()
- self._consume_retry_delay_current = self._consume_retry_delay_minimum
-
- return responses.ConsumeEventResponse(
- status_code=http_res.status_code, body=body
- )
-
- def consume_failed(self) -> responses.ConsumeFailedResponse:
- """Consume the failed message from the pipeline
-
- Returns:
- Response object containing the status code and the raw response
-
- Raises:
- errors.ClientError: If an error occurred while consuming the event
-
- """
-
- self._respect_retry_delay()
- endpoint = f"/pipelines/{self.pipeline_id}/topics/failed/events/consume"
- http_res = self._request(method="POST", endpoint=endpoint)
-
- self._update_retry_delay(http_res.status_code)
- body = None
- if http_res.status_code == 200:
- body = http_res.json()
- self._consume_retry_delay_current = self._consume_retry_delay_minimum
-
- return responses.ConsumeFailedResponse(
- status_code=http_res.status_code, body=body
- )
-
- def _update_retry_delay(self, status_code: int):
- if status_code == 200:
- self._consume_retry_delay_current = self._consume_retry_delay_minimum
- elif status_code == 204 or status_code == 429:
- self._consume_retry_delay_current *= 2
- self._consume_retry_delay_current = min(
- self._consume_retry_delay_current, self._consume_retry_delay_max
- )
- self._consume_retry_delay_current += random.uniform(0, 0.1)
-
- def _respect_retry_delay(self):
- if self._consume_retry_delay_current > self._consume_retry_delay_minimum:
- # sleep before making the request
- time.sleep(self._consume_retry_delay_current)
diff --git a/src/glassflow/secret.py b/src/glassflow/secret.py
deleted file mode 100644
index 1961778..0000000
--- a/src/glassflow/secret.py
+++ /dev/null
@@ -1,117 +0,0 @@
-from __future__ import annotations
-
-import re
-
-from .api_client import APIClient
-from .models import api, errors
-
-
-class Secret(APIClient):
- def __init__(
- self,
- personal_access_token: str,
- key: str | None = None,
- value: str | None = None,
- organization_id: str | None = None,
- ):
- """
- Creates a new Glassflow Secret object
-
- Args:
- personal_access_token: The personal access token to authenticate
- against GlassFlow
- key: Name of the secret. It must start with a letter,
- and it can only contain characters in a-zA-Z0-9_
- value: Value of the secret to store
-
- Raises:
- errors.SecretInvalidKeyError: If secret key is invalid
- """
- super().__init__()
- self.personal_access_token = personal_access_token
- self.organization_id = organization_id
- self.key = key
- self.value = value
- self.headers = {"Personal-Access-Token": self.personal_access_token}
- self.query_params = {"organization_id": self.organization_id}
-
- if self.key and not self._is_key_valid(self.key):
- raise errors.SecretInvalidKeyError(self.key)
-
- def create(self) -> Secret:
- """
- Creates a new Glassflow Secret
-
- Returns:
- Secret object
-
- Raises:
- ValueError: If secret key or value are not set in the constructor
- errors.SecretUnauthorizedError: If personal access token is invalid
- """
- if self.key is None:
- raise ValueError("Secret key is required in the constructor")
- if self.value is None:
- raise ValueError("Secret value is required in the constructor")
-
- secret_api_obj = api.CreateSecret(
- **{
- "key": self.key,
- "value": self.value,
- }
- )
-
- endpoint = "/secrets"
- self._request(
- method="POST", endpoint=endpoint, json=secret_api_obj.model_dump()
- )
- return self
-
- def delete(self) -> None:
- """
- Deletes a Glassflow Secret.
-
- Raises:
- errors.SecretUnauthorizedError: If personal access token is invalid
- errors.SecretNotFoundError: If secret key does not exist
- ValueError: If secret key is not set in the constructor
- """
- if self.key is None:
- raise ValueError("Secret key is required in the constructor")
-
- endpoint = f"/secrets/{self.key}"
- self._request(method="DELETE", endpoint=endpoint)
-
- @staticmethod
- def _is_key_valid(key: str) -> bool:
- search = re.compile(r"[^a-zA-Z0-9_]").search
- return not bool(search(key))
-
- def _request(
- self,
- method,
- endpoint,
- request_headers=None,
- json=None,
- request_query_params=None,
- files=None,
- data=None,
- ):
- headers = {**self.headers, **(request_headers or {})}
- query_params = {**self.query_params, **(request_query_params or {})}
- try:
- return super()._request(
- method=method,
- endpoint=endpoint,
- request_headers=headers,
- json=json,
- request_query_params=query_params,
- files=files,
- data=data,
- )
- except errors.UnknownError as e:
- if e.status_code == 401:
- raise errors.SecretUnauthorizedError(self.key, e.raw_response) from e
- if e.status_code == 404:
- raise errors.SecretNotFoundError(self.key, e.raw_response) from e
- raise e
diff --git a/src/glassflow/space.py b/src/glassflow/space.py
deleted file mode 100644
index 312e3e9..0000000
--- a/src/glassflow/space.py
+++ /dev/null
@@ -1,108 +0,0 @@
-from __future__ import annotations
-
-import datetime
-
-from .client import APIClient
-from .models import api, errors
-
-
-class Space(APIClient):
- def __init__(
- self,
- personal_access_token: str,
- name: str | None = None,
- id: str | None = None,
- created_at: datetime.datetime | None = None,
- organization_id: str | None = None,
- ):
- """Creates a new GlassFlow space object
-
- Args:
- personal_access_token: The personal access token to authenticate
- against GlassFlow
- name: Name of the space
- id: ID of the GlassFlow Space you want to create the pipeline in
- created_at: Timestamp when the space was created
-
- """
- super().__init__()
- self.name = name
- self.id = id
- self.created_at = created_at
- self.organization_id = organization_id
- self.personal_access_token = personal_access_token
- self.headers = {"Personal-Access-Token": self.personal_access_token}
- self.query_params = {"organization_id": self.organization_id}
-
- def create(self) -> Space:
- """
- Creates a new GlassFlow space
-
- Returns:
- Space object
-
- Raises:
- ValueError: If name is not provided in the constructor
-
- """
- space_api_obj = api.CreateSpace(name=self.name)
-
- endpoint = "/spaces"
- http_res = self._request(
- method="POST", endpoint=endpoint, json=space_api_obj.model_dump()
- )
-
- space_created = api.Space(**http_res.json())
- self.id = space_created.id
- self.created_at = space_created.created_at
- self.name = space_created.name
- return self
-
- def delete(self) -> None:
- """
- Deletes a GlassFlow space
-
- Raises:
- ValueError: If ID is not provided in the constructor
- errors.SpaceNotFoundError: If ID provided does not match any
- existing space in GlassFlow
- errors.SpaceUnauthorizedError: If the Personal Access Token is not
- provided or is invalid
- errors.SpaceIsNotEmptyError: If the Space is not empty
- """
- if self.id is None:
- raise ValueError("Space id must be provided in the constructor")
-
- endpoint = f"/spaces/{self.id}"
- self._request(method="DELETE", endpoint=endpoint)
-
- def _request(
- self,
- method,
- endpoint,
- request_headers=None,
- json=None,
- request_query_params=None,
- files=None,
- data=None,
- ):
- headers = {**self.headers, **(request_headers or {})}
- query_params = {**self.query_params, **(request_query_params or {})}
- try:
- return super()._request(
- method=method,
- endpoint=endpoint,
- request_headers=headers,
- json=json,
- request_query_params=query_params,
- files=files,
- data=data,
- )
- except errors.UnknownError as e:
- if e.status_code == 401:
- raise errors.SpaceUnauthorizedError(self.id, e.raw_response) from e
- if e.status_code == 404:
- raise errors.SpaceNotFoundError(self.id, e.raw_response) from e
- if e.status_code == 409:
- raise errors.SpaceIsNotEmptyError(e.raw_response) from e
- raise e
diff --git a/src/cli/__init__.py b/tests/__init__.py
similarity index 100%
rename from src/cli/__init__.py
rename to tests/__init__.py
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..ed717fc
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,116 @@
+from unittest.mock import patch
+
+import pytest
+
+from glassflow.etl.dlq import DLQ
+from glassflow.etl.models import PipelineConfig
+from glassflow.etl.pipeline import Pipeline
+from tests.data import mock_responses, pipeline_configs
+
+
+@pytest.fixture
+def mock_track(autouse=True):
+ """Mock the Mixpanel track method."""
+ with patch("glassflow.etl.tracking.mixpanel.Mixpanel.track") as mock:
+ yield mock
+
+
+@pytest.fixture
+def valid_config() -> dict:
+ """Fixture for a valid pipeline configuration."""
+ return pipeline_configs.get_valid_pipeline_config()
+
+
+@pytest.fixture
+def valid_config_without_joins() -> dict:
+ """Fixture for a valid pipeline configuration without joins."""
+ return pipeline_configs.get_valid_config_without_joins()
+
+
+@pytest.fixture
+def valid_config_with_dedup_disabled() -> dict:
+ """Fixture for a valid pipeline configuration with deduplication disabled."""
+ return pipeline_configs.get_valid_config_with_dedup_disabled()
+
+
+@pytest.fixture
+def valid_config_without_joins_and_dedup_disabled() -> dict:
+ """Fixture for a valid pipeline configuration without joins and deduplication."""
+ return pipeline_configs.get_valid_config_without_joins_and_dedup_disabled()
+
+
+@pytest.fixture
+def invalid_config() -> dict:
+ """Fixture for an invalid pipeline configuration."""
+ return pipeline_configs.get_invalid_config()
+
+
+@pytest.fixture
+def mock_success_response():
+ """Fixture for a successful HTTP response."""
+ return mock_responses.create_mock_response_factory()(
+ status_code=200,
+ json_data={"message": "Success"},
+ )
+
+
+@pytest.fixture
+def mock_not_found_response():
+ """Fixture for a 404 Not Found HTTP response."""
+ return mock_responses.create_mock_response_factory()(
+ status_code=404,
+ json_data={"message": "Not Found"},
+ )
+
+
+@pytest.fixture
+def mock_forbidden_response():
+ """Fixture for a 403 Forbidden HTTP response."""
+ return mock_responses.create_mock_response_factory()(
+ status_code=403,
+ json_data={"message": "Forbidden"},
+ )
+
+
+@pytest.fixture
+def mock_bad_request_response():
+ """Fixture for a 400 Bad Request HTTP response."""
+ return mock_responses.create_mock_response_factory()(
+ status_code=400,
+ json_data={"message": "Bad Request"},
+ )
+
+
+@pytest.fixture
+def mock_connection_error():
+ """Fixture for a connection error."""
+ return mock_responses.create_mock_connection_error()
+
+
+@pytest.fixture
+def mock_success_get_pipeline(valid_config):
+ """Fixture for a successful GET pipeline response."""
+ return mock_responses.create_mock_response_factory()(
+ status_code=200,
+ json_data=valid_config,
+ )
+
+
+@pytest.fixture
+def pipeline_from_id(mock_success_get_pipeline):
+ """Fixture for a successful GET request."""
+ with patch("httpx.Client.request", return_value=mock_success_get_pipeline):
+ return Pipeline(pipeline_id="test-pipeline-id").get()
+
+
+@pytest.fixture
+def dlq():
+ """Fixture for a DLQ instance."""
+ return DLQ(host="http://localhost:8080", pipeline_id="test-pipeline")
+
+
+@pytest.fixture
+def pipeline(valid_config):
+ """Base pipeline fixture with valid config."""
+ config = PipelineConfig(**valid_config)
+ return Pipeline(host="http://localhost:8080", config=config)
diff --git a/tests/data/__init__.py b/tests/data/__init__.py
new file mode 100644
index 0000000..f6f1ddc
--- /dev/null
+++ b/tests/data/__init__.py
@@ -0,0 +1 @@
+# Test data package
diff --git a/tests/data/error_scenarios.py b/tests/data/error_scenarios.py
new file mode 100644
index 0000000..a73e7b7
--- /dev/null
+++ b/tests/data/error_scenarios.py
@@ -0,0 +1,229 @@
+"""Error scenario test data."""
+
+from pydantic import ValidationError
+
+from glassflow.etl import errors, models
+
+
+def get_validation_error_scenarios():
+ """Get validation error test scenarios."""
+ return [
+ {
+ "name": "empty_pipeline_id",
+ "config": {"pipeline_id": ""},
+ "expected_error": ValueError,
+ "error_message": "pipeline_id cannot be empty",
+ },
+ {
+ "name": "invalid_pipeline_id_format",
+ "config": {"pipeline_id": "Test_Pipeline"},
+ "expected_error": ValueError,
+ "error_message": (
+ "pipeline_id can only contain lowercase letters, numbers, and hyphens"
+ ),
+ },
+ {
+ "name": "pipeline_id_too_long",
+ "config": {
+ "pipeline_id": "test-pipeline-1234567890123456789012345678901234567890"
+ },
+ "expected_error": ValueError,
+ "error_message": "pipeline_id cannot be longer than 40 characters",
+ },
+ {
+ "name": "pipeline_id_starts_with_hyphen",
+ "config": {"pipeline_id": "-test-pipeline"},
+ "expected_error": ValueError,
+ "error_message": "pipeline_id must start with a lowercase alphanumeric",
+ },
+ {
+ "name": "pipeline_id_ends_with_hyphen",
+ "config": {"pipeline_id": "test-pipeline-"},
+ "expected_error": ValueError,
+ "error_message": "pipeline_id must end with a lowercase alphanumeric",
+ },
+ ]
+
+
+def get_http_error_scenarios():
+ """Get HTTP error test scenarios."""
+ return [
+ {
+ "name": "not_found",
+ "status_code": 404,
+ "text": "Pipeline not found",
+ "expected_error": errors.PipelineNotFoundError,
+ "error_message": "not found",
+ },
+ {
+ "name": "forbidden",
+ "status_code": 403,
+ "text": "Pipeline already active",
+ "expected_error": errors.PipelineAlreadyExistsError,
+ "error_message": "already exists",
+ },
+ {
+ "name": "bad_request",
+ "status_code": 400,
+ "text": "Bad request",
+ "expected_error": errors.ValidationError,
+ "error_message": "Bad request",
+ },
+ {
+ "name": "server_error",
+ "status_code": 500,
+ "text": "Internal server error",
+ "expected_error": errors.ServerError,
+ "error_message": "Internal server error",
+ },
+ ]
+
+
+def get_dlq_error_scenarios():
+ """Get DLQ error test scenarios."""
+ return [
+ {
+ "name": "invalid_batch_size_negative",
+ "batch_size": -1,
+ "expected_error": ValueError,
+ "error_message": "batch_size must be an integer between 1 and 100",
+ },
+ {
+ "name": "invalid_batch_size_zero",
+ "batch_size": 0,
+ "expected_error": ValueError,
+ "error_message": "batch_size must be an integer between 1 and 100",
+ },
+ {
+ "name": "invalid_batch_size_too_large",
+ "batch_size": 101,
+ "expected_error": ValueError,
+ "error_message": "batch_size must be an integer between 1 and 100",
+ },
+ {
+ "name": "invalid_batch_size_non_integer",
+ "batch_size": "invalid",
+ "expected_error": ValueError,
+ "error_message": "batch_size must be an integer between 1 and 100",
+ },
+ {
+ "name": "http_error_422_validation_error",
+ "status_code": 422,
+ "text": "Invalid batch size",
+ "expected_error": errors.InvalidBatchSizeError,
+ "error_message": "Invalid batch size",
+ },
+ {
+ "name": "http_error_500_server_error",
+ "status_code": 500,
+ "text": "Internal server error",
+ "expected_error": errors.ServerError,
+ "error_message": "Internal server error",
+ },
+ ]
+
+
+def get_join_validation_error_scenarios():
+ """Get join validation error test scenarios."""
+
+ def get_join_with_source_id_not_found(valid_config):
+ join = valid_config["join"].copy()
+ join["sources"][0]["source_id"] = "non-existent-topic"
+ return join
+
+ def get_join_with_join_key_not_found(valid_config):
+ join = valid_config["join"].copy()
+ join["sources"][0]["join_key"] = "non-existent-field"
+ return join
+
+ def get_join_with_same_orientation(valid_config):
+ join = valid_config["join"].copy()
+ join["sources"][0]["orientation"] = models.JoinOrientation.LEFT
+ join["sources"][1]["orientation"] = models.JoinOrientation.LEFT
+ return join
+
+ def get_join_with_only_one_source(valid_config):
+ join = valid_config["join"].copy()
+ join["sources"] = [join["sources"][0]]
+ return join
+
+ def get_join_with_invalid_type(valid_config):
+ join = valid_config["join"].copy()
+ join["type"] = None
+ return join
+
+ return [
+ {
+ "name": "source_id_not_found",
+ "join": get_join_with_source_id_not_found,
+ "expected_error": ValueError,
+ "error_message": "does not exist in any topic",
+ },
+ {
+ "name": "join_key_not_found",
+ "join": get_join_with_join_key_not_found,
+ "expected_error": ValueError,
+ "error_message": "does not exist in source",
+ },
+ {
+ "name": "same_orientation",
+ "join": get_join_with_same_orientation,
+ "expected_error": ValidationError,
+ "error_message": "join sources must have opposite orientations",
+ },
+ {
+ "name": "join_with_only_one_source",
+ "join": get_join_with_only_one_source,
+ "expected_error": ValueError,
+ "error_message": "join must have exactly two sources when enabled",
+ },
+ {
+ "name": "join_with_invalid_type",
+ "join": get_join_with_invalid_type,
+ "expected_error": ValueError,
+ "error_message": "type is required when join is enabled",
+ },
+ ]
+
+
+def get_sink_validation_error_scenarios():
+ """Get sink validation error test scenarios."""
+
+ def get_sink_with_source_id_not_found(valid_config):
+ sink = valid_config["sink"]
+ sink["table_mapping"] = [
+ models.TableMapping(
+ source_id="non-existent-topic",
+ field_name="id",
+ column_name="id",
+ column_type="String",
+ )
+ ]
+ return sink
+
+ def get_sink_with_field_name_not_found(valid_config):
+ sink = valid_config["sink"]
+ sink["table_mapping"] = [
+ models.TableMapping(
+ source_id=valid_config["source"]["topics"][0]["name"],
+ field_name="non-existent-field",
+ column_name="id",
+ column_type="String",
+ )
+ ]
+ return sink
+
+ return [
+ {
+ "name": "source_id_not_found",
+ "sink": get_sink_with_source_id_not_found,
+ "expected_error": ValueError,
+ "error_message": "does not exist in any topic",
+ },
+ {
+ "name": "field_name_not_found",
+ "sink": get_sink_with_field_name_not_found,
+ "expected_error": ValueError,
+ "error_message": "does not exist in source",
+ },
+ ]
diff --git a/tests/data/mock_responses.py b/tests/data/mock_responses.py
new file mode 100644
index 0000000..e6f0b4c
--- /dev/null
+++ b/tests/data/mock_responses.py
@@ -0,0 +1,38 @@
+"""Mock response test data."""
+
+from unittest.mock import MagicMock
+
+import httpx
+
+
+def create_mock_connection_error():
+ """Create a mock connection error."""
+ return httpx.ConnectError("Connection failed")
+
+
+def create_mock_response_factory():
+ """
+ Create a factory for generating mock responses with
+ specific status codes and text.
+ """
+
+ def factory(status_code=200, json_data=None, text=None):
+ mock_response = MagicMock()
+ mock_response.status_code = status_code
+ mock_response.text = text or ""
+ mock_response.json.return_value = json_data or {}
+
+ # For error status codes, we need to raise HTTPStatusError directly
+ if status_code >= 400:
+ # Create the error that will be raised when the response is used
+ error = httpx.HTTPStatusError(
+ f"{status_code} Error", request=MagicMock(), response=mock_response
+ )
+ # Make the response raise the error when raise_for_status is called
+ mock_response.raise_for_status.side_effect = error
+ else:
+ mock_response.raise_for_status.return_value = None
+
+ return mock_response
+
+ return factory
diff --git a/tests/data/pipeline_configs.py b/tests/data/pipeline_configs.py
new file mode 100644
index 0000000..be48ff0
--- /dev/null
+++ b/tests/data/pipeline_configs.py
@@ -0,0 +1,272 @@
+"""Pipeline configuration test data."""
+
+import copy
+
+
+def get_valid_pipeline_config() -> dict:
+ """Get a valid pipeline configuration for testing."""
+ return {
+ "pipeline_id": "test-pipeline",
+ "source": {
+ "type": "kafka",
+ "provider": "aiven",
+ "connection_params": {
+ "brokers": [
+ "kafka-broker-0:9092",
+ "kafka-broker-1:9092",
+ ],
+ "protocol": "SASL_SSL",
+ "mechanism": "SCRAM-SHA-256",
+ "username": "",
+ "password": "",
+ "root_ca": "",
+ },
+ "topics": [
+ {
+ "consumer_group_initial_offset": "earliest",
+ "name": "user_logins",
+ "schema": {
+ "type": "json",
+ "fields": [
+ {
+ "name": "session_id",
+ "type": "string",
+ },
+ {
+ "name": "user_id",
+ "type": "string",
+ },
+ {
+ "name": "timestamp",
+ "type": "String",
+ },
+ ],
+ },
+ "deduplication": {
+ "enabled": True,
+ "id_field": "session_id",
+ "id_field_type": "string",
+ "time_window": "12h",
+ },
+ },
+ {
+ "consumer_group_initial_offset": "earliest",
+ "name": "orders",
+ "schema": {
+ "type": "json",
+ "fields": [
+ {
+ "name": "user_id",
+ "type": "string",
+ },
+ {
+ "name": "order_id",
+ "type": "string",
+ },
+ {
+ "name": "timestamp",
+ "type": "string",
+ },
+ ],
+ },
+ "deduplication": {
+ "enabled": True,
+ "id_field": "order_id",
+ "id_field_type": "string",
+ "time_window": "12h",
+ },
+ },
+ ],
+ },
+ "join": {
+ "enabled": True,
+ "type": "temporal",
+ "sources": [
+ {
+ "source_id": "user_logins",
+ "join_key": "user_id",
+ "time_window": "1h",
+ "orientation": "left",
+ },
+ {
+ "source_id": "orders",
+ "join_key": "user_id",
+ "time_window": "1h",
+ "orientation": "right",
+ },
+ ],
+ },
+ "sink": {
+ "type": "clickhouse",
+ "provider": "aiven",
+ "host": "",
+ "port": "12753",
+ "http_port": "12754",
+ "database": "default",
+ "username": "",
+ "password": "",
+ "secure": True,
+ "max_batch_size": 1,
+ "table": "user_orders",
+ "table_mapping": [
+ {
+ "source_id": "user_logins",
+ "field_name": "session_id",
+ "column_name": "session_id",
+ "column_type": "string",
+ },
+ {
+ "source_id": "user_logins",
+ "field_name": "user_id",
+ "column_name": "user_id",
+ "column_type": "STRING",
+ },
+ {
+ "source_id": "orders",
+ "field_name": "order_id",
+ "column_name": "order_id",
+ "column_type": "string",
+ },
+ {
+ "source_id": "user_logins",
+ "field_name": "timestamp",
+ "column_name": "login_at",
+ "column_type": "DateTime",
+ },
+ {
+ "source_id": "orders",
+ "field_name": "timestamp",
+ "column_name": "order_placed_at",
+ "column_type": "DateTime",
+ },
+ ],
+ },
+ }
+
+
+def get_valid_config_without_joins() -> dict:
+ """Get a valid pipeline configuration without joins."""
+ return {
+ "pipeline_id": "test-pipeline",
+ "source": {
+ "type": "kafka",
+ "provider": "aiven",
+ "connection_params": {
+ "brokers": [
+ "kafka-broker-0:9092",
+ "kafka-broker-1:9092",
+ ],
+ "protocol": "SASL_SSL",
+ "mechanism": "SCRAM-SHA-256",
+ "username": "",
+ "password": "",
+ "root_ca": "",
+ },
+ "topics": [
+ {
+ "consumer_group_initial_offset": "earliest",
+ "name": "user_logins",
+ "schema": {
+ "type": "json",
+ "fields": [
+ {
+ "name": "session_id",
+ "type": "string",
+ },
+ {
+ "name": "user_id",
+ "type": "string",
+ },
+ {
+ "name": "timestamp",
+ "type": "String",
+ },
+ ],
+ },
+ "deduplication": {
+ "enabled": True,
+ "id_field": "session_id",
+ "id_field_type": "string",
+ "time_window": "12h",
+ },
+ },
+ ],
+ },
+ "sink": {
+ "type": "clickhouse",
+ "provider": "aiven",
+ "host": "",
+ "port": "12753",
+ "http_port": "12754",
+ "database": "default",
+ "username": "",
+ "password": "",
+ "secure": True,
+ "max_batch_size": 1,
+ "table": "user_orders",
+ "table_mapping": [
+ {
+ "source_id": "user_logins",
+ "field_name": "session_id",
+ "column_name": "session_id",
+ "column_type": "string",
+ },
+ {
+ "source_id": "user_logins",
+ "field_name": "user_id",
+ "column_name": "user_id",
+ "column_type": "STRING",
+ },
+ {
+ "source_id": "user_logins",
+ "field_name": "timestamp",
+ "column_name": "login_at",
+ "column_type": "DateTime",
+ },
+ ],
+ },
+ }
+
+
+def get_valid_config_with_dedup_disabled() -> dict:
+ """Get a valid pipeline configuration with deduplication disabled."""
+ config = copy.deepcopy(get_valid_pipeline_config())
+ for idx, _ in enumerate(config["source"]["topics"]):
+ config["source"]["topics"][idx]["deduplication"] = None
+ return config
+
+
+def get_valid_config_without_joins_and_dedup_disabled() -> dict:
+ """Get a valid pipeline configuration without joins and deduplication."""
+ config = copy.deepcopy(get_valid_config_without_joins())
+ for idx, _ in enumerate(config["source"]["topics"]):
+ config["source"]["topics"][idx]["deduplication"] = None
+ return config
+
+
+def get_invalid_config() -> dict:
+ """Get an invalid pipeline configuration for testing."""
+ return {
+ "pipeline_id": "", # Empty pipeline_id should trigger validation error
+ "source": {
+ "type": "kafka",
+ "connection_params": {
+ "brokers": ["kafka:9092"],
+ "protocol": "SASL_SSL",
+ "mechanism": "SCRAM-SHA-256",
+ "username": "user",
+ "password": "pass",
+ },
+ "topics": [], # Empty topics list should trigger validation error
+ },
+ "sink": {
+ "type": "clickhouse",
+ "host": "clickhouse:8443",
+ "port": "8443",
+ "database": "test",
+ "username": "default",
+ "password": "pass",
+ "table": "test_table",
+ "table_mapping": [], # Empty table mapping should trigger validation error
+ },
+ }
diff --git a/tests/data/transformation.py b/tests/data/transformation.py
deleted file mode 100644
index ff23e28..0000000
--- a/tests/data/transformation.py
+++ /dev/null
@@ -1,3 +0,0 @@
-def handler(data, log):
- data["new_field"] = "new_value"
- return data
diff --git a/tests/data/transformation_2.py b/tests/data/transformation_2.py
deleted file mode 100644
index 9f903c3..0000000
--- a/tests/data/transformation_2.py
+++ /dev/null
@@ -1,4 +0,0 @@
-def handler(data, log):
- data["new_field"] = "new_value"
- log.info(data)
- return data
diff --git a/tests/data/valid_pipeline.json b/tests/data/valid_pipeline.json
new file mode 100644
index 0000000..ee8d7d7
--- /dev/null
+++ b/tests/data/valid_pipeline.json
@@ -0,0 +1,140 @@
+{
+ "pipeline_id": "test-pipeline",
+ "name": "Test Pipeline",
+ "source": {
+ "type": "kafka",
+ "provider": "aiven",
+ "connection_params": {
+ "brokers": [
+ "kafka-broker-0:9092",
+ "kafka-broker-1:9092"
+ ],
+ "protocol": "SASL_SSL",
+ "mechanism": "SCRAM-SHA-256",
+ "username": "",
+ "password": "",
+ "root_ca": "",
+ "skip_auth": false
+ },
+ "topics": [
+ {
+ "consumer_group_initial_offset": "earliest",
+ "name": "user_logins",
+ "schema": {
+ "type": "json",
+ "fields": [
+ {
+ "name": "session_id",
+ "type": "string"
+ },
+ {
+ "name": "user_id",
+ "type": "string"
+ },
+ {
+ "name": "timestamp",
+ "type": "string"
+ }
+ ]
+ },
+ "deduplication": {
+ "enabled": true,
+ "id_field": "session_id",
+ "id_field_type": "string",
+ "time_window": "12h"
+ }
+ },
+ {
+ "consumer_group_initial_offset": "earliest",
+ "name": "orders",
+ "schema": {
+ "type": "json",
+ "fields": [
+ {
+ "name": "user_id",
+ "type": "string"
+ },
+ {
+ "name": "order_id",
+ "type": "string"
+ },
+ {
+ "name": "timestamp",
+ "type": "string"
+ }
+ ]
+ },
+ "deduplication": {
+ "enabled": true,
+ "id_field": "order_id",
+ "id_field_type": "string",
+ "time_window": "12h"
+ }
+ }
+ ]
+ },
+ "join": {
+ "enabled": true,
+ "type": "temporal",
+ "sources": [
+ {
+ "source_id": "user_logins",
+ "join_key": "user_id",
+ "time_window": "1h",
+ "orientation": "left"
+ },
+ {
+ "source_id": "orders",
+ "join_key": "user_id",
+ "time_window": "1h",
+ "orientation": "right"
+ }
+ ]
+ },
+ "sink": {
+ "type": "clickhouse",
+ "provider": "aiven",
+ "host": "",
+ "port": "12753",
+ "http_port": "12754",
+ "database": "default",
+ "username": "",
+ "password": "",
+ "secure": true,
+ "max_batch_size": 1,
+ "max_delay_time": "10m",
+ "table": "user_orders",
+ "table_mapping": [
+ {
+ "source_id": "user_logins",
+ "field_name": "session_id",
+ "column_name": "session_id",
+ "column_type": "String"
+ },
+ {
+ "source_id": "user_logins",
+ "field_name": "user_id",
+ "column_name": "user_id",
+ "column_type": "String"
+ },
+ {
+ "source_id": "orders",
+ "field_name": "order_id",
+ "column_name": "order_id",
+ "column_type": "String"
+ },
+ {
+ "source_id": "user_logins",
+ "field_name": "timestamp",
+ "column_name": "login_at",
+ "column_type": "DateTime"
+ },
+ {
+ "source_id": "orders",
+ "field_name": "timestamp",
+ "column_name": "order_placed_at",
+ "column_type": "DateTime"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/tests/data/valid_pipeline.yaml b/tests/data/valid_pipeline.yaml
new file mode 100644
index 0000000..bff8106
--- /dev/null
+++ b/tests/data/valid_pipeline.yaml
@@ -0,0 +1,94 @@
+join:
+ enabled: true
+ sources:
+ - join_key: user_id
+ orientation: left
+ source_id: user_logins
+ time_window: 1h
+ - join_key: user_id
+ orientation: right
+ source_id: orders
+ time_window: 1h
+ type: temporal
+name: Test Pipeline
+pipeline_id: test-pipeline
+sink:
+ database: default
+ host:
+ max_batch_size: 1
+ max_delay_time: 10m
+ password:
+ port: "12753"
+ http_port: "12754"
+ provider: aiven
+ secure: true
+ table: user_orders
+ table_mapping:
+ - column_name: session_id
+ column_type: String
+ field_name: session_id
+ source_id: user_logins
+ - column_name: user_id
+ column_type: String
+ field_name: user_id
+ source_id: user_logins
+ - column_name: order_id
+ column_type: String
+ field_name: order_id
+ source_id: orders
+ - column_name: login_at
+ column_type: DateTime
+ field_name: timestamp
+ source_id: user_logins
+ - column_name: order_placed_at
+ column_type: DateTime
+ field_name: timestamp
+ source_id: orders
+ type: clickhouse
+ username:
+source:
+ connection_params:
+ brokers:
+ - kafka-broker-0:9092
+ - kafka-broker-1:9092
+ mechanism: SCRAM-SHA-256
+ password:
+ protocol: SASL_SSL
+ root_ca:
+ skip_auth: false
+ username:
+ provider: aiven
+ topics:
+ - consumer_group_initial_offset: earliest
+ deduplication:
+ enabled: true
+ id_field: session_id
+ id_field_type: string
+ time_window: 12h
+ name: user_logins
+ schema:
+ fields:
+ - name: session_id
+ type: string
+ - name: user_id
+ type: string
+ - name: timestamp
+ type: string
+ type: json
+ - consumer_group_initial_offset: earliest
+ deduplication:
+ enabled: true
+ id_field: order_id
+ id_field_type: string
+ time_window: 12h
+ name: orders
+ schema:
+ fields:
+ - name: user_id
+ type: string
+ - name: order_id
+ type: string
+ - name: timestamp
+ type: string
+ type: json
+ type: kafka
diff --git a/tests/glassflow/conftest.py b/tests/glassflow/conftest.py
deleted file mode 100644
index 6b49766..0000000
--- a/tests/glassflow/conftest.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from glassflow.api_client import APIClient
-
-# Use staging v2 server
-APIClient.glassflow_config.server_url = "https://staging.api.glassflow.dev/v1"
diff --git a/tests/glassflow/integration_tests/client_test.py b/tests/glassflow/integration_tests/client_test.py
deleted file mode 100644
index 43bb6ee..0000000
--- a/tests/glassflow/integration_tests/client_test.py
+++ /dev/null
@@ -1,28 +0,0 @@
-def test_get_pipeline_ok(client, creating_pipeline):
- p = client.get_pipeline(pipeline_id=creating_pipeline.id)
-
- assert p.id == creating_pipeline.id
- assert p.name == creating_pipeline.name
-
-
-def test_list_pipelines_ok(client, creating_pipeline):
- res = client.list_pipelines()
-
- assert res.total_amount >= 1
- assert res.pipelines[-1].id == creating_pipeline.id
- assert res.pipelines[-1].name == creating_pipeline.name
-
-
-def test_list_spaces_ok(client, creating_space):
- res = client.list_spaces()
-
- assert res.total_amount >= 1
- assert res.spaces[-1].id == creating_space.id
- assert res.spaces[-1].name == creating_space.name
-
-
-def test_list_secrets_ok(client, creating_secret):
- res = client.list_secrets()
-
- assert res.total_amount >= 1
- assert res.secrets[-1].key == creating_secret.key
diff --git a/tests/glassflow/integration_tests/conftest.py b/tests/glassflow/integration_tests/conftest.py
deleted file mode 100644
index 627227a..0000000
--- a/tests/glassflow/integration_tests/conftest.py
+++ /dev/null
@@ -1,156 +0,0 @@
-import os
-import uuid
-
-import pytest
-
-from glassflow import (
- GlassFlowClient,
- Pipeline,
- PipelineDataSink,
- PipelineDataSource,
- Secret,
- Space,
-)
-
-
-@pytest.fixture(scope="session")
-def client():
- return GlassFlowClient(os.getenv("PERSONAL_ACCESS_TOKEN"))
-
-
-@pytest.fixture
-def space(client):
- return Space(
- name="integration-tests", personal_access_token=client.personal_access_token
- )
-
-
-@pytest.fixture
-def creating_space(space):
- space.create()
- yield space
- space.delete()
-
-
-@pytest.fixture
-def space_with_random_id(client):
- return Space(
- id=str(uuid.uuid4()),
- personal_access_token=client.personal_access_token,
- )
-
-
-@pytest.fixture
-def space_with_random_id_and_invalid_token():
- return Space(
- id=str(uuid.uuid4()),
- personal_access_token="invalid-token",
- )
-
-
-@pytest.fixture
-def pipeline(client, creating_space):
- return Pipeline(
- name="test_pipeline",
- space_id=creating_space.id,
- transformation_file="tests/data/transformation.py",
- personal_access_token=client.personal_access_token,
- metadata={"view_only": True},
- source_kind="google_pubsub",
- source_config={
- "project_id": "my-project-id",
- "subscription_id": "my-subscription-id",
- "credentials_json": "my-credentials.json",
- },
- )
-
-
-@pytest.fixture
-def pipeline_with_random_id(client):
- return Pipeline(
- id=str(uuid.uuid4()),
- personal_access_token=client.personal_access_token,
- )
-
-
-@pytest.fixture
-def pipeline_with_random_id_and_invalid_token():
- return Pipeline(
- id=str(uuid.uuid4()),
- personal_access_token="invalid-token",
- )
-
-
-@pytest.fixture
-def creating_pipeline(pipeline):
- pipeline.create()
- yield pipeline
- pipeline.delete()
-
-
-@pytest.fixture
-def source(creating_pipeline):
- return PipelineDataSource(
- pipeline_id=creating_pipeline.id,
- pipeline_access_token=creating_pipeline.access_tokens[0].token,
- )
-
-
-@pytest.fixture
-def source_with_invalid_access_token(creating_pipeline):
- return PipelineDataSource(
- pipeline_id=creating_pipeline.id, pipeline_access_token="invalid-access-token"
- )
-
-
-@pytest.fixture
-def source_with_non_existing_id(creating_pipeline):
- return PipelineDataSource(
- pipeline_id=str(uuid.uuid4()),
- pipeline_access_token=creating_pipeline.access_tokens[0].token,
- )
-
-
-@pytest.fixture
-def source_with_published_events(source):
- source.publish({"test_field": "test_value"})
- yield source
-
-
-@pytest.fixture
-def sink(source_with_published_events):
- return PipelineDataSink(
- pipeline_id=source_with_published_events.pipeline_id,
- pipeline_access_token=source_with_published_events.pipeline_access_token,
- )
-
-
-@pytest.fixture
-def secret(client):
- return Secret(
- key="SecretKey",
- value="SecretValue",
- personal_access_token=client.personal_access_token,
- )
-
-
-@pytest.fixture
-def creating_secret(secret):
- secret.create()
- yield secret
- secret.delete()
-
-
-@pytest.fixture
-def secret_with_invalid_key_and_token():
- return Secret(
- key="InvalidSecretKey",
- personal_access_token="invalid-token",
- )
-
-
-@pytest.fixture
-def secret_with_invalid_key(client):
- return Secret(
- key="InvalidSecretKey", personal_access_token=client.personal_access_token
- )
diff --git a/tests/glassflow/integration_tests/pipeline_data_test.py b/tests/glassflow/integration_tests/pipeline_data_test.py
deleted file mode 100644
index 5fcb5fa..0000000
--- a/tests/glassflow/integration_tests/pipeline_data_test.py
+++ /dev/null
@@ -1,53 +0,0 @@
-import pytest
-
-from glassflow import errors
-
-
-def test_using_staging_server(source, sink):
- assert source.glassflow_config.server_url == "https://staging.api.glassflow.dev/v1"
- assert sink.glassflow_config.server_url == "https://staging.api.glassflow.dev/v1"
-
-
-def test_validate_credentials_from_pipeline_data_source_ok(source):
- try:
- source.validate_credentials()
- except Exception as e:
- pytest.fail(e)
-
-
-def test_validate_credentials_from_pipeline_data_source_fail_with_invalid_access_token(
- source_with_invalid_access_token,
-):
- with pytest.raises(errors.PipelineAccessTokenInvalidError):
- source_with_invalid_access_token.validate_credentials()
-
-
-def test_validate_credentials_from_pipeline_data_source_fail_with_id_not_found(
- source_with_non_existing_id,
-):
- with pytest.raises(errors.PipelineNotFoundError):
- source_with_non_existing_id.validate_credentials()
-
-
-def test_publish_to_pipeline_data_source_ok(source):
- publish_response = source.publish({"test_field": "test_value"})
- assert publish_response.status_code == 200
-
-
-def test_consume_from_pipeline_data_sink_ok(sink):
- n_tries = 0
- max_tries = 10
- while True:
- if n_tries == max_tries:
- pytest.fail("Max tries exceeded")
-
- consume_response = sink.consume()
- assert consume_response.status_code in (200, 204)
- if consume_response.status_code == 200:
- assert consume_response.event() == {
- "test_field": "test_value",
- "new_field": "new_value",
- }
- break
- else:
- n_tries += 1
diff --git a/tests/glassflow/integration_tests/pipeline_test.py b/tests/glassflow/integration_tests/pipeline_test.py
deleted file mode 100644
index 12f3766..0000000
--- a/tests/glassflow/integration_tests/pipeline_test.py
+++ /dev/null
@@ -1,101 +0,0 @@
-import pytest
-
-from glassflow.models import api, errors
-
-
-def test_create_pipeline_ok(creating_pipeline):
- assert creating_pipeline.name == "test_pipeline"
- assert creating_pipeline.id is not None
-
-
-def test_fetch_pipeline_ok(creating_pipeline):
- creating_pipeline.fetch()
- assert creating_pipeline.name == "test_pipeline"
- assert creating_pipeline.id is not None
- assert creating_pipeline.created_at is not None
-
-
-def test_fetch_pipeline_fail_with_404(pipeline_with_random_id):
- with pytest.raises(errors.PipelineNotFoundError):
- pipeline_with_random_id.fetch()
-
-
-def test_fetch_pipeline_fail_with_401(pipeline_with_random_id_and_invalid_token):
- with pytest.raises(errors.PipelineUnauthorizedError):
- pipeline_with_random_id_and_invalid_token.fetch()
-
-
-def test_update_pipeline_ok(creating_pipeline):
- import time
-
- time.sleep(1)
- updated_pipeline = creating_pipeline.update(
- name="new_name",
- sink_kind="webhook",
- state="paused",
- sink_config={
- "url": "www.test-url.com",
- "method": "GET",
- "headers": [{"name": "header1", "value": "header1"}],
- },
- transformation_file="tests/data/transformation_2.py",
- requirements="requests\npandas",
- env_vars=[
- {"name": "env1", "value": "env1"},
- {"name": "env2", "value": "env2"},
- ],
- )
- assert updated_pipeline.name == "new_name"
- assert updated_pipeline.sink_kind == "webhook"
- assert updated_pipeline.sink_config.model_dump(mode="json") == {
- "url": "www.test-url.com",
- "method": "GET",
- "headers": [{"name": "header1", "value": "header1"}],
- }
- assert updated_pipeline.env_vars.model_dump(mode="json") == [
- {"name": "env1", "value": "env1"},
- {"name": "env2", "value": "env2"},
- ]
- with open("tests/data/transformation_2.py") as f:
- assert updated_pipeline.transformation_code == f.read()
-
- assert updated_pipeline.source_kind == creating_pipeline.source_kind
- assert updated_pipeline.source_config == creating_pipeline.source_config
- assert updated_pipeline.state == api.PipelineState.paused
-
-
-def test_delete_pipeline_fail_with_404(pipeline_with_random_id):
- with pytest.raises(errors.PipelineNotFoundError):
- pipeline_with_random_id.delete()
-
-
-def test_delete_pipeline_fail_with_401(pipeline_with_random_id_and_invalid_token):
- with pytest.raises(errors.PipelineUnauthorizedError):
- pipeline_with_random_id_and_invalid_token.delete()
-
-
-def test_get_logs_from_pipeline_ok(creating_pipeline):
- import time
-
- n_tries = 0
- max_tries = 20
- while True:
- if n_tries == max_tries:
- pytest.fail("Max tries reached")
-
- logs = creating_pipeline.get_logs(severity_code=100)
- if len(logs.logs) >= 2:
- break
- else:
- n_tries += 1
- time.sleep(1)
- log_records = [log for log in logs.logs if log.level == "INFO"]
- assert log_records[0].payload.message == "Function is uploaded"
- assert log_records[1].payload.message == "Pipeline is created"
-
-
-def test_test_pipeline_ok(creating_pipeline):
- test_message = {"message": "test"}
- response = creating_pipeline.test(test_message)
-
- assert response.payload == test_message
diff --git a/tests/glassflow/integration_tests/secret_test.py b/tests/glassflow/integration_tests/secret_test.py
deleted file mode 100644
index e3e6fd6..0000000
--- a/tests/glassflow/integration_tests/secret_test.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import pytest
-
-from glassflow import errors
-
-
-def test_create_secret_ok(creating_secret):
- assert creating_secret.key == "SecretKey"
- assert creating_secret.value == "SecretValue"
-
-
-def test_delete_secret_fail_with_401(secret_with_invalid_key_and_token):
- with pytest.raises(errors.SecretUnauthorizedError):
- secret_with_invalid_key_and_token.delete()
-
-
-def test_delete_secret_fail_with_404(secret_with_invalid_key):
- with pytest.raises(errors.SecretNotFoundError):
- secret_with_invalid_key.delete()
diff --git a/tests/glassflow/integration_tests/space_test.py b/tests/glassflow/integration_tests/space_test.py
deleted file mode 100644
index 27d3fac..0000000
--- a/tests/glassflow/integration_tests/space_test.py
+++ /dev/null
@@ -1,18 +0,0 @@
-import pytest
-
-from glassflow import errors
-
-
-def test_create_space_ok(creating_space):
- assert creating_space.name == "integration-tests"
- assert creating_space.id is not None
-
-
-def test_delete_space_fail_with_404(space_with_random_id):
- with pytest.raises(errors.SpaceNotFoundError):
- space_with_random_id.delete()
-
-
-def test_delete_space_fail_with_401(space_with_random_id_and_invalid_token):
- with pytest.raises(errors.SpaceUnauthorizedError):
- space_with_random_id_and_invalid_token.delete()
diff --git a/tests/glassflow/unit_tests/__init__.py b/tests/glassflow/unit_tests/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/glassflow/unit_tests/client_test.py b/tests/glassflow/unit_tests/client_test.py
deleted file mode 100644
index e62eb3b..0000000
--- a/tests/glassflow/unit_tests/client_test.py
+++ /dev/null
@@ -1,46 +0,0 @@
-import pytest
-
-from glassflow.models import errors
-
-
-@pytest.fixture
-def list_pipelines_response():
- return {
- "total_amount": 1,
- "pipelines": [
- {
- "name": "test-name",
- "space_id": "test-space-id",
- "metadata": {"additionalProp1": {}},
- "id": "test-id",
- "created_at": "2024-09-25T13:52:17.910Z",
- "state": "running",
- "space_name": "test-space-name",
- }
- ],
- }
-
-
-def test_list_pipelines_ok(requests_mock, list_pipelines_response, client):
- requests_mock.get(
- client.glassflow_config.server_url + "/pipelines",
- json=list_pipelines_response,
- status_code=200,
- headers={"Content-Type": "application/json"},
- )
-
- res = client.list_pipelines()
-
- assert res.total_amount == list_pipelines_response["total_amount"]
- assert res.pipelines[0].name == list_pipelines_response["pipelines"][0]["name"]
-
-
-def test_list_pipelines_fail_with_401(requests_mock, client):
- requests_mock.get(
- client.glassflow_config.server_url + "/pipelines",
- status_code=401,
- headers={"Content-Type": "application/json"},
- )
-
- with pytest.raises(errors.UnauthorizedError):
- client.list_pipelines()
diff --git a/tests/glassflow/unit_tests/conftest.py b/tests/glassflow/unit_tests/conftest.py
deleted file mode 100644
index c00b6cd..0000000
--- a/tests/glassflow/unit_tests/conftest.py
+++ /dev/null
@@ -1,197 +0,0 @@
-import pytest
-
-from glassflow import GlassFlowClient
-from glassflow.models import api
-
-
-@pytest.fixture
-def client():
- return GlassFlowClient()
-
-
-@pytest.fixture
-def get_pipeline_request_mock(client, requests_mock, fetch_pipeline_response):
- return requests_mock.get(
- client.glassflow_config.server_url + "/pipelines/test-id",
- json=fetch_pipeline_response.model_dump(mode="json"),
- status_code=200,
- headers={"Content-Type": "application/json"},
- )
-
-
-@pytest.fixture
-def get_access_token_request_mock(
- client, requests_mock, fetch_pipeline_response, access_tokens_response
-):
- return requests_mock.get(
- client.glassflow_config.server_url
- + f"/pipelines/{fetch_pipeline_response.id}/access_tokens",
- json=access_tokens_response,
- status_code=200,
- headers={"Content-Type": "application/json"},
- )
-
-
-@pytest.fixture
-def get_pipeline_function_source_request_mock(
- client, requests_mock, fetch_pipeline_response, function_source_response
-):
- return requests_mock.get(
- client.glassflow_config.server_url
- + f"/pipelines/{fetch_pipeline_response.id}/functions/main/artifacts/latest",
- json=function_source_response,
- status_code=200,
- headers={"Content-Type": "application/json"},
- )
-
-
-@pytest.fixture
-def update_pipeline_request_mock(
- client, requests_mock, fetch_pipeline_response, update_pipeline_response
-):
- return requests_mock.patch(
- client.glassflow_config.server_url + f"/pipelines/{fetch_pipeline_response.id}",
- json=update_pipeline_response.model_dump(mode="json"),
- status_code=200,
- headers={"Content-Type": "application/json"},
- )
-
-
-@pytest.fixture
-def fetch_pipeline_response():
- return api.GetDetailedSpacePipeline(
- **{
- "id": "test-id",
- "name": "test-name",
- "space_id": "test-space-id",
- "metadata": {},
- "created_at": "2024-09-23T10:08:45.529Z",
- "state": "running",
- "space_name": "test-space-name",
- "source_connector": {
- "kind": "google_pubsub",
- "config": {
- "project_id": "test-project",
- "subscription_id": "test-subscription",
- "credentials_json": "credentials.json",
- },
- },
- "sink_connector": {
- "kind": "webhook",
- "config": {
- "url": "www.test-url.com",
- "method": "GET",
- "headers": [
- {"name": "header1", "value": "header1"},
- {"name": "header2", "value": "header2"},
- ],
- },
- },
- "environments": [{"name": "test-var", "value": "test-var"}],
- }
- )
-
-
-@pytest.fixture
-def update_pipeline_response(fetch_pipeline_response):
- fetch_pipeline_response.name = "updated name"
- fetch_pipeline_response.source_connector = api.SourceConnector(root=None)
- return fetch_pipeline_response
-
-
-@pytest.fixture
-def create_pipeline_response():
- return {
- "name": "test-name",
- "space_id": "string",
- "metadata": {"additionalProp1": {}},
- "id": "test-id",
- "created_at": "2024-09-23T10:08:45.529Z",
- "state": "running",
- "access_token": "string",
- }
-
-
-@pytest.fixture
-def create_space_response():
- return {
- "name": "test-space",
- "id": "test-space-id",
- "created_at": "2024-09-30T02:47:51.901Z",
- }
-
-
-@pytest.fixture
-def access_tokens_response():
- return {
- "total_amount": 2,
- "access_tokens": [
- {
- "name": "token1",
- "id": "string",
- "token": "string",
- "created_at": "2024-09-25T10:46:18.468Z",
- },
- {
- "name": "token2",
- "id": "string",
- "token": "string",
- "created_at": "2024-09-26T04:28:51.782Z",
- },
- ],
- }
-
-
-@pytest.fixture
-def function_source_response():
- return {
- "files": [{"name": "string", "content": "string"}],
- "transformation_function": "string",
- "requirements_txt": "string",
- }
-
-
-@pytest.fixture
-def get_logs_response():
- return {
- "logs": [
- {
- "level": "INFO",
- "severity_code": 0,
- "timestamp": "2024-09-30T16:04:08.211Z",
- "payload": {"message": "Info Message Log", "additionalProp1": {}},
- },
- {
- "level": "ERROR",
- "severity_code": 500,
- "timestamp": "2024-09-30T16:04:08.211Z",
- "payload": {"message": "Error Message Log", "additionalProp1": {}},
- },
- ],
- "next": "string",
- }
-
-
-@pytest.fixture
-def test_pipeline_response():
- return {
- "req_id": "string",
- "receive_time": "2024-11-06T09:37:46.310Z",
- "payload": {"message": "Test Message"},
- "event_context": {
- "request_id": "string",
- "receive_time": "2024-11-06T09:37:46.310Z",
- "external_id": "2141513fa4ed38wyfphce",
- },
- "status": "string",
- "response": {"message": "Test Response"},
- "error_details": "Error message",
- "stack_trace": "Error Stack trace",
- }
-
-
-@pytest.fixture
-def create_secret_response():
- return {
- "name": "test-name",
- }
diff --git a/tests/glassflow/unit_tests/pipeline_data_test.py b/tests/glassflow/unit_tests/pipeline_data_test.py
deleted file mode 100644
index 8e3ae83..0000000
--- a/tests/glassflow/unit_tests/pipeline_data_test.py
+++ /dev/null
@@ -1,303 +0,0 @@
-import pytest
-
-from glassflow import PipelineDataSink, PipelineDataSource
-from glassflow.models import errors
-from glassflow.pipeline_data import PipelineDataClient
-
-
-@pytest.fixture
-def consume_payload():
- return {
- "req_id": "string",
- "receive_time": "2024-09-23T07:28:27.958Z",
- "payload": {},
- "event_context": {
- "request_id": "string",
- "external_id": "string",
- "receive_time": "2024-09-23T07:28:27.958Z",
- },
- "status": "string",
- "response": {},
- }
-
-
-def test_validate_credentials_ok(requests_mock):
- data_client = PipelineDataClient(
- pipeline_id="test-id",
- pipeline_access_token="test-token",
- )
- requests_mock.get(
- data_client.glassflow_config.server_url
- + "/pipelines/test-id/status/access_token",
- status_code=200,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": data_client.pipeline_access_token,
- },
- )
- data_client.validate_credentials()
-
-
-def test_push_to_pipeline_data_source_ok(requests_mock):
- source = PipelineDataSource(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- source.glassflow_config.server_url + "/pipelines/test-id/topics/input/events",
- status_code=200,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- res = source.publish({"test": "test"})
-
- assert res.status_code == 200
-
-
-def test_push_to_pipeline_data_source_fail_with_404(requests_mock):
- source = PipelineDataSource(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- source.glassflow_config.server_url + "/pipelines/test-id/topics/input/events",
- status_code=404,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- with pytest.raises(errors.PipelineNotFoundError):
- source.publish({"test": "test"})
-
-
-def test_push_to_pipeline_data_source_fail_with_401(requests_mock):
- source = PipelineDataSource(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- source.glassflow_config.server_url + "/pipelines/test-id/topics/input/events",
- status_code=401,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- with pytest.raises(errors.PipelineAccessTokenInvalidError):
- source.publish({"test": "test"})
-
-
-def test_consume_from_pipeline_data_sink_ok(requests_mock, consume_payload):
- sink = PipelineDataSink(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- sink.glassflow_config.server_url
- + "/pipelines/test-id/topics/output/events/consume",
- json=consume_payload,
- status_code=200,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- res = sink.consume()
-
- assert res.status_code == 200
- assert res.body.event_context.request_id == consume_payload["req_id"]
-
-
-def test_consume_from_pipeline_data_sink_fail_with_404(requests_mock):
- sink = PipelineDataSink(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- sink.glassflow_config.server_url
- + "/pipelines/test-id/topics/output/events/consume",
- json={"test-data": "test-data"},
- status_code=404,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- with pytest.raises(errors.PipelineNotFoundError):
- sink.consume()
-
-
-def test_consume_from_pipeline_data_sink_fail_with_401(requests_mock):
- sink = PipelineDataSink(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- sink.glassflow_config.server_url
- + "/pipelines/test-id/topics/output/events/consume",
- json={"test-data": "test-data"},
- status_code=401,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- with pytest.raises(errors.PipelineAccessTokenInvalidError):
- sink.consume()
-
-
-def test_consume_from_pipeline_data_sink_ok_with_empty_response(requests_mock):
- sink = PipelineDataSink(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- sink.glassflow_config.server_url
- + "/pipelines/test-id/topics/output/events/consume",
- status_code=204,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- res = sink.consume()
-
- assert res.status_code == 204
- assert res.body is None
-
-
-def test_consume_from_pipeline_data_sink_ok_with_too_many_requests(requests_mock):
- sink = PipelineDataSink(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- sink.glassflow_config.server_url
- + "/pipelines/test-id/topics/output/events/consume",
- status_code=429,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- res = sink.consume()
-
- assert res.status_code == 429
- assert res.body is None
-
-
-def test_consume_failed_from_pipeline_data_sink_ok(requests_mock, consume_payload):
- sink = PipelineDataSink(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- sink.glassflow_config.server_url
- + "/pipelines/test-id/topics/failed/events/consume",
- json=consume_payload,
- status_code=200,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- res = sink.consume_failed()
-
- assert res.status_code == 200
- assert res.body.event_context.request_id == consume_payload["req_id"]
-
-
-def test_consume_failed_from_pipeline_data_sink_ok_with_empty_response(requests_mock):
- sink = PipelineDataSink(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- sink.glassflow_config.server_url
- + "/pipelines/test-id/topics/failed/events/consume",
- status_code=204,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- res = sink.consume_failed()
-
- assert res.status_code == 204
- assert res.body is None
-
-
-def test_consume_failed_from_pipeline_data_sink_ok_with_too_many_requests(
- requests_mock,
-):
- sink = PipelineDataSink(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- sink.glassflow_config.server_url
- + "/pipelines/test-id/topics/failed/events/consume",
- status_code=429,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- res = sink.consume_failed()
-
- assert res.status_code == 429
- assert res.body is None
-
-
-def test_consume_failed_from_pipeline_data_sink_fail_with_404(requests_mock):
- sink = PipelineDataSink(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- sink.glassflow_config.server_url
- + "/pipelines/test-id/topics/failed/events/consume",
- json={"test-data": "test-data"},
- status_code=404,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- with pytest.raises(errors.PipelineNotFoundError):
- sink.consume_failed()
-
-
-def test_consume_failed_from_pipeline_data_sink_fail_with_401(requests_mock):
- sink = PipelineDataSink(
- pipeline_id="test-id",
- pipeline_access_token="test-access-token",
- )
- requests_mock.post(
- sink.glassflow_config.server_url
- + "/pipelines/test-id/topics/failed/events/consume",
- json={"test-data": "test-data"},
- status_code=401,
- headers={
- "Content-Type": "application/json",
- "X-pipeline-access-token": "test-access-token",
- },
- )
-
- with pytest.raises(errors.PipelineAccessTokenInvalidError):
- sink.consume_failed()
diff --git a/tests/glassflow/unit_tests/pipeline_test.py b/tests/glassflow/unit_tests/pipeline_test.py
deleted file mode 100644
index 78cb410..0000000
--- a/tests/glassflow/unit_tests/pipeline_test.py
+++ /dev/null
@@ -1,293 +0,0 @@
-import pytest
-
-from glassflow.models import errors
-from glassflow.pipeline import Pipeline
-
-
-def test_pipeline_with_transformation_file():
- try:
- p = Pipeline(
- transformation_file="tests/data/transformation.py",
- personal_access_token="test-token",
- )
- p._read_transformation_file()
- assert p.transformation_code is not None
- except Exception as e:
- pytest.fail(e)
-
-
-def test_pipeline_fail_with_file_not_found():
- with pytest.raises(FileNotFoundError):
- p = Pipeline(
- transformation_file="fake_file.py", personal_access_token="test-token"
- )
- p._read_transformation_file()
-
-
-def test_pipeline_fail_with_connection_config_value_error():
- with pytest.raises(errors.ConnectorConfigValueError):
- Pipeline(
- transformation_file="tests/data/transformation.py",
- personal_access_token="test-token",
- sink_kind="webhook",
- )
-
- with pytest.raises(errors.ConnectorConfigValueError):
- Pipeline(
- transformation_file="tests/data/transformation.py",
- personal_access_token="test-token",
- source_kind="google_pubsub",
- )
-
- with pytest.raises(errors.ConnectorConfigValueError):
- Pipeline(
- transformation_file="tests/data/transformation.py",
- personal_access_token="test-token",
- source_config={"url": "test-url"},
- )
-
-
-def test_fetch_pipeline_ok(
- get_pipeline_request_mock,
- get_access_token_request_mock,
- get_pipeline_function_source_request_mock,
- fetch_pipeline_response,
- function_source_response,
-):
- pipeline = Pipeline(
- id=fetch_pipeline_response.id,
- personal_access_token="test-token",
- ).fetch()
-
- assert pipeline.name == fetch_pipeline_response.name
- assert len(pipeline.access_tokens) > 0
- assert (
- pipeline.transformation_code
- == function_source_response["transformation_function"]
- )
- assert pipeline.requirements == function_source_response["requirements_txt"]
-
-
-def test_fetch_pipeline_fail_with_404(requests_mock, fetch_pipeline_response, client):
- requests_mock.get(
- client.glassflow_config.server_url + "/pipelines/test-id",
- json=fetch_pipeline_response.model_dump(mode="json"),
- status_code=404,
- headers={"Content-Type": "application/json"},
- )
-
- with pytest.raises(errors.PipelineNotFoundError):
- Pipeline(
- id=fetch_pipeline_response.id,
- personal_access_token="test-token",
- ).fetch()
-
-
-def test_fetch_pipeline_fail_with_401(requests_mock, fetch_pipeline_response, client):
- requests_mock.get(
- client.glassflow_config.server_url + "/pipelines/test-id",
- json=fetch_pipeline_response.model_dump(mode="json"),
- status_code=401,
- headers={"Content-Type": "application/json"},
- )
-
- with pytest.raises(errors.PipelineUnauthorizedError):
- Pipeline(
- id=fetch_pipeline_response.id,
- personal_access_token="test-token",
- ).fetch()
-
-
-def test_create_pipeline_ok(
- requests_mock, fetch_pipeline_response, create_pipeline_response, client
-):
- requests_mock.post(
- client.glassflow_config.server_url + "/pipelines",
- json=create_pipeline_response,
- status_code=200,
- headers={"Content-Type": "application/json"},
- )
- pipeline = Pipeline(
- name=fetch_pipeline_response.name,
- space_id=create_pipeline_response["space_id"],
- transformation_file="tests/data/transformation.py",
- personal_access_token="test-token",
- ).create()
-
- assert pipeline.id == "test-id"
- assert pipeline.name == "test-name"
-
-
-def test_create_pipeline_fail_with_missing_name(client):
- with pytest.raises(ValueError) as e:
- Pipeline(
- space_id="test-space-id",
- transformation_file="tests/data/transformation.py",
- personal_access_token="test-token",
- ).create()
-
- assert e.value.__str__() == (
- "Name must be provided in order to create the pipeline"
- )
-
-
-def test_create_pipeline_fail_with_missing_space_id(client):
- with pytest.raises(ValueError) as e:
- Pipeline(
- name="test-name",
- transformation_file="tests/data/transformation.py",
- personal_access_token="test-token",
- ).create()
-
- assert str(e.value) == ("Argument space_id must be provided in the constructor")
-
-
-def test_create_pipeline_fail_with_missing_transformation(client):
- with pytest.raises(ValueError) as e:
- Pipeline(
- name="test-name",
- space_id="test-space-id",
- personal_access_token="test-token",
- ).create()
-
- assert str(e.value) == (
- "Argument transformation_file must be provided in the constructor"
- )
-
-
-def test_update_pipeline_ok(
- get_pipeline_request_mock,
- get_access_token_request_mock,
- get_pipeline_function_source_request_mock,
- update_pipeline_request_mock,
- fetch_pipeline_response,
- update_pipeline_response,
-):
- pipeline = (
- Pipeline(personal_access_token="test-token")
- ._fill_pipeline_details(fetch_pipeline_response)
- .update()
- )
-
- assert pipeline.name == update_pipeline_response.name
- assert pipeline.source_connector == update_pipeline_response.source_connector
-
-
-def test_delete_pipeline_ok(requests_mock, client):
- requests_mock.delete(
- client.glassflow_config.server_url + "/pipelines/test-pipeline-id",
- status_code=204,
- headers={"Content-Type": "application/json"},
- )
- pipeline = Pipeline(
- id="test-pipeline-id",
- personal_access_token="test-token",
- )
- pipeline.delete()
-
-
-def test_delete_pipeline_fail_with_missing_pipeline_id(client):
- pipeline = Pipeline(
- personal_access_token="test-token",
- )
- with pytest.raises(ValueError):
- pipeline.delete()
-
-
-def test_get_source_from_pipeline_ok(
- client,
- fetch_pipeline_response,
- get_pipeline_request_mock,
- get_access_token_request_mock,
- get_pipeline_function_source_request_mock,
- access_tokens_response,
-):
- p = client.get_pipeline(fetch_pipeline_response.id)
- source = p.get_source()
- source2 = p.get_source(pipeline_access_token_name="token2")
-
- assert source.pipeline_id == p.id
- assert (
- source.pipeline_access_token
- == access_tokens_response["access_tokens"][0]["token"]
- )
-
- assert source2.pipeline_id == p.id
- assert (
- source2.pipeline_access_token
- == access_tokens_response["access_tokens"][1]["token"]
- )
-
-
-def test_get_source_from_pipeline_fail_with_missing_id(client):
- pipeline = Pipeline(personal_access_token="test-token")
- with pytest.raises(ValueError) as e:
- pipeline.get_source()
-
- assert e.value.__str__() == "Pipeline id must be provided in the constructor"
-
-
-def test_get_sink_from_pipeline_ok(
- client,
- fetch_pipeline_response,
- get_pipeline_request_mock,
- get_access_token_request_mock,
- get_pipeline_function_source_request_mock,
- access_tokens_response,
-):
- p = client.get_pipeline(fetch_pipeline_response.id)
- sink = p.get_sink()
- sink2 = p.get_sink(pipeline_access_token_name="token2")
-
- assert sink.pipeline_id == p.id
- assert (
- sink.pipeline_access_token
- == access_tokens_response["access_tokens"][0]["token"]
- )
-
- assert sink2.pipeline_id == p.id
- assert (
- sink2.pipeline_access_token
- == access_tokens_response["access_tokens"][1]["token"]
- )
-
-
-def test_get_logs_from_pipeline_ok(client, requests_mock, get_logs_response):
- pipeline_id = "test-pipeline-id"
- requests_mock.get(
- client.glassflow_config.server_url
- + f"/pipelines/{pipeline_id}/functions/main/logs",
- json=get_logs_response,
- status_code=200,
- headers={"Content-Type": "application/json"},
- )
- pipeline = Pipeline(id=pipeline_id, personal_access_token="test-token")
- logs = pipeline.get_logs(page_size=50, severity_code=100)
-
- assert logs.next == get_logs_response["next"]
- for idx, log in enumerate(logs.logs):
- assert log.level == get_logs_response["logs"][idx]["level"]
- assert log.severity_code == get_logs_response["logs"][idx]["severity_code"]
- assert (
- log.payload.message == get_logs_response["logs"][idx]["payload"]["message"]
- )
-
-
-def test_test_pipeline_ok(client, requests_mock, test_pipeline_response):
- pipeline_id = "test-pipeline-id"
- requests_mock.post(
- client.glassflow_config.server_url
- + f"/pipelines/{pipeline_id}/functions/main/test",
- json=test_pipeline_response,
- status_code=200,
- headers={"Content-Type": "application/json"},
- )
- pipeline = Pipeline(id=pipeline_id, personal_access_token="test-token")
- response = pipeline.test(test_pipeline_response["payload"])
-
- assert (
- response.event_context.external_id
- == test_pipeline_response["event_context"]["external_id"]
- )
- assert response.status == test_pipeline_response["status"]
- assert response.response == test_pipeline_response["response"]
diff --git a/tests/glassflow/unit_tests/secret_test.py b/tests/glassflow/unit_tests/secret_test.py
deleted file mode 100644
index d428e07..0000000
--- a/tests/glassflow/unit_tests/secret_test.py
+++ /dev/null
@@ -1,41 +0,0 @@
-import pytest
-
-from glassflow import Secret, errors
-
-
-def test_create_secret_ok(requests_mock, client):
- requests_mock.post(
- client.glassflow_config.server_url + "/secrets",
- status_code=201,
- headers={"Content-Type": "application/json"},
- )
- Secret(
- key="SecretKey", value="SecretValue", personal_access_token="test-token"
- ).create()
-
-
-def test_create_secret_fail_with_invalid_key_error(client):
- with pytest.raises(errors.SecretInvalidKeyError):
- Secret(
- key="secret-key", value="secret-value", personal_access_token="test-token"
- )
-
-
-def test_create_secret_fail_with_value_error(client):
- with pytest.raises(ValueError):
- Secret(personal_access_token="test-token").create()
-
-
-def test_delete_secret_ok(requests_mock, client):
- secret_key = "SecretKey"
- requests_mock.delete(
- client.glassflow_config.server_url + f"/secrets/{secret_key}",
- status_code=204,
- headers={"Content-Type": "application/json"},
- )
- Secret(key=secret_key, personal_access_token="test-token").delete()
-
-
-def test_delete_secret_fail_with_value_error(client):
- with pytest.raises(ValueError):
- Secret(personal_access_token="test-token").delete()
diff --git a/tests/glassflow/unit_tests/space_test.py b/tests/glassflow/unit_tests/space_test.py
deleted file mode 100644
index b4e5470..0000000
--- a/tests/glassflow/unit_tests/space_test.py
+++ /dev/null
@@ -1,45 +0,0 @@
-from datetime import datetime
-
-import pytest
-
-from glassflow import Space
-
-
-def test_create_space_ok(requests_mock, create_space_response, client):
- requests_mock.post(
- client.glassflow_config.server_url + "/spaces",
- json=create_space_response,
- status_code=200,
- headers={"Content-Type": "application/json"},
- )
- space = Space(
- name=create_space_response["name"], personal_access_token="test-token"
- ).create()
-
- assert space.name == create_space_response["name"]
- assert space.id == create_space_response["id"]
-
- parsed_response_space_created_at = datetime.strptime(
- create_space_response["created_at"], "%Y-%m-%dT%H:%M:%S.%fZ"
- )
- assert space.created_at.replace(tzinfo=None) == parsed_response_space_created_at
-
-
-def test_delete_space_ok(requests_mock, client):
- requests_mock.delete(
- client.glassflow_config.server_url + "/spaces/test-space-id",
- status_code=204,
- headers={"Content-Type": "application/json"},
- )
- space = Space(
- id="test-space-id",
- personal_access_token="test-token",
- )
- space.delete()
-
-
-def test_delete_space_fail_with_missing_id(client):
- with pytest.raises(ValueError) as e:
- Space(personal_access_token="test-token").delete()
-
- assert str(e.value) == "Space id must be provided in the constructor"
diff --git a/tests/test_client.py b/tests/test_client.py
new file mode 100644
index 0000000..8de60ad
--- /dev/null
+++ b/tests/test_client.py
@@ -0,0 +1,234 @@
+from unittest.mock import patch
+
+import pytest
+
+from glassflow.etl import errors
+from glassflow.etl.client import Client
+from glassflow.etl.models import PipelineConfig
+from glassflow.etl.pipeline import Pipeline
+from tests.data import mock_responses
+
+
+class TestClient:
+ """Tests for the Client class."""
+
+ def test_client_init(self):
+ """Test Client initialization."""
+ client = Client(host="https://example.com")
+ assert client.host == "https://example.com"
+ assert client.http_client.base_url == "https://example.com"
+
+ def test_client_get_pipeline_success(self, valid_config, mock_success_response):
+ """Test successful pipeline retrieval by ID."""
+ client = Client()
+ pipeline_id = "test-pipeline-id"
+
+ mock_success_response.json.return_value = valid_config
+
+ with patch(
+ "httpx.Client.request", return_value=mock_success_response
+ ) as mock_request:
+ pipeline = client.get_pipeline(pipeline_id)
+ mock_request.assert_called_once_with(
+ "GET", f"{client.ENDPOINT}/{pipeline_id}"
+ )
+ assert isinstance(pipeline, Pipeline)
+ assert pipeline.pipeline_id == pipeline_id
+
+ def test_client_get_pipeline_not_found(self, mock_not_found_response):
+ """Test pipeline retrieval when pipeline is not found."""
+ client = Client()
+ pipeline_id = "non-existent-pipeline"
+
+ with patch("httpx.Client.request", return_value=mock_not_found_response):
+ with pytest.raises(errors.PipelineNotFoundError) as exc_info:
+ client.get_pipeline(pipeline_id)
+ assert "not found" in str(exc_info.value)
+
+ def test_client_list_pipelines_success_list_format(self):
+ """Test successful pipeline listing with list format response."""
+ client = Client()
+ mock_response = mock_responses.create_mock_response_factory()(
+ status_code=200,
+ json_data=[
+ {
+ "pipeline_id": "loadtest",
+ "name": "loadtest",
+ "transformation_type": "Deduplication",
+ "created_at": "2025-07-28T11:50:05.478766129Z",
+ "state": "",
+ },
+ {
+ "pipeline_id": "loadtest-4",
+ "name": "loadtest-4",
+ "transformation_type": "Ingest Only",
+ "created_at": "2025-07-28T11:52:53.210108151Z",
+ "state": "",
+ },
+ {
+ "pipeline_id": "loadtest-5",
+ "name": "loadtest-5",
+ "transformation_type": "Join",
+ "created_at": "2025-07-28T11:54:46.270842895Z",
+ "state": "",
+ },
+ ],
+ )
+
+ with patch("httpx.Client.request", return_value=mock_response) as mock_request:
+ pipelines = client.list_pipelines()
+ mock_request.assert_called_once_with("GET", client.ENDPOINT)
+ assert len(pipelines) == 3
+ assert pipelines[0]["pipeline_id"] == "loadtest"
+ assert pipelines[0]["name"] == "loadtest"
+ assert pipelines[0]["transformation_type"] == "Deduplication"
+ assert pipelines[1]["pipeline_id"] == "loadtest-4"
+ assert pipelines[1]["transformation_type"] == "Ingest Only"
+ assert pipelines[2]["pipeline_id"] == "loadtest-5"
+ assert pipelines[2]["transformation_type"] == "Join"
+
+ def test_client_list_pipeline_success_single_item(self):
+ """Test successful pipeline listing with single pipeline in list response."""
+ client = Client()
+ mock_response = mock_responses.create_mock_response_factory()(
+ status_code=200,
+ json_data=[
+ {
+ "pipeline_id": "single-pipeline",
+ "name": "single-pipeline",
+ "transformation_type": "Ingest Only",
+ "created_at": "2025-07-28T11:50:05.478766129Z",
+ "state": "",
+ }
+ ],
+ )
+
+ with patch("httpx.Client.request", return_value=mock_response) as mock_request:
+ pipelines = client.list_pipelines()
+ mock_request.assert_called_once_with("GET", client.ENDPOINT)
+ assert len(pipelines) == 1
+ assert pipelines[0]["pipeline_id"] == "single-pipeline"
+ assert pipelines[0]["name"] == "single-pipeline"
+ assert pipelines[0]["transformation_type"] == "Ingest Only"
+
+ def test_client_list_pipelines_empty(self):
+ """Test pipeline listing when no pipelines exist."""
+ client = Client()
+ mock_response = mock_responses.create_mock_response_factory()(
+ status_code=404,
+ json_data=[],
+ )
+
+ with patch("httpx.Client.request", return_value=mock_response) as mock_request:
+ pipelines = client.list_pipelines()
+ mock_request.assert_called_once_with("GET", client.ENDPOINT)
+ assert pipelines == []
+
+ def test_client_create_pipeline_success(self, valid_config, mock_success_response):
+ """Test successful pipeline creation."""
+ client = Client()
+
+ with patch(
+ "httpx.Client.request", return_value=mock_success_response
+ ) as mock_request:
+ pipeline = client.create_pipeline(valid_config)
+ mock_request.assert_called_once_with(
+ "POST", client.ENDPOINT, json=mock_request.call_args[1]["json"]
+ )
+ assert isinstance(pipeline, Pipeline)
+ assert pipeline.pipeline_id == valid_config["pipeline_id"]
+
+ def test_client_create_pipeline_already_exists(
+ self, valid_config, mock_forbidden_response
+ ):
+ """Test pipeline creation when pipeline already exists."""
+ client = Client()
+
+ with patch("httpx.Client.request", return_value=mock_forbidden_response):
+ with pytest.raises(errors.PipelineAlreadyExistsError):
+ client.create_pipeline(valid_config)
+
+ def test_client_create_pipeline_from_yaml_success(self, mock_success_response):
+ """Test pipeline creation from YAML file."""
+ client = Client()
+ with patch(
+ "httpx.Client.request", return_value=mock_success_response
+ ) as mock_request:
+ client.create_pipeline(
+ pipeline_config_yaml_path="tests/data/valid_pipeline.yaml"
+ )
+ mock_request.assert_called_once_with(
+ "POST", client.ENDPOINT, json=mock_request.call_args[1]["json"]
+ )
+
+ def test_client_create_pipeline_from_json_success(self, mock_success_response):
+ """Test pipeline creation from JSON file."""
+ client = Client()
+ with patch(
+ "httpx.Client.request", return_value=mock_success_response
+ ) as mock_request:
+ client.create_pipeline(
+ pipeline_config_json_path="tests/data/valid_pipeline.json"
+ )
+ mock_request.assert_called_once_with(
+ "POST", client.ENDPOINT, json=mock_request.call_args[1]["json"]
+ )
+
+ def test_client_create_pipeline_value_error(self, valid_config):
+ """Test pipeline creation with invalid configuration."""
+ client = Client()
+ with pytest.raises(ValueError):
+ client.create_pipeline(
+ pipeline_config=valid_config,
+ pipeline_config_yaml_path="tests/data/valid_pipeline.yaml",
+ pipeline_config_json_path="tests/data/valid_pipeline.json",
+ )
+ with pytest.raises(ValueError):
+ client.create_pipeline()
+
+ def test_client_delete_pipeline_success(
+ self, mock_success_response, mock_success_get_pipeline
+ ):
+ """Test successful pipeline deletion."""
+ client = Client()
+ pipeline_id = "test-pipeline-id"
+
+ with patch("glassflow.etl.pipeline.Pipeline.get") as pipeline_get:
+ with patch(
+ "httpx.Client.request", return_value=mock_success_response
+ ) as mock_delete_request:
+ client.delete_pipeline(pipeline_id, terminate=True)
+ pipeline_get.assert_called_once_with()
+ mock_delete_request.assert_called_once_with(
+ "DELETE", f"{client.ENDPOINT}/{pipeline_id}/terminate"
+ )
+
+ def test_client_delete_pipeline_not_found(self, mock_not_found_response):
+ """Test pipeline deletion when pipeline is not found."""
+ client = Client()
+ pipeline_id = "non-existent-pipeline"
+
+ with patch("httpx.Client.request", return_value=mock_not_found_response):
+ with pytest.raises(errors.PipelineNotFoundError) as exc_info:
+ client.delete_pipeline(pipeline_id)
+ assert "not found" in str(exc_info.value)
+
+ def test_pipeline_to_dict(self, valid_config):
+ """Test Pipeline to_dict method."""
+ config = PipelineConfig(**valid_config)
+ pipeline = Pipeline(config=config)
+
+ pipeline_dict = pipeline.to_dict()
+ assert isinstance(pipeline_dict, dict)
+ assert pipeline_dict["pipeline_id"] == valid_config["pipeline_id"]
+
+ def test_pipeline_delete(self, pipeline_from_id, mock_success_response):
+ """Test Pipeline delete with explicit pipeline_id."""
+ with patch(
+ "httpx.Client.request", return_value=mock_success_response
+ ) as mock_request:
+ pipeline_from_id.delete(terminate=True)
+ mock_request.assert_called_once_with(
+ "DELETE",
+ f"{pipeline_from_id.ENDPOINT}/{pipeline_from_id.pipeline_id}/terminate",
+ )
diff --git a/tests/test_dlq.py b/tests/test_dlq.py
new file mode 100644
index 0000000..d82a5f3
--- /dev/null
+++ b/tests/test_dlq.py
@@ -0,0 +1,158 @@
+"""Tests for DLQ (Dead Letter Queue) functionality."""
+
+from unittest.mock import patch
+
+import pytest
+
+from glassflow.etl import DLQ, Pipeline, errors
+from tests.data import error_scenarios, mock_responses
+
+
+class TestDLQ:
+ """Test cases for DLQ class."""
+
+ def test_dlq_initialization(self, dlq):
+ """Test DLQ initialization."""
+ assert dlq.http_client.base_url == "http://localhost:8080"
+ assert dlq.endpoint == "/api/v1/pipeline/test-pipeline/dlq"
+
+ def test_consume_success(self, dlq):
+ """Test successful DLQ consume operation."""
+ mock_response = mock_responses.create_mock_response_factory()(
+ status_code=200,
+ json_data=[
+ {"id": "msg1", "content": "test message 1"},
+ {"id": "msg2", "content": "test message 2"},
+ ],
+ )
+
+ with patch("httpx.Client.request", return_value=mock_response) as mock_get:
+ result = dlq.consume(batch_size=50)
+
+ mock_get.assert_called_once_with(
+ "GET", f"{dlq.endpoint}/consume", params={"batch_size": 50}
+ )
+ assert result == [
+ {"id": "msg1", "content": "test message 1"},
+ {"id": "msg2", "content": "test message 2"},
+ ]
+
+ @pytest.mark.parametrize(
+ "scenario",
+ [
+ s
+ for s in error_scenarios.get_dlq_error_scenarios()
+ if s["name"].startswith("invalid_batch_size")
+ ],
+ ids=lambda s: s["name"],
+ )
+ def test_consume_invalid_batch_size_scenarios(self, dlq, scenario):
+ """Test DLQ consume with various invalid batch size scenarios."""
+ with pytest.raises(scenario["expected_error"]) as exc_info:
+ dlq.consume(batch_size=scenario["batch_size"])
+
+ assert scenario["error_message"] in str(exc_info.value)
+
+ @pytest.mark.parametrize(
+ "scenario",
+ [
+ s
+ for s in error_scenarios.get_dlq_error_scenarios()
+ if s["name"].startswith("http_error")
+ ],
+ ids=lambda s: s["name"],
+ )
+ def test_consume_http_error_scenarios(self, dlq, scenario):
+ """Test DLQ consume with HTTP error scenarios."""
+ mock_response = mock_responses.create_mock_response_factory()(
+ status_code=scenario["status_code"],
+ json_data={"message": scenario["text"]},
+ text=scenario["text"],
+ )
+
+ with patch(
+ "httpx.Client.request",
+ side_effect=mock_response.raise_for_status.side_effect,
+ ):
+ with pytest.raises(scenario["expected_error"]) as exc_info:
+ dlq.consume(batch_size=50)
+
+ assert scenario["error_message"] in str(exc_info.value)
+
+ def test_state_success(self, dlq):
+ """Test successful DLQ state operation."""
+ mock_response = mock_responses.create_mock_response_factory()(
+ status_code=200,
+ json_data={
+ "total_messages": 42,
+ "pending_messages": 5,
+ "last_updated": "2023-01-01T00:00:00Z",
+ },
+ )
+
+ with patch("httpx.Client.request", return_value=mock_response) as mock_get:
+ result = dlq.state()
+
+ mock_get.assert_called_once_with("GET", f"{dlq.endpoint}/state")
+ assert result == {
+ "total_messages": 42,
+ "pending_messages": 5,
+ "last_updated": "2023-01-01T00:00:00Z",
+ }
+
+ def test_state_server_error(self, dlq):
+ """Test DLQ state with server error."""
+ mock_response = mock_responses.create_mock_response_factory()(
+ status_code=500,
+ json_data={"message": "Internal server error"},
+ )
+
+ with patch(
+ "httpx.Client.request",
+ side_effect=mock_response.raise_for_status.side_effect,
+ ):
+ with pytest.raises(errors.ServerError) as exc_info:
+ dlq.state()
+
+ assert "Internal server error" in str(exc_info.value)
+
+
+class TestPipelineDLQIntegration:
+ """Test cases for Pipeline-DLQ integration."""
+
+ def test_pipeline_dlq_property(self, pipeline):
+ """Test that Pipeline has a DLQ property."""
+ assert hasattr(pipeline, "dlq")
+ assert isinstance(pipeline.dlq, DLQ)
+
+ def test_pipeline_dlq_property_same_url(self):
+ """Test that Pipeline DLQ uses the same base URL."""
+ custom_url = "http://custom-url:9000"
+ pipeline = Pipeline(host=custom_url, pipeline_id="test-pipeline-id")
+
+ assert pipeline.http_client.base_url == custom_url
+ assert pipeline.dlq.http_client.base_url == custom_url
+
+ def test_pipeline_dlq_consume_integration(self, pipeline):
+ """Test Pipeline DLQ consume functionality."""
+ mock_response = mock_responses.create_mock_response_factory()(
+ status_code=200,
+ json_data=[{"id": "msg1", "content": "test"}],
+ )
+
+ with patch("httpx.Client.request", return_value=mock_response):
+ result = pipeline.dlq.consume(batch_size=10)
+
+ assert result == [{"id": "msg1", "content": "test"}]
+
+ def test_pipeline_dlq_state_integration(self, pipeline):
+ """Test Pipeline DLQ state functionality."""
+ mock_response = mock_responses.create_mock_response_factory()(
+ status_code=200,
+ json_data={"total_messages": 10},
+ )
+
+ with patch("httpx.Client.request", return_value=mock_response):
+ result = pipeline.dlq.state()
+
+ assert result == {"total_messages": 10}
diff --git a/tests/glassflow/integration_tests/__init__.py b/tests/test_models/__init__.py
similarity index 100%
rename from tests/glassflow/integration_tests/__init__.py
rename to tests/test_models/__init__.py
diff --git a/tests/test_models/test_data_types.py b/tests/test_models/test_data_types.py
new file mode 100644
index 0000000..a3c86d5
--- /dev/null
+++ b/tests/test_models/test_data_types.py
@@ -0,0 +1,17 @@
+import pytest
+
+from glassflow.etl import errors, models
+
+
+class TestDataTypeCompatibility:
+ """Tests for data type compatibility validation."""
+
+ def test_validate_data_type_compatibility_invalid_mapping(self, valid_config):
+ """Test data type compatibility validation with invalid type mappings."""
+ # Modify the sink configuration to have an invalid type mapping
+ valid_config["sink"]["table_mapping"][0]["column_type"] = (
+ models.ClickhouseDataType.INT32
+ )
+
+ with pytest.raises(errors.InvalidDataTypeMappingError):
+ models.PipelineConfig(**valid_config)
diff --git a/tests/test_models/test_deduplication.py b/tests/test_models/test_deduplication.py
new file mode 100644
index 0000000..dbd6a96
--- /dev/null
+++ b/tests/test_models/test_deduplication.py
@@ -0,0 +1,58 @@
+import pytest
+
+from glassflow.etl import models
+
+
+class TestDeduplicationConfig:
+ """Tests for DeduplicationConfig model."""
+
+ def test_deduplication_config_enabled_true(self):
+ """Test DeduplicationConfig when enabled is True."""
+ with pytest.raises(ValueError) as exc_info:
+ models.DeduplicationConfig(
+ enabled=True,
+ id_field=None,
+ id_field_type=None,
+ time_window=None,
+ )
+ assert "is required when deduplication is enabled" in str(exc_info.value)
+
+ # All fields should be required when enabled is True
+ config = models.DeduplicationConfig(
+ enabled=True,
+ id_field="id",
+ id_field_type="string",
+ time_window="1h",
+ )
+ assert config.enabled is True
+ assert config.id_field == "id"
+ assert config.id_field_type == "string"
+ assert config.time_window == "1h"
+
+ def test_deduplication_config_enabled_false(self):
+ """Test DeduplicationConfig when enabled is False."""
+ # All fields should be optional when enabled is False
+ config = models.DeduplicationConfig(
+ enabled=False,
+ id_field=None,
+ id_field_type=None,
+ time_window=None,
+ )
+ assert config.enabled is False
+ assert config.id_field is None
+ assert config.id_field_type is None
+ assert config.time_window is None
+
+ def test_deduplication_config_enabled_false_with_fields(self):
+ """Test DeduplicationConfig when enabled is False."""
+ # All fields should be optional when enabled is False
+ config = models.DeduplicationConfig(
+ enabled=False,
+ id_field="",
+ id_field_type="",
+ time_window=None,
+ )
+ assert config.enabled is False
+ assert config.id_field is None
+ assert config.id_field_type is None
+ assert config.time_window is None
diff --git a/tests/test_models/test_join.py b/tests/test_models/test_join.py
new file mode 100644
index 0000000..3d4ea99
--- /dev/null
+++ b/tests/test_models/test_join.py
@@ -0,0 +1,61 @@
+import pytest
+
+from glassflow.etl import models
+from tests.data import error_scenarios
+
+
+class TestJoinConfig:
+ """Tests for JoinConfig model."""
+
+ def test_valid_enabled_join_config(self):
+ """Test JoinConfig when enabled is True."""
+ config = models.JoinConfig(
+ enabled=True,
+ type=models.JoinType.TEMPORAL,
+ sources=[
+ models.JoinSourceConfig(
+ source_id="test-topic-1",
+ join_key="id",
+ time_window="1h",
+ orientation=models.JoinOrientation.LEFT,
+ ),
+ models.JoinSourceConfig(
+ source_id="test-topic-2",
+ join_key="id",
+ time_window="1h",
+ orientation=models.JoinOrientation.RIGHT,
+ ),
+ ],
+ )
+ assert config.enabled is True
+ assert config.type == models.JoinType.TEMPORAL
+ assert len(config.sources) == 2
+ assert config.sources[0].orientation == models.JoinOrientation.LEFT
+ assert config.sources[1].orientation == models.JoinOrientation.RIGHT
+
+ def test_valid_disabled_join_config(self):
+ """Test JoinConfig when enabled is False."""
+ config = models.JoinConfig(
+ enabled=False,
+ type=None,
+ sources=None,
+ )
+ assert config.enabled is False
+ assert config.type is None
+ assert config.sources is None
+
+ @pytest.mark.parametrize(
+ "scenario",
+ error_scenarios.get_join_validation_error_scenarios(),
+ ids=lambda s: s["name"],
+ )
+ def test_join_validation_error_scenarios(self, valid_config, scenario):
+ """Test join validation with various error scenarios."""
+ with pytest.raises(scenario["expected_error"]) as exc_info:
+ models.PipelineConfig(
+ pipeline_id="test-pipeline",
+ source=valid_config["source"],
+ join=scenario["join"](valid_config),
+ sink=valid_config["sink"],
+ )
+ assert scenario["error_message"] in str(exc_info.value)
diff --git a/tests/test_models/test_pipeline_config.py b/tests/test_models/test_pipeline_config.py
new file mode 100644
index 0000000..08877df
--- /dev/null
+++ b/tests/test_models/test_pipeline_config.py
@@ -0,0 +1,106 @@
+import pytest
+
+from glassflow.etl import models
+
+
+class TestPipelineConfig:
+ """Tests for PipelineConfig model."""
+
+ def test_pipeline_config_creation(self, valid_config):
+ """Test successful PipelineConfig creation."""
+ pipeline_config = models.PipelineConfig(**valid_config)
+ assert pipeline_config.pipeline_id == "test-pipeline"
+ assert pipeline_config.source.type == "kafka"
+ assert pipeline_config.sink.type == "clickhouse"
+
+ def test_invalid_pipeline_config(self, invalid_config):
+ """Test PipelineConfig creation with invalid configuration."""
+ with pytest.raises(ValueError):
+ models.PipelineConfig(**invalid_config)
+
+ def test_pipeline_config_pipeline_id_validation(self, valid_config):
+ """Test PipelineConfig validation for pipeline_id."""
+ # Test with valid configuration
+ config = models.PipelineConfig(
+ pipeline_id="test-pipeline-123a",
+ source=valid_config["source"],
+ join=valid_config["join"],
+ sink=valid_config["sink"],
+ )
+ assert config.pipeline_id == "test-pipeline-123a"
+
+ # Test with invalid configuration
+ with pytest.raises(ValueError) as exc_info:
+ models.PipelineConfig(
+ pipeline_id="",
+ source=valid_config["source"],
+ join=valid_config["join"],
+ sink=valid_config["sink"],
+ )
+ assert "pipeline_id cannot be empty" in str(exc_info.value)
+
+ with pytest.raises(ValueError) as exc_info:
+ models.PipelineConfig(
+ pipeline_id="Test_Pipeline",
+ source=valid_config["source"],
+ join=valid_config["join"],
+ sink=valid_config["sink"],
+ )
+ assert (
+ "pipeline_id can only contain lowercase letters, numbers, and hyphens"
+ in str(exc_info.value)
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ models.PipelineConfig(
+ pipeline_id="test-pipeline-1234567890123456789012345678901234567890",
+ source=valid_config["source"],
+ join=valid_config["join"],
+ sink=valid_config["sink"],
+ )
+ assert "pipeline_id cannot be longer than 40 characters" in str(exc_info.value)
+
+ with pytest.raises(ValueError) as exc_info:
+ models.PipelineConfig(
+ pipeline_id="-test-pipeline",
+ source=valid_config["source"],
+ join=valid_config["join"],
+ sink=valid_config["sink"],
+ )
+ assert "pipeline_id must start with a lowercase alphanumeric" in str(
+ exc_info.value
+ )
+
+ with pytest.raises(ValueError) as exc_info:
+ models.PipelineConfig(
+ pipeline_id="test-pipeline-",
+ source=valid_config["source"],
+ join=valid_config["join"],
+ sink=valid_config["sink"],
+ )
+ assert "pipeline_id must end with a lowercase alphanumeric" in str(
+ exc_info.value
+ )
+
+ def test_pipeline_config_pipeline_name_provided(self, valid_config):
+ """Test PipelineConfig when pipeline_name is explicitly provided."""
+ config = models.PipelineConfig(
+ pipeline_id="test-pipeline",
+ name="My Custom Pipeline Name",
+ source=valid_config["source"],
+ join=valid_config["join"],
+ sink=valid_config["sink"],
+ )
+ assert config.pipeline_id == "test-pipeline"
+ assert config.name == "My Custom Pipeline Name"
+
+ def test_pipeline_config_pipeline_name_not_provided(self, valid_config):
+ """Test PipelineConfig when pipeline_name is not provided (default behavior)."""
+ config = models.PipelineConfig(
+ pipeline_id="test-pipeline",
+ source=valid_config["source"],
+ join=valid_config["join"],
+ sink=valid_config["sink"],
+ )
+ assert config.pipeline_id == "test-pipeline"
+ assert config.name == "Test Pipeline"
diff --git a/tests/test_models/test_sink.py b/tests/test_models/test_sink.py
new file mode 100644
index 0000000..77d8d06
--- /dev/null
+++ b/tests/test_models/test_sink.py
@@ -0,0 +1,24 @@
+import pytest
+
+from glassflow.etl import models
+from tests.data import error_scenarios
+
+
+class TestSinkConfig:
+ """Tests for SinkConfig validation."""
+
+ @pytest.mark.parametrize(
+ "scenario",
+ error_scenarios.get_sink_validation_error_scenarios(),
+ ids=lambda s: s["name"],
+ )
+ def test_sink_validation_error_scenarios(self, valid_config, scenario):
+ """Test sink validation with various error scenarios."""
+
+ with pytest.raises(scenario["expected_error"]) as exc_info:
+ models.PipelineConfig(
+ pipeline_id="test-pipeline",
+ source=valid_config["source"],
+ sink=scenario["sink"](valid_config),
+ )
+ assert scenario["error_message"] in str(exc_info.value)
diff --git a/tests/test_models/test_topic.py b/tests/test_models/test_topic.py
new file mode 100644
index 0000000..b63af81
--- /dev/null
+++ b/tests/test_models/test_topic.py
@@ -0,0 +1,93 @@
+import pytest
+
+from glassflow.etl import models
+
+
+class TestTopicConfig:
+ """Tests for TopicConfig deduplication validation."""
+
+ def test_topic_config_deduplication_id_field_validation(self):
+ """Test TopicConfig validation for deduplication ID field."""
+ # Test with valid configuration
+ config = models.TopicConfig(
+ name="test-topic",
+ consumer_group_initial_offset=models.ConsumerGroupOffset.EARLIEST,
+ schema=models.Schema(
+ type=models.SchemaType.JSON,
+ fields=[
+ models.SchemaField(name="id", type=models.KafkaDataType.STRING),
+ models.SchemaField(name="name", type=models.KafkaDataType.STRING),
+ ],
+ ),
+ deduplication=models.DeduplicationConfig(
+ enabled=True,
+ id_field="id",
+ id_field_type=models.KafkaDataType.STRING,
+ time_window="1h",
+ ),
+ )
+ assert config.name == "test-topic"
+ assert config.deduplication.id_field == "id"
+ assert config.deduplication.id_field_type == models.KafkaDataType.STRING
+
+ # Test with non-existent ID field
+ with pytest.raises(ValueError) as exc_info:
+ models.TopicConfig(
+ name="test-topic",
+ consumer_group_initial_offset=models.ConsumerGroupOffset.EARLIEST,
+ schema=models.Schema(
+ type=models.SchemaType.JSON,
+ fields=[
+ models.SchemaField(
+ name="name",
+ type=models.KafkaDataType.STRING,
+ ),
+ ],
+ ),
+ deduplication=models.DeduplicationConfig(
+ enabled=True,
+ id_field="non-existent-field",
+ id_field_type=models.KafkaDataType.STRING,
+ time_window="1h",
+ ),
+ )
+ assert "does not exist in the event schema" in str(exc_info.value)
+
+ # Test with mismatched field type
+ with pytest.raises(ValueError) as exc_info:
+ models.TopicConfig(
+ name="test-topic",
+ consumer_group_initial_offset=models.ConsumerGroupOffset.EARLIEST,
+ schema=models.Schema(
+ type=models.SchemaType.JSON,
+ fields=[
+ models.SchemaField(name="id", type=models.KafkaDataType.INT64),
+ ],
+ ),
+ deduplication=models.DeduplicationConfig(
+ enabled=True,
+ id_field="id",
+ id_field_type=models.KafkaDataType.STRING,
+ time_window="1h",
+ ),
+ )
+ assert "does not match schema field type" in str(exc_info.value)
+
+ # Test with disabled deduplication (should not validate)
+ config = models.TopicConfig(
+ name="test-topic",
+ consumer_group_initial_offset=models.ConsumerGroupOffset.EARLIEST,
+ schema=models.Schema(
+ type=models.SchemaType.JSON,
+ fields=[
+ models.SchemaField(name="name", type=models.KafkaDataType.STRING),
+ ],
+ ),
+ deduplication=models.DeduplicationConfig(
+ enabled=False,
+ id_field="non-existent-field",
+ id_field_type=models.KafkaDataType.STRING,
+ time_window="1h",
+ ),
+ )
+ assert config.deduplication.enabled is False
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
new file mode 100644
index 0000000..eb31c38
--- /dev/null
+++ b/tests/test_pipeline.py
@@ -0,0 +1,330 @@
+import os
+import tempfile
+from unittest.mock import patch
+
+import pytest
+from pydantic import ValidationError
+
+from glassflow.etl import errors
+from glassflow.etl.models import PipelineConfig
+from glassflow.etl.pipeline import Pipeline
+from tests.data import error_scenarios, mock_responses
+
+
+class TestPipelineCreation:
+ """Tests for pipeline creation operations."""
+
+ def test_create_success(self, pipeline, mock_success_response):
+ """Test successful pipeline creation."""
+ with patch(
+ "httpx.Client.request", return_value=mock_success_response
+ ) as mock_request:
+ result = pipeline.create()
+ mock_request.assert_called_once_with(
+ "POST",
+ pipeline.ENDPOINT,
+ json=pipeline.config.model_dump(mode="json", by_alias=True),
+ )
+ assert result == pipeline
+
+ def test_create_invalid_config(self, invalid_config):
+ """Test pipeline creation with invalid configuration."""
+ with pytest.raises((ValueError, ValidationError)) as exc_info:
+ Pipeline(host="http://localhost:8080", config=invalid_config)
+ assert "pipeline_id cannot be empty" in str(exc_info.value)
+
+ def test_create_value_error(self, valid_config):
+ """Test pipeline creation with value error."""
+ with pytest.raises(ValueError):
+ Pipeline(host="http://localhost:8080")
+
+ with pytest.raises(ValueError):
+ Pipeline(config=valid_config, pipeline_id="test-pipeline")
+
+ with pytest.raises(ValueError):
+ Pipeline(host="http://localhost:8080", pipeline_id="test-pipeline").create()
+
+ def test_create_connection_error(self, pipeline, mock_connection_error):
+ """Test pipeline creation with connection error."""
+ with patch("httpx.Client.request", side_effect=mock_connection_error):
+ with pytest.raises(errors.ConnectionError) as exc_info:
+ pipeline.create()
+ assert "Failed to connect to GlassFlow ETL API" in str(exc_info.value)
+
+ @pytest.mark.parametrize(
+ "scenario",
+ error_scenarios.get_http_error_scenarios(),
+ ids=lambda s: s["name"],
+ )
+ def test_create_http_error_scenarios(self, pipeline, scenario):
+ """Test pipeline creation with various HTTP error scenarios."""
+ mock_response = mock_responses.create_mock_response_factory()(
+ status_code=scenario["status_code"],
+ json_data={"message": scenario["text"]},
+ text=scenario["text"],
+ )
+
+ with patch(
+ "httpx.Client.request",
+ side_effect=mock_response.raise_for_status.side_effect,
+ ):
+ with pytest.raises(scenario["expected_error"]) as exc_info:
+ pipeline.create()
+ assert scenario["error_message"] in str(exc_info.value)
+
+
+class TestPipelineLifecycle:
+ """Tests for pause, resume, delete operations."""
+
+ @pytest.mark.parametrize(
+ "operation,method,endpoint,params",
+ [
+ ("get", "GET", "", {}),
+ ("delete", "DELETE", "/terminate", {"terminate": True}),
+ ],
+ )
+ def test_lifecycle_operations(
+ self,
+ pipeline,
+ mock_success_response,
+ operation,
+ method,
+ endpoint,
+ params,
+ valid_config,
+ ):
+ """Test common pipeline lifecycle operations."""
+ with patch(
+ "httpx.Client.request", return_value=mock_success_response
+ ) as mock_request:
+ if method == "GET":
+ mock_request.return_value.json.return_value = valid_config
+ result = getattr(pipeline, operation)(**params)
+ expected_endpoint = f"{pipeline.ENDPOINT}/{pipeline.pipeline_id}{endpoint}"
+ mock_request.assert_called_once_with(method, expected_endpoint)
+ if operation == "delete":
+ assert result is None
+ else:
+ assert result == pipeline
+
+ @pytest.mark.parametrize("operation", ["get", "delete"])
+ def test_lifecycle_not_found(self, pipeline, mock_not_found_response, operation):
+ """Test lifecycle operations when pipeline is not found."""
+ with patch("httpx.Client.request", return_value=mock_not_found_response):
+ with pytest.raises(errors.PipelineNotFoundError):
+ getattr(pipeline, operation)()
+
+ @pytest.mark.parametrize("operation", ["get", "delete"])
+ def test_lifecycle_connection_error(
+ self, pipeline, mock_connection_error, operation
+ ):
+ """Test lifecycle operations with connection error."""
+ with patch("httpx.Client.request", side_effect=mock_connection_error):
+ with pytest.raises(errors.ConnectionError) as exc_info:
+ getattr(pipeline, operation)()
+ assert "Failed to connect to GlassFlow ETL API" in str(exc_info.value)
+
+
+class TestPipelineModification:
+ """Tests for update, rename operations."""
+
+ def test_rename_success(self, pipeline, mock_success_response):
+ """Test successful pipeline rename."""
+ new_name = "renamed-pipeline"
+ with patch(
+ "httpx.Client.request", return_value=mock_success_response
+ ) as mock_request:
+ result = pipeline.rename(new_name)
+ mock_request.assert_called_once_with(
+ "PATCH",
+ f"{pipeline.ENDPOINT}/{pipeline.pipeline_id}",
+ json={"name": new_name},
+ )
+ assert result == pipeline
+ # After rename, the config should be loaded and name should be updated
+
+ def test_rename_not_found(self, pipeline, mock_not_found_response):
+ """Test pipeline rename when pipeline is not found."""
+ new_name = "renamed-pipeline"
+ with patch("httpx.Client.request", return_value=mock_not_found_response):
+ with pytest.raises(errors.PipelineNotFoundError):
+ pipeline.rename(new_name)
+
+ def test_rename_connection_error(self, pipeline, mock_connection_error):
+ """Test pipeline rename with connection error."""
+ new_name = "renamed-pipeline"
+ with patch("httpx.Client.request", side_effect=mock_connection_error):
+ with pytest.raises(errors.ConnectionError) as exc_info:
+ pipeline.rename(new_name)
+ assert "Failed to connect to GlassFlow ETL API" in str(exc_info.value)
+
+
+class TestPipelineValidation:
+ """Tests for config validation."""
+
+ def test_validate_config_valid(self, valid_config):
+ """Test validation of a valid pipeline configuration."""
+ config = PipelineConfig(**valid_config)
+ Pipeline.validate_config(config)
+ # No exception should be raised
+
+ @pytest.mark.parametrize(
+ "scenario",
+ error_scenarios.get_validation_error_scenarios(),
+ ids=lambda s: s["name"],
+ )
+ def test_pipeline_id_validation_scenarios(self, scenario):
+ """Test pipeline ID validation with various error scenarios."""
+ # Create a minimal valid config and override pipeline_id
+ from tests.data.pipeline_configs import get_valid_pipeline_config
+
+ config_data = get_valid_pipeline_config()
+ config_data.update(scenario["config"])
+
+ with pytest.raises(scenario["expected_error"]) as exc_info:
+ PipelineConfig(**config_data)
+
+ assert scenario["error_message"] in str(exc_info.value)
+
+
+class TestPipelineTracking:
+ """Tests for tracking functionality."""
+
+ def test_tracking_info(
+ self,
+ valid_config,
+ valid_config_with_dedup_disabled,
+ valid_config_without_joins,
+ valid_config_without_joins_and_dedup_disabled,
+ ):
+ """Test tracking info."""
+ pipeline = Pipeline(host="http://localhost:8080", config=valid_config)
+ assert pipeline._tracking_info() == {
+ "pipeline_id": valid_config["pipeline_id"],
+ "join_enabled": True,
+ "deduplication_enabled": True,
+ "source_auth_method": "SCRAM-SHA-256",
+ "source_security_protocol": "SASL_SSL",
+ "source_root_ca_provided": True,
+ "source_skip_auth": False,
+ }
+
+ pipeline = Pipeline(
+ host="http://localhost:8080",
+ config=valid_config_with_dedup_disabled,
+ )
+ assert pipeline._tracking_info() == {
+ "pipeline_id": valid_config_with_dedup_disabled["pipeline_id"],
+ "join_enabled": True,
+ "deduplication_enabled": False,
+ "source_auth_method": "SCRAM-SHA-256",
+ "source_security_protocol": "SASL_SSL",
+ "source_root_ca_provided": True,
+ "source_skip_auth": False,
+ }
+
+ pipeline = Pipeline(
+ host="http://localhost:8080", config=valid_config_without_joins
+ )
+ assert pipeline._tracking_info() == {
+ "pipeline_id": valid_config_without_joins["pipeline_id"],
+ "join_enabled": False,
+ "deduplication_enabled": True,
+ "source_auth_method": "SCRAM-SHA-256",
+ "source_security_protocol": "SASL_SSL",
+ "source_root_ca_provided": True,
+ "source_skip_auth": False,
+ }
+
+ pipeline = Pipeline(
+ host="http://localhost:8080",
+ config=valid_config_without_joins_and_dedup_disabled,
+ )
+ pipeline_id = valid_config_without_joins_and_dedup_disabled["pipeline_id"]
+ assert pipeline._tracking_info() == {
+ "pipeline_id": pipeline_id,
+ "join_enabled": False,
+ "deduplication_enabled": False,
+ "source_auth_method": "SCRAM-SHA-256",
+ "source_security_protocol": "SASL_SSL",
+ "source_root_ca_provided": True,
+ "source_skip_auth": False,
+ }
+
+
+class TestPipelineIO:
+ """Tests for file operations."""
+
+ def test_to_yaml(self, pipeline):
+ """Test pipeline to YAML file."""
+ # Use a temporary file that will be automatically cleaned up
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".yaml", delete=False
+ ) as temp_file:
+ temp_path = temp_file.name
+ try:
+ pipeline.to_yaml(temp_path)
+ assert os.path.exists(temp_path)
+ finally:
+ # Clean up the temporary file
+ if os.path.exists(temp_path):
+ os.unlink(temp_path)
+
+ def test_to_json(self, pipeline):
+ """Test pipeline to JSON file."""
+ # Use a temporary file that will be automatically cleaned up
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".json", delete=False
+ ) as temp_file:
+ temp_path = temp_file.name
+ try:
+ pipeline.to_json(temp_path)
+ assert os.path.exists(temp_path)
+ finally:
+ # Clean up the temporary file
+ if os.path.exists(temp_path):
+ os.unlink(temp_path)
+
+ def test_from_yaml(self, pipeline):
+ """Test pipeline from YAML file."""
+ pipeline = Pipeline.from_yaml("tests/data/valid_pipeline.yaml")
+ assert pipeline.pipeline_id == "test-pipeline"
+
+ def test_from_json(self, pipeline):
+ """Test pipeline from JSON file."""
+ pipeline = Pipeline.from_json("tests/data/valid_pipeline.json")
+ assert pipeline.pipeline_id == "test-pipeline"
+
+ def test_to_dict(self, pipeline):
+ """Test pipeline to dictionary."""
+ assert pipeline.to_dict() == pipeline.config.model_dump(
+ mode="json", by_alias=True
+ )
+
+ pipeline = Pipeline(host="http://localhost:8080", pipeline_id="test-pipeline")
+ assert pipeline.to_dict() == {"pipeline_id": "test-pipeline"}
+
+
+class TestPipelineHealth:
+ """Tests for pipeline health endpoint."""
+
+ def test_health_success(self, pipeline, mock_success_response):
+ """Test successful health fetch returns expected payload."""
+ expected = {
+ "pipeline_id": "test-pipeline",
+ "pipeline_name": "Test Pipeline",
+ "overall_status": "Running",
+ "created_at": "2025-08-31T16:05:09.163872763Z",
+ "updated_at": "2025-08-31T16:05:10.638243216Z",
+ }
+ mock_success_response.json.return_value = expected
+
+ with patch(
+ "httpx.Client.request", return_value=mock_success_response
+ ) as mock_request:
+ result = pipeline.health()
+ mock_request.assert_called_once_with(
+ "GET",
+ f"{pipeline.ENDPOINT}/{pipeline.pipeline_id}/health",
+ )
+ assert result == expected
diff --git a/tests/test_tracking.py b/tests/test_tracking.py
new file mode 100644
index 0000000..a76aaab
--- /dev/null
+++ b/tests/test_tracking.py
@@ -0,0 +1,65 @@
+import os
+from unittest.mock import patch
+
+from glassflow.etl.tracking import Tracking
+
+
+class TestTracking:
+ """Tests for the Tracking class."""
+
+ def test_tracking_disabled(self, mock_track):
+ """Test that tracking is not called when disabled."""
+ with patch.dict(os.environ, {"GF_TRACKING_ENABLED": "false"}):
+ tracking = Tracking(distinct_id="distinct-id")
+ assert not tracking.enabled
+
+ tracking.track_event("test_event", {"test": "data"})
+ mock_track.assert_not_called()
+
+ def test_tracking_enabled(self, mock_track):
+ """Test that tracking is called with correct data when enabled."""
+ with patch.dict(os.environ, {"GF_TRACKING_ENABLED": "true"}):
+ tracking = Tracking(distinct_id="distinct-id")
+ assert tracking.enabled
+
+ tracking.track_event("test_event", {"test": "data"})
+ mock_track.assert_called_once_with(
+ distinct_id=tracking._distinct_id,
+ event_name="test_event",
+ properties={
+ "sdk_version": tracking.sdk_version,
+ "platform": tracking.platform,
+ "python_version": tracking.python_version,
+ "test": "data",
+ },
+ )
+
+ def test_tracking_enabled_no_properties(self, mock_track):
+ """
+ Test that tracking is called with only base properties when no
+ additional properties are provided.
+ """
+ with patch.dict(os.environ, {"GF_TRACKING_ENABLED": "true"}):
+ tracking = Tracking(distinct_id="distinct-id")
+ assert tracking.enabled
+
+ tracking.track_event("test_event")
+ mock_track.assert_called_once_with(
+ distinct_id=tracking._distinct_id,
+ event_name="test_event",
+ properties={
+ "sdk_version": tracking.sdk_version,
+ "platform": tracking.platform,
+ "python_version": tracking.python_version,
+ },
+ )
+
+ def test_tracking_error_handling(self, mock_track):
+ """Test that tracking errors are handled gracefully."""
+ with patch.dict(os.environ, {"GF_TRACKING_ENABLED": "true"}):
+ tracking = Tracking(distinct_id="distinct-id")
+ assert tracking.enabled
+
+ mock_track.side_effect = Exception("Test error")
+ # Should not raise an exception
+ tracking.track_event("test_event", {"test": "data"})
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..2fc8623
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,1390 @@
+version = 1
+requires-python = ">=3.9"
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
+]
+
+[[package]]
+name = "anyio"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "idna" },
+ { name = "sniffio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181 },
+]
+
+[[package]]
+name = "build"
+version = "1.2.2.post1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "os_name == 'nt'" },
+ { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" },
+ { name = "packaging" },
+ { name = "pyproject-hooks" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/46/aeab111f8e06793e4f0e421fcad593d547fb8313b50990f31681ee2fb1ad/build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7", size = 46701 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/c2/80633736cd183ee4a62107413def345f7e6e3c01563dbca1417363cf957e/build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5", size = 22950 },
+]
+
+[[package]]
+name = "certifi"
+version = "2025.1.31"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 },
+ { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 },
+ { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 },
+ { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 },
+ { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 },
+ { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 },
+ { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 },
+ { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 },
+ { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 },
+ { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 },
+ { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 },
+ { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 },
+ { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264 },
+ { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651 },
+ { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259 },
+ { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200 },
+ { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235 },
+ { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721 },
+ { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242 },
+ { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999 },
+ { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242 },
+ { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604 },
+ { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727 },
+ { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400 },
+ { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178 },
+ { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840 },
+ { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803 },
+ { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850 },
+ { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729 },
+ { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256 },
+ { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424 },
+ { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568 },
+ { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736 },
+ { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448 },
+ { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976 },
+ { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
+ { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
+ { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
+ { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
+ { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
+ { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
+ { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
+ { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
+ { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
+ { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
+ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
+ { url = "https://files.pythonhosted.org/packages/b9/ea/8bb50596b8ffbc49ddd7a1ad305035daa770202a6b782fc164647c2673ad/cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16", size = 182220 },
+ { url = "https://files.pythonhosted.org/packages/ae/11/e77c8cd24f58285a82c23af484cf5b124a376b32644e445960d1a4654c3a/cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36", size = 178605 },
+ { url = "https://files.pythonhosted.org/packages/ed/65/25a8dc32c53bf5b7b6c2686b42ae2ad58743f7ff644844af7cdb29b49361/cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8", size = 424910 },
+ { url = "https://files.pythonhosted.org/packages/42/7a/9d086fab7c66bd7c4d0f27c57a1b6b068ced810afc498cc8c49e0088661c/cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576", size = 447200 },
+ { url = "https://files.pythonhosted.org/packages/da/63/1785ced118ce92a993b0ec9e0d0ac8dc3e5dbfbcaa81135be56c69cabbb6/cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87", size = 454565 },
+ { url = "https://files.pythonhosted.org/packages/74/06/90b8a44abf3556599cdec107f7290277ae8901a58f75e6fe8f970cd72418/cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0", size = 435635 },
+ { url = "https://files.pythonhosted.org/packages/bd/62/a1f468e5708a70b1d86ead5bab5520861d9c7eacce4a885ded9faa7729c3/cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3", size = 445218 },
+ { url = "https://files.pythonhosted.org/packages/5b/95/b34462f3ccb09c2594aa782d90a90b045de4ff1f70148ee79c69d37a0a5a/cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595", size = 460486 },
+ { url = "https://files.pythonhosted.org/packages/fc/fc/a1e4bebd8d680febd29cf6c8a40067182b64f00c7d105f8f26b5bc54317b/cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a", size = 437911 },
+ { url = "https://files.pythonhosted.org/packages/e6/c3/21cab7a6154b6a5ea330ae80de386e7665254835b9e98ecc1340b3a7de9a/cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e", size = 460632 },
+ { url = "https://files.pythonhosted.org/packages/cb/b5/fd9f8b5a84010ca169ee49f4e4ad6f8c05f4e3545b72ee041dbbcb159882/cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7", size = 171820 },
+ { url = "https://files.pythonhosted.org/packages/8c/52/b08750ce0bce45c143e1b5d7357ee8c55341b52bdef4b0f081af1eb248c2/cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662", size = 181290 },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/58/5580c1716040bc89206c77d8f74418caf82ce519aae06450393ca73475d1/charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de", size = 198013 },
+ { url = "https://files.pythonhosted.org/packages/d0/11/00341177ae71c6f5159a08168bcb98c6e6d196d372c94511f9f6c9afe0c6/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176", size = 141285 },
+ { url = "https://files.pythonhosted.org/packages/01/09/11d684ea5819e5a8f5100fb0b38cf8d02b514746607934134d31233e02c8/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037", size = 151449 },
+ { url = "https://files.pythonhosted.org/packages/08/06/9f5a12939db324d905dc1f70591ae7d7898d030d7662f0d426e2286f68c9/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f", size = 143892 },
+ { url = "https://files.pythonhosted.org/packages/93/62/5e89cdfe04584cb7f4d36003ffa2936681b03ecc0754f8e969c2becb7e24/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a", size = 146123 },
+ { url = "https://files.pythonhosted.org/packages/a9/ac/ab729a15c516da2ab70a05f8722ecfccc3f04ed7a18e45c75bbbaa347d61/charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a", size = 147943 },
+ { url = "https://files.pythonhosted.org/packages/03/d2/3f392f23f042615689456e9a274640c1d2e5dd1d52de36ab8f7955f8f050/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247", size = 142063 },
+ { url = "https://files.pythonhosted.org/packages/f2/e3/e20aae5e1039a2cd9b08d9205f52142329f887f8cf70da3650326670bddf/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408", size = 150578 },
+ { url = "https://files.pythonhosted.org/packages/8d/af/779ad72a4da0aed925e1139d458adc486e61076d7ecdcc09e610ea8678db/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb", size = 153629 },
+ { url = "https://files.pythonhosted.org/packages/c2/b6/7aa450b278e7aa92cf7732140bfd8be21f5f29d5bf334ae987c945276639/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d", size = 150778 },
+ { url = "https://files.pythonhosted.org/packages/39/f4/d9f4f712d0951dcbfd42920d3db81b00dd23b6ab520419626f4023334056/charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807", size = 146453 },
+ { url = "https://files.pythonhosted.org/packages/49/2b/999d0314e4ee0cff3cb83e6bc9aeddd397eeed693edb4facb901eb8fbb69/charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f", size = 95479 },
+ { url = "https://files.pythonhosted.org/packages/2d/ce/3cbed41cff67e455a386fb5e5dd8906cdda2ed92fbc6297921f2e4419309/charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f", size = 102790 },
+ { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 },
+ { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 },
+ { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 },
+ { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 },
+ { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 },
+ { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 },
+ { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 },
+ { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 },
+ { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 },
+ { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 },
+ { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 },
+ { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 },
+ { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 },
+ { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 },
+ { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 },
+ { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 },
+ { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 },
+ { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 },
+ { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 },
+ { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 },
+ { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 },
+ { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 },
+ { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 },
+ { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 },
+ { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 },
+ { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 },
+ { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 },
+ { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 },
+ { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 },
+ { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 },
+ { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 },
+ { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 },
+ { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 },
+ { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 },
+ { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 },
+ { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 },
+ { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 },
+ { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 },
+ { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 },
+ { url = "https://files.pythonhosted.org/packages/7f/c0/b913f8f02836ed9ab32ea643c6fe4d3325c3d8627cf6e78098671cafff86/charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41", size = 197867 },
+ { url = "https://files.pythonhosted.org/packages/0f/6c/2bee440303d705b6fb1e2ec789543edec83d32d258299b16eed28aad48e0/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f", size = 141385 },
+ { url = "https://files.pythonhosted.org/packages/3d/04/cb42585f07f6f9fd3219ffb6f37d5a39b4fd2db2355b23683060029c35f7/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2", size = 151367 },
+ { url = "https://files.pythonhosted.org/packages/54/54/2412a5b093acb17f0222de007cc129ec0e0df198b5ad2ce5699355269dfe/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770", size = 143928 },
+ { url = "https://files.pythonhosted.org/packages/5a/6d/e2773862b043dcf8a221342954f375392bb2ce6487bcd9f2c1b34e1d6781/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4", size = 146203 },
+ { url = "https://files.pythonhosted.org/packages/b9/f8/ca440ef60d8f8916022859885f231abb07ada3c347c03d63f283bec32ef5/charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537", size = 148082 },
+ { url = "https://files.pythonhosted.org/packages/04/d2/42fd330901aaa4b805a1097856c2edf5095e260a597f65def493f4b8c833/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496", size = 142053 },
+ { url = "https://files.pythonhosted.org/packages/9e/af/3a97a4fa3c53586f1910dadfc916e9c4f35eeada36de4108f5096cb7215f/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78", size = 150625 },
+ { url = "https://files.pythonhosted.org/packages/26/ae/23d6041322a3556e4da139663d02fb1b3c59a23ab2e2b56432bd2ad63ded/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7", size = 153549 },
+ { url = "https://files.pythonhosted.org/packages/94/22/b8f2081c6a77cb20d97e57e0b385b481887aa08019d2459dc2858ed64871/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6", size = 150945 },
+ { url = "https://files.pythonhosted.org/packages/c7/0b/c5ec5092747f801b8b093cdf5610e732b809d6cb11f4c51e35fc28d1d389/charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294", size = 146595 },
+ { url = "https://files.pythonhosted.org/packages/0c/5a/0b59704c38470df6768aa154cc87b1ac7c9bb687990a1559dc8765e8627e/charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5", size = 95453 },
+ { url = "https://files.pythonhosted.org/packages/85/2d/a9790237cb4d01a6d57afadc8573c8b73c609ade20b80f4cda30802009ee/charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765", size = 102811 },
+ { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
+]
+
+[[package]]
+name = "click"
+version = "8.1.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
+[[package]]
+name = "coverage"
+version = "7.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/4f/2251e65033ed2ce1e68f00f91a0294e0f80c80ae8c3ebbe2f12828c4cd53/coverage-7.8.0.tar.gz", hash = "sha256:7a3d62b3b03b4b6fd41a085f3574874cf946cb4604d2b4d3e8dca8cd570ca501", size = 811872 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/01/1c5e6ee4ebaaa5e079db933a9a45f61172048c7efa06648445821a201084/coverage-7.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2931f66991175369859b5fd58529cd4b73582461877ecfd859b6549869287ffe", size = 211379 },
+ { url = "https://files.pythonhosted.org/packages/e9/16/a463389f5ff916963471f7c13585e5f38c6814607306b3cb4d6b4cf13384/coverage-7.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52a523153c568d2c0ef8826f6cc23031dc86cffb8c6aeab92c4ff776e7951b28", size = 211814 },
+ { url = "https://files.pythonhosted.org/packages/b8/b1/77062b0393f54d79064dfb72d2da402657d7c569cfbc724d56ac0f9c67ed/coverage-7.8.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c8a5c139aae4c35cbd7cadca1df02ea8cf28a911534fc1b0456acb0b14234f3", size = 240937 },
+ { url = "https://files.pythonhosted.org/packages/d7/54/c7b00a23150083c124e908c352db03bcd33375494a4beb0c6d79b35448b9/coverage-7.8.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a26c0c795c3e0b63ec7da6efded5f0bc856d7c0b24b2ac84b4d1d7bc578d676", size = 238849 },
+ { url = "https://files.pythonhosted.org/packages/f7/ec/a6b7cfebd34e7b49f844788fda94713035372b5200c23088e3bbafb30970/coverage-7.8.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:821f7bcbaa84318287115d54becb1915eece6918136c6f91045bb84e2f88739d", size = 239986 },
+ { url = "https://files.pythonhosted.org/packages/21/8c/c965ecef8af54e6d9b11bfbba85d4f6a319399f5f724798498387f3209eb/coverage-7.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a321c61477ff8ee705b8a5fed370b5710c56b3a52d17b983d9215861e37b642a", size = 239896 },
+ { url = "https://files.pythonhosted.org/packages/40/83/070550273fb4c480efa8381735969cb403fa8fd1626d74865bfaf9e4d903/coverage-7.8.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ed2144b8a78f9d94d9515963ed273d620e07846acd5d4b0a642d4849e8d91a0c", size = 238613 },
+ { url = "https://files.pythonhosted.org/packages/07/76/fbb2540495b01d996d38e9f8897b861afed356be01160ab4e25471f4fed1/coverage-7.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:042e7841a26498fff7a37d6fda770d17519982f5b7d8bf5278d140b67b61095f", size = 238909 },
+ { url = "https://files.pythonhosted.org/packages/a3/7e/76d604db640b7d4a86e5dd730b73e96e12a8185f22b5d0799025121f4dcb/coverage-7.8.0-cp310-cp310-win32.whl", hash = "sha256:f9983d01d7705b2d1f7a95e10bbe4091fabc03a46881a256c2787637b087003f", size = 213948 },
+ { url = "https://files.pythonhosted.org/packages/5c/a7/f8ce4aafb4a12ab475b56c76a71a40f427740cf496c14e943ade72e25023/coverage-7.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:5a570cd9bd20b85d1a0d7b009aaf6c110b52b5755c17be6962f8ccd65d1dbd23", size = 214844 },
+ { url = "https://files.pythonhosted.org/packages/2b/77/074d201adb8383addae5784cb8e2dac60bb62bfdf28b2b10f3a3af2fda47/coverage-7.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7ac22a0bb2c7c49f441f7a6d46c9c80d96e56f5a8bc6972529ed43c8b694e27", size = 211493 },
+ { url = "https://files.pythonhosted.org/packages/a9/89/7a8efe585750fe59b48d09f871f0e0c028a7b10722b2172dfe021fa2fdd4/coverage-7.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf13d564d310c156d1c8e53877baf2993fb3073b2fc9f69790ca6a732eb4bfea", size = 211921 },
+ { url = "https://files.pythonhosted.org/packages/e9/ef/96a90c31d08a3f40c49dbe897df4f1fd51fb6583821a1a1c5ee30cc8f680/coverage-7.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5761c70c017c1b0d21b0815a920ffb94a670c8d5d409d9b38857874c21f70d7", size = 244556 },
+ { url = "https://files.pythonhosted.org/packages/89/97/dcd5c2ce72cee9d7b0ee8c89162c24972fb987a111b92d1a3d1d19100c61/coverage-7.8.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ff52d790c7e1628241ffbcaeb33e07d14b007b6eb00a19320c7b8a7024c040", size = 242245 },
+ { url = "https://files.pythonhosted.org/packages/b2/7b/b63cbb44096141ed435843bbb251558c8e05cc835c8da31ca6ffb26d44c0/coverage-7.8.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d39fc4817fd67b3915256af5dda75fd4ee10621a3d484524487e33416c6f3543", size = 244032 },
+ { url = "https://files.pythonhosted.org/packages/97/e3/7fa8c2c00a1ef530c2a42fa5df25a6971391f92739d83d67a4ee6dcf7a02/coverage-7.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b44674870709017e4b4036e3d0d6c17f06a0e6d4436422e0ad29b882c40697d2", size = 243679 },
+ { url = "https://files.pythonhosted.org/packages/4f/b3/e0a59d8df9150c8a0c0841d55d6568f0a9195692136c44f3d21f1842c8f6/coverage-7.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f99eb72bf27cbb167b636eb1726f590c00e1ad375002230607a844d9e9a2318", size = 241852 },
+ { url = "https://files.pythonhosted.org/packages/9b/82/db347ccd57bcef150c173df2ade97976a8367a3be7160e303e43dd0c795f/coverage-7.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b571bf5341ba8c6bc02e0baeaf3b061ab993bf372d982ae509807e7f112554e9", size = 242389 },
+ { url = "https://files.pythonhosted.org/packages/21/f6/3f7d7879ceb03923195d9ff294456241ed05815281f5254bc16ef71d6a20/coverage-7.8.0-cp311-cp311-win32.whl", hash = "sha256:e75a2ad7b647fd8046d58c3132d7eaf31b12d8a53c0e4b21fa9c4d23d6ee6d3c", size = 213997 },
+ { url = "https://files.pythonhosted.org/packages/28/87/021189643e18ecf045dbe1e2071b2747901f229df302de01c998eeadf146/coverage-7.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:3043ba1c88b2139126fc72cb48574b90e2e0546d4c78b5299317f61b7f718b78", size = 214911 },
+ { url = "https://files.pythonhosted.org/packages/aa/12/4792669473297f7973518bec373a955e267deb4339286f882439b8535b39/coverage-7.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bbb5cc845a0292e0c520656d19d7ce40e18d0e19b22cb3e0409135a575bf79fc", size = 211684 },
+ { url = "https://files.pythonhosted.org/packages/be/e1/2a4ec273894000ebedd789e8f2fc3813fcaf486074f87fd1c5b2cb1c0a2b/coverage-7.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4dfd9a93db9e78666d178d4f08a5408aa3f2474ad4d0e0378ed5f2ef71640cb6", size = 211935 },
+ { url = "https://files.pythonhosted.org/packages/f8/3a/7b14f6e4372786709a361729164125f6b7caf4024ce02e596c4a69bccb89/coverage-7.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f017a61399f13aa6d1039f75cd467be388d157cd81f1a119b9d9a68ba6f2830d", size = 245994 },
+ { url = "https://files.pythonhosted.org/packages/54/80/039cc7f1f81dcbd01ea796d36d3797e60c106077e31fd1f526b85337d6a1/coverage-7.8.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0915742f4c82208ebf47a2b154a5334155ed9ef9fe6190674b8a46c2fb89cb05", size = 242885 },
+ { url = "https://files.pythonhosted.org/packages/10/e0/dc8355f992b6cc2f9dcd5ef6242b62a3f73264893bc09fbb08bfcab18eb4/coverage-7.8.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a40fcf208e021eb14b0fac6bdb045c0e0cab53105f93ba0d03fd934c956143a", size = 245142 },
+ { url = "https://files.pythonhosted.org/packages/43/1b/33e313b22cf50f652becb94c6e7dae25d8f02e52e44db37a82de9ac357e8/coverage-7.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a1f406a8e0995d654b2ad87c62caf6befa767885301f3b8f6f73e6f3c31ec3a6", size = 244906 },
+ { url = "https://files.pythonhosted.org/packages/05/08/c0a8048e942e7f918764ccc99503e2bccffba1c42568693ce6955860365e/coverage-7.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:77af0f6447a582fdc7de5e06fa3757a3ef87769fbb0fdbdeba78c23049140a47", size = 243124 },
+ { url = "https://files.pythonhosted.org/packages/5b/62/ea625b30623083c2aad645c9a6288ad9fc83d570f9adb913a2abdba562dd/coverage-7.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f2d32f95922927186c6dbc8bc60df0d186b6edb828d299ab10898ef3f40052fe", size = 244317 },
+ { url = "https://files.pythonhosted.org/packages/62/cb/3871f13ee1130a6c8f020e2f71d9ed269e1e2124aa3374d2180ee451cee9/coverage-7.8.0-cp312-cp312-win32.whl", hash = "sha256:769773614e676f9d8e8a0980dd7740f09a6ea386d0f383db6821df07d0f08545", size = 214170 },
+ { url = "https://files.pythonhosted.org/packages/88/26/69fe1193ab0bfa1eb7a7c0149a066123611baba029ebb448500abd8143f9/coverage-7.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:e5d2b9be5b0693cf21eb4ce0ec8d211efb43966f6657807f6859aab3814f946b", size = 214969 },
+ { url = "https://files.pythonhosted.org/packages/f3/21/87e9b97b568e223f3438d93072479c2f36cc9b3f6b9f7094b9d50232acc0/coverage-7.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ac46d0c2dd5820ce93943a501ac5f6548ea81594777ca585bf002aa8854cacd", size = 211708 },
+ { url = "https://files.pythonhosted.org/packages/75/be/882d08b28a0d19c9c4c2e8a1c6ebe1f79c9c839eb46d4fca3bd3b34562b9/coverage-7.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:771eb7587a0563ca5bb6f622b9ed7f9d07bd08900f7589b4febff05f469bea00", size = 211981 },
+ { url = "https://files.pythonhosted.org/packages/7a/1d/ce99612ebd58082fbe3f8c66f6d8d5694976c76a0d474503fa70633ec77f/coverage-7.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42421e04069fb2cbcbca5a696c4050b84a43b05392679d4068acbe65449b5c64", size = 245495 },
+ { url = "https://files.pythonhosted.org/packages/dc/8d/6115abe97df98db6b2bd76aae395fcc941d039a7acd25f741312ced9a78f/coverage-7.8.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:554fec1199d93ab30adaa751db68acec2b41c5602ac944bb19187cb9a41a8067", size = 242538 },
+ { url = "https://files.pythonhosted.org/packages/cb/74/2f8cc196643b15bc096d60e073691dadb3dca48418f08bc78dd6e899383e/coverage-7.8.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5aaeb00761f985007b38cf463b1d160a14a22c34eb3f6a39d9ad6fc27cb73008", size = 244561 },
+ { url = "https://files.pythonhosted.org/packages/22/70/c10c77cd77970ac965734fe3419f2c98665f6e982744a9bfb0e749d298f4/coverage-7.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:581a40c7b94921fffd6457ffe532259813fc68eb2bdda60fa8cc343414ce3733", size = 244633 },
+ { url = "https://files.pythonhosted.org/packages/38/5a/4f7569d946a07c952688debee18c2bb9ab24f88027e3d71fd25dbc2f9dca/coverage-7.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f319bae0321bc838e205bf9e5bc28f0a3165f30c203b610f17ab5552cff90323", size = 242712 },
+ { url = "https://files.pythonhosted.org/packages/bb/a1/03a43b33f50475a632a91ea8c127f7e35e53786dbe6781c25f19fd5a65f8/coverage-7.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04bfec25a8ef1c5f41f5e7e5c842f6b615599ca8ba8391ec33a9290d9d2db3a3", size = 244000 },
+ { url = "https://files.pythonhosted.org/packages/6a/89/ab6c43b1788a3128e4d1b7b54214548dcad75a621f9d277b14d16a80d8a1/coverage-7.8.0-cp313-cp313-win32.whl", hash = "sha256:dd19608788b50eed889e13a5d71d832edc34fc9dfce606f66e8f9f917eef910d", size = 214195 },
+ { url = "https://files.pythonhosted.org/packages/12/12/6bf5f9a8b063d116bac536a7fb594fc35cb04981654cccb4bbfea5dcdfa0/coverage-7.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a9abbccd778d98e9c7e85038e35e91e67f5b520776781d9a1e2ee9d400869487", size = 214998 },
+ { url = "https://files.pythonhosted.org/packages/2a/e6/1e9df74ef7a1c983a9c7443dac8aac37a46f1939ae3499424622e72a6f78/coverage-7.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:18c5ae6d061ad5b3e7eef4363fb27a0576012a7447af48be6c75b88494c6cf25", size = 212541 },
+ { url = "https://files.pythonhosted.org/packages/04/51/c32174edb7ee49744e2e81c4b1414ac9df3dacfcb5b5f273b7f285ad43f6/coverage-7.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:95aa6ae391a22bbbce1b77ddac846c98c5473de0372ba5c463480043a07bff42", size = 212767 },
+ { url = "https://files.pythonhosted.org/packages/e9/8f/f454cbdb5212f13f29d4a7983db69169f1937e869a5142bce983ded52162/coverage-7.8.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e013b07ba1c748dacc2a80e69a46286ff145935f260eb8c72df7185bf048f502", size = 256997 },
+ { url = "https://files.pythonhosted.org/packages/e6/74/2bf9e78b321216d6ee90a81e5c22f912fc428442c830c4077b4a071db66f/coverage-7.8.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d766a4f0e5aa1ba056ec3496243150698dc0481902e2b8559314368717be82b1", size = 252708 },
+ { url = "https://files.pythonhosted.org/packages/92/4d/50d7eb1e9a6062bee6e2f92e78b0998848a972e9afad349b6cdde6fa9e32/coverage-7.8.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad80e6b4a0c3cb6f10f29ae4c60e991f424e6b14219d46f1e7d442b938ee68a4", size = 255046 },
+ { url = "https://files.pythonhosted.org/packages/40/9e/71fb4e7402a07c4198ab44fc564d09d7d0ffca46a9fb7b0a7b929e7641bd/coverage-7.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b87eb6fc9e1bb8f98892a2458781348fa37e6925f35bb6ceb9d4afd54ba36c73", size = 256139 },
+ { url = "https://files.pythonhosted.org/packages/49/1a/78d37f7a42b5beff027e807c2843185961fdae7fe23aad5a4837c93f9d25/coverage-7.8.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d1ba00ae33be84066cfbe7361d4e04dec78445b2b88bdb734d0d1cbab916025a", size = 254307 },
+ { url = "https://files.pythonhosted.org/packages/58/e9/8fb8e0ff6bef5e170ee19d59ca694f9001b2ec085dc99b4f65c128bb3f9a/coverage-7.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f3c38e4e5ccbdc9198aecc766cedbb134b2d89bf64533973678dfcf07effd883", size = 255116 },
+ { url = "https://files.pythonhosted.org/packages/56/b0/d968ecdbe6fe0a863de7169bbe9e8a476868959f3af24981f6a10d2b6924/coverage-7.8.0-cp313-cp313t-win32.whl", hash = "sha256:379fe315e206b14e21db5240f89dc0774bdd3e25c3c58c2c733c99eca96f1ada", size = 214909 },
+ { url = "https://files.pythonhosted.org/packages/87/e9/d6b7ef9fecf42dfb418d93544af47c940aa83056c49e6021a564aafbc91f/coverage-7.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2e4b6b87bb0c846a9315e3ab4be2d52fac905100565f4b92f02c445c8799e257", size = 216068 },
+ { url = "https://files.pythonhosted.org/packages/60/0c/5da94be095239814bf2730a28cffbc48d6df4304e044f80d39e1ae581997/coverage-7.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa260de59dfb143af06dcf30c2be0b200bed2a73737a8a59248fcb9fa601ef0f", size = 211377 },
+ { url = "https://files.pythonhosted.org/packages/d5/cb/b9e93ebf193a0bb89dbcd4f73d7b0e6ecb7c1b6c016671950e25f041835e/coverage-7.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96121edfa4c2dfdda409877ea8608dd01de816a4dc4a0523356067b305e4e17a", size = 211803 },
+ { url = "https://files.pythonhosted.org/packages/78/1a/cdbfe9e1bb14d3afcaf6bb6e1b9ba76c72666e329cd06865bbd241efd652/coverage-7.8.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b8af63b9afa1031c0ef05b217faa598f3069148eeee6bb24b79da9012423b82", size = 240561 },
+ { url = "https://files.pythonhosted.org/packages/59/04/57f1223f26ac018d7ce791bfa65b0c29282de3e041c1cd3ed430cfeac5a5/coverage-7.8.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89b1f4af0d4afe495cd4787a68e00f30f1d15939f550e869de90a86efa7e0814", size = 238488 },
+ { url = "https://files.pythonhosted.org/packages/b7/b1/0f25516ae2a35e265868670384feebe64e7857d9cffeeb3887b0197e2ba2/coverage-7.8.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94ec0be97723ae72d63d3aa41961a0b9a6f5a53ff599813c324548d18e3b9e8c", size = 239589 },
+ { url = "https://files.pythonhosted.org/packages/e0/a4/99d88baac0d1d5a46ceef2dd687aac08fffa8795e4c3e71b6f6c78e14482/coverage-7.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8a1d96e780bdb2d0cbb297325711701f7c0b6f89199a57f2049e90064c29f6bd", size = 239366 },
+ { url = "https://files.pythonhosted.org/packages/ea/9e/1db89e135feb827a868ed15f8fc857160757f9cab140ffee21342c783ceb/coverage-7.8.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f1d8a2a57b47142b10374902777e798784abf400a004b14f1b0b9eaf1e528ba4", size = 237591 },
+ { url = "https://files.pythonhosted.org/packages/1b/6d/ac4d6fdfd0e201bc82d1b08adfacb1e34b40d21a22cdd62cfaf3c1828566/coverage-7.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf60dd2696b457b710dd40bf17ad269d5f5457b96442f7f85722bdb16fa6c899", size = 238572 },
+ { url = "https://files.pythonhosted.org/packages/25/5e/917cbe617c230f7f1745b6a13e780a3a1cd1cf328dbcd0fd8d7ec52858cd/coverage-7.8.0-cp39-cp39-win32.whl", hash = "sha256:be945402e03de47ba1872cd5236395e0f4ad635526185a930735f66710e1bd3f", size = 213966 },
+ { url = "https://files.pythonhosted.org/packages/bd/93/72b434fe550135869f9ea88dd36068af19afce666db576e059e75177e813/coverage-7.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:90e7fbc6216ecaffa5a880cdc9c77b7418c1dcb166166b78dbc630d07f278cc3", size = 214852 },
+ { url = "https://files.pythonhosted.org/packages/c4/f1/1da77bb4c920aa30e82fa9b6ea065da3467977c2e5e032e38e66f1c57ffd/coverage-7.8.0-pp39.pp310.pp311-none-any.whl", hash = "sha256:b8194fb8e50d556d5849753de991d390c5a1edeeba50f68e3a9253fbd8bf8ccd", size = 203443 },
+ { url = "https://files.pythonhosted.org/packages/59/f1/4da7717f0063a222db253e7121bd6a56f6fb1ba439dcc36659088793347c/coverage-7.8.0-py3-none-any.whl", hash = "sha256:dbf364b4c5e7bae9250528167dfe40219b62e2d573c854d74be213e1e52069f7", size = 203435 },
+]
+
+[package.optional-dependencies]
+toml = [
+ { name = "tomli", marker = "python_full_version <= '3.11'" },
+]
+
+[[package]]
+name = "cryptography"
+version = "44.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cd/25/4ce80c78963834b8a9fd1cc1266be5ed8d1840785c0f2e1b73b8d128d505/cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0", size = 710807 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/30/ec/7ea7c1e4c8fc8329506b46c6c4a52e2f20318425d48e0fe597977c71dbce/cryptography-44.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29ecec49f3ba3f3849362854b7253a9f59799e3763b0c9d0826259a88efa02f1", size = 3952350 },
+ { url = "https://files.pythonhosted.org/packages/27/61/72e3afdb3c5ac510330feba4fc1faa0fe62e070592d6ad00c40bb69165e5/cryptography-44.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc821e161ae88bfe8088d11bb39caf2916562e0a2dc7b6d56714a48b784ef0bb", size = 4166572 },
+ { url = "https://files.pythonhosted.org/packages/26/e4/ba680f0b35ed4a07d87f9e98f3ebccb05091f3bf6b5a478b943253b3bbd5/cryptography-44.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3c00b6b757b32ce0f62c574b78b939afab9eecaf597c4d624caca4f9e71e7843", size = 3958124 },
+ { url = "https://files.pythonhosted.org/packages/9c/e8/44ae3e68c8b6d1cbc59040288056df2ad7f7f03bbcaca6b503c737ab8e73/cryptography-44.0.2-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7bdcd82189759aba3816d1f729ce42ffded1ac304c151d0a8e89b9996ab863d5", size = 3678122 },
+ { url = "https://files.pythonhosted.org/packages/27/7b/664ea5e0d1eab511a10e480baf1c5d3e681c7d91718f60e149cec09edf01/cryptography-44.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4973da6ca3db4405c54cd0b26d328be54c7747e89e284fcff166132eb7bccc9c", size = 4191831 },
+ { url = "https://files.pythonhosted.org/packages/2a/07/79554a9c40eb11345e1861f46f845fa71c9e25bf66d132e123d9feb8e7f9/cryptography-44.0.2-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4e389622b6927d8133f314949a9812972711a111d577a5d1f4bee5e58736b80a", size = 3960583 },
+ { url = "https://files.pythonhosted.org/packages/bb/6d/858e356a49a4f0b591bd6789d821427de18432212e137290b6d8a817e9bf/cryptography-44.0.2-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f514ef4cd14bb6fb484b4a60203e912cfcb64f2ab139e88c2274511514bf7308", size = 4191753 },
+ { url = "https://files.pythonhosted.org/packages/b2/80/62df41ba4916067fa6b125aa8c14d7e9181773f0d5d0bd4dcef580d8b7c6/cryptography-44.0.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1bc312dfb7a6e5d66082c87c34c8a62176e684b6fe3d90fcfe1568de675e6688", size = 4079550 },
+ { url = "https://files.pythonhosted.org/packages/f3/cd/2558cc08f7b1bb40683f99ff4327f8dcfc7de3affc669e9065e14824511b/cryptography-44.0.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b721b8b4d948b218c88cb8c45a01793483821e709afe5f622861fc6182b20a7", size = 4298367 },
+ { url = "https://files.pythonhosted.org/packages/06/88/638865be7198a84a7713950b1db7343391c6066a20e614f8fa286eb178ed/cryptography-44.0.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81276f0ea79a208d961c433a947029e1a15948966658cf6710bbabb60fcc2639", size = 3951919 },
+ { url = "https://files.pythonhosted.org/packages/d7/fc/99fe639bcdf58561dfad1faa8a7369d1dc13f20acd78371bb97a01613585/cryptography-44.0.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a1e657c0f4ea2a23304ee3f964db058c9e9e635cc7019c4aa21c330755ef6fd", size = 4167812 },
+ { url = "https://files.pythonhosted.org/packages/53/7b/aafe60210ec93d5d7f552592a28192e51d3c6b6be449e7fd0a91399b5d07/cryptography-44.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6210c05941994290f3f7f175a4a57dbbb2afd9273657614c506d5976db061181", size = 3958571 },
+ { url = "https://files.pythonhosted.org/packages/16/32/051f7ce79ad5a6ef5e26a92b37f172ee2d6e1cce09931646eef8de1e9827/cryptography-44.0.2-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1c3572526997b36f245a96a2b1713bf79ce99b271bbcf084beb6b9b075f29ea", size = 3679832 },
+ { url = "https://files.pythonhosted.org/packages/78/2b/999b2a1e1ba2206f2d3bca267d68f350beb2b048a41ea827e08ce7260098/cryptography-44.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b042d2a275c8cee83a4b7ae30c45a15e6a4baa65a179a0ec2d78ebb90e4f6699", size = 4193719 },
+ { url = "https://files.pythonhosted.org/packages/72/97/430e56e39a1356e8e8f10f723211a0e256e11895ef1a135f30d7d40f2540/cryptography-44.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d03806036b4f89e3b13b6218fefea8d5312e450935b1a2d55f0524e2ed7c59d9", size = 3960852 },
+ { url = "https://files.pythonhosted.org/packages/89/33/c1cf182c152e1d262cac56850939530c05ca6c8d149aa0dcee490b417e99/cryptography-44.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c7362add18b416b69d58c910caa217f980c5ef39b23a38a0880dfd87bdf8cd23", size = 4193906 },
+ { url = "https://files.pythonhosted.org/packages/e1/99/87cf26d4f125380dc674233971069bc28d19b07f7755b29861570e513650/cryptography-44.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8cadc6e3b5a1f144a039ea08a0bdb03a2a92e19c46be3285123d32029f40a922", size = 4081572 },
+ { url = "https://files.pythonhosted.org/packages/b3/9f/6a3e0391957cc0c5f84aef9fbdd763035f2b52e998a53f99345e3ac69312/cryptography-44.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f101b1f780f7fc613d040ca4bdf835c6ef3b00e9bd7125a4255ec574c7916e4", size = 4298631 },
+ { url = "https://files.pythonhosted.org/packages/2f/b4/424ea2d0fce08c24ede307cead3409ecbfc2f566725d4701b9754c0a1174/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:0529b1d5a0105dd3731fa65680b45ce49da4d8115ea76e9da77a875396727b41", size = 3892387 },
+ { url = "https://files.pythonhosted.org/packages/28/20/8eaa1a4f7c68a1cb15019dbaad59c812d4df4fac6fd5f7b0b9c5177f1edd/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7ca25849404be2f8e4b3c59483d9d3c51298a22c1c61a0e84415104dacaf5562", size = 4109922 },
+ { url = "https://files.pythonhosted.org/packages/11/25/5ed9a17d532c32b3bc81cc294d21a36c772d053981c22bd678396bc4ae30/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:268e4e9b177c76d569e8a145a6939eca9a5fec658c932348598818acf31ae9a5", size = 3895715 },
+ { url = "https://files.pythonhosted.org/packages/63/31/2aac03b19c6329b62c45ba4e091f9de0b8f687e1b0cd84f101401bece343/cryptography-44.0.2-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9eb9d22b0a5d8fd9925a7764a054dca914000607dff201a24c791ff5c799e1fa", size = 4109876 },
+ { url = "https://files.pythonhosted.org/packages/d6/d7/f30e75a6aa7d0f65031886fa4a1485c2fbfe25a1896953920f6a9cfe2d3b/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:909c97ab43a9c0c0b0ada7a1281430e4e5ec0458e6d9244c0e821bbf152f061d", size = 3887513 },
+ { url = "https://files.pythonhosted.org/packages/9c/b4/7a494ce1032323ca9db9a3661894c66e0d7142ad2079a4249303402d8c71/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:96e7a5e9d6e71f9f4fca8eebfd603f8e86c5225bb18eb621b2c1e50b290a9471", size = 4107432 },
+ { url = "https://files.pythonhosted.org/packages/45/f8/6b3ec0bc56123b344a8d2b3264a325646d2dcdbdd9848b5e6f3d37db90b3/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d1b3031093a366ac767b3feb8bcddb596671b3aaff82d4050f984da0c248b615", size = 3891421 },
+ { url = "https://files.pythonhosted.org/packages/57/ff/f3b4b2d007c2a646b0f69440ab06224f9cf37a977a72cdb7b50632174e8a/cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390", size = 4107081 },
+]
+
+[[package]]
+name = "distlib"
+version = "0.3.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 },
+]
+
+[[package]]
+name = "docutils"
+version = "0.21.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/ed/aefcc8cd0ba62a0560c3c18c33925362d46c6075480bfa4df87b28e169a9/docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f", size = 2204444 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2", size = 587408 },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
+]
+
+[[package]]
+name = "filelock"
+version = "3.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 },
+]
+
+[[package]]
+name = "glassflow"
+source = { editable = "." }
+dependencies = [
+ { name = "httpx" },
+ { name = "mixpanel" },
+ { name = "pydantic" },
+ { name = "python-dotenv" },
+ { name = "pyyaml" },
+ { name = "requests" },
+]
+
+[package.optional-dependencies]
+build = [
+ { name = "build" },
+ { name = "hatch" },
+ { name = "twine" },
+]
+test = [
+ { name = "pytest" },
+ { name = "pytest-cov" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "build", marker = "extra == 'build'", specifier = ">=1.0.0" },
+ { name = "hatch", marker = "extra == 'build'", specifier = ">=1.0.0" },
+ { name = "httpx", specifier = ">=0.26.0" },
+ { name = "mixpanel", specifier = ">=4.10.0" },
+ { name = "pydantic", specifier = ">=2.0.0" },
+ { name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" },
+ { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.0.0" },
+ { name = "python-dotenv", specifier = ">=1.0.0" },
+ { name = "pyyaml", specifier = ">=6.0.2" },
+ { name = "requests", specifier = ">=2.31.0" },
+ { name = "ruff", marker = "extra == 'test'", specifier = ">=0.1.0" },
+ { name = "twine", marker = "extra == 'build'", specifier = ">=4.0.0" },
+]
+
+[[package]]
+name = "h11"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
+]
+
+[[package]]
+name = "hatch"
+version = "1.14.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "hatchling" },
+ { name = "httpx" },
+ { name = "hyperlink" },
+ { name = "keyring" },
+ { name = "packaging" },
+ { name = "pexpect" },
+ { name = "platformdirs" },
+ { name = "rich" },
+ { name = "shellingham" },
+ { name = "tomli-w" },
+ { name = "tomlkit" },
+ { name = "userpath" },
+ { name = "uv" },
+ { name = "virtualenv" },
+ { name = "zstandard" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1f/43/c0b37db0e857a44ce5ffdb7e8a9b8fa6425d0b74dea698fafcd9bddb50d1/hatch-1.14.1.tar.gz", hash = "sha256:ca1aff788f8596b0dd1f8f8dfe776443d2724a86b1976fabaf087406ba3d0713", size = 5188180 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a5/40/19c0935bf9f25808541a0e3144ac459de696c5b6b6d4511a98d456c69604/hatch-1.14.1-py3-none-any.whl", hash = "sha256:39cdaa59e47ce0c5505d88a951f4324a9c5aafa17e4a877e2fde79b36ab66c21", size = 125770 },
+]
+
+[[package]]
+name = "hatchling"
+version = "1.27.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "pluggy" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+ { name = "trove-classifiers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8f/8a/cc1debe3514da292094f1c3a700e4ca25442489731ef7c0814358816bb03/hatchling-1.27.0.tar.gz", hash = "sha256:971c296d9819abb3811112fc52c7a9751c8d381898f36533bb16f9791e941fd6", size = 54983 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/e7/ae38d7a6dfba0533684e0b2136817d667588ae3ec984c1a4e5df5eb88482/hatchling-1.27.0-py3-none-any.whl", hash = "sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b", size = 75794 },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
+]
+
+[[package]]
+name = "hyperlink"
+version = "21.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/51/1947bd81d75af87e3bb9e34593a4cf118115a8feb451ce7a69044ef1412e/hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b", size = 140743 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/aa/8caf6a0a3e62863cbb9dab27135660acba46903b703e224f14f447e57934/hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4", size = 74638 },
+]
+
+[[package]]
+name = "id"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611 },
+]
+
+[[package]]
+name = "idna"
+version = "3.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
+]
+
+[[package]]
+name = "importlib-metadata"
+version = "8.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "zipp" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/33/08/c1395a292bb23fd03bdf572a1357c5a733d3eecbab877641ceacab23db6e/importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580", size = 55767 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/9d/0fb148dc4d6fa4a7dd1d8378168d9b4cd8d4560a6fbf6f0121c5fc34eb68/importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e", size = 26971 },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 },
+]
+
+[[package]]
+name = "jaraco-context"
+version = "6.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "backports-tarfile", marker = "python_full_version < '3.12'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825 },
+]
+
+[[package]]
+name = "jaraco-functools"
+version = "4.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ab/23/9894b3df5d0a6eb44611c36aec777823fc2e07740dabbd0b810e19594013/jaraco_functools-4.1.0.tar.gz", hash = "sha256:70f7e0e2ae076498e212562325e805204fc092d7b4c17e0e86c959e249701a9d", size = 19159 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9f/4f/24b319316142c44283d7540e76c7b5a6dbd5db623abd86bb7b3491c21018/jaraco.functools-4.1.0-py3-none-any.whl", hash = "sha256:ad159f13428bc4acbf5541ad6dec511f91573b90fba04df61dafa2a1231cf649", size = 10187 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 },
+]
+
+[[package]]
+name = "keyring"
+version = "25.6.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/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d3/32/da7f44bcb1105d3e88a0b74ebdca50c59121d2ddf71c9e34ba47df7f3a56/keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd", size = 39085 },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "3.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
+]
+
+[[package]]
+name = "mixpanel"
+version = "4.10.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+ { name = "six" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bd/a3/9d71562db2107da31be6a988cac88cd1be11364d103b618a98ba92d2487b/mixpanel-4.10.1.tar.gz", hash = "sha256:29a6b5773dd34f05cf8e249f4e1d16e7b6280d6b58894551ce9a5aad7700a115", size = 9831 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/5a/8048544f73e22ebd27bdeca64ce51578578873e5da9ba3d3f99d692f9034/mixpanel-4.10.1-py2.py3-none-any.whl", hash = "sha256:a7a338b7197327e36356dbc1903086e7626db6d88367ccdd732b3f3c60d3b3ed", size = 8954 },
+]
+
+[[package]]
+name = "more-itertools"
+version = "10.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/88/3b/7fa1fe835e2e93fd6d7b52b2f95ae810cf5ba133e1845f726f5a992d62c2/more-itertools-10.6.0.tar.gz", hash = "sha256:2cd7fad1009c31cc9fb6a035108509e6547547a7a738374f10bd49a09eb3ee3b", size = 125009 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/23/62/0fe302c6d1be1c777cab0616e6302478251dfbf9055ad426f5d0def75c89/more_itertools-10.6.0-py3-none-any.whl", hash = "sha256:6eb054cb4b6db1473f6e15fcc676a08e4732548acd47c708f0e179c2c7c01e89", size = 63038 },
+]
+
+[[package]]
+name = "nh3"
+version = "0.2.21"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/37/30/2f81466f250eb7f591d4d193930df661c8c23e9056bdc78e365b646054d8/nh3-0.2.21.tar.gz", hash = "sha256:4990e7ee6a55490dbf00d61a6f476c9a3258e31e711e13713b2ea7d6616f670e", size = 16581 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/81/b83775687fcf00e08ade6d4605f0be9c4584cb44c4973d9f27b7456a31c9/nh3-0.2.21-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fcff321bd60c6c5c9cb4ddf2554e22772bb41ebd93ad88171bbbb6f271255286", size = 1297678 },
+ { url = "https://files.pythonhosted.org/packages/22/ee/d0ad8fb4b5769f073b2df6807f69a5e57ca9cea504b78809921aef460d20/nh3-0.2.21-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31eedcd7d08b0eae28ba47f43fd33a653b4cdb271d64f1aeda47001618348fde", size = 733774 },
+ { url = "https://files.pythonhosted.org/packages/ea/76/b450141e2d384ede43fe53953552f1c6741a499a8c20955ad049555cabc8/nh3-0.2.21-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d426d7be1a2f3d896950fe263332ed1662f6c78525b4520c8e9861f8d7f0d243", size = 760012 },
+ { url = "https://files.pythonhosted.org/packages/97/90/1182275db76cd8fbb1f6bf84c770107fafee0cb7da3e66e416bcb9633da2/nh3-0.2.21-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9d67709bc0d7d1f5797b21db26e7a8b3d15d21c9c5f58ccfe48b5328483b685b", size = 923619 },
+ { url = "https://files.pythonhosted.org/packages/29/c7/269a7cfbec9693fad8d767c34a755c25ccb8d048fc1dfc7a7d86bc99375c/nh3-0.2.21-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:55823c5ea1f6b267a4fad5de39bc0524d49a47783e1fe094bcf9c537a37df251", size = 1000384 },
+ { url = "https://files.pythonhosted.org/packages/68/a9/48479dbf5f49ad93f0badd73fbb48b3d769189f04c6c69b0df261978b009/nh3-0.2.21-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:818f2b6df3763e058efa9e69677b5a92f9bc0acff3295af5ed013da544250d5b", size = 918908 },
+ { url = "https://files.pythonhosted.org/packages/d7/da/0279c118f8be2dc306e56819880b19a1cf2379472e3b79fc8eab44e267e3/nh3-0.2.21-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b3b5c58161e08549904ac4abd450dacd94ff648916f7c376ae4b2c0652b98ff9", size = 909180 },
+ { url = "https://files.pythonhosted.org/packages/26/16/93309693f8abcb1088ae143a9c8dbcece9c8f7fb297d492d3918340c41f1/nh3-0.2.21-cp313-cp313t-win32.whl", hash = "sha256:637d4a10c834e1b7d9548592c7aad760611415fcd5bd346f77fd8a064309ae6d", size = 532747 },
+ { url = "https://files.pythonhosted.org/packages/a2/3a/96eb26c56cbb733c0b4a6a907fab8408ddf3ead5d1b065830a8f6a9c3557/nh3-0.2.21-cp313-cp313t-win_amd64.whl", hash = "sha256:713d16686596e556b65e7f8c58328c2df63f1a7abe1277d87625dcbbc012ef82", size = 528908 },
+ { url = "https://files.pythonhosted.org/packages/ba/1d/b1ef74121fe325a69601270f276021908392081f4953d50b03cbb38b395f/nh3-0.2.21-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a772dec5b7b7325780922dd904709f0f5f3a79fbf756de5291c01370f6df0967", size = 1316133 },
+ { url = "https://files.pythonhosted.org/packages/b8/f2/2c7f79ce6de55b41e7715f7f59b159fd59f6cdb66223c05b42adaee2b645/nh3-0.2.21-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d002b648592bf3033adfd875a48f09b8ecc000abd7f6a8769ed86b6ccc70c759", size = 758328 },
+ { url = "https://files.pythonhosted.org/packages/6d/ad/07bd706fcf2b7979c51b83d8b8def28f413b090cf0cb0035ee6b425e9de5/nh3-0.2.21-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2a5174551f95f2836f2ad6a8074560f261cf9740a48437d6151fd2d4d7d617ab", size = 747020 },
+ { url = "https://files.pythonhosted.org/packages/75/99/06a6ba0b8a0d79c3d35496f19accc58199a1fb2dce5e711a31be7e2c1426/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b8d55ea1fc7ae3633d758a92aafa3505cd3cc5a6e40470c9164d54dff6f96d42", size = 944878 },
+ { url = "https://files.pythonhosted.org/packages/79/d4/dc76f5dc50018cdaf161d436449181557373869aacf38a826885192fc587/nh3-0.2.21-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ae319f17cd8960d0612f0f0ddff5a90700fa71926ca800e9028e7851ce44a6f", size = 903460 },
+ { url = "https://files.pythonhosted.org/packages/cd/c3/d4f8037b2ab02ebf5a2e8637bd54736ed3d0e6a2869e10341f8d9085f00e/nh3-0.2.21-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ca02ac6f27fc80f9894409eb61de2cb20ef0a23740c7e29f9ec827139fa578", size = 839369 },
+ { url = "https://files.pythonhosted.org/packages/11/a9/1cd3c6964ec51daed7b01ca4686a5c793581bf4492cbd7274b3f544c9abe/nh3-0.2.21-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f77e62aed5c4acad635239ac1290404c7e940c81abe561fd2af011ff59f585", size = 739036 },
+ { url = "https://files.pythonhosted.org/packages/fd/04/bfb3ff08d17a8a96325010ae6c53ba41de6248e63cdb1b88ef6369a6cdfc/nh3-0.2.21-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:087ffadfdcd497658c3adc797258ce0f06be8a537786a7217649fc1c0c60c293", size = 768712 },
+ { url = "https://files.pythonhosted.org/packages/9e/aa/cfc0bf545d668b97d9adea4f8b4598667d2b21b725d83396c343ad12bba7/nh3-0.2.21-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ac7006c3abd097790e611fe4646ecb19a8d7f2184b882f6093293b8d9b887431", size = 930559 },
+ { url = "https://files.pythonhosted.org/packages/78/9d/6f5369a801d3a1b02e6a9a097d56bcc2f6ef98cffebf03c4bb3850d8e0f0/nh3-0.2.21-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:6141caabe00bbddc869665b35fc56a478eb774a8c1dfd6fba9fe1dfdf29e6efa", size = 1008591 },
+ { url = "https://files.pythonhosted.org/packages/a6/df/01b05299f68c69e480edff608248313cbb5dbd7595c5e048abe8972a57f9/nh3-0.2.21-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:20979783526641c81d2f5bfa6ca5ccca3d1e4472474b162c6256745fbfe31cd1", size = 925670 },
+ { url = "https://files.pythonhosted.org/packages/3d/79/bdba276f58d15386a3387fe8d54e980fb47557c915f5448d8c6ac6f7ea9b/nh3-0.2.21-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a7ea28cd49293749d67e4fcf326c554c83ec912cd09cd94aa7ec3ab1921c8283", size = 917093 },
+ { url = "https://files.pythonhosted.org/packages/e7/d8/c6f977a5cd4011c914fb58f5ae573b071d736187ccab31bfb1d539f4af9f/nh3-0.2.21-cp38-abi3-win32.whl", hash = "sha256:6c9c30b8b0d291a7c5ab0967ab200598ba33208f754f2f4920e9343bdd88f79a", size = 537623 },
+ { url = "https://files.pythonhosted.org/packages/23/fc/8ce756c032c70ae3dd1d48a3552577a325475af2a2f629604b44f571165c/nh3-0.2.21-cp38-abi3-win_amd64.whl", hash = "sha256:bb0014948f04d7976aabae43fcd4cb7f551f9f8ce785a4c9ef66e6c2590f8629", size = 535283 },
+]
+
+[[package]]
+name = "packaging"
+version = "24.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
+]
+
+[[package]]
+name = "pathspec"
+version = "0.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 },
+]
+
+[[package]]
+name = "pexpect"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ptyprocess" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.3.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
+]
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 },
+]
+
+[[package]]
+name = "pycparser"
+version = "2.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.11.2"
+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/b0/41/832125a41fe098b58d1fdd04ae819b4dc6b34d6b09ed78304fd93d4bc051/pydantic-2.11.2.tar.gz", hash = "sha256:2138628e050bd7a1e70b91d4bf4a91167f4ad76fdb83209b107c8d84b854917e", size = 784742 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bf/c2/0f3baea344d0b15e35cb3e04ad5b953fa05106b76efbf4c782a3f47f22f5/pydantic-2.11.2-py3-none-any.whl", hash = "sha256:7f17d25846bcdf89b670a86cdfe7b29a9f1c9ca23dee154221c9aa81845cfca7", size = 443295 },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.33.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/ea/5f572806ab4d4223d11551af814d243b0e3e02cc6913def4d1fe4a5ca41c/pydantic_core-2.33.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3077cfdb6125cc8dab61b155fdd714663e401f0e6883f9632118ec12cf42df26", size = 2044021 },
+ { url = "https://files.pythonhosted.org/packages/8c/d1/f86cc96d2aa80e3881140d16d12ef2b491223f90b28b9a911346c04ac359/pydantic_core-2.33.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ffab8b2908d152e74862d276cf5017c81a2f3719f14e8e3e8d6b83fda863927", size = 1861742 },
+ { url = "https://files.pythonhosted.org/packages/37/08/fbd2cd1e9fc735a0df0142fac41c114ad9602d1c004aea340169ae90973b/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5183e4f6a2d468787243ebcd70cf4098c247e60d73fb7d68d5bc1e1beaa0c4db", size = 1910414 },
+ { url = "https://files.pythonhosted.org/packages/7f/73/3ac217751decbf8d6cb9443cec9b9eb0130eeada6ae56403e11b486e277e/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:398a38d323f37714023be1e0285765f0a27243a8b1506b7b7de87b647b517e48", size = 1996848 },
+ { url = "https://files.pythonhosted.org/packages/9a/f5/5c26b265cdcff2661e2520d2d1e9db72d117ea00eb41e00a76efe68cb009/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87d3776f0001b43acebfa86f8c64019c043b55cc5a6a2e313d728b5c95b46969", size = 2141055 },
+ { url = "https://files.pythonhosted.org/packages/5d/14/a9c3cee817ef2f8347c5ce0713e91867a0dceceefcb2973942855c917379/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c566dd9c5f63d22226409553531f89de0cac55397f2ab8d97d6f06cfce6d947e", size = 2753806 },
+ { url = "https://files.pythonhosted.org/packages/f2/68/866ce83a51dd37e7c604ce0050ff6ad26de65a7799df89f4db87dd93d1d6/pydantic_core-2.33.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d5f3acc81452c56895e90643a625302bd6be351e7010664151cc55b7b97f89", size = 2007777 },
+ { url = "https://files.pythonhosted.org/packages/b6/a8/36771f4404bb3e49bd6d4344da4dede0bf89cc1e01f3b723c47248a3761c/pydantic_core-2.33.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3a07fadec2a13274a8d861d3d37c61e97a816beae717efccaa4b36dfcaadcde", size = 2122803 },
+ { url = "https://files.pythonhosted.org/packages/18/9c/730a09b2694aa89360d20756369822d98dc2f31b717c21df33b64ffd1f50/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f99aeda58dce827f76963ee87a0ebe75e648c72ff9ba1174a253f6744f518f65", size = 2086755 },
+ { url = "https://files.pythonhosted.org/packages/54/8e/2dccd89602b5ec31d1c58138d02340ecb2ebb8c2cac3cc66b65ce3edb6ce/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:902dbc832141aa0ec374f4310f1e4e7febeebc3256f00dc359a9ac3f264a45dc", size = 2257358 },
+ { url = "https://files.pythonhosted.org/packages/d1/9c/126e4ac1bfad8a95a9837acdd0963695d69264179ba4ede8b8c40d741702/pydantic_core-2.33.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fe44d56aa0b00d66640aa84a3cbe80b7a3ccdc6f0b1ca71090696a6d4777c091", size = 2257916 },
+ { url = "https://files.pythonhosted.org/packages/7d/ba/91eea2047e681a6853c81c20aeca9dcdaa5402ccb7404a2097c2adf9d038/pydantic_core-2.33.1-cp310-cp310-win32.whl", hash = "sha256:ed3eb16d51257c763539bde21e011092f127a2202692afaeaccb50db55a31383", size = 1923823 },
+ { url = "https://files.pythonhosted.org/packages/94/c0/fcdf739bf60d836a38811476f6ecd50374880b01e3014318b6e809ddfd52/pydantic_core-2.33.1-cp310-cp310-win_amd64.whl", hash = "sha256:694ad99a7f6718c1a498dc170ca430687a39894a60327f548e02a9c7ee4b6504", size = 1952494 },
+ { url = "https://files.pythonhosted.org/packages/d6/7f/c6298830cb780c46b4f46bb24298d01019ffa4d21769f39b908cd14bbd50/pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24", size = 2044224 },
+ { url = "https://files.pythonhosted.org/packages/a8/65/6ab3a536776cad5343f625245bd38165d6663256ad43f3a200e5936afd6c/pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30", size = 1858845 },
+ { url = "https://files.pythonhosted.org/packages/e9/15/9a22fd26ba5ee8c669d4b8c9c244238e940cd5d818649603ca81d1c69861/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595", size = 1910029 },
+ { url = "https://files.pythonhosted.org/packages/d5/33/8cb1a62818974045086f55f604044bf35b9342900318f9a2a029a1bec460/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e", size = 1997784 },
+ { url = "https://files.pythonhosted.org/packages/c0/ca/49958e4df7715c71773e1ea5be1c74544923d10319173264e6db122543f9/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a", size = 2141075 },
+ { url = "https://files.pythonhosted.org/packages/7b/a6/0b3a167a9773c79ba834b959b4e18c3ae9216b8319bd8422792abc8a41b1/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505", size = 2745849 },
+ { url = "https://files.pythonhosted.org/packages/0b/60/516484135173aa9e5861d7a0663dce82e4746d2e7f803627d8c25dfa5578/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f", size = 2005794 },
+ { url = "https://files.pythonhosted.org/packages/86/70/05b1eb77459ad47de00cf78ee003016da0cedf8b9170260488d7c21e9181/pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77", size = 2123237 },
+ { url = "https://files.pythonhosted.org/packages/c7/57/12667a1409c04ae7dc95d3b43158948eb0368e9c790be8b095cb60611459/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961", size = 2086351 },
+ { url = "https://files.pythonhosted.org/packages/57/61/cc6d1d1c1664b58fdd6ecc64c84366c34ec9b606aeb66cafab6f4088974c/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1", size = 2258914 },
+ { url = "https://files.pythonhosted.org/packages/d1/0a/edb137176a1f5419b2ddee8bde6a0a548cfa3c74f657f63e56232df8de88/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c", size = 2257385 },
+ { url = "https://files.pythonhosted.org/packages/26/3c/48ca982d50e4b0e1d9954919c887bdc1c2b462801bf408613ccc641b3daa/pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896", size = 1923765 },
+ { url = "https://files.pythonhosted.org/packages/33/cd/7ab70b99e5e21559f5de38a0928ea84e6f23fdef2b0d16a6feaf942b003c/pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83", size = 1950688 },
+ { url = "https://files.pythonhosted.org/packages/4b/ae/db1fc237b82e2cacd379f63e3335748ab88b5adde98bf7544a1b1bd10a84/pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89", size = 1908185 },
+ { url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640 },
+ { url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649 },
+ { url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472 },
+ { url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509 },
+ { url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702 },
+ { url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428 },
+ { url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753 },
+ { url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849 },
+ { url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541 },
+ { url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225 },
+ { url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373 },
+ { url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034 },
+ { url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848 },
+ { url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986 },
+ { url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551 },
+ { url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785 },
+ { url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758 },
+ { url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109 },
+ { url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159 },
+ { url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222 },
+ { url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980 },
+ { url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840 },
+ { url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518 },
+ { url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025 },
+ { url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991 },
+ { url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262 },
+ { url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626 },
+ { url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590 },
+ { url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963 },
+ { url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896 },
+ { url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810 },
+ { url = "https://files.pythonhosted.org/packages/49/78/b86bad645cc3e8dfa6858c70ec38939bf350e54004837c48de09474b2b9e/pydantic_core-2.33.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5ab77f45d33d264de66e1884fca158bc920cb5e27fd0764a72f72f5756ae8bdb", size = 2044282 },
+ { url = "https://files.pythonhosted.org/packages/3b/00/a02531331773b2bf08743d84c6b776bd6a449d23b3ae6b0e3229d568bac4/pydantic_core-2.33.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7aaba1b4b03aaea7bb59e1b5856d734be011d3e6d98f5bcaa98cb30f375f2ad", size = 1877598 },
+ { url = "https://files.pythonhosted.org/packages/a1/fa/32cc152b84a1f420f8a7d80161373e8d87d4ffa077e67d6c8aab3ce1a6ab/pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fb66263e9ba8fea2aa85e1e5578980d127fb37d7f2e292773e7bc3a38fb0c7b", size = 1911021 },
+ { url = "https://files.pythonhosted.org/packages/5e/87/ea553e0d98bce6c4876f8c50f65cb45597eff6e0aaa8b15813e9972bb19d/pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3f2648b9262607a7fb41d782cc263b48032ff7a03a835581abbf7a3bec62bcf5", size = 1997276 },
+ { url = "https://files.pythonhosted.org/packages/f7/9b/60cb9f4b52158b3adac0066492bbadd0b8473f4f8da5bcc73972655b76ef/pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:723c5630c4259400818b4ad096735a829074601805d07f8cafc366d95786d331", size = 2141348 },
+ { url = "https://files.pythonhosted.org/packages/9b/38/374d254e270d4de0add68a8239f4ed0f444fdd7b766ea69244fb9491dccb/pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d100e3ae783d2167782391e0c1c7a20a31f55f8015f3293647544df3f9c67824", size = 2753708 },
+ { url = "https://files.pythonhosted.org/packages/05/a8/fd79111eb5ab9bc4ef98d8fb0b3a2ffdc80107b2c59859a741ab379c96f8/pydantic_core-2.33.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177d50460bc976a0369920b6c744d927b0ecb8606fb56858ff542560251b19e5", size = 2008699 },
+ { url = "https://files.pythonhosted.org/packages/35/31/2e06619868eb4c18642c5601db420599c1cf9cf50fe868c9ac09cd298e24/pydantic_core-2.33.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3edde68d1a1f9af1273b2fe798997b33f90308fb6d44d8550c89fc6a3647cf6", size = 2123426 },
+ { url = "https://files.pythonhosted.org/packages/4a/d0/3531e8783a311802e3db7ee5a1a5ed79e5706e930b1b4e3109ce15eeb681/pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a62c3c3ef6a7e2c45f7853b10b5bc4ddefd6ee3cd31024754a1a5842da7d598d", size = 2087330 },
+ { url = "https://files.pythonhosted.org/packages/ac/32/5ff252ed73bacd7677a706ab17723e261a76793f98b305aa20cfc10bbd56/pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:c91dbb0ab683fa0cd64a6e81907c8ff41d6497c346890e26b23de7ee55353f96", size = 2258171 },
+ { url = "https://files.pythonhosted.org/packages/c9/f9/e96e00f92b8f5b3e2cddc80c5ee6cf038f8a0f238c44b67b01759943a7b4/pydantic_core-2.33.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f466e8bf0a62dc43e068c12166281c2eca72121dd2adc1040f3aa1e21ef8599", size = 2258745 },
+ { url = "https://files.pythonhosted.org/packages/54/1e/51c86688e809d94797fdf0efc41514f001caec982a05f62d90c180a9639d/pydantic_core-2.33.1-cp39-cp39-win32.whl", hash = "sha256:ab0277cedb698749caada82e5d099dc9fed3f906a30d4c382d1a21725777a1e5", size = 1923626 },
+ { url = "https://files.pythonhosted.org/packages/57/18/c2da959fd8d019b70cadafdda2bf845378ada47973e0bad6cc84f56dbe6e/pydantic_core-2.33.1-cp39-cp39-win_amd64.whl", hash = "sha256:5773da0ee2d17136b1f1c6fbde543398d452a6ad2a7b54ea1033e2daa739b8d2", size = 1953703 },
+ { url = "https://files.pythonhosted.org/packages/9c/c7/8b311d5adb0fe00a93ee9b4e92a02b0ec08510e9838885ef781ccbb20604/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c834f54f8f4640fd7e4b193f80eb25a0602bba9e19b3cd2fc7ffe8199f5ae02", size = 2041659 },
+ { url = "https://files.pythonhosted.org/packages/8a/d6/4f58d32066a9e26530daaf9adc6664b01875ae0691570094968aaa7b8fcc/pydantic_core-2.33.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:049e0de24cf23766f12cc5cc71d8abc07d4a9deb9061b334b62093dedc7cb068", size = 1873294 },
+ { url = "https://files.pythonhosted.org/packages/f7/3f/53cc9c45d9229da427909c751f8ed2bf422414f7664ea4dde2d004f596ba/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a28239037b3d6f16916a4c831a5a0eadf856bdd6d2e92c10a0da3a59eadcf3e", size = 1903771 },
+ { url = "https://files.pythonhosted.org/packages/f0/49/bf0783279ce674eb9903fb9ae43f6c614cb2f1c4951370258823f795368b/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d3da303ab5f378a268fa7d45f37d7d85c3ec19769f28d2cc0c61826a8de21fe", size = 2083558 },
+ { url = "https://files.pythonhosted.org/packages/9c/5b/0d998367687f986c7d8484a2c476d30f07bf5b8b1477649a6092bd4c540e/pydantic_core-2.33.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:25626fb37b3c543818c14821afe0fd3830bc327a43953bc88db924b68c5723f1", size = 2118038 },
+ { url = "https://files.pythonhosted.org/packages/b3/33/039287d410230ee125daee57373ac01940d3030d18dba1c29cd3089dc3ca/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3ab2d36e20fbfcce8f02d73c33a8a7362980cff717926bbae030b93ae46b56c7", size = 2079315 },
+ { url = "https://files.pythonhosted.org/packages/1f/85/6d8b2646d99c062d7da2d0ab2faeb0d6ca9cca4c02da6076376042a20da3/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2f9284e11c751b003fd4215ad92d325d92c9cb19ee6729ebd87e3250072cdcde", size = 2249063 },
+ { url = "https://files.pythonhosted.org/packages/17/d7/c37d208d5738f7b9ad8f22ae8a727d88ebf9c16c04ed2475122cc3f7224a/pydantic_core-2.33.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:048c01eee07d37cbd066fc512b9d8b5ea88ceeb4e629ab94b3e56965ad655add", size = 2254631 },
+ { url = "https://files.pythonhosted.org/packages/13/e0/bafa46476d328e4553b85ab9b2f7409e7aaef0ce4c937c894821c542d347/pydantic_core-2.33.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5ccd429694cf26af7997595d627dd2637e7932214486f55b8a357edaac9dae8c", size = 2080877 },
+ { url = "https://files.pythonhosted.org/packages/0b/76/1794e440c1801ed35415238d2c728f26cd12695df9057154ad768b7b991c/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a", size = 2042858 },
+ { url = "https://files.pythonhosted.org/packages/73/b4/9cd7b081fb0b1b4f8150507cd59d27b275c3e22ad60b35cb19ea0977d9b9/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc", size = 1873745 },
+ { url = "https://files.pythonhosted.org/packages/e1/d7/9ddb7575d4321e40d0363903c2576c8c0c3280ebea137777e5ab58d723e3/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b", size = 1904188 },
+ { url = "https://files.pythonhosted.org/packages/d1/a8/3194ccfe461bb08da19377ebec8cb4f13c9bd82e13baebc53c5c7c39a029/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe", size = 2083479 },
+ { url = "https://files.pythonhosted.org/packages/42/c7/84cb569555d7179ca0b3f838cef08f66f7089b54432f5b8599aac6e9533e/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5", size = 2118415 },
+ { url = "https://files.pythonhosted.org/packages/3b/67/72abb8c73e0837716afbb58a59cc9e3ae43d1aa8677f3b4bc72c16142716/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761", size = 2079623 },
+ { url = "https://files.pythonhosted.org/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", size = 2250175 },
+ { url = "https://files.pythonhosted.org/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", size = 2254674 },
+ { url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951 },
+ { url = "https://files.pythonhosted.org/packages/2d/a8/c2c8f29bd18f7ef52de32a6deb9e3ee87ba18b7b2122636aa9f4438cf627/pydantic_core-2.33.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7edbc454a29fc6aeae1e1eecba4f07b63b8d76e76a748532233c4c167b4cb9ea", size = 2041791 },
+ { url = "https://files.pythonhosted.org/packages/08/ad/328081b1c82543ae49d0650048305058583c51f1a9a56a0d6e87bb3a2443/pydantic_core-2.33.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad05b683963f69a1d5d2c2bdab1274a31221ca737dbbceaa32bcb67359453cdd", size = 1873579 },
+ { url = "https://files.pythonhosted.org/packages/6e/8a/bc65dbf7e501e88367cdab06a2c1340457c785f0c72288cae737fd80c0fa/pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df6a94bf9452c6da9b5d76ed229a5683d0306ccb91cca8e1eea883189780d568", size = 1904189 },
+ { url = "https://files.pythonhosted.org/packages/9a/db/30ca6aefda211fb01ef185ca73cb7a0c6e7fe952c524025c8782b5acd771/pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7965c13b3967909a09ecc91f21d09cfc4576bf78140b988904e94f130f188396", size = 2084446 },
+ { url = "https://files.pythonhosted.org/packages/f2/89/a12b55286e30c9f476eab7c53c9249ec76faf70430596496ab0309f28629/pydantic_core-2.33.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3f1fdb790440a34f6ecf7679e1863b825cb5ffde858a9197f851168ed08371e5", size = 2118215 },
+ { url = "https://files.pythonhosted.org/packages/8e/55/12721c4a8d7951584ad3d9848b44442559cf1876e0bb424148d1060636b3/pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:5277aec8d879f8d05168fdd17ae811dd313b8ff894aeeaf7cd34ad28b4d77e33", size = 2079963 },
+ { url = "https://files.pythonhosted.org/packages/bd/0c/3391bd5d6ff62ea998db94732528d9bc32c560b0ed861c39119759461946/pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8ab581d3530611897d863d1a649fb0644b860286b4718db919bfd51ece41f10b", size = 2249388 },
+ { url = "https://files.pythonhosted.org/packages/d3/5f/3e4feb042998d7886a9b523b372d83955cbc192a07013dcd24276db078ee/pydantic_core-2.33.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0483847fa9ad5e3412265c1bd72aad35235512d9ce9d27d81a56d935ef489672", size = 2255226 },
+ { url = "https://files.pythonhosted.org/packages/25/f2/1647933efaaad61846109a27619f3704929e758a09e6431b8f932a053d40/pydantic_core-2.33.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:de9e06abe3cc5ec6a2d5f75bc99b0bdca4f5c719a5b34026f8c57efbdecd2ee3", size = 2081073 },
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
+]
+
+[[package]]
+name = "pyproject-hooks"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216 },
+]
+
+[[package]]
+name = "pytest"
+version = "8.3.5"
+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 = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "6.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage", extra = ["toml"] },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/25/69/5f1e57f6c5a39f81411b550027bf72842c4567ff5fd572bed1edc9e4b5d9/pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a", size = 66857 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/28/d0/def53b4a790cfb21483016430ed828f64830dd981ebe1089971cd10cab25/pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde", size = 23841 },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 },
+ { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 },
+ { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 },
+ { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 },
+ { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 },
+ { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 },
+ { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 },
+ { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 },
+ { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 },
+ { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612 },
+ { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040 },
+ { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829 },
+ { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 },
+ { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 },
+ { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 },
+ { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 },
+ { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 },
+ { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 },
+ { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 },
+ { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 },
+ { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 },
+ { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 },
+ { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 },
+ { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 },
+ { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 },
+ { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 },
+ { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 },
+ { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 },
+ { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 },
+ { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 },
+ { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 },
+ { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 },
+ { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 },
+ { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 },
+ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
+ { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 },
+ { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 },
+ { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 },
+ { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 },
+ { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 },
+ { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 },
+ { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 },
+ { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 },
+ { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 },
+]
+
+[[package]]
+name = "readme-renderer"
+version = "44.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docutils" },
+ { name = "nh3" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310 },
+]
+
+[[package]]
+name = "requests"
+version = "2.32.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
+]
+
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 },
+]
+
+[[package]]
+name = "rfc3986"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326 },
+]
+
+[[package]]
+name = "rich"
+version = "14.0.0"
+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/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 },
+]
+
+[[package]]
+name = "ruff"
+version = "0.11.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/5b/3ae20f89777115944e89c2d8c2e795dcc5b9e04052f76d5347e35e0da66e/ruff-0.11.4.tar.gz", hash = "sha256:f45bd2fb1a56a5a85fae3b95add03fb185a0b30cf47f5edc92aa0355ca1d7407", size = 3933063 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9c/db/baee59ac88f57527fcbaad3a7b309994e42329c6bc4d4d2b681a3d7b5426/ruff-0.11.4-py3-none-linux_armv6l.whl", hash = "sha256:d9f4a761ecbde448a2d3e12fb398647c7f0bf526dbc354a643ec505965824ed2", size = 10106493 },
+ { url = "https://files.pythonhosted.org/packages/c1/d6/9a0962cbb347f4ff98b33d699bf1193ff04ca93bed4b4222fd881b502154/ruff-0.11.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8c1747d903447d45ca3d40c794d1a56458c51e5cc1bc77b7b64bd2cf0b1626cc", size = 10876382 },
+ { url = "https://files.pythonhosted.org/packages/3a/8f/62bab0c7d7e1ae3707b69b157701b41c1ccab8f83e8501734d12ea8a839f/ruff-0.11.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:51a6494209cacca79e121e9b244dc30d3414dac8cc5afb93f852173a2ecfc906", size = 10237050 },
+ { url = "https://files.pythonhosted.org/packages/09/96/e296965ae9705af19c265d4d441958ed65c0c58fc4ec340c27cc9d2a1f5b/ruff-0.11.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f171605f65f4fc49c87f41b456e882cd0c89e4ac9d58e149a2b07930e1d466f", size = 10424984 },
+ { url = "https://files.pythonhosted.org/packages/e5/56/644595eb57d855afed6e54b852e2df8cd5ca94c78043b2f29bdfb29882d5/ruff-0.11.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebf99ea9af918878e6ce42098981fc8c1db3850fef2f1ada69fb1dcdb0f8e79e", size = 9957438 },
+ { url = "https://files.pythonhosted.org/packages/86/83/9d3f3bed0118aef3e871ded9e5687fb8c5776bde233427fd9ce0a45db2d4/ruff-0.11.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edad2eac42279df12e176564a23fc6f4aaeeb09abba840627780b1bb11a9d223", size = 11547282 },
+ { url = "https://files.pythonhosted.org/packages/40/e6/0c6e4f5ae72fac5ccb44d72c0111f294a5c2c8cc5024afcb38e6bda5f4b3/ruff-0.11.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f103a848be9ff379fc19b5d656c1f911d0a0b4e3e0424f9532ececf319a4296e", size = 12182020 },
+ { url = "https://files.pythonhosted.org/packages/b5/92/4aed0e460aeb1df5ea0c2fbe8d04f9725cccdb25d8da09a0d3f5b8764bf8/ruff-0.11.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:193e6fac6eb60cc97b9f728e953c21cc38a20077ed64f912e9d62b97487f3f2d", size = 11679154 },
+ { url = "https://files.pythonhosted.org/packages/1b/d3/7316aa2609f2c592038e2543483eafbc62a0e1a6a6965178e284808c095c/ruff-0.11.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7af4e5f69b7c138be8dcffa5b4a061bf6ba6a3301f632a6bce25d45daff9bc99", size = 13905985 },
+ { url = "https://files.pythonhosted.org/packages/63/80/734d3d17546e47ff99871f44ea7540ad2bbd7a480ed197fe8a1c8a261075/ruff-0.11.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:126b1bf13154aa18ae2d6c3c5efe144ec14b97c60844cfa6eb960c2a05188222", size = 11348343 },
+ { url = "https://files.pythonhosted.org/packages/04/7b/70fc7f09a0161dce9613a4671d198f609e653d6f4ff9eee14d64c4c240fb/ruff-0.11.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8806daaf9dfa881a0ed603f8a0e364e4f11b6ed461b56cae2b1c0cab0645304", size = 10308487 },
+ { url = "https://files.pythonhosted.org/packages/1a/22/1cdd62dabd678d75842bf4944fd889cf794dc9e58c18cc547f9eb28f95ed/ruff-0.11.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5d94bb1cc2fc94a769b0eb975344f1b1f3d294da1da9ddbb5a77665feb3a3019", size = 9929091 },
+ { url = "https://files.pythonhosted.org/packages/9f/20/40e0563506332313148e783bbc1e4276d657962cc370657b2fff20e6e058/ruff-0.11.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:995071203d0fe2183fc7a268766fd7603afb9996785f086b0d76edee8755c896", size = 10924659 },
+ { url = "https://files.pythonhosted.org/packages/b5/41/eef9b7aac8819d9e942f617f9db296f13d2c4576806d604aba8db5a753f1/ruff-0.11.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7a37ca937e307ea18156e775a6ac6e02f34b99e8c23fe63c1996185a4efe0751", size = 11428160 },
+ { url = "https://files.pythonhosted.org/packages/ff/61/c488943414fb2b8754c02f3879de003e26efdd20f38167ded3fb3fc1cda3/ruff-0.11.4-py3-none-win32.whl", hash = "sha256:0e9365a7dff9b93af933dab8aebce53b72d8f815e131796268709890b4a83270", size = 10311496 },
+ { url = "https://files.pythonhosted.org/packages/b6/2b/2a1c8deb5f5dfa3871eb7daa41492c4d2b2824a74d2b38e788617612a66d/ruff-0.11.4-py3-none-win_amd64.whl", hash = "sha256:5a9fa1c69c7815e39fcfb3646bbfd7f528fa8e2d4bebdcf4c2bd0fa037a255fb", size = 11399146 },
+ { url = "https://files.pythonhosted.org/packages/4f/03/3aec4846226d54a37822e4c7ea39489e4abd6f88388fba74e3d4abe77300/ruff-0.11.4-py3-none-win_arm64.whl", hash = "sha256:d435db6b9b93d02934cf61ef332e66af82da6d8c69aefdea5994c89997c7a0fc", size = 10450306 },
+]
+
+[[package]]
+name = "secretstorage"
+version = "3.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+ { name = "jeepney" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/24/b4293291fa1dd830f353d2cb163295742fa87f179fcc8a20a306a81978b7/SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99", size = 15221 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 },
+]
+
+[[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 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
+]
+
+[[package]]
+name = "tomli"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 },
+ { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 },
+ { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 },
+ { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 },
+ { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 },
+ { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 },
+ { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 },
+ { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 },
+ { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 },
+ { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 },
+ { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 },
+ { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 },
+ { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 },
+ { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 },
+ { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 },
+ { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 },
+ { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 },
+ { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 },
+ { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 },
+ { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 },
+ { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 },
+ { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 },
+ { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 },
+ { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 },
+ { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 },
+ { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 },
+ { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 },
+ { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 },
+ { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 },
+ { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 },
+ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
+]
+
+[[package]]
+name = "tomli-w"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675 },
+]
+
+[[package]]
+name = "tomlkit"
+version = "0.13.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 },
+]
+
+[[package]]
+name = "trove-classifiers"
+version = "2025.3.19.19"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/23/c6/1bc495f33ab4cd16c1044bde55d5ac76646c6c759df751218c7c2aeb3bba/trove_classifiers-2025.3.19.19.tar.gz", hash = "sha256:98e9d396fe908d5f43b7454fa4c43d17cd0fdadf046f45fb38a5e3af8d959ecd", size = 16280 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/f8/9c6d334002e7b4ff34a875d2f6fe76c6c1544bd7fde3e39cb7cd2593488f/trove_classifiers-2025.3.19.19-py3-none-any.whl", hash = "sha256:5fc02770ecd81588a605ac98b9d85d50a5a3f9daa30af2a6b1361a1999d75d07", size = 13678 },
+]
+
+[[package]]
+name = "twine"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "id" },
+ { name = "importlib-metadata", marker = "python_full_version < '3.10'" },
+ { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
+ { name = "packaging" },
+ { name = "readme-renderer" },
+ { name = "requests" },
+ { name = "requests-toolbelt" },
+ { name = "rfc3986" },
+ { name = "rich" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c8/a2/6df94fc5c8e2170d21d7134a565c3a8fb84f9797c1dd65a5976aaf714418/twine-6.1.0.tar.gz", hash = "sha256:be324f6272eff91d07ee93f251edf232fc647935dd585ac003539b42404a8dbd", size = 168404 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/b6/74e927715a285743351233f33ea3c684528a0d374d2e43ff9ce9585b73fe/twine-6.1.0-py3-none-any.whl", hash = "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", size = 40791 },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.13.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/ad/cd3e3465232ec2416ae9b983f27b9e94dc8171d56ac99b345319a9475967/typing_extensions-4.13.1.tar.gz", hash = "sha256:98795af00fb9640edec5b8e31fc647597b4691f099ad75f469a2616be1a76dff", size = 106633 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/c5/e7a0b0f5ed69f94c8ab7379c599e6036886bffcde609969a5325f47f1332/typing_extensions-4.13.1-py3-none-any.whl", hash = "sha256:4b6cf02909eb5495cfbc3f6e8fd49217e6cc7944e145cdda8caa3734777f9e69", size = 45739 },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
+]
+
+[[package]]
+name = "userpath"
+version = "1.9.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d5/b7/30753098208505d7ff9be5b3a32112fb8a4cb3ddfccbbb7ba9973f2e29ff/userpath-1.9.2.tar.gz", hash = "sha256:6c52288dab069257cc831846d15d48133522455d4677ee69a9781f11dbefd815", size = 11140 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/99/3ec6335ded5b88c2f7ed25c56ffd952546f7ed007ffb1e1539dc3b57015a/userpath-1.9.2-py3-none-any.whl", hash = "sha256:2cbf01a23d655a1ff8fc166dfb78da1b641d1ceabf0fe5f970767d380b14e89d", size = 9065 },
+]
+
+[[package]]
+name = "uv"
+version = "0.6.13"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/4a/657ac37f3f1707d320e993a64c30fd261af39b4dd6811f2e96e61514129c/uv-0.6.13.tar.gz", hash = "sha256:2bfa717647b546c1b1548a1217cf1f0162d9eb9f385ec5df48d9479ff62c4c91", size = 3120652 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/a3/a659da098f123ee5b4e77c062363e75a0056a5bd16e72b2937fe988a0d24/uv-0.6.13-py3-none-linux_armv6l.whl", hash = "sha256:efa468a3c996a35f5107a8a314ea9d5ec7b33a78e253973c4840408d5ed5d852", size = 16218108 },
+ { url = "https://files.pythonhosted.org/packages/0d/7c/cca11b36d5dc32d9cdd73f8735c7733b384266637807c353c9f26d0ffe7d/uv-0.6.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6d27daafbfe2c608a9ad8e229e4ff68cc05cabe1fb326c3c9f2bf82dba4a325c", size = 16237424 },
+ { url = "https://files.pythonhosted.org/packages/02/39/b5e25bd80a76a7129102015db8dfaed212a7659a23384341e8db13f9fbfc/uv-0.6.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:87800f3624a0c24b893bdbefde5bd46a27e72b8b3c5b009213e0f8060c36e414", size = 15093672 },
+ { url = "https://files.pythonhosted.org/packages/b7/b2/a56b5c30a35b6cc09da9a616e2d46bca8b0e06c84996ee5e32c27b0587b4/uv-0.6.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:f1d3d827f65a45223380e463b978d56d86ad01b8306f198a5d0ff7427ab45e04", size = 15523105 },
+ { url = "https://files.pythonhosted.org/packages/d4/e2/a9c472ac25177fd24581682fe4363513df3844768130c17b41d21e849727/uv-0.6.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2feaa4df5fb0f74bd1b7f7595659a9a9a889a5c6c77e0a63a243d4abc2eacae7", size = 15922333 },
+ { url = "https://files.pythonhosted.org/packages/2b/ab/2a52f2520c0cbc72e234e4b71deec6485466543a292daa24cd2eec3647fc/uv-0.6.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:422f3aa2e121b091d0ae3b4833ec552de7ef18077a04b804d1393b59cde3cb50", size = 16659800 },
+ { url = "https://files.pythonhosted.org/packages/a4/16/f16e2e1fc3c418ae7e79111de16fe22ce536b6b0832301a9e78df6aa24d8/uv-0.6.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2cd489d2bdcdf8ab94057d2bdbadeeeca40334da4c847b66f561ed49afc75ac9", size = 17562514 },
+ { url = "https://files.pythonhosted.org/packages/91/d6/128114ccad859edbaf9446bbe2126c3ca35ac720c5741f8c393495a956c2/uv-0.6.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5a251a073c91767952363ea99d0b831166f6bb1866de1f524abfc40b6de7747", size = 17222543 },
+ { url = "https://files.pythonhosted.org/packages/bb/b8/dbc8883f6e958429e61a9b4d5bf64bbd82340ceb62d9f244f556c09202be/uv-0.6.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9c459147c743fd8c3a114150de03334540e5d2d58bb8d046de601a1984d9f9a", size = 21579483 },
+ { url = "https://files.pythonhosted.org/packages/c4/f3/9e7f4895931b9ac1a362b76faa8cab89cc4a7ffa97a4dcc91b4d1e655b09/uv-0.6.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbf2190b1bd9df9dafdd5646d1f29f7d7d45e87e21f95ccf8e698b16b26de106", size = 16864072 },
+ { url = "https://files.pythonhosted.org/packages/b4/04/b8c9bae00cbe124c4ed4663c4ded32d15b1972484d8ab5bb649b9d8cd700/uv-0.6.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:83d3760b34b7c775c5a3e7541dada253a26a1a3dc48a1d88a51b061f6f424644", size = 15779760 },
+ { url = "https://files.pythonhosted.org/packages/db/01/d7b81016fb7dc9811e133780a13147a3880b8d0c746070c777966e74e4ca/uv-0.6.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:eab97a6116e767c72cb5d42792e75f1c9a30fc4557ddf24fb48584d1de9f3e16", size = 15923206 },
+ { url = "https://files.pythonhosted.org/packages/95/8d/147e6432675defe0c64996ea411510a5c9dcf9fedef1350dbc952770adec/uv-0.6.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:b3476cb2298fe109c02e9c768130a97b0c9c49322bc9bf3145433c8f43a90883", size = 16253488 },
+ { url = "https://files.pythonhosted.org/packages/af/0b/4eb49e40155ec0a7f5bfb6e3bccdae2985bbc85cd71f463e7c58e1155a63/uv-0.6.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:c2e1b23d9110a8cb0b9d0dddaaa20fda736ed24b3cdcdba2d7fc2ddd9159207a", size = 17009491 },
+ { url = "https://files.pythonhosted.org/packages/43/86/5de7e2dc054b7fc9a29f21f42d97e8226ab4675cc2b7dd0a0e620cee189a/uv-0.6.13-py3-none-win32.whl", hash = "sha256:fbbe2c41d5ec103848a61143bb3e4c3453192ae422a3e687dc43ab09b870e072", size = 16096346 },
+ { url = "https://files.pythonhosted.org/packages/21/4c/ee21b8efbe81f346133b64bcb02f652ee1b6c326d3fd3a1a651ccf069219/uv-0.6.13-py3-none-win_amd64.whl", hash = "sha256:0fefc48b668d2a03c692e6925691e5e9c606f609ddd90bac8e8c4db2b42179e0", size = 17586464 },
+ { url = "https://files.pythonhosted.org/packages/76/33/423bf6556a2622d333c4002dee3e4ddae8f64f9cebc9c8dd9655467f4442/uv-0.6.13-py3-none-win_arm64.whl", hash = "sha256:393072d201e3aeacc952a6b0ffea8072f924f3deea67dcb5117ecc7b1ed74f5b", size = 16347513 },
+]
+
+[[package]]
+name = "virtualenv"
+version = "20.30.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "distlib" },
+ { name = "filelock" },
+ { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/38/e0/633e369b91bbc664df47dcb5454b6c7cf441e8f5b9d0c250ce9f0546401e/virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8", size = 4346945 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4c/ed/3cfeb48175f0671ec430ede81f628f9fb2b1084c9064ca67ebe8c0ed6a05/virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6", size = 4329461 },
+]
+
+[[package]]
+name = "zipp"
+version = "3.21.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 },
+]
+
+[[package]]
+name = "zstandard"
+version = "0.23.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation == 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/2ac0287b442160a89d726b17a9184a4c615bb5237db763791a7fd16d9df1/zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09", size = 681701 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/55/bd0487e86679db1823fc9ee0d8c9c78ae2413d34c0b461193b5f4c31d22f/zstandard-0.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf0a05b6059c0528477fba9054d09179beb63744355cab9f38059548fedd46a9", size = 788701 },
+ { url = "https://files.pythonhosted.org/packages/e1/8a/ccb516b684f3ad987dfee27570d635822e3038645b1a950c5e8022df1145/zstandard-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fc9ca1c9718cb3b06634c7c8dec57d24e9438b2aa9a0f02b8bb36bf478538880", size = 633678 },
+ { url = "https://files.pythonhosted.org/packages/12/89/75e633d0611c028e0d9af6df199423bf43f54bea5007e6718ab7132e234c/zstandard-0.23.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77da4c6bfa20dd5ea25cbf12c76f181a8e8cd7ea231c673828d0386b1740b8dc", size = 4941098 },
+ { url = "https://files.pythonhosted.org/packages/4a/7a/bd7f6a21802de358b63f1ee636ab823711c25ce043a3e9f043b4fcb5ba32/zstandard-0.23.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2170c7e0367dde86a2647ed5b6f57394ea7f53545746104c6b09fc1f4223573", size = 5308798 },
+ { url = "https://files.pythonhosted.org/packages/79/3b/775f851a4a65013e88ca559c8ae42ac1352db6fcd96b028d0df4d7d1d7b4/zstandard-0.23.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c16842b846a8d2a145223f520b7e18b57c8f476924bda92aeee3a88d11cfc391", size = 5341840 },
+ { url = "https://files.pythonhosted.org/packages/09/4f/0cc49570141dd72d4d95dd6fcf09328d1b702c47a6ec12fbed3b8aed18a5/zstandard-0.23.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:157e89ceb4054029a289fb504c98c6a9fe8010f1680de0201b3eb5dc20aa6d9e", size = 5440337 },
+ { url = "https://files.pythonhosted.org/packages/e7/7c/aaa7cd27148bae2dc095191529c0570d16058c54c4597a7d118de4b21676/zstandard-0.23.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:203d236f4c94cd8379d1ea61db2fce20730b4c38d7f1c34506a31b34edc87bdd", size = 4861182 },
+ { url = "https://files.pythonhosted.org/packages/ac/eb/4b58b5c071d177f7dc027129d20bd2a44161faca6592a67f8fcb0b88b3ae/zstandard-0.23.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dc5d1a49d3f8262be192589a4b72f0d03b72dcf46c51ad5852a4fdc67be7b9e4", size = 4932936 },
+ { url = "https://files.pythonhosted.org/packages/44/f9/21a5fb9bb7c9a274b05ad700a82ad22ce82f7ef0f485980a1e98ed6e8c5f/zstandard-0.23.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:752bf8a74412b9892f4e5b58f2f890a039f57037f52c89a740757ebd807f33ea", size = 5464705 },
+ { url = "https://files.pythonhosted.org/packages/49/74/b7b3e61db3f88632776b78b1db597af3f44c91ce17d533e14a25ce6a2816/zstandard-0.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80080816b4f52a9d886e67f1f96912891074903238fe54f2de8b786f86baded2", size = 4857882 },
+ { url = "https://files.pythonhosted.org/packages/4a/7f/d8eb1cb123d8e4c541d4465167080bec88481ab54cd0b31eb4013ba04b95/zstandard-0.23.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84433dddea68571a6d6bd4fbf8ff398236031149116a7fff6f777ff95cad3df9", size = 4697672 },
+ { url = "https://files.pythonhosted.org/packages/5e/05/f7dccdf3d121309b60342da454d3e706453a31073e2c4dac8e1581861e44/zstandard-0.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ab19a2d91963ed9e42b4e8d77cd847ae8381576585bad79dbd0a8837a9f6620a", size = 5206043 },
+ { url = "https://files.pythonhosted.org/packages/86/9d/3677a02e172dccd8dd3a941307621c0cbd7691d77cb435ac3c75ab6a3105/zstandard-0.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:59556bf80a7094d0cfb9f5e50bb2db27fefb75d5138bb16fb052b61b0e0eeeb0", size = 5667390 },
+ { url = "https://files.pythonhosted.org/packages/41/7e/0012a02458e74a7ba122cd9cafe491facc602c9a17f590367da369929498/zstandard-0.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:27d3ef2252d2e62476389ca8f9b0cf2bbafb082a3b6bfe9d90cbcbb5529ecf7c", size = 5198901 },
+ { url = "https://files.pythonhosted.org/packages/65/3a/8f715b97bd7bcfc7342d8adcd99a026cb2fb550e44866a3b6c348e1b0f02/zstandard-0.23.0-cp310-cp310-win32.whl", hash = "sha256:5d41d5e025f1e0bccae4928981e71b2334c60f580bdc8345f824e7c0a4c2a813", size = 430596 },
+ { url = "https://files.pythonhosted.org/packages/19/b7/b2b9eca5e5a01111e4fe8a8ffb56bdcdf56b12448a24effe6cfe4a252034/zstandard-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:519fbf169dfac1222a76ba8861ef4ac7f0530c35dd79ba5727014613f91613d4", size = 495498 },
+ { url = "https://files.pythonhosted.org/packages/9e/40/f67e7d2c25a0e2dc1744dd781110b0b60306657f8696cafb7ad7579469bd/zstandard-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34895a41273ad33347b2fc70e1bff4240556de3c46c6ea430a7ed91f9042aa4e", size = 788699 },
+ { url = "https://files.pythonhosted.org/packages/e8/46/66d5b55f4d737dd6ab75851b224abf0afe5774976fe511a54d2eb9063a41/zstandard-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:77ea385f7dd5b5676d7fd943292ffa18fbf5c72ba98f7d09fc1fb9e819b34c23", size = 633681 },
+ { url = "https://files.pythonhosted.org/packages/63/b6/677e65c095d8e12b66b8f862b069bcf1f1d781b9c9c6f12eb55000d57583/zstandard-0.23.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:983b6efd649723474f29ed42e1467f90a35a74793437d0bc64a5bf482bedfa0a", size = 4944328 },
+ { url = "https://files.pythonhosted.org/packages/59/cc/e76acb4c42afa05a9d20827116d1f9287e9c32b7ad58cc3af0721ce2b481/zstandard-0.23.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80a539906390591dd39ebb8d773771dc4db82ace6372c4d41e2d293f8e32b8db", size = 5311955 },
+ { url = "https://files.pythonhosted.org/packages/78/e4/644b8075f18fc7f632130c32e8f36f6dc1b93065bf2dd87f03223b187f26/zstandard-0.23.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:445e4cb5048b04e90ce96a79b4b63140e3f4ab5f662321975679b5f6360b90e2", size = 5344944 },
+ { url = "https://files.pythonhosted.org/packages/76/3f/dbafccf19cfeca25bbabf6f2dd81796b7218f768ec400f043edc767015a6/zstandard-0.23.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd30d9c67d13d891f2360b2a120186729c111238ac63b43dbd37a5a40670b8ca", size = 5442927 },
+ { url = "https://files.pythonhosted.org/packages/0c/c3/d24a01a19b6733b9f218e94d1a87c477d523237e07f94899e1c10f6fd06c/zstandard-0.23.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d20fd853fbb5807c8e84c136c278827b6167ded66c72ec6f9a14b863d809211c", size = 4864910 },
+ { url = "https://files.pythonhosted.org/packages/1c/a9/cf8f78ead4597264f7618d0875be01f9bc23c9d1d11afb6d225b867cb423/zstandard-0.23.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ed1708dbf4d2e3a1c5c69110ba2b4eb6678262028afd6c6fbcc5a8dac9cda68e", size = 4935544 },
+ { url = "https://files.pythonhosted.org/packages/2c/96/8af1e3731b67965fb995a940c04a2c20997a7b3b14826b9d1301cf160879/zstandard-0.23.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:be9b5b8659dff1f913039c2feee1aca499cfbc19e98fa12bc85e037c17ec6ca5", size = 5467094 },
+ { url = "https://files.pythonhosted.org/packages/ff/57/43ea9df642c636cb79f88a13ab07d92d88d3bfe3e550b55a25a07a26d878/zstandard-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:65308f4b4890aa12d9b6ad9f2844b7ee42c7f7a4fd3390425b242ffc57498f48", size = 4860440 },
+ { url = "https://files.pythonhosted.org/packages/46/37/edb78f33c7f44f806525f27baa300341918fd4c4af9472fbc2c3094be2e8/zstandard-0.23.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98da17ce9cbf3bfe4617e836d561e433f871129e3a7ac16d6ef4c680f13a839c", size = 4700091 },
+ { url = "https://files.pythonhosted.org/packages/c1/f1/454ac3962671a754f3cb49242472df5c2cced4eb959ae203a377b45b1a3c/zstandard-0.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed7d27cb56b3e058d3cf684d7200703bcae623e1dcc06ed1e18ecda39fee003", size = 5208682 },
+ { url = "https://files.pythonhosted.org/packages/85/b2/1734b0fff1634390b1b887202d557d2dd542de84a4c155c258cf75da4773/zstandard-0.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:b69bb4f51daf461b15e7b3db033160937d3ff88303a7bc808c67bbc1eaf98c78", size = 5669707 },
+ { url = "https://files.pythonhosted.org/packages/52/5a/87d6971f0997c4b9b09c495bf92189fb63de86a83cadc4977dc19735f652/zstandard-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:034b88913ecc1b097f528e42b539453fa82c3557e414b3de9d5632c80439a473", size = 5201792 },
+ { url = "https://files.pythonhosted.org/packages/79/02/6f6a42cc84459d399bd1a4e1adfc78d4dfe45e56d05b072008d10040e13b/zstandard-0.23.0-cp311-cp311-win32.whl", hash = "sha256:f2d4380bf5f62daabd7b751ea2339c1a21d1c9463f1feb7fc2bdcea2c29c3160", size = 430586 },
+ { url = "https://files.pythonhosted.org/packages/be/a2/4272175d47c623ff78196f3c10e9dc7045c1b9caf3735bf041e65271eca4/zstandard-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:62136da96a973bd2557f06ddd4e8e807f9e13cbb0bfb9cc06cfe6d98ea90dfe0", size = 495420 },
+ { url = "https://files.pythonhosted.org/packages/7b/83/f23338c963bd9de687d47bf32efe9fd30164e722ba27fb59df33e6b1719b/zstandard-0.23.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b4567955a6bc1b20e9c31612e615af6b53733491aeaa19a6b3b37f3b65477094", size = 788713 },
+ { url = "https://files.pythonhosted.org/packages/5b/b3/1a028f6750fd9227ee0b937a278a434ab7f7fdc3066c3173f64366fe2466/zstandard-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e172f57cd78c20f13a3415cc8dfe24bf388614324d25539146594c16d78fcc8", size = 633459 },
+ { url = "https://files.pythonhosted.org/packages/26/af/36d89aae0c1f95a0a98e50711bc5d92c144939efc1f81a2fcd3e78d7f4c1/zstandard-0.23.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0e166f698c5a3e914947388c162be2583e0c638a4703fc6a543e23a88dea3c1", size = 4945707 },
+ { url = "https://files.pythonhosted.org/packages/cd/2e/2051f5c772f4dfc0aae3741d5fc72c3dcfe3aaeb461cc231668a4db1ce14/zstandard-0.23.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a289832e520c6bd4dcaad68e944b86da3bad0d339ef7989fb7e88f92e96072", size = 5306545 },
+ { url = "https://files.pythonhosted.org/packages/0a/9e/a11c97b087f89cab030fa71206963090d2fecd8eb83e67bb8f3ffb84c024/zstandard-0.23.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d50d31bfedd53a928fed6707b15a8dbeef011bb6366297cc435accc888b27c20", size = 5337533 },
+ { url = "https://files.pythonhosted.org/packages/fc/79/edeb217c57fe1bf16d890aa91a1c2c96b28c07b46afed54a5dcf310c3f6f/zstandard-0.23.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72c68dda124a1a138340fb62fa21b9bf4848437d9ca60bd35db36f2d3345f373", size = 5436510 },
+ { url = "https://files.pythonhosted.org/packages/81/4f/c21383d97cb7a422ddf1ae824b53ce4b51063d0eeb2afa757eb40804a8ef/zstandard-0.23.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53dd9d5e3d29f95acd5de6802e909ada8d8d8cfa37a3ac64836f3bc4bc5512db", size = 4859973 },
+ { url = "https://files.pythonhosted.org/packages/ab/15/08d22e87753304405ccac8be2493a495f529edd81d39a0870621462276ef/zstandard-0.23.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6a41c120c3dbc0d81a8e8adc73312d668cd34acd7725f036992b1b72d22c1772", size = 4936968 },
+ { url = "https://files.pythonhosted.org/packages/eb/fa/f3670a597949fe7dcf38119a39f7da49a8a84a6f0b1a2e46b2f71a0ab83f/zstandard-0.23.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:40b33d93c6eddf02d2c19f5773196068d875c41ca25730e8288e9b672897c105", size = 5467179 },
+ { url = "https://files.pythonhosted.org/packages/4e/a9/dad2ab22020211e380adc477a1dbf9f109b1f8d94c614944843e20dc2a99/zstandard-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9206649ec587e6b02bd124fb7799b86cddec350f6f6c14bc82a2b70183e708ba", size = 4848577 },
+ { url = "https://files.pythonhosted.org/packages/08/03/dd28b4484b0770f1e23478413e01bee476ae8227bbc81561f9c329e12564/zstandard-0.23.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76e79bc28a65f467e0409098fa2c4376931fd3207fbeb6b956c7c476d53746dd", size = 4693899 },
+ { url = "https://files.pythonhosted.org/packages/2b/64/3da7497eb635d025841e958bcd66a86117ae320c3b14b0ae86e9e8627518/zstandard-0.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:66b689c107857eceabf2cf3d3fc699c3c0fe8ccd18df2219d978c0283e4c508a", size = 5199964 },
+ { url = "https://files.pythonhosted.org/packages/43/a4/d82decbab158a0e8a6ebb7fc98bc4d903266bce85b6e9aaedea1d288338c/zstandard-0.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9c236e635582742fee16603042553d276cca506e824fa2e6489db04039521e90", size = 5655398 },
+ { url = "https://files.pythonhosted.org/packages/f2/61/ac78a1263bc83a5cf29e7458b77a568eda5a8f81980691bbc6eb6a0d45cc/zstandard-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8fffdbd9d1408006baaf02f1068d7dd1f016c6bcb7538682622c556e7b68e35", size = 5191313 },
+ { url = "https://files.pythonhosted.org/packages/e7/54/967c478314e16af5baf849b6ee9d6ea724ae5b100eb506011f045d3d4e16/zstandard-0.23.0-cp312-cp312-win32.whl", hash = "sha256:dc1d33abb8a0d754ea4763bad944fd965d3d95b5baef6b121c0c9013eaf1907d", size = 430877 },
+ { url = "https://files.pythonhosted.org/packages/75/37/872d74bd7739639c4553bf94c84af7d54d8211b626b352bc57f0fd8d1e3f/zstandard-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:64585e1dba664dc67c7cdabd56c1e5685233fbb1fc1966cfba2a340ec0dfff7b", size = 495595 },
+ { url = "https://files.pythonhosted.org/packages/80/f1/8386f3f7c10261fe85fbc2c012fdb3d4db793b921c9abcc995d8da1b7a80/zstandard-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9", size = 788975 },
+ { url = "https://files.pythonhosted.org/packages/16/e8/cbf01077550b3e5dc86089035ff8f6fbbb312bc0983757c2d1117ebba242/zstandard-0.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a", size = 633448 },
+ { url = "https://files.pythonhosted.org/packages/06/27/4a1b4c267c29a464a161aeb2589aff212b4db653a1d96bffe3598f3f0d22/zstandard-0.23.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2", size = 4945269 },
+ { url = "https://files.pythonhosted.org/packages/7c/64/d99261cc57afd9ae65b707e38045ed8269fbdae73544fd2e4a4d50d0ed83/zstandard-0.23.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ef230a8fd217a2015bc91b74f6b3b7d6522ba48be29ad4ea0ca3a3775bf7dd5", size = 5306228 },
+ { url = "https://files.pythonhosted.org/packages/7a/cf/27b74c6f22541f0263016a0fd6369b1b7818941de639215c84e4e94b2a1c/zstandard-0.23.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:774d45b1fac1461f48698a9d4b5fa19a69d47ece02fa469825b442263f04021f", size = 5336891 },
+ { url = "https://files.pythonhosted.org/packages/fa/18/89ac62eac46b69948bf35fcd90d37103f38722968e2981f752d69081ec4d/zstandard-0.23.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f77fa49079891a4aab203d0b1744acc85577ed16d767b52fc089d83faf8d8ed", size = 5436310 },
+ { url = "https://files.pythonhosted.org/packages/a8/a8/5ca5328ee568a873f5118d5b5f70d1f36c6387716efe2e369010289a5738/zstandard-0.23.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac184f87ff521f4840e6ea0b10c0ec90c6b1dcd0bad2f1e4a9a1b4fa177982ea", size = 4859912 },
+ { url = "https://files.pythonhosted.org/packages/ea/ca/3781059c95fd0868658b1cf0440edd832b942f84ae60685d0cfdb808bca1/zstandard-0.23.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c363b53e257246a954ebc7c488304b5592b9c53fbe74d03bc1c64dda153fb847", size = 4936946 },
+ { url = "https://files.pythonhosted.org/packages/ce/11/41a58986f809532742c2b832c53b74ba0e0a5dae7e8ab4642bf5876f35de/zstandard-0.23.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e7792606d606c8df5277c32ccb58f29b9b8603bf83b48639b7aedf6df4fe8171", size = 5466994 },
+ { url = "https://files.pythonhosted.org/packages/83/e3/97d84fe95edd38d7053af05159465d298c8b20cebe9ccb3d26783faa9094/zstandard-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a0817825b900fcd43ac5d05b8b3079937073d2b1ff9cf89427590718b70dd840", size = 4848681 },
+ { url = "https://files.pythonhosted.org/packages/6e/99/cb1e63e931de15c88af26085e3f2d9af9ce53ccafac73b6e48418fd5a6e6/zstandard-0.23.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9da6bc32faac9a293ddfdcb9108d4b20416219461e4ec64dfea8383cac186690", size = 4694239 },
+ { url = "https://files.pythonhosted.org/packages/ab/50/b1e703016eebbc6501fc92f34db7b1c68e54e567ef39e6e59cf5fb6f2ec0/zstandard-0.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd7699e8fd9969f455ef2926221e0233f81a2542921471382e77a9e2f2b57f4b", size = 5200149 },
+ { url = "https://files.pythonhosted.org/packages/aa/e0/932388630aaba70197c78bdb10cce2c91fae01a7e553b76ce85471aec690/zstandard-0.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d477ed829077cd945b01fc3115edd132c47e6540ddcd96ca169facff28173057", size = 5655392 },
+ { url = "https://files.pythonhosted.org/packages/02/90/2633473864f67a15526324b007a9f96c96f56d5f32ef2a56cc12f9548723/zstandard-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33", size = 5191299 },
+ { url = "https://files.pythonhosted.org/packages/b0/4c/315ca5c32da7e2dc3455f3b2caee5c8c2246074a61aac6ec3378a97b7136/zstandard-0.23.0-cp313-cp313-win32.whl", hash = "sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd", size = 430862 },
+ { url = "https://files.pythonhosted.org/packages/a2/bf/c6aaba098e2d04781e8f4f7c0ba3c7aa73d00e4c436bcc0cf059a66691d1/zstandard-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b", size = 495578 },
+ { url = "https://files.pythonhosted.org/packages/fb/96/4fcafeb7e013a2386d22f974b5b97a0b9a65004ed58c87ae001599bfbd48/zstandard-0.23.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa014d55c3af933c1315eb4bb06dd0459661cc0b15cd61077afa6489bec63bb", size = 788697 },
+ { url = "https://files.pythonhosted.org/packages/83/ff/a52ce725be69b86a2967ecba0497a8184540cc284c0991125515449e54e2/zstandard-0.23.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7f0804bb3799414af278e9ad51be25edf67f78f916e08afdb983e74161b916", size = 633679 },
+ { url = "https://files.pythonhosted.org/packages/34/0f/3dc62db122f6a9c481c335fff6fc9f4e88d8f6e2d47321ee3937328addb4/zstandard-0.23.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb2b1ecfef1e67897d336de3a0e3f52478182d6a47eda86cbd42504c5cbd009a", size = 4940416 },
+ { url = "https://files.pythonhosted.org/packages/1d/e5/9fe0dd8c85fdc2f635e6660d07872a5dc4b366db566630161e39f9f804e1/zstandard-0.23.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:837bb6764be6919963ef41235fd56a6486b132ea64afe5fafb4cb279ac44f259", size = 5307693 },
+ { url = "https://files.pythonhosted.org/packages/73/bf/fe62c0cd865c171ee8ed5bc83174b5382a2cb729c8d6162edfb99a83158b/zstandard-0.23.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1516c8c37d3a053b01c1c15b182f3b5f5eef19ced9b930b684a73bad121addf4", size = 5341236 },
+ { url = "https://files.pythonhosted.org/packages/39/86/4fe79b30c794286110802a6cd44a73b6a314ac8196b9338c0fbd78c2407d/zstandard-0.23.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48ef6a43b1846f6025dde6ed9fee0c24e1149c1c25f7fb0a0585572b2f3adc58", size = 5439101 },
+ { url = "https://files.pythonhosted.org/packages/72/ed/cacec235c581ebf8c608c7fb3d4b6b70d1b490d0e5128ea6996f809ecaef/zstandard-0.23.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11e3bf3c924853a2d5835b24f03eeba7fc9b07d8ca499e247e06ff5676461a15", size = 4860320 },
+ { url = "https://files.pythonhosted.org/packages/f6/1e/2c589a2930f93946b132fc852c574a19d5edc23fad2b9e566f431050c7ec/zstandard-0.23.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2fb4535137de7e244c230e24f9d1ec194f61721c86ebea04e1581d9d06ea1269", size = 4931933 },
+ { url = "https://files.pythonhosted.org/packages/8e/f5/30eadde3686d902b5d4692bb5f286977cbc4adc082145eb3f49d834b2eae/zstandard-0.23.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8c24f21fa2af4bb9f2c492a86fe0c34e6d2c63812a839590edaf177b7398f700", size = 5463878 },
+ { url = "https://files.pythonhosted.org/packages/e0/c8/8aed1f0ab9854ef48e5ad4431367fcb23ce73f0304f7b72335a8edc66556/zstandard-0.23.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a8c86881813a78a6f4508ef9daf9d4995b8ac2d147dcb1a450448941398091c9", size = 4857192 },
+ { url = "https://files.pythonhosted.org/packages/a8/c6/55e666cfbcd032b9e271865e8578fec56e5594d4faeac379d371526514f5/zstandard-0.23.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fe3b385d996ee0822fd46528d9f0443b880d4d05528fd26a9119a54ec3f91c69", size = 4696513 },
+ { url = "https://files.pythonhosted.org/packages/dc/bd/720b65bea63ec9de0ac7414c33b9baf271c8de8996e5ff324dc93fc90ff1/zstandard-0.23.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:82d17e94d735c99621bf8ebf9995f870a6b3e6d14543b99e201ae046dfe7de70", size = 5204823 },
+ { url = "https://files.pythonhosted.org/packages/d8/40/d678db1556e3941d330cd4e95623a63ef235b18547da98fa184cbc028ecf/zstandard-0.23.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c7c517d74bea1a6afd39aa612fa025e6b8011982a0897768a2f7c8ab4ebb78a2", size = 5666490 },
+ { url = "https://files.pythonhosted.org/packages/ed/cc/c89329723d7515898a1fc7ef5d251264078548c505719d13e9511800a103/zstandard-0.23.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fd7e0f1cfb70eb2f95a19b472ee7ad6d9a0a992ec0ae53286870c104ca939e5", size = 5196622 },
+ { url = "https://files.pythonhosted.org/packages/78/4c/634289d41e094327a94500dfc919e58841b10ea3a9efdfafbac614797ec2/zstandard-0.23.0-cp39-cp39-win32.whl", hash = "sha256:43da0f0092281bf501f9c5f6f3b4c975a8a0ea82de49ba3f7100e64d422a1274", size = 430620 },
+ { url = "https://files.pythonhosted.org/packages/a2/e2/0b0c5a0f4f7699fecd92c1ba6278ef9b01f2b0b0dd46f62bfc6729c05659/zstandard-0.23.0-cp39-cp39-win_amd64.whl", hash = "sha256:f8346bfa098532bc1fb6c7ef06783e969d87a99dd1d2a5a18a892c1d7a643c58", size = 495528 },
+]