diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..cfddc67
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,658 @@
+name: tests
+
+on:
+ push:
+ branches:
+ - main
+ - dev
+ pull_request:
+ branches:
+ - main
+ - dev
+ workflow_dispatch:
+ inputs:
+ is_release:
+ description: "Set to true if a release version."
+ required: true
+ default: false
+ type: boolean
+ sha:
+ description: "The git SHA to use for release. Only set if needing to publish"
+ required: false
+ default: ""
+ type: string
+ version:
+ description: "The Release version. Allowed format: x.y.z[-alphaN | -betaN | -rcN | -devN | -postN]"
+ required: false
+ default: ""
+ type: string
+ config:
+ description: "JSON formatted object representing various build system input parameters."
+ required: false
+ default: ""
+ type: string
+ workflow_call:
+ inputs:
+ is_release:
+ description: "Set to true if a release version."
+ required: true
+ default: false
+ type: boolean
+ sha:
+ description: "The git SHA to use for release. Only set if needing to publish"
+ required: false
+ default: ""
+ type: string
+ version:
+ description: "The Release version. Allowed format: x.y.z[-alphaN | -betaN | -rcN | -devN | -postN]"
+ required: false
+ default: ""
+ type: string
+ config:
+ description: "JSON formatted object representing various build system input parameters."
+ required: false
+ default: ""
+ type: string
+ outputs:
+ workflow_run_id:
+ description: "The workflow run ID"
+ value: ${{ github.run_id }}
+
+env:
+ CBCI_PROJECT_TYPE: "ANALYTICS"
+ CBCI_DEFAULT_PYTHON: "3.9"
+ CBCI_SUPPORTED_PYTHON_VERSIONS: "3.9 3.10 3.11 3.12 3.13"
+ CBCI_SUPPORTED_X86_64_PLATFORMS: "linux alpine macos windows"
+ CBCI_SUPPORTED_ARM64_PLATFORMS: "linux macos"
+ CBCI_DEFAULT_LINUX_PLATFORM: "ubuntu-22.04"
+ CBCI_DEFAULT_MACOS_X86_64_PLATFORM: "macos-13"
+ CBCI_DEFAULT_MACOS_ARM64_PLATFORM: "macos-14"
+ CBCI_DEFAULT_WINDOWS_PLATFORM: "windows-2022"
+ CBCI_DEFAULT_LINUX_CONTAINER: "slim-bookworm"
+ CBCI_DEFAULT_ALPINE_CONTAINER: "alpine"
+ CBCI_CBDINO_VERSION: "v0.0.80"
+ CI_SCRIPTS_URL: "https://raw.githubusercontent.com/couchbaselabs/sdkbuild-jenkinsfiles/master/python/ci_scripts_v1"
+
+jobs:
+ ci-scripts:
+ runs-on: ubuntu-22.04
+ steps:
+ - name: Download CI Scripts
+ run: |
+ mkdir ci_scripts
+ cd ci_scripts
+ curl -o gha.sh ${CI_SCRIPTS_URL}/gha.sh
+ curl -o pygha.py ${CI_SCRIPTS_URL}/pygha.py
+ ls -alh
+ - name: Upload CI scripts
+ uses: actions/upload-artifact@v4
+ with:
+ retention-days: 1
+ name: ci_scripts
+ path: |
+ ci_scripts/
+
+ validate-input:
+ runs-on: ubuntu-22.04
+ needs: ci-scripts
+ env:
+ CBCI_IS_RELEASE: ${{ inputs.is_release }}
+ CBCI_SHA: ${{ inputs.sha }}
+ CBCI_VERSION: ${{ inputs.version }}
+ CBCI_CONFIG: ${{ inputs.config }}
+ steps:
+ - name: Download CI scripts
+ uses: actions/download-artifact@v4
+ with:
+ name: ci_scripts
+ path: ci_scripts
+ - name: Verify Scripts
+ run: |
+ ls -alh ci_scripts
+ chmod +x ci_scripts/gha.sh
+ ls -alh ci_scripts
+ - name: Display workflow info
+ run: |
+ ./ci_scripts/gha.sh display_info
+ - name: Validate workflow info
+ run: |
+ ./ci_scripts/gha.sh validate_input ${{ github.workflow }}
+
+ setup:
+ runs-on: ubuntu-22.04
+ needs: validate-input
+ env:
+ CBCI_CONFIG: ${{ inputs.config }}
+ outputs:
+ stage_matrices: ${{ steps.build_matrices.outputs.stage_matrices }}
+ steps:
+ - uses: actions/checkout@v4
+ - name: Download CI scripts
+ uses: actions/download-artifact@v4
+ with:
+ name: ci_scripts
+ path: ci_scripts
+ - name: Enable CI Scripts
+ run: |
+ chmod +x ci_scripts/gha.sh
+ - name: Setup Python ${{ env.CBCI_DEFAULT_PYTHON }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.CBCI_DEFAULT_PYTHON }}
+ - name: Confirm Python version
+ run: python -c "import sys; print(sys.version)"
+ - name: Build stage matrices
+ id: build_matrices
+ run: |
+ exit_code=0
+ STAGE_MATRICES=$(./ci_scripts/gha.sh get_stage_matrices) || exit_code=$?
+ if [ $exit_code -ne 0 ]; then
+ echo "Failed to obtain stage matrices."
+ exit 1
+ fi
+ stage_matrices_json=$(jq -cn --argjson matrices "$STAGE_MATRICES" '$matrices')
+ echo "STAGE_MATRICES_JSON=$stage_matrices_json"
+ echo "stage_matrices=$stage_matrices_json" >> "$GITHUB_OUTPUT"
+
+ confirm-matrices:
+ runs-on: ubuntu-22.04
+ needs: setup
+ steps:
+ - name: Linux Test Unit Stage
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.has_linux }}
+ run: |
+ echo "${{ toJson(fromJson(needs.setup.outputs.stage_matrices).test_unit.linux) }}"
+ - name: Macos Test Unit Stage
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.has_macos }}
+ run: |
+ echo "${{ toJson(fromJson(needs.setup.outputs.stage_matrices).test_unit.macos) }}"
+ - name: Windows Test Unit Stage
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.has_windows }}
+ run: |
+ echo "${{ toJson(fromJson(needs.setup.outputs.stage_matrices).test_unit.windows) }}"
+ - name: Linux cbdino Stage
+ if: >-
+ ${{ fromJson(needs.setup.outputs.stage_matrices).test_integration.has_linux_cbdino
+ && !fromJson(needs.setup.outputs.stage_matrices).test_integration.skip_cbdino }}
+ run: |
+ echo cbdino config:
+ echo "${{ toJson(fromJson(needs.setup.outputs.stage_matrices).test_integration.cbdino_config) }}"
+ echo cbdino linux:
+ echo "${{ toJson(fromJson(needs.setup.outputs.stage_matrices).test_integration.linux_cbdino) }}"
+ - name: Linux Integration Stage
+ if: >-
+ ${{ fromJson(needs.setup.outputs.stage_matrices).test_integration.has_linux
+ && !fromJson(needs.setup.outputs.stage_matrices).test_integration.skip_integration }}
+ run: |
+ echo "${{ toJson(fromJson(needs.setup.outputs.stage_matrices).test_integration.test_config) }}"
+
+
+ test-setup:
+ runs-on: ubuntu-22.04
+ needs: confirm-matrices
+ env:
+ CBCI_CONFIG: ${{ inputs.config }}
+ steps:
+ - uses: actions/checkout@v4
+ - name: Download CI scripts
+ uses: actions/download-artifact@v4
+ with:
+ name: ci_scripts
+ path: ci_scripts
+ - name: Enable CI Scripts
+ run: |
+ chmod +x ci_scripts/gha.sh
+ - name: Setup Python ${{ env.CBCI_DEFAULT_PYTHON }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.CBCI_DEFAULT_PYTHON }}
+ - name: Confirm Python version
+ run: python -c "import sys; print(sys.version)"
+ - name: Build test setup
+ run: |
+ ./ci_scripts/gha.sh build_test_setup
+ - name: Confirm test setup
+ run: |
+ echo "pycbac_test directory contents:"
+ ls -alh pycbac_test
+ echo "pycbac_test/acb/tests contents:"
+ ls -alh pycbac_test/acb/tests
+ echo "pycbac_test/cb/tests contents:"
+ ls -alh pycbac_test/cb/tests
+ echo "pycbac_test/tests contents:"
+ ls -alh pycbac_test/tests
+ echo "pycbac_test/conftest.py contents:"
+ cat pycbac_test/conftest.py
+ echo "pycbac_test/requirements-test.txt contents:"
+ cat pycbac_test/requirements-test.txt
+ echo "pycbac_test/pytest.ini contents:"
+ cat pycbac_test/pytest.ini
+ echo "pycbac_test/tests/test_config.ini contents:"
+ cat pycbac_test/tests/test_config.ini
+ - name: Upload test setup
+ uses: actions/upload-artifact@v4
+ with:
+ retention-days: 1
+ name: pycbac-test-setup
+ path: |
+ pycbac_test/
+
+ lint:
+ runs-on: ubuntu-22.04
+ needs: validate-input
+ env:
+ CBCI_VERSION: ${{ inputs.version }}
+ steps:
+ - uses: actions/checkout@v4
+ - name: Download CI scripts
+ uses: actions/download-artifact@v4
+ with:
+ name: ci_scripts
+ path: ci_scripts
+ - name: Enable CI Scripts
+ run: |
+ chmod +x ci_scripts/gha.sh
+ - name: Setup Python ${{ env.CBCI_DEFAULT_PYTHON }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.CBCI_DEFAULT_PYTHON }}
+ - name: Confirm Python version
+ run: python -c "import sys; print(sys.version)"
+ - name: Execute linting
+ run: |
+ ls -alh
+ ./ci_scripts/gha.sh lint
+
+ sdist-wheel:
+ runs-on: ubuntu-22.04
+ needs: lint
+ env:
+ CBCI_VERSION: ${{ inputs.version }}
+ CBCI_CONFIG: ${{ inputs.config }}
+ outputs:
+ sdist_name: ${{ steps.create_sdist.outputs.sdist_name }}
+ wheel_name: ${{ steps.create_wheel.outputs.wheel_name }}
+ steps:
+ - name: Checkout (with SHA)
+ if: inputs.sha != ''
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.sha }}
+ - name: Checkout (no SHA)
+ if: inputs.sha == ''
+ uses: actions/checkout@v4
+ - name: Download CI scripts
+ uses: actions/download-artifact@v4
+ with:
+ name: ci_scripts
+ path: ci_scripts
+ - name: Enable CI Scripts
+ run: |
+ chmod +x ci_scripts/gha.sh
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.CBCI_DEFAULT_PYTHON }}
+ - name: Confirm Python version
+ run: python -c "import sys; print(sys.version)"
+ - name: Create sdist
+ id: create_sdist
+ run: |
+ ./ci_scripts/gha.sh sdist
+ exit_code=0
+ sdist_name=$(./ci_scripts/gha.sh get_sdist_name) || exit_code=$?
+ if [ $exit_code -ne 0 ]; then
+ echo "Failed to obtain sdist name."
+ exit 1
+ fi
+ echo "SDIST_NAME=$sdist_name"
+ echo "sdist_name=$sdist_name" >> "$GITHUB_OUTPUT"
+ - name: Create wheel
+ id: create_wheel
+ run: |
+ ./ci_scripts/gha.sh wheel
+ wheel_name=$(find ./dist -name '*.whl' | cut -c 8-)
+ echo "WHEEL_NAME=$wheel_name"
+ echo "wheel_name=$wheel_name" >> "$GITHUB_OUTPUT"
+ - name: Upload Python sdk
+ uses: actions/upload-artifact@v4
+ with:
+ retention-days: 1
+ name: pycbac-artifact-sdist
+ path: |
+ ./dist/*.tar.gz
+ - name: Upload Python wheel
+ uses: actions/upload-artifact@v4
+ with:
+ retention-days: 1
+ name: pycbac-artifact-wheel
+ path: |
+ ./dist/*.whl
+
+ linux-unit-tests:
+ needs: [setup, test-setup, sdist-wheel]
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.has_linux }}
+ name: Run unit tests; Python ${{ matrix.python-version }} - ${{ matrix.linux-type }} (${{ matrix.arch }})
+ runs-on: ubuntu-22.04
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.linux }}
+ steps:
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Confirm Python version
+ run: python -c "import sys; print(sys.version)"
+ - name: Set up QEMU
+ if: ${{ matrix.arch == 'aarch64' }}
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: arm64
+ - name: Download sdist
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_sdist_install }}
+ uses: actions/download-artifact@v4
+ with:
+ name: pycbac-artifact-sdist
+ path: pycbac
+ - name: Download wheel
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_wheel_install }}
+ uses: actions/download-artifact@v4
+ with:
+ name: pycbac-artifact-wheel
+ path: pycbac
+ - name: Download test setup
+ uses: actions/download-artifact@v4
+ with:
+ name: pycbac-test-setup
+ path: pycbac
+ - name: Run unit tests in docker via sdist install
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_sdist_install }}
+ uses: addnab/docker-run-action@v3
+ with:
+ image: python:${{ matrix.python-version }}-${{ matrix.linux-type == 'manylinux' && env.CBCI_DEFAULT_LINUX_CONTAINER || env.CBCI_DEFAULT_ALPINE_CONTAINER }}
+ options: >-
+ --platform linux/${{ matrix.arch == 'aarch64' && 'arm64' || 'amd64'}}
+ -v ${{ github.workspace }}/pycbac:/pycbac
+ run: |
+ apt-get update && apt-get install -y jq
+ python -m pip install --upgrade pip setuptools wheel
+ cd pycbac
+ ls -alh
+ python -m pip install -r requirements-test.txt
+ SDIST_NAME=${{ needs.sdist-wheel.outputs.sdist_name }}
+ echo "SDIST_NAME=$SDIST_NAME.tar.gz"
+ python -m pip install ${SDIST_NAME}.tar.gz
+ python -m pip list
+ TEST_ACOUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_acouchbase_api }}
+ if [ "$TEST_ACOUCHBASE_API" = "true" ]; then
+ python -m pytest -m "pycbac_acouchbase and pycbac_unit" -rA -vv
+ fi
+ TEST_COUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_couchbase_api }}
+ if [ "$TEST_COUCHBASE_API" = "true" ]; then
+ python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv
+ fi
+ - name: Run unit tests in docker via wheel install
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_wheel_install }}
+ uses: addnab/docker-run-action@v3
+ with:
+ image: python:${{ matrix.python-version }}-${{ matrix.linux-type == 'manylinux' && env.CBCI_DEFAULT_LINUX_CONTAINER || env.CBCI_DEFAULT_ALPINE_CONTAINER }}
+ options: >-
+ --platform linux/${{ matrix.arch == 'aarch64' && 'arm64' || 'amd64'}}
+ -v ${{ github.workspace }}/pycbac:/pycbac
+ run: |
+ apt-get update && apt-get install -y jq
+ python -m pip install --upgrade pip setuptools wheel
+ cd pycbac
+ ls -alh
+ python -m pip install -r requirements-test.txt
+ WHEEL_NAME=${{ needs.sdist-wheel.outputs.wheel_name }}
+ echo "WHEEL_NAME=$WHEEL_NAME"
+ python -m pip install ${WHEEL_NAME}
+ python -m pip list
+ TEST_ACOUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_acouchbase_api }}
+ if [ "$TEST_ACOUCHBASE_API" = "true" ]; then
+ python -m pytest -m "pycbac_acouchbase and pycbac_unit" -rA -vv
+ fi
+ TEST_COUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_couchbase_api }}
+ if [ "$TEST_COUCHBASE_API" = "true" ]; then
+ python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv
+ fi
+
+ macos-unit-tests:
+ needs: [setup, test-setup, sdist-wheel]
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.has_macos }}
+ name: Run unit tests; Python ${{ matrix.python-version }} - ${{ matrix.arch }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.macos }}
+ steps:
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Confirm Python version
+ run: python -c "import sys; print(sys.version)"
+ - name: Download sdist
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_sdist_install }}
+ uses: actions/download-artifact@v4
+ with:
+ name: pycbac-artifact-sdist
+ path: pycbac
+ - name: Download wheel
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_wheel_install }}
+ uses: actions/download-artifact@v4
+ with:
+ name: pycbac-artifact-wheel
+ path: pycbac
+ - name: Download test setup
+ uses: actions/download-artifact@v4
+ with:
+ name: pycbac-test-setup
+ path: pycbac
+ - name: Run unit tests via sdist install
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_sdist_install }}
+ run: |
+ python -m pip install --upgrade pip setuptools wheel
+ cd pycbac
+ ls -alh
+ python -m pip install -r requirements-test.txt
+ SDIST_NAME=${{ needs.sdist-wheel.outputs.sdist_name }}
+ echo "SDIST_NAME=$SDIST_NAME.tar.gz"
+ python -m pip install ${SDIST_NAME}.tar.gz
+ python -m pip list
+ TEST_ACOUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_acouchbase_api }}
+ if [ "$TEST_ACOUCHBASE_API" = "true" ]; then
+ python -m pytest -m "pycbac_acouchbase and pycbac_unit" -rA -vv --log-cli-level=DEBUG
+ fi
+ TEST_COUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_couchbase_api }}
+ if [ "$TEST_COUCHBASE_API" = "true" ]; then
+ python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv --log-cli-level=DEBUG
+ fi
+ python -m pip uninstall couchbase-analytics -y
+ - name: Run unit tests via wheel install
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_wheel_install }}
+ run: |
+ python -m pip install --upgrade pip setuptools wheel
+ cd pycbac
+ ls -alh
+ python -m pip install -r requirements-test.txt
+ WHEEL_NAME=${{ needs.sdist-wheel.outputs.wheel_name }}
+ echo "WHEEL_NAME=$WHEEL_NAME"
+ python -m pip install ${WHEEL_NAME} --no-cache-dir
+ python -m pip list
+ TEST_ACOUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_acouchbase_api }}
+ if [ "$TEST_ACOUCHBASE_API" = "true" ]; then
+ python -m pytest -m "pycbac_acouchbase and pycbac_unit" -rA -vv --log-cli-level=DEBUG
+ fi
+ TEST_COUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_couchbase_api }}
+ if [ "$TEST_COUCHBASE_API" = "true" ]; then
+ python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv --log-cli-level=DEBUG
+ fi
+
+ windows-unit-tests:
+ needs: [setup, test-setup, sdist-wheel]
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.has_windows }}
+ name: Run unit tests; Python ${{ matrix.python-version }} - ${{ matrix.arch }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.windows }}
+ steps:
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Confirm Python version
+ run: python -c "import sys; print(sys.version)"
+ - name: Download sdist
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_sdist_install }}
+ uses: actions/download-artifact@v4
+ with:
+ name: pycbac-artifact-sdist
+ path: pycbac
+ - name: Download wheel
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_wheel_install }}
+ uses: actions/download-artifact@v4
+ with:
+ name: pycbac-artifact-wheel
+ path: pycbac
+ - name: Download test setup
+ uses: actions/download-artifact@v4
+ with:
+ name: pycbac-test-setup
+ path: pycbac
+ - name: Run unit tests via sdist install
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_sdist_install }}
+ run: |
+ python -m pip install --upgrade pip setuptools wheel
+ cd pycbac
+ dir
+ python -m pip install -r requirements-test.txt
+ $SDIST_NAME="${{ needs.sdist-wheel.outputs.sdist_name }}" + ".tar.gz"
+ echo "SDIST_NAME=$SDIST_NAME"
+ python -m pip install "$SDIST_NAME"
+ python -m pip list
+ $TEST_ACOUCHBASE_API="${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_acouchbase_api }}"
+ if ( $TEST_ACOUCHBASE_API -eq "true" ) {
+ python -m pytest -m "pycbac_acouchbase and pycbac_unit" -rA -vv
+ }
+ $TEST_COUCHBASE_API="${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_couchbase_api }}"
+ if ( $TEST_COUCHBASE_API = "true" ) {
+ python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv
+ }
+ python -m pip uninstall couchbase-analytics -y
+ - name: Run unit tests via wheel install
+ if: ${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_wheel_install }}
+ run: |
+ python -m pip install --upgrade pip setuptools wheel
+ cd pycbac
+ dir
+ python -m pip install -r requirements-test.txt
+ $WHEEL_NAME="${{ needs.sdist-wheel.outputs.wheel_name }}"
+ echo "WHEEL_NAME=$WHEEL_NAME"
+ python -m pip install "$WHEEL_NAME" --no-cache-dir
+ python -m pip list
+ $TEST_ACOUCHBASE_API="${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_acouchbase_api }}"
+ if ( $TEST_ACOUCHBASE_API -eq "true" ) {
+ python -m pytest -m "pycbac_acouchbase and pycbac_unit" -rA -vv
+ }
+ $TEST_COUCHBASE_API="${{ fromJson(needs.setup.outputs.stage_matrices).test_unit.test_couchbase_api }}"
+ if ( $TEST_COUCHBASE_API = "true" ) {
+ python -m pytest -m "pycbac_couchbase and pycbac_unit" -rA -vv
+ }
+
+ cbdino-integration-tests:
+ needs: [setup, test-setup, sdist-wheel]
+ if: >-
+ ${{ fromJson(needs.setup.outputs.stage_matrices).test_integration.has_linux_cbdino
+ && !fromJson(needs.setup.outputs.stage_matrices).test_integration.skip_cbdino }}
+ name: Run integration tests w/ cbdino; Python ${{ matrix.python-version }} - ${{ matrix.os }} (${{ matrix.arch }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix: ${{ fromJson(needs.setup.outputs.stage_matrices).test_integration.linux_cbdino }}
+ steps:
+ - name: Install cbdinocluster
+ run: |
+ mkdir -p "$HOME/bin"
+ CB_DINO_VERSION=${{ env.CBCI_CBDINO_VERSION }}
+ CB_DINO_TYPE="cbdinocluster-${{ matrix.arch == 'x86_64' && 'linux-amd64' || 'linux-arm64' }}"
+ wget -nv -O $HOME/bin/cbdinocluster https://github.com/couchbaselabs/cbdinocluster/releases/download/$CB_DINO_VERSION/$CB_DINO_TYPE
+ chmod +x $HOME/bin/cbdinocluster
+ echo "$HOME/bin" >> $GITHUB_PATH
+ - name: Install s3mock
+ run: |
+ docker pull adobe/s3mock
+ docker pull nginx
+ - name: Initialize cbdinocluster
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ cbdinocluster -v init --auto
+ - name: Setup Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Confirm Python version
+ run: python -c "import sys; print(sys.version)"
+ - name: Download CI scripts
+ uses: actions/download-artifact@v4
+ with:
+ name: ci_scripts
+ path: ci_scripts
+ - name: Enable CI Scripts
+ run: |
+ chmod +x ci_scripts/gha.sh
+ - name: Download wheel
+ uses: actions/download-artifact@v4
+ with:
+ name: pycbac-artifact-wheel
+ path: pycbac
+ - name: Download test setup
+ uses: actions/download-artifact@v4
+ with:
+ name: pycbac-test-setup
+ path: pycbac
+ - name: Start couchbase cluster
+ run: |
+ cd pycbac
+ cat cluster_def.yaml
+ CBDC_ID=$(cbdinocluster -v alloc --def-file=cluster_def.yaml)
+ CBDC_CONNSTR=$(cbdinocluster -v connstr --analytics $CBDC_ID)
+ echo "CBDC_ID=$CBDC_ID" >> "$GITHUB_ENV"
+ echo "CBDC_CONNSTR=$CBDC_CONNSTR" >> "$GITHUB_ENV"
+ echo "CBDC_CONNSTR=$CBDC_CONNSTR"
+ cbdinocluster buckets load-sample $CBDC_ID travel-sample
+ - name: Update test_config.ini
+ env:
+ PYCBAC_USERNAME: 'Administrator'
+ PYCBAC_PASSWORD: 'password'
+ PYCBAC_FQDN: 'travel-sample.inventory.airline'
+ CBCONNSTR: ${{ env.CBDC_CONNSTR }}
+ run: |
+ ./ci_scripts/gha.sh build_test_config_ini pycbac/tests
+ - name: Run tests
+ timeout-minutes: 30
+ run: |
+ python -m pip install --upgrade pip setuptools wheel
+ cd pycbac
+ ls -alh
+ cat tests/test_config.ini
+ python -m pip install -r requirements-test.txt
+ WHEEL_NAME=${{ needs.sdist-wheel.outputs.wheel_name }}
+ echo "WHEEL_NAME=$WHEEL_NAME"
+ python -m pip install ${WHEEL_NAME}
+ python -m pip list
+ TEST_ACOUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_integration.test_acouchbase_api }}
+ if [ "$TEST_ACOUCHBASE_API" = "true" ]; then
+ python -m pytest -m "pycbac_acouchbase and pycbac_integration" -rA -vv
+ fi
+ TEST_COUCHBASE_API=${{ fromJson(needs.setup.outputs.stage_matrices).test_integration.test_couchbase_api }}
+ if [ "$TEST_COUCHBASE_API" = "true" ]; then
+ python -m pytest -m "pycbac_couchbase and pycbac_integration" -rA -vv
+ fi
+ - name: Cleanup cbdino cluster
+ run: |
+ cbdinocluster rm ${{ env.CBDC_ID }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2511da8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,180 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+.idea/
+
+# Distribution / packaging
+build/
+couchbase_analytics/_version.py
+
+# Sphinx
+docs/_build/
+
+# VS Code
+.vscode/
+
+# tests
+tests/test_logs/
+CouchbaseMock*.jar
+gocaves*
+.pytest_cache/
+test_scripts/
+
+# rff
+.ruff_cache/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..6edfbdc
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,67 @@
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v4.6.0
+ hooks:
+ - id: trailing-whitespace
+ - id: end-of-file-fixer
+ - id: check-yaml
+ args: [--allow-multiple-documents]
+ - id: check-added-large-files
+ - id: check-toml
+ - id: check-merge-conflict
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ # Ruff version.
+ rev: v0.12.1
+ hooks:
+ # Run the linter.
+ - id: ruff-check
+ types_or: [ python, pyi ]
+ # Run the formatter.
+ - id: ruff-format
+ types_or: [ python, pyi ]
+ - repo: https://github.com/PyCQA/bandit
+ rev: 1.8.6
+ hooks:
+ - id: bandit
+ exclude: |
+ (?x)^(
+ acouchbase_analytics/tests/|
+ couchbase_analytics/tests/|
+ tests/|
+ couchbase_analytics_version.py
+ )
+ args:
+ [
+ --quiet
+ ]
+ - repo: local
+ hooks:
+ - id: mypy
+ name: mypy
+ entry: "./run-mypy"
+ language: python
+ additional_dependencies:
+ - mypy~=1.16.1
+ - pytest~=8.3.5
+ - httpx~=0.28.1
+ - aiohttp~=3.11.10
+ types:
+ - python
+ require_serial: true
+ verbose: true
+ - repo: https://github.com/astral-sh/uv-pre-commit
+ # uv version.
+ rev: 0.7.19
+ hooks:
+ # Compile requirements
+ - id: pip-compile
+ name: pip-compile requirements.in
+ args: [requirements.in, --python-version, '3.9', --universal, -o, requirements.txt]
+ - id: pip-compile
+ name: pip-compile requirements-dev.in
+ args: [requirements-dev.in, --python-version, '3.9', --universal, -o, requirements-dev.txt]
+ files: ^requirements-dev\.(in|txt)$
+ - id: pip-compile
+ name: pip-compile requirements-sphinx.in
+ args: [requirements-sphinx.in, --python-version, '3.9', --universal, -o, requirements-sphinx.txt]
+ files: ^requirements-sphinx\.(in|txt)$
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e69de29
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..ea232c5
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,10 @@
+include *.txt LICENSE CONTRIBUTING.md pyproject.toml couchbase_analytics_version.py
+include couchbase-sdk-analytics-python-black-duck-manifest.yaml
+include couchbase_analytics/common/_core/nonprod_certificates/*.pem
+include couchbase_analytics/common/_core/capella_certificates/*.pem
+recursive-include couchbase_analytics *.py
+exclude couchbase_analytics/tests/*.py
+recursive-include acouchbase_analytics *.py
+exclude acouchbase_analytics/tests/*.py
+global-exclude *.py[cod] *.DS_Store
+exclude .git .gitignore .gitmodules gocaves* *.jar .clang* .cmake* .pre* .flake* MANIFEST.in
diff --git a/README.md b/README.md
index bea1cba..0d04e21 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,133 @@
# Couchbase Python Analytics Client
-Python client for [Couchbase](https://couchbase.com) Analytics.
\ No newline at end of file
+Python client for [Couchbase](https://couchbase.com) Analytics.
+
+Currently Python 3.9 - Python 3.13 is supported.
+
+The Analytics SDK supports static typing. Currently only [mypy](https://github.com/python/mypy) is supported. You mileage may vary (YMMV) with the use of other static type checkers (e.g. [pyright](https://github.com/microsoft/pyright)).
+
+# Installing the SDK
+
+Until a version is available on PyPI, the SDK can be installed via pip with the following command (note the `dev` branch in the url).
+
+Install the SDK via `pip`:
+```console
+python3 -m pip install git+https://github.com/couchbaselabs/analytics-python-client@dev
+```
+
+# Using the SDK
+
+Some more examples are provided in the [examples directory](https://github.com/couchbaselabs/analytics-python-client/tree/dev/examples).
+
+**Connecting and executing a query**
+```python
+from couchbase_analytics.cluster import Cluster
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.options import QueryOptions
+
+
+def main() -> None:
+ # Update this to your cluster
+ # IMPORTANT: The appropriate port needs to be specified. The SDK's default ports are 80 (http) and 443 (https).
+ # If attempting to connect to Capella, the correct ports are most likely to be 8095 (http) and 18095 (https).
+ # Capella example: https://cb.2xg3vwszqgqcrsix.cloud.couchbase.com:18095
+ endpoint = 'https://--your-instance--'
+ username = 'username'
+ pw = 'password'
+ # User Input ends here.
+
+ cred = Credential.from_username_and_password(username, pw)
+ cluster = Cluster.create_instance(endpoint, cred)
+
+ # Execute a query and buffer all result rows in client memory.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline LIMIT 10;'
+ res = cluster.execute_query(statement)
+ all_rows = res.get_all_rows()
+ for row in all_rows:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a query and process rows as they arrive from server.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country="United States" LIMIT 10;'
+ res = cluster.execute_query(statement)
+ for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming query with positional arguments.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$1 LIMIT $2;'
+ res = cluster.execute_query(statement, QueryOptions(positional_parameters=['United States', 10]))
+ for row in res:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming query with named arguments.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$country LIMIT $limit;'
+ res = cluster.execute_query(statement, QueryOptions(named_parameters={'country': 'United States',
+ 'limit': 10}))
+ for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+
+if __name__ == '__main__':
+ main()
+
+```
+
+## Using the async API
+```python
+import asyncio
+
+from acouchbase_analytics.cluster import AsyncCluster
+from acouchbase_analytics.credential import Credential
+from acouchbase_analytics.options import QueryOptions
+
+
+async def main() -> None:
+ # Update this to your cluster
+ # IMPORTANT: The appropriate port needs to be specified. The SDK's default ports are 80 (http) and 443 (https).
+ # If attempting to connect to Capella, the correct ports are most likely to be 8095 (http) and 18095 (https).
+ # Capella example: https://cb.2xg3vwszqgqcrsix.cloud.couchbase.com:18095
+ endpoint = 'https://--your-instance--'
+ username = 'username'
+ pw = 'password'
+ # User Input ends here.
+
+ cred = Credential.from_username_and_password(username, pw)
+ cluster = AsyncCluster.create_instance(endpoint, cred)
+
+ # Execute a query and buffer all result rows in client memory.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline LIMIT 10;'
+ res = await cluster.execute_query(statement)
+ all_rows = await res.get_all_rows()
+ # NOTE: all_rows is a list, _do not_ use `async for`
+ for row in all_rows:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a query and process rows as they arrive from server.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country="United States" LIMIT 10;'
+ res = await cluster.execute_query(statement)
+ async for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming query with positional arguments.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$1 LIMIT $2;'
+ res = await cluster.execute_query(statement, QueryOptions(positional_parameters=['United States', 10]))
+ async for row in res:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming query with named arguments.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$country LIMIT $limit;'
+ res = await cluster.execute_query(statement, QueryOptions(named_parameters={'country': 'United States',
+ 'limit': 10}))
+ async for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+if __name__ == '__main__':
+ asyncio.run(main())
+
+```
diff --git a/acouchbase_analytics/__init__.py b/acouchbase_analytics/__init__.py
new file mode 100644
index 0000000..0222448
--- /dev/null
+++ b/acouchbase_analytics/__init__.py
@@ -0,0 +1,20 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from acouchbase_analytics.protocol import get_event_loop as get_event_loop # noqa: F401
+from couchbase_analytics.common import LOG_DATE_FORMAT as LOG_DATE_FORMAT # noqa: F401
+from couchbase_analytics.common import LOG_FORMAT as LOG_FORMAT # noqa: F401
+from couchbase_analytics.common import JSONType as JSONType # noqa: F401
diff --git a/acouchbase_analytics/cluster.py b/acouchbase_analytics/cluster.py
new file mode 100644
index 0000000..02d869f
--- /dev/null
+++ b/acouchbase_analytics/cluster.py
@@ -0,0 +1,220 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING, Awaitable, Optional
+
+if sys.version_info < (3, 10):
+ from typing_extensions import TypeAlias
+else:
+ from typing import TypeAlias
+
+from acouchbase_analytics.database import AsyncDatabase
+from couchbase_analytics.result import AsyncQueryResult
+
+if TYPE_CHECKING:
+ from couchbase_analytics.credential import Credential
+ from couchbase_analytics.options import ClusterOptions
+
+
+class AsyncCluster:
+ """Create an AsyncCluster instance.
+
+ The cluster instance exposes the operations which are available to be performed against a Columnar cluster.
+
+ .. important::
+ Use the static :meth:`.AsyncCluster.create_instance` method to create an AsyncCluster.
+
+ Args:
+ connstr:
+ The connection string to use for connecting to the cluster.
+ The format of the connection string is the *scheme* (``couchbases`` as TLS enabled connections are _required_), followed a hostname
+ credential: User credentials.
+ options: Global options to set for the cluster.
+ Some operations allow the global options to be overriden by passing in options to the operation.
+ **kwargs: keyword arguments that can be used in place or to overrride provided :class:`~acouchbase_analytics.options.ClusterOptions`
+
+ Raises:
+ ValueError: If incorrect connstr is provided.
+ ValueError: If incorrect options are provided.
+
+ """ # noqa: E501
+
+ def __init__(
+ self, connstr: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object
+ ) -> None:
+ from acouchbase_analytics.protocol.cluster import AsyncCluster as _AsyncCluster
+
+ self._impl = _AsyncCluster(connstr, credential, options, **kwargs)
+
+ def database(self, name: str) -> AsyncDatabase:
+ """Creates a database instance.
+
+ .. seealso::
+ :class:`~acouchbase_analytics.database.AsyncDatabase`
+
+ Args:
+ name: Name of the database
+
+ Returns:
+ An AsyncDatabase instance.
+
+ """
+ return AsyncDatabase(self._impl, name)
+
+ def execute_query(self, statement: str, *args: object, **kwargs: object) -> Awaitable[AsyncQueryResult]:
+ """Executes a query against a Capella Columnar cluster.
+
+ .. note::
+ A departure from the operational SDK, the query is *NOT* executed lazily.
+
+ .. seealso::
+ :meth:`acouchbase_analytics.AsyncScope.execute_query`: For how to execute scope-level queries.
+
+ Args:
+ statement: The SQL++ statement to execute.
+ options (:class:`~acouchbase_analytics.options.QueryOptions`): Optional parameters for the query operation.
+ **kwargs (Dict[str, Any]): keyword arguments that can be used in place or to override provided :class:`~couchbase_analytics.options.QueryOptions`
+
+ Returns:
+ Future[:class:`~couchbase_analytics.result.AsyncQueryResult`]: A :class:`~asyncio.Future` is returned.
+ Once the :class:`~asyncio.Future` completes, an instance of a :class:`~acouchbase_analytics.result.AsyncQueryResult`
+ is available to provide access to iterate over the query results and access metadata and metrics about the query.
+
+ Examples:
+ Simple query::
+
+ q_str = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country LIKE 'United%' LIMIT 2;'
+ q_res = cluster.execute_query(q_str)
+ async for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ Simple query with positional parameters::
+
+ from acouchbase_analytics.options import QueryOptions
+
+ # ... other code ...
+
+ q_str = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country LIKE $1 LIMIT $2;'
+ q_res = cluster.execute_query(q_str, QueryOptions(positional_parameters=['United%', 5]))
+ async for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ Simple query with named parameters::
+
+ from acouchbase_analytics.options import QueryOptions
+
+ # ... other code ...
+
+ q_str = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country LIKE $country LIMIT $lim;'
+ q_res = cluster.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2}))
+ async for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ Retrieve metadata and/or metrics from query::
+
+ from acouchbase_analytics.options import QueryOptions
+
+ # ... other code ...
+
+ q_str = 'SELECT * FROM `travel-sample` WHERE country LIKE $country LIMIT $lim;'
+ q_res = cluster.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2}))
+ async for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ print(f'Query metadata: {q_res.metadata()}')
+ print(f'Query metrics: {q_res.metadata().metrics()}')
+
+ """ # noqa: E501
+ return self._impl.execute_query(statement, *args, **kwargs)
+
+ def shutdown(self) -> None:
+ """Shuts down this cluster instance. Cleaning up all resources associated with it.
+
+ .. warning::
+ Use of this method is almost *always* unnecessary. Cluster resources should be cleaned
+ up once the cluster instance falls out of scope. However, in some applications tuning resources
+ is necessary and in those types of applications, this method might be beneficial.
+
+ """
+ return self._impl.shutdown()
+
+ @classmethod
+ def create_instance(
+ cls, connstr: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object
+ ) -> AsyncCluster:
+ """Create an AsyncCluster instance
+
+ Args:
+ connstr:
+ The connection string to use for connecting to the cluster.
+ The format of the connection string is the *scheme* (``couchbases`` as TLS enabled connections are _required_), followed a hostname
+ credential: User credentials.
+ options: Global options to set for the cluster.
+ Some operations allow the global options to be overriden by passing in options to the operation.
+ **kwargs: Keyword arguments that can be used in place or to overrride provided :class:`~acouchbase_analytics.options.ClusterOptions`
+
+
+ Returns:
+ A Capella Columnar Cluster instance.
+
+ Raises:
+ ValueError: If incorrect connstr is provided.
+ ValueError: If incorrect options are provided.
+
+
+ Examples:
+ Initialize cluster using default options::
+
+ from acouchbase_analytics import get_event_loop
+ from acouchbase_analytics.cluster import AsyncCluster
+ from acouchbase_analytics.credential import Credential
+
+ async def main() -> None:
+ cred = Credential.from_username_and_password('username', 'password')
+ cluster = AsyncCluster.create_instance('couchbases://hostname', cred)
+ # ... other async code ...
+
+ if __name__ == '__main__':
+ loop = get_event_loop()
+ loop.run_until_complete(main())
+
+
+ Initialize cluster using with global timeout options::
+
+ from datetime import timedelta
+
+ from acouchbase_analytics import get_event_loop
+ from acouchbase_analytics.cluster import AsyncCluster
+ from acouchbase_analytics.credential import Credential
+ from acouchbase_analytics.options import ClusterOptions, ClusterTimeoutOptions
+
+ async def main() -> None:
+ cred = Credential.from_username_and_password('username', 'password')
+ opts = ClusterOptions(timeout_options=ClusterTimeoutOptions(query_timeout=timedelta(seconds=120)))
+ cluster = AsyncCluster.create_instance('couchbases://hostname', cred, opts)
+ # ... other async code ...
+
+ if __name__ == '__main__':
+ loop = get_event_loop()
+ loop.run_until_complete(main())
+
+ """ # noqa: E501
+ return cls(connstr, credential, options, **kwargs)
+
+
+Cluster: TypeAlias = AsyncCluster
diff --git a/acouchbase_analytics/cluster.pyi b/acouchbase_analytics/cluster.pyi
new file mode 100644
index 0000000..3969716
--- /dev/null
+++ b/acouchbase_analytics/cluster.pyi
@@ -0,0 +1,81 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+from typing import Awaitable, overload
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Unpack
+else:
+ from typing import Unpack
+
+from acouchbase_analytics.database import AsyncDatabase
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs
+from couchbase_analytics.result import AsyncQueryResult
+
+class AsyncCluster:
+ @overload
+ def __init__(self, http_endpoint: str, credential: Credential) -> None: ...
+ @overload
+ def __init__(self, http_endpoint: str, credential: Credential, options: ClusterOptions) -> None: ...
+ @overload
+ def __init__(self, http_endpoint: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ...
+ @overload
+ def __init__(
+ self,
+ http_endpoint: str,
+ credential: Credential,
+ options: ClusterOptions,
+ **kwargs: Unpack[ClusterOptionsKwargs],
+ ) -> None: ...
+ def database(self, database_name: str) -> AsyncDatabase: ...
+ @overload
+ def execute_query(self, statement: str) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, options: QueryOptions) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: str, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: str, **kwargs: str
+ ) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, *args: str, **kwargs: str) -> Awaitable[AsyncQueryResult]: ...
+ def shutdown(self) -> None: ...
+ @overload
+ @classmethod
+ def create_instance(cls, http_endpoint: str, credential: Credential) -> AsyncCluster: ...
+ @overload
+ @classmethod
+ def create_instance(cls, http_endpoint: str, credential: Credential, options: ClusterOptions) -> AsyncCluster: ...
+ @overload
+ @classmethod
+ def create_instance(
+ cls, http_endpoint: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs]
+ ) -> AsyncCluster: ...
+ @overload
+ @classmethod
+ def create_instance(
+ cls, http_endpoint: str, credential: Credential, options: ClusterOptions, **kwargs: Unpack[ClusterOptionsKwargs]
+ ) -> AsyncCluster: ...
diff --git a/acouchbase_analytics/credential.py b/acouchbase_analytics/credential.py
new file mode 100644
index 0000000..c3aa770
--- /dev/null
+++ b/acouchbase_analytics/credential.py
@@ -0,0 +1,16 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common.credential import Credential as Credential # noqa: F401
diff --git a/acouchbase_analytics/database.py b/acouchbase_analytics/database.py
new file mode 100644
index 0000000..8cb1935
--- /dev/null
+++ b/acouchbase_analytics/database.py
@@ -0,0 +1,58 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING
+
+if sys.version_info < (3, 10):
+ from typing_extensions import TypeAlias
+else:
+ from typing import TypeAlias
+
+from acouchbase_analytics.scope import AsyncScope
+
+if TYPE_CHECKING:
+ from acouchbase_analytics.protocol.cluster import AsyncCluster
+
+
+class AsyncDatabase:
+ def __init__(self, cluster: AsyncCluster, database_name: str) -> None:
+ from acouchbase_analytics.protocol.database import AsyncDatabase as _AsyncDatabase
+
+ self._impl = _AsyncDatabase(cluster, database_name)
+
+ @property
+ def name(self) -> str:
+ """
+ str: The name of this :class:`~acouchbase_analytics.database.AsyncDatabase` instance.
+ """
+ return self._impl.name
+
+ def scope(self, scope_name: str) -> AsyncScope:
+ """Creates a :class:`~acouchbase_analytics.scope.AsyncScope` instance.
+
+ Args:
+ scope_name (str): Name of the scope.
+
+ Returns:
+ :class:`~acouchbase_analytics.scope.AsyncScope`
+
+ """
+ return AsyncScope(self._impl, scope_name)
+
+
+Database: TypeAlias = AsyncDatabase
diff --git a/acouchbase_analytics/database.pyi b/acouchbase_analytics/database.pyi
new file mode 100644
index 0000000..90fd597
--- /dev/null
+++ b/acouchbase_analytics/database.pyi
@@ -0,0 +1,23 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from acouchbase_analytics.protocol.cluster import AsyncCluster as AsyncCluster
+from acouchbase_analytics.scope import AsyncScope
+
+class AsyncDatabase:
+ def __init__(self, cluster: AsyncCluster, database_name: str) -> None: ...
+ @property
+ def name(self) -> str: ...
+ def scope(self, scope_name: str) -> AsyncScope: ...
diff --git a/acouchbase_analytics/deserializer.py b/acouchbase_analytics/deserializer.py
new file mode 100644
index 0000000..d5aed73
--- /dev/null
+++ b/acouchbase_analytics/deserializer.py
@@ -0,0 +1,18 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common.deserializer import DefaultJsonDeserializer as DefaultJsonDeserializer # noqa: F401
+from couchbase_analytics.common.deserializer import Deserializer as Deserializer # noqa: F401
+from couchbase_analytics.common.deserializer import PassthroughDeserializer as PassthroughDeserializer # noqa: F401
diff --git a/acouchbase_analytics/errors.py b/acouchbase_analytics/errors.py
new file mode 100644
index 0000000..3b18f30
--- /dev/null
+++ b/acouchbase_analytics/errors.py
@@ -0,0 +1,20 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common.errors import AnalyticsError as AnalyticsError # noqa: F401
+from couchbase_analytics.common.errors import InternalSDKError as InternalSDKError # noqa: F401
+from couchbase_analytics.common.errors import InvalidCredentialError as InvalidCredentialError # noqa: F401
+from couchbase_analytics.common.errors import QueryError as QueryError # noqa: F401
+from couchbase_analytics.common.errors import TimeoutError as TimeoutError # noqa: F401
diff --git a/acouchbase_analytics/options.py b/acouchbase_analytics/options.py
new file mode 100644
index 0000000..ef2074d
--- /dev/null
+++ b/acouchbase_analytics/options.py
@@ -0,0 +1,23 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common.options import ClusterOptions as ClusterOptions # noqa: F401
+from couchbase_analytics.common.options import ClusterOptionsKwargs as ClusterOptionsKwargs # noqa: F401
+from couchbase_analytics.common.options import QueryOptions as QueryOptions # noqa: F401
+from couchbase_analytics.common.options import QueryOptionsKwargs as QueryOptionsKwargs # noqa: F401
+from couchbase_analytics.common.options import SecurityOptions as SecurityOptions # noqa: F401
+from couchbase_analytics.common.options import SecurityOptionsKwargs as SecurityOptionsKwargs # noqa: F401
+from couchbase_analytics.common.options import TimeoutOptions as TimeoutOptions # noqa: F401
+from couchbase_analytics.common.options import TimeoutOptionsKwargs as TimeoutOptionsKwargs # noqa: F401
diff --git a/acouchbase_analytics/protocol/__init__.py b/acouchbase_analytics/protocol/__init__.py
new file mode 100644
index 0000000..e880775
--- /dev/null
+++ b/acouchbase_analytics/protocol/__init__.py
@@ -0,0 +1,86 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import asyncio
+import selectors
+from typing import Optional
+
+
+class _LoopValidator:
+ """
+ **INTERNAL**
+ """
+
+ REQUIRED_METHODS = {'add_reader', 'remove_reader', 'add_writer', 'remove_writer'}
+
+ @staticmethod
+ def _get_working_loop() -> asyncio.AbstractEventLoop:
+ """
+ **INTERNAL**
+ """
+ evloop = asyncio.get_event_loop()
+ gen_new_loop = not _LoopValidator._is_valid_loop(evloop)
+ if gen_new_loop:
+ evloop.close()
+ selector = selectors.SelectSelector()
+ new_loop = asyncio.SelectorEventLoop(selector)
+ asyncio.set_event_loop(new_loop)
+ return new_loop
+
+ return evloop
+
+ @staticmethod
+ def _is_valid_loop(evloop: Optional[asyncio.AbstractEventLoop] = None) -> bool:
+ """
+ **INTERNAL**
+ """
+ if not evloop:
+ return False
+ for meth in _LoopValidator.REQUIRED_METHODS:
+ abs_meth, actual_meth = (getattr(asyncio.AbstractEventLoop, meth), getattr(evloop.__class__, meth))
+ if abs_meth == actual_meth:
+ return False
+ return True
+
+ @staticmethod
+ def get_event_loop(evloop: Optional[asyncio.AbstractEventLoop] = None) -> asyncio.AbstractEventLoop:
+ """
+ **INTERNAL**
+ """
+ if evloop and _LoopValidator._is_valid_loop(evloop):
+ return evloop
+ return _LoopValidator._get_working_loop()
+
+ @staticmethod
+ def close_loop() -> None:
+ """
+ **INTERNAL**
+ """
+ evloop = asyncio.get_event_loop()
+ evloop.close()
+
+
+def get_event_loop(evloop: Optional[asyncio.AbstractEventLoop] = None) -> asyncio.AbstractEventLoop:
+ """
+ Get an event loop compatible with acouchbase_analytics.
+ Some Event loops, such as ProactorEventLoop (the default asyncio event
+ loop for Python 3.8 on Windows) are not compatible with acouchbase_analytics as
+ they don't implement all members in the abstract base class.
+
+ :param evloop: preferred event loop
+ :return: The preferred event loop, if compatible, otherwise, a compatible
+ alternative event loop.
+ """
+ return _LoopValidator.get_event_loop(evloop)
diff --git a/acouchbase_analytics/protocol/_core/__init__.py b/acouchbase_analytics/protocol/_core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/acouchbase_analytics/protocol/_core/anyio_utils.py b/acouchbase_analytics/protocol/_core/anyio_utils.py
new file mode 100644
index 0000000..3a29499
--- /dev/null
+++ b/acouchbase_analytics/protocol/_core/anyio_utils.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from asyncio import AbstractEventLoop
+from typing import Optional
+
+import anyio
+
+
+def get_time() -> float:
+ """
+ Get the current time in seconds since the epoch.
+ """
+ return anyio.current_time()
+
+
+async def sleep(delay: float) -> None:
+ await anyio.sleep(delay)
+
+
+class AsyncBackend:
+ def __init__(self, backend_lib: str) -> None:
+ """
+ Initialize the async backend.
+ """
+ self._backend_lib = backend_lib
+
+ @property
+ def backend_lib(self) -> str:
+ """
+ Get the name of the async backend library
+ """
+ return self._backend_lib
+
+ @property
+ def loop(self) -> Optional[AbstractEventLoop]:
+ """
+ Get the event loop for the async backend, if it exists
+ """
+ if not hasattr(self, '_loop'):
+ if self._backend_lib == 'asyncio':
+ import asyncio
+
+ self._loop = asyncio.get_event_loop()
+ else:
+ raise RuntimeError('Unsupported async backend library.')
+ return self._loop
+
+
+def current_async_library() -> Optional[AsyncBackend]:
+ try:
+ import sniffio
+ except ImportError:
+ async_lib = 'asyncio'
+
+ # TODO: This helps make tests work.
+ # Should we work through the scenario when sniffio cannot find the async library?
+ try:
+ async_lib = sniffio.current_async_library()
+ except sniffio.AsyncLibraryNotFoundError:
+ async_lib = 'asyncio'
+
+ if async_lib not in ('asyncio', 'trio'):
+ raise RuntimeError('Running under an unsupported async environment.')
+
+ # TODO: confirm trio support
+ if async_lib == 'trio':
+ raise RuntimeError('trio currently not supported')
+
+ return AsyncBackend(async_lib)
diff --git a/acouchbase_analytics/protocol/_core/async_json_stream.py b/acouchbase_analytics/protocol/_core/async_json_stream.py
new file mode 100644
index 0000000..474638e
--- /dev/null
+++ b/acouchbase_analytics/protocol/_core/async_json_stream.py
@@ -0,0 +1,216 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from typing import AsyncIterator, Callable, Optional
+
+import ijson
+from anyio import EndOfStream, Event, create_memory_object_stream
+
+from acouchbase_analytics.protocol._core.async_json_token_parser import AsyncJsonTokenParser
+from couchbase_analytics.common._core.json_parsing import JsonStreamConfig, ParsedResult, ParsedResultType
+from couchbase_analytics.common._core.json_token_parser_base import JsonTokenParsingError
+from couchbase_analytics.common.errors import AnalyticsError
+from couchbase_analytics.common.logging import LogLevel
+
+
+class AsyncJsonStream:
+ def __init__(
+ self,
+ http_stream_iter: AsyncIterator[bytes],
+ *,
+ stream_config: Optional[JsonStreamConfig] = None,
+ logger_handler: Optional[Callable[[str, LogLevel], None]] = None,
+ ) -> None:
+ # HTTP stream handling
+ if stream_config is None:
+ stream_config = JsonStreamConfig()
+ self._http_stream_iter = http_stream_iter
+ self._http_stream_buffer_size = stream_config.http_stream_buffer_size
+ self._http_response_buffer = bytearray()
+ self._http_stream_exhausted = False
+
+ # logging
+ self._log_handler = logger_handler
+
+ # results handling
+ self._send_stream, self._receive_stream = create_memory_object_stream[ParsedResult](
+ max_buffer_size=stream_config.buffered_row_max
+ )
+ self._json_stream_parser = None
+ self._buffer_entire_result = stream_config.buffer_entire_result
+ handler = None if self._buffer_entire_result is True else self._handle_json_result
+ self._json_token_parser = AsyncJsonTokenParser(handler)
+ self._token_stream_exhausted = False
+ self._has_results_or_errors_evt = Event()
+ self._results_or_errors_type = ParsedResultType.UNKNOWN
+
+ @property
+ def has_results_or_errors(self) -> Event:
+ """
+ **INTERNAL**
+ """
+ return self._has_results_or_errors_evt
+
+ @property
+ def results_or_errors_type(self) -> ParsedResultType:
+ """
+ **INTERNAL**
+ """
+ return self._results_or_errors_type
+
+ @property
+ def token_stream_exhausted(self) -> bool:
+ """
+ **INTERNAL**
+ """
+ return self._token_stream_exhausted
+
+ def _continue_processing(self) -> bool:
+ """
+ **INTERNAL**
+ """
+ if self._token_stream_exhausted:
+ return False
+ if self._buffer_entire_result:
+ return True
+
+ stats = self._receive_stream.statistics()
+ if stats.current_buffer_used >= stats.max_buffer_size:
+ return False
+ return True
+
+ def _log_message(self, message: str, level: LogLevel) -> None:
+ if self._log_handler is not None:
+ self._log_handler(message, level)
+
+ async def _send_to_stream(self, result: ParsedResult, close: Optional[bool] = False) -> None:
+ """
+ **INTERNAL**
+ """
+ await self._send_stream.send(result)
+ if close is True:
+ await self._send_stream.aclose()
+
+ async def _handle_json_result(self, row: bytes) -> None:
+ """
+ **INTERNAL**
+ """
+ if not self._has_results_or_errors_evt.is_set():
+ self._handle_notification(ParsedResultType.ROW)
+ await self._send_to_stream(ParsedResult(row, ParsedResultType.ROW))
+
+ def _handle_notification(self, result_type: Optional[ParsedResultType] = None) -> None:
+ if self._has_results_or_errors_evt.is_set():
+ return
+
+ if result_type is None:
+ self._results_or_errors_type = ParsedResultType.END
+ self._has_results_or_errors_evt.set()
+ return
+
+ self._results_or_errors_type = result_type
+ self._has_results_or_errors_evt.set()
+
+ async def _process_token_stream(self) -> None:
+ """
+ **INTERNAL**
+ """
+ if self._json_stream_parser is None:
+ self._json_stream_parser = ijson.parse_async(self, buf_size=self._http_stream_buffer_size)
+
+ while self._continue_processing():
+ try:
+ _, event, value = await self._json_stream_parser.__anext__() # type: ignore[attr-defined]
+ # this is a hack b/c the ijson.parse_async iterator does not yield to the event loop
+ # TODO: create PYCO to either build custom JSON parsing, or dig into ijson root cause
+ await self._json_token_parser.parse_token(event, value)
+ except StopAsyncIteration:
+ self._token_stream_exhausted = True
+ except JsonTokenParsingError as ex:
+ ex_str = str(ex)
+ self._log_message(f'JSON token parsing error encountered: {ex_str}', LogLevel.ERROR)
+ self._token_stream_exhausted = True
+ await self._send_to_stream(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR), close=True)
+ self._handle_notification(ParsedResultType.ERROR)
+ return
+ except ijson.common.IncompleteJSONError as ex:
+ ex_str = str(ex)
+ self._log_message(f'Incomplete JSON error encountered: {ex_str}', LogLevel.ERROR)
+ self._token_stream_exhausted = True
+ await self._send_to_stream(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR), close=True)
+ self._handle_notification(ParsedResultType.ERROR)
+ return
+ except ijson.common.JSONError as ex:
+ ex_str = str(ex)
+ self._log_message(f'JSON error encountered: {ex_str}', LogLevel.ERROR)
+ self._token_stream_exhausted = True
+ await self._send_to_stream(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR), close=True)
+ self._handle_notification(ParsedResultType.ERROR)
+ return
+ except ijson.backends.python.UnexpectedSymbol as ex:
+ ex_str = str(ex)
+ self._log_message(f'Unexpected symbol encountered: {ex_str}', LogLevel.ERROR)
+ self._token_stream_exhausted = True
+ await self._send_to_stream(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR), close=True)
+ self._handle_notification(ParsedResultType.ERROR)
+ return
+
+ if self._token_stream_exhausted:
+ result_type = ParsedResultType.ERROR if self._json_token_parser.has_errors else ParsedResultType.END
+ await self._send_to_stream(ParsedResult(self._json_token_parser.get_result(), result_type), close=True)
+ self._handle_notification(result_type)
+
+ async def read(self, size: Optional[int] = -1) -> bytes:
+ """
+ **INTERNAL**
+ """
+ if size is None or size == 0 or self._http_stream_exhausted:
+ return b''
+
+ while not self._http_stream_exhausted:
+ if size >= 0 and len(self._http_response_buffer) > size:
+ break
+ try:
+ chunk = await self._http_stream_iter.__anext__()
+ self._http_response_buffer += chunk
+ except StopAsyncIteration:
+ self._http_stream_exhausted = True
+ break
+
+ if size == -1:
+ data = bytes(self._http_response_buffer[:])
+ del self._http_response_buffer[:]
+ else:
+ end = min(size, len(self._http_response_buffer))
+ data = bytes(self._http_response_buffer[:end])
+ del self._http_response_buffer[:end]
+ return data
+
+ async def get_result(self) -> ParsedResult:
+ try:
+ return await self._receive_stream.receive()
+ except EndOfStream as ex:
+ raise AnalyticsError(ex, 'AsyncJsonStream has been closed.') from None
+
+ async def start_parsing(self) -> None:
+ if self._json_stream_parser is not None:
+ self._log_message('JSON stream parser already exists', LogLevel.WARNING)
+ return
+ await self._process_token_stream()
+
+ async def continue_parsing(self) -> None:
+ await self._process_token_stream()
diff --git a/acouchbase_analytics/protocol/_core/async_json_token_parser.py b/acouchbase_analytics/protocol/_core/async_json_token_parser.py
new file mode 100644
index 0000000..6983f68
--- /dev/null
+++ b/acouchbase_analytics/protocol/_core/async_json_token_parser.py
@@ -0,0 +1,94 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from typing import Any, Callable, Coroutine, List, Optional
+
+from couchbase_analytics.common._core.json_token_parser_base import (
+ POP_EVENTS,
+ START_EVENTS,
+ VALUE_TOKENS,
+ JsonTokenParserBase,
+ JsonTokenParsingError,
+ ParsingState,
+ TokenType,
+)
+
+
+class AsyncJsonTokenParser(JsonTokenParserBase):
+ def __init__(self, results_handler: Optional[Callable[[bytes], Coroutine[Any, Any, None]]] = None) -> None:
+ self._results_handler = results_handler
+ super().__init__(emit_results_enabled=results_handler is not None)
+
+ async def _handle_obj_emit(self, obj: str) -> bool:
+ if (
+ self._emit_results_enabled
+ and self._results_handler is not None
+ and ParsingState.okay_to_emit(self._state, self._previous_state)
+ ):
+ await self._results_handler(bytes(obj, 'utf-8'))
+ return True
+ return False
+
+ async def _handle_pop_event(self, token_type: TokenType) -> None:
+ matching_token = self._get_matching_token(token_type)
+ obj_pairs: List[str] = []
+ while self._stack:
+ next_token = self._pop()
+ if next_token.type == matching_token.type:
+ should_emit = self._handle_pop_transition(next_token.state)
+ # NOTE: obj_pairs.reverse() vs. reversed(obj_pairs) are essentially the same _because_ we convert
+ # the obj_pairs to a string (e.g. ",".join(...)); using reversed() in this case is slightly
+ # more convenient as it returns an iterator
+ if matching_token.type == TokenType.START_ARRAY:
+ obj = f'[{",".join(reversed(obj_pairs))}]'
+ else:
+ obj = f'{{{",".join(reversed(obj_pairs))}}}'
+
+ if should_emit:
+ object_emitted = await self._handle_obj_emit(obj)
+ if object_emitted:
+ break # this means we emiited the result/error, so stop processing the stack
+
+ if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY:
+ map_key = self._pop()
+ # If we are emitting rows and/or errors,
+ # we don't keep them in the stack and therefore don't need to return the results
+ if self._should_push_pair(next_token):
+ self._push(TokenType.PAIR, f'{map_key.value}:{obj}')
+ else:
+ self._push(TokenType.OBJECT, obj)
+
+ break
+ obj_pairs.append(next_token.value)
+
+ def get_result(self) -> Optional[bytes]:
+ return bytes(self._stack.pop().value, 'utf-8') if self._stack else None
+
+ async def parse_token(self, token: str, value: str) -> None:
+ token_type = TokenType.from_str(token)
+ if token_type in VALUE_TOKENS:
+ val = self._handle_value_token(token_type, value)
+ if val is not None:
+ await self._handle_obj_emit(val)
+ elif token_type == TokenType.MAP_KEY:
+ self._handle_map_key_token(value)
+ elif token_type in START_EVENTS:
+ self._handle_start_event(token_type)
+ elif token_type in POP_EVENTS:
+ await self._handle_pop_event(token_type)
+ else:
+ raise JsonTokenParsingError(f'Invalid token type: {token_type}; {value=}')
diff --git a/acouchbase_analytics/protocol/_core/client_adapter.py b/acouchbase_analytics/protocol/_core/client_adapter.py
new file mode 100644
index 0000000..ad9727e
--- /dev/null
+++ b/acouchbase_analytics/protocol/_core/client_adapter.py
@@ -0,0 +1,190 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Optional, cast
+from uuid import uuid4
+
+from httpx import URL, AsyncClient, BasicAuth, Response
+
+from couchbase_analytics.common.credential import Credential
+from couchbase_analytics.common.deserializer import Deserializer
+from couchbase_analytics.common.logging import LogLevel, log_message
+from couchbase_analytics.protocol.connection import _ConnectionDetails
+from couchbase_analytics.protocol.options import OptionsBuilder
+
+if TYPE_CHECKING:
+ from couchbase_analytics.protocol._core.request import QueryRequest
+
+
+class _AsyncClientAdapter:
+ """
+ **INTERNAL**
+ """
+
+ ANALYTICS_PATH = '/api/v1/request'
+ LOGGER_NAME = 'acouchbase_analytics'
+
+ def __init__(
+ self, http_endpoint: str, credential: Credential, options: Optional[object] = None, **kwargs: object
+ ) -> None:
+ self._client_id = str(uuid4())
+ self._prefix = ''
+ self._cluster_id = cast(str, kwargs.pop('cluster_id', ''))
+ self._opts_builder = OptionsBuilder()
+ kwargs['logger_name'] = self.logger_name
+ self._conn_details = _ConnectionDetails.create(self._opts_builder, http_endpoint, credential, options, **kwargs)
+ # PYCO-67: Do we want to allow supporting custom HTTP transports?
+ self._http_transport_cls = None
+
+ @property
+ def analytics_path(self) -> str:
+ """
+ **INTERNAL**
+ """
+ return self.ANALYTICS_PATH
+
+ @property
+ def client(self) -> AsyncClient:
+ """
+ **INTERNAL**
+ """
+ return self._client
+
+ @property
+ def client_id(self) -> str:
+ """
+ **INTERNAL**
+ """
+ return self._client_id
+
+ @property
+ def connection_details(self) -> _ConnectionDetails:
+ """
+ **INTERNAL**
+ """
+ return self._conn_details
+
+ @property
+ def default_deserializer(self) -> Deserializer:
+ """
+ **INTERNAL**
+ """
+ return self._conn_details.default_deserializer
+
+ @property
+ def has_client(self) -> bool:
+ """
+ **INTERNAL**
+ """
+ return hasattr(self, '_client')
+
+ @property
+ def log_prefix(self) -> str:
+ """
+ **INTERNAL**
+ """
+ if self._prefix:
+ return self._prefix
+ self._prefix = f'[{self._cluster_id}'
+ if self.has_client:
+ self._prefix += f'/{self._client_id}'
+ if self.connection_details.is_secure():
+ self._prefix += '/https]'
+ else:
+ self._prefix += '/http]'
+
+ return self._prefix
+
+ @property
+ def logger_name(self) -> str:
+ """
+ **INTERNAL**
+ """
+ return self.LOGGER_NAME
+
+ @property
+ def options_builder(self) -> OptionsBuilder:
+ """
+ **INTERNAL**
+ """
+ return self._opts_builder
+
+ async def close_client(self) -> None:
+ """
+ **INTERNAL**
+ """
+ if hasattr(self, '_client'):
+ await self._client.aclose()
+ self.log_message('Cluster HTTP client closed', LogLevel.INFO)
+
+ async def create_client(self) -> None:
+ """
+ **INTERNAL**
+ """
+ if not hasattr(self, '_client'):
+ if self._conn_details.is_secure():
+ if self._conn_details.ssl_context is None:
+ raise ValueError('SSL context is required for secure connections.')
+ transport = None
+ if self._http_transport_cls is not None:
+ transport = self._http_transport_cls(verify=self._conn_details.ssl_context)
+ self._client = AsyncClient(
+ verify=self._conn_details.ssl_context,
+ auth=BasicAuth(*self._conn_details.credential),
+ transport=transport,
+ )
+ else:
+ transport = None
+ if self._http_transport_cls is not None:
+ transport = self._http_transport_cls()
+ self._client = AsyncClient(auth=BasicAuth(*self._conn_details.credential), transport=transport)
+ self.log_message(
+ (f'Cluster HTTP client created: connection_details={self._conn_details.get_init_details()}'),
+ LogLevel.INFO,
+ )
+ else:
+ self.log_message('Cluster HTTP client already exists, skipping creation.', LogLevel.INFO)
+
+ def log_message(self, message: str, log_level: LogLevel) -> None:
+ log_message(logger, f'{self.log_prefix} {message}', log_level)
+
+ async def send_request(self, request: QueryRequest) -> Response:
+ """
+ **INTERNAL**
+ """
+ if not hasattr(self, '_client'):
+ raise RuntimeError('Client not created yet')
+
+ url = URL(
+ scheme=request.url.scheme,
+ host=request.url.ip,
+ port=request.url.port,
+ path=request.url.path,
+ )
+ req = self._client.build_request(request.method, url, json=request.body, extensions=request.extensions)
+ return await self._client.send(req, stream=True)
+
+ def reset_client(self) -> None:
+ """
+ **INTERNAL**
+ """
+ if hasattr(self, '_client'):
+ del self._client
+
+
+logger = logging.getLogger(_AsyncClientAdapter.LOGGER_NAME)
diff --git a/acouchbase_analytics/protocol/_core/net_utils.py b/acouchbase_analytics/protocol/_core/net_utils.py
new file mode 100644
index 0000000..cc75af7
--- /dev/null
+++ b/acouchbase_analytics/protocol/_core/net_utils.py
@@ -0,0 +1,52 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import socket
+from ipaddress import IPv4Address, IPv6Address, ip_address
+from random import choice
+from typing import Callable, Optional, Union
+
+import anyio
+
+from acouchbase_analytics.protocol.errors import ErrorMapper
+from couchbase_analytics.common.logging import LogLevel
+
+
+@ErrorMapper.handle_socket_error_async
+async def get_request_ip_async(host: str, port: int, logger_handler: Optional[Callable[..., None]] = None) -> str:
+ # Lets not call getaddrinfo, if the host is already an IP address
+ try:
+ ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host)
+ except ValueError:
+ ip = None
+
+ # if we have localhost, httpx does not seem to be able to resolve IPv6 localhost (::1) properly
+ # TODO: IPv6 support for localhost??
+ if host == 'localhost':
+ ip = '127.0.0.1'
+
+ if not ip:
+ result = await anyio.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC)
+ res_ip = choice([addr[4][0] for addr in result]) # nosec B311
+ ip = str(res_ip)
+ if logger_handler:
+ message_data = {'results': [f'{addr[4][0]}' for addr in result], 'selected_ip': ip}
+ logger_handler(f'getaddrinfo() returned {len(result)} results', LogLevel.DEBUG, message_data=message_data)
+ else:
+ ip = str(ip)
+
+ return ip
diff --git a/acouchbase_analytics/protocol/_core/request_context.py b/acouchbase_analytics/protocol/_core/request_context.py
new file mode 100644
index 0000000..e8bc22d
--- /dev/null
+++ b/acouchbase_analytics/protocol/_core/request_context.py
@@ -0,0 +1,451 @@
+from __future__ import annotations
+
+import json
+import math
+from asyncio import CancelledError, Task
+from types import TracebackType
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Coroutine, Dict, List, Optional, Type, Union
+from uuid import uuid4
+
+import anyio
+from httpx import Response as HttpCoreResponse
+from httpx import TimeoutException
+
+from acouchbase_analytics.protocol._core.anyio_utils import AsyncBackend, current_async_library, get_time
+from acouchbase_analytics.protocol._core.async_json_stream import AsyncJsonStream
+from acouchbase_analytics.protocol._core.net_utils import get_request_ip_async
+from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType
+from couchbase_analytics.common._core.error_context import ErrorContext
+from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator
+from couchbase_analytics.common.errors import AnalyticsError
+from couchbase_analytics.common.logging import LogLevel
+from couchbase_analytics.common.request import RequestState
+from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS
+from couchbase_analytics.protocol.errors import ErrorMapper
+
+if TYPE_CHECKING:
+ from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter
+ from couchbase_analytics.protocol._core.request import QueryRequest
+
+
+class AsyncRequestContext:
+ # TODO: AsyncExitStack??
+ # https://anyio.readthedocs.io/en/stable/cancellation.html
+
+ def __init__(
+ self,
+ client_adapter: _AsyncClientAdapter,
+ request: QueryRequest,
+ stream_config: Optional[JsonStreamConfig] = None,
+ backend: Optional[AsyncBackend] = None,
+ ) -> None:
+ self._id = str(uuid4())
+ self._client_adapter = client_adapter
+ self._request = request
+ self._backend = backend or current_async_library()
+ self._backoff_calc = DefaultBackoffCalculator()
+ self._error_ctx = ErrorContext(num_attempts=0, method=request.method, statement=request.get_request_statement())
+ self._request_state = RequestState.NotStarted
+ self._stream_config = stream_config or JsonStreamConfig()
+ self._json_stream: AsyncJsonStream
+ self._stage_completed: Optional[anyio.Event] = None
+ self._request_error: Optional[Union[BaseException, Exception]] = None
+ connect_timeout = self._client_adapter.connection_details.get_connect_timeout()
+ self._connect_deadline = get_time() + connect_timeout
+ self._cancel_scope_deadline_updated = False
+ self._shutdown = False
+ self._request_deadline = math.inf
+
+ @property
+ def cancelled(self) -> bool:
+ self._check_timed_out()
+ return self._request_state in [RequestState.Cancelled, RequestState.AsyncCancelledPriorToTimeout]
+
+ @property
+ def error_context(self) -> ErrorContext:
+ return self._error_ctx
+
+ @property
+ def has_stage_completed(self) -> bool:
+ return self._stage_completed is not None and self._stage_completed.is_set()
+
+ @property
+ def is_shutdown(self) -> bool:
+ return self._shutdown
+
+ @property
+ def okay_to_iterate(self) -> bool:
+ self._check_timed_out()
+ return RequestState.okay_to_iterate(self._request_state)
+
+ @property
+ def okay_to_stream(self) -> bool:
+ self._check_timed_out()
+ return RequestState.okay_to_stream(self._request_state)
+
+ @property
+ def request_error(self) -> Optional[Union[BaseException, Exception]]:
+ return self._request_error
+
+ @property
+ def request_state(self) -> RequestState:
+ return self._request_state
+
+ @property
+ def retry_limit_exceeded(self) -> bool:
+ return self.error_context.num_attempts > self._request.max_retries
+
+ @property
+ def results_or_errors_type(self) -> ParsedResultType:
+ return self._json_stream.results_or_errors_type
+
+ @property
+ def timed_out(self) -> bool:
+ self._check_timed_out()
+ return self._request_state == RequestState.Timeout
+
+ def _check_timed_out(self) -> None:
+ if self._request_state in [RequestState.Timeout, RequestState.Cancelled, RequestState.Error]:
+ return
+
+ if hasattr(self, '_request_deadline') is False:
+ return
+
+ current_time = get_time()
+ timed_out = current_time >= self._request_deadline
+ if timed_out:
+ message_data = {'current_time': f'{current_time}', 'request_deadline': f'{self._request_deadline}'}
+ self.log_message('Request has timed out', LogLevel.DEBUG, message_data=message_data)
+ if self._request_state == RequestState.Cancelled:
+ self._request_state = RequestState.AsyncCancelledPriorToTimeout
+ else:
+ self._request_state = RequestState.Timeout
+
+ async def _execute(self, fn: Callable[..., Awaitable[Any]], *args: object) -> None:
+ await fn(*args)
+ if self._stage_completed is not None:
+ self._stage_completed.set()
+
+ def _maybe_set_request_error(
+ self, exc_type: Optional[Type[BaseException]] = None, exc_val: Optional[BaseException] = None
+ ) -> None:
+ self._check_timed_out()
+ if exc_val is None:
+ return
+ if not RequestState.is_timeout_or_cancelled(self._request_state):
+ # This handles httpx timeouts
+ if exc_type is not None and issubclass(exc_type, TimeoutException):
+ self._request_state = RequestState.Timeout
+ elif issubclass(type(exc_val), TimeoutException):
+ self._request_state = RequestState.Timeout
+ elif isinstance(exc_val, CancelledError):
+ self._request_state = RequestState.Cancelled
+ else:
+ self._request_state = RequestState.Error
+ self._request_error = exc_val
+
+ async def _process_error(
+ self, json_data: Union[str, List[Dict[str, Any]]], handle_context_shutdown: Optional[bool] = False
+ ) -> None:
+ self._request_state = RequestState.Error
+ if isinstance(json_data, str):
+ self._request_error = ErrorMapper.build_error_from_http_status_code(json_data, self._error_ctx)
+ elif not isinstance(json_data, list):
+ self._request_error = AnalyticsError(
+ 'Cannot parse error response; expected JSON array', context=str(self._error_ctx)
+ )
+ else:
+ self._request_error = ErrorMapper.build_error_from_json(json_data, self._error_ctx)
+ if handle_context_shutdown is True:
+ await self.reraise_after_shutdown(self._request_error)
+
+ raise self._request_error
+
+ def _reset_stream(self) -> None:
+ if hasattr(self, '_json_stream'):
+ del self._json_stream
+ self._request_state = RequestState.ResetAndNotStarted
+ self._stage_completed = None
+ self._cancel_scope_deadline_updated = False
+
+ def _start_next_stage(
+ self, fn: Callable[..., Awaitable[Any]], *args: object, reset_previous_stage: Optional[bool] = False
+ ) -> None:
+ if self._stage_completed is not None:
+ if reset_previous_stage is True:
+ self._stage_completed = None
+ else:
+ raise RuntimeError('Task already running in this context.')
+
+ self._stage_completed = anyio.Event()
+ self._taskgroup.start_soon(self._execute, fn, *args)
+
+ async def _trace_handler(self, event_name: str, _: str) -> None:
+ if event_name == 'connection.connect_tcp.complete':
+ # after connection is established, we need to update the cancel_scope deadline to match the query_timeout
+ self._update_cancel_scope_deadline(self._request_deadline, is_absolute=True)
+ self._cancel_scope_deadline_updated = True
+ elif self._cancel_scope_deadline_updated is False and event_name.endswith('send_request_headers.started'):
+ # if the socket is reused, we won't get the connect_tcp.complete event,
+ # so the deadline at the next closest event
+ self._update_cancel_scope_deadline(self._request_deadline, is_absolute=True)
+ self._cancel_scope_deadline_updated = True
+
+ def _update_cancel_scope_deadline(self, deadline: float, is_absolute: Optional[bool] = False) -> None:
+ # TODO: confirm scenario of get_time() < self._taskgroup.cancel_scope.deadline is handled by anyio
+ new_deadline = deadline if is_absolute else get_time() + deadline
+ current_time = get_time()
+ if current_time >= new_deadline:
+ self.log_message(
+ 'Deadline already exceeded, cancelling request',
+ LogLevel.DEBUG,
+ message_data={
+ 'current_time': f'{current_time}',
+ 'new_deadline': f'{new_deadline}',
+ },
+ )
+ self._taskgroup.cancel_scope.cancel()
+ else:
+ self.log_message(
+ f'Updating cancel scope deadline: {self._taskgroup.cancel_scope.deadline} -> {new_deadline}',
+ LogLevel.DEBUG,
+ )
+ self._taskgroup.cancel_scope.deadline = new_deadline
+
+ async def _wait_for_stage_to_complete(self) -> None:
+ if self._stage_completed is None:
+ return
+ await self._stage_completed.wait()
+
+ def calculate_backoff(self) -> float:
+ return self._backoff_calc.calculate_backoff(self._error_ctx.num_attempts) / 1000
+
+ def cancel_request(self, fn: Optional[Callable[..., Awaitable[Any]]] = None, *args: object) -> None:
+ if fn is not None:
+ self._taskgroup.start_soon(fn, *args)
+ if self._request_state == RequestState.Timeout:
+ return
+ self._taskgroup.cancel_scope.cancel()
+ self._request_state = RequestState.Cancelled
+
+ def create_response_task(self, fn: Callable[..., Coroutine[Any, Any, Any]], *args: object) -> Task[Any]:
+ if self._backend is None or self._backend.backend_lib != 'asyncio':
+ raise RuntimeError('Must use the asyncio backend to create a response task.')
+ if self._backend.loop is None:
+ raise RuntimeError('Async backend loop is not initialized.')
+ task_name = f'{self._id}-response-task'
+ task: Task[Any] = self._backend.loop.create_task(fn(*args), name=task_name)
+ # TODO: Confirm if callback is useful/necessary;
+ # def task_done(t: Task[Any]) -> None:
+ # print(f'Task done callback task=({t.get_name()}); done: {t.done()}, cancelled: {t.cancelled()}')
+ # task.add_done_callback(task_done)
+ self._response_task = task
+ return task
+
+ def deserialize_result(self, result: bytes) -> Any:
+ return self._request.deserializer.deserialize(result)
+
+ async def finish_processing_stream(self) -> None:
+ if not self.has_stage_completed:
+ await self._wait_for_stage_to_complete()
+
+ while not self._json_stream.token_stream_exhausted:
+ self._start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True)
+ await self._wait_for_stage_to_complete()
+
+ async def get_result_from_stream(self) -> ParsedResult:
+ return await self._json_stream.get_result()
+
+ async def initialize(self) -> None:
+ if self._request_state == RequestState.ResetAndNotStarted:
+ current_time = get_time()
+ self.log_message(
+ 'Request is a retry, skipping initialization',
+ LogLevel.DEBUG,
+ message_data={'current_time': f'{current_time}', 'request_deadline': f'{self._request_deadline}'},
+ )
+ return
+ await self.__aenter__()
+ self._request_state = RequestState.Started
+ # we set the request timeout once the context is initialized in order to create the deadline
+ # closer to when the upstream logic will begin to use the request context
+ timeouts = self._request.get_request_timeouts() or {}
+ current_time = get_time()
+ self._request_deadline = current_time + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout'])
+ message_data = {'current_time': f'{current_time}', 'request_deadline': f'{self._request_deadline}'}
+ self.log_message('Request context initialized', LogLevel.DEBUG, message_data=message_data)
+
+ def log_message(
+ self,
+ message: str,
+ log_level: LogLevel,
+ message_data: Optional[Dict[str, str]] = None,
+ append_ctx: Optional[bool] = True,
+ ) -> None:
+ if append_ctx is True:
+ message = f'{message}: ctx={self._id}'
+ if message_data is not None:
+ message_data_str = ', '.join(f'{k}={v}' for k, v in message_data.items())
+ message = f'{message}, {message_data_str}'
+ self._client_adapter.log_message(message, log_level)
+
+ def maybe_continue_to_process_stream(self) -> None:
+ if not self.has_stage_completed:
+ return
+
+ if self._json_stream.token_stream_exhausted:
+ return
+
+ self._start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True)
+
+ def okay_to_delay_and_retry(self, delay: float) -> bool:
+ self._check_timed_out()
+ if self._request_state in [RequestState.Timeout, RequestState.Cancelled]:
+ return False
+
+ current_time = get_time()
+ delay_time = current_time + delay
+ will_time_out = self._request_deadline < delay_time
+ if will_time_out:
+ self._request_state = RequestState.Timeout
+ message_data = {
+ 'current_time': f'{current_time}',
+ 'delay_time': f'{delay_time}',
+ 'request_deadline': f'{self._request_deadline}',
+ }
+ self.log_message('Request will timeout after delay', LogLevel.DEBUG, message_data=message_data)
+ return False
+ elif self.retry_limit_exceeded:
+ self._request_state = RequestState.Error
+ message_data = {
+ 'num_attempts': f'{self.error_context.num_attempts}',
+ 'max_retries': f'{self._request.max_retries}',
+ }
+ self.log_message('Request has exceeded max retries', LogLevel.DEBUG, message_data=message_data)
+ return False
+ else:
+ self._reset_stream()
+ return True
+
+ async def process_response(
+ self,
+ close_handler: Callable[[], Coroutine[Any, Any, None]],
+ raw_response: Optional[ParsedResult] = None,
+ handle_context_shutdown: Optional[bool] = False,
+ ) -> Any:
+ if raw_response is None:
+ raw_response = await self._json_stream.get_result()
+ if raw_response is None:
+ await close_handler()
+ raise AnalyticsError(
+ message='Received unexpected empty result from JsonStream.', context=str(self._error_ctx)
+ )
+
+ if raw_response.value is None:
+ await close_handler()
+ raise AnalyticsError(
+ message='Received unexpected empty result from JsonStream.', context=str(self._error_ctx)
+ )
+
+ # we have all the data, close the core response/stream
+ await close_handler()
+
+ try:
+ json_response = json.loads(raw_response.value)
+ except json.JSONDecodeError:
+ await self._process_error(str(raw_response.value), handle_context_shutdown=handle_context_shutdown)
+ else:
+ if 'errors' in json_response:
+ await self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown)
+ return json_response
+
+ async def reraise_after_shutdown(self, err: Exception) -> None:
+ try:
+ raise err
+ except Exception as ex:
+ await self.shutdown(type(ex), ex, ex.__traceback__)
+ raise ex from None
+
+ async def send_request(self, enable_trace_handling: Optional[bool] = False) -> HttpCoreResponse:
+ self._error_ctx.update_num_attempts()
+ ip = await get_request_ip_async(self._request.url.host, self._request.url.port, self.log_message)
+ if enable_trace_handling is True:
+ (
+ self._request.update_url(ip, self._client_adapter.analytics_path).add_trace_to_extensions(
+ self._trace_handler
+ )
+ )
+ else:
+ self._request.update_url(ip, self._client_adapter.analytics_path)
+ self._error_ctx.update_request_context(self._request)
+ message_data = {
+ 'url': f'{self._request.url.get_formatted_url()}',
+ 'body': f'{self._request.body}',
+ 'request_deadline': f'{self._request_deadline}',
+ }
+ self.log_message('HTTP request', LogLevel.DEBUG, message_data=message_data)
+ response = await self._client_adapter.send_request(self._request)
+ self._error_ctx.update_response_context(response)
+ message_data = {
+ 'status_code': f'{response.status_code}',
+ 'last_dispatched_to': f'{self._error_ctx.last_dispatched_to}',
+ 'last_dispatched_from': f'{self._error_ctx.last_dispatched_from}',
+ 'request_deadline': f'{self._request_deadline}',
+ }
+ self.log_message('HTTP response', LogLevel.DEBUG, message_data=message_data)
+ return response
+
+ async def shutdown(
+ self,
+ exc_type: Optional[Type[BaseException]] = None,
+ exc_val: Optional[BaseException] = None,
+ exc_tb: Optional[TracebackType] = None,
+ ) -> None:
+ if self.is_shutdown:
+ self.log_message('Request context already shutdown', LogLevel.WARNING)
+ return
+ if hasattr(self, '_taskgroup'):
+ await self.__aexit__(exc_type, exc_val, exc_tb)
+ else:
+ self._maybe_set_request_error(exc_type, exc_val)
+
+ if RequestState.is_okay(self._request_state):
+ self._request_state = RequestState.Completed
+ self._shutdown = True
+ self.log_message('Request context shutdown complete', LogLevel.INFO)
+
+ def start_stream(self, core_response: HttpCoreResponse) -> None:
+ if hasattr(self, '_json_stream'):
+ # TODO: logging; I don't think this is an error...
+ return
+
+ self._json_stream = AsyncJsonStream(
+ core_response.aiter_bytes(), stream_config=self._stream_config, logger_handler=self.log_message
+ )
+ self._start_next_stage(self._json_stream.start_parsing)
+
+ async def wait_for_results_or_errors(self) -> None:
+ await self._json_stream.has_results_or_errors.wait()
+ if self._json_stream.results_or_errors_type == ParsedResultType.ROW:
+ # we move to iterating rows
+ self._request_state = RequestState.StreamingResults
+
+ async def __aenter__(self) -> AsyncRequestContext:
+ self._taskgroup = anyio.create_task_group()
+ message_data = {'cancel_scope': f'{id(self._taskgroup.cancel_scope):x}'}
+ self.log_message('Task group created', LogLevel.DEBUG, message_data=message_data)
+ await self._taskgroup.__aenter__()
+ return self
+
+ async def __aexit__(
+ self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
+ ) -> Optional[bool]:
+ try:
+ await self._taskgroup.__aexit__(exc_type, exc_val, exc_tb)
+ except BaseException:
+ pass # we handle the error when the context is shutdown (which is what calls __aexit__())
+ finally:
+ self._maybe_set_request_error(exc_type, exc_val)
+ del self._taskgroup
+ # TODO: should we suppress here (e.g., return True)
+ return None # noqa: B012
diff --git a/acouchbase_analytics/protocol/_core/retries.py b/acouchbase_analytics/protocol/_core/retries.py
new file mode 100644
index 0000000..a23330a
--- /dev/null
+++ b/acouchbase_analytics/protocol/_core/retries.py
@@ -0,0 +1,164 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from asyncio import CancelledError
+from functools import wraps
+from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional, Union
+
+from httpx import ConnectError, ConnectTimeout, CookieConflict, HTTPError, InvalidURL, ReadTimeout, StreamError
+
+from acouchbase_analytics.protocol._core.anyio_utils import sleep
+from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError
+from couchbase_analytics.common.logging import LogLevel
+from couchbase_analytics.common.request import RequestState
+from couchbase_analytics.protocol.errors import WrappedError
+
+if TYPE_CHECKING:
+ from acouchbase_analytics.protocol._core.request_context import AsyncRequestContext
+ from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse
+
+
+class AsyncRetryHandler:
+ """
+ **INTERNAL**
+ """
+
+ @staticmethod
+ async def handle_httpx_retry(
+ ex: Union[ConnectError, ConnectTimeout], ctx: AsyncRequestContext
+ ) -> Optional[Exception]:
+ err_str = str(ex)
+ if 'SSL:' in err_str:
+ message = 'TLS connection error occurred.'
+ return AnalyticsError(cause=ex, message=message, context=str(ctx.error_context))
+ delay = ctx.calculate_backoff()
+ err: Optional[Exception] = None
+ if not ctx.okay_to_delay_and_retry(delay):
+ if ctx.retry_limit_exceeded:
+ err = AnalyticsError(cause=ex, message='Retry limit exceeded.', context=str(ctx.error_context))
+ else:
+ err = TimeoutError(message='Request timed out during retry delay.', context=str(ctx.error_context))
+ if err:
+ return err
+ await sleep(delay)
+ ctx.log_message(
+ 'Retrying request',
+ LogLevel.DEBUG,
+ {'num_attempts': f'{ctx.error_context.num_attempts}', 'delay': f'{delay}s'},
+ )
+ return None
+
+ @staticmethod
+ async def handle_retry(ex: WrappedError, ctx: AsyncRequestContext) -> Optional[Union[BaseException, Exception]]:
+ if ex.retriable is True:
+ delay = ctx.calculate_backoff()
+ err: Optional[Union[BaseException, Exception]] = None
+ if not ctx.okay_to_delay_and_retry(delay):
+ if ctx.retry_limit_exceeded:
+ if ex.is_cause_query_err:
+ ex.maybe_set_cause_context(ctx.error_context)
+ err = ex.unwrap()
+ else:
+ err = AnalyticsError(
+ cause=ex.unwrap(), message='Retry limit exceeded.', context=str(ctx.error_context)
+ )
+ else:
+ err = TimeoutError(message='Request timed out during retry delay.', context=str(ctx.error_context))
+
+ if err:
+ return err
+ await sleep(delay)
+ ctx.log_message(
+ 'Retrying request',
+ LogLevel.DEBUG,
+ {'num_attempts': f'{ctx.error_context.num_attempts}', 'delay': f'{delay}s'},
+ )
+ return None
+ ex.maybe_set_cause_context(ctx.error_context)
+ return ex.unwrap()
+
+ @staticmethod
+ def with_retries( # noqa: C901
+ fn: Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]],
+ ) -> Callable[[AsyncHttpStreamingResponse], Coroutine[Any, Any, None]]:
+ @wraps(fn)
+ async def wrapped_fn(self: AsyncHttpStreamingResponse) -> None: # noqa: C901
+ while True:
+ try:
+ await fn(self)
+ break
+ except WrappedError as ex:
+ err = await AsyncRetryHandler.handle_retry(ex, self._request_context)
+ if err is None:
+ continue
+ await self._request_context.shutdown(type(ex), ex, ex.__traceback__)
+ raise err from None
+ except (ConnectError, ConnectTimeout) as ex:
+ err = await AsyncRetryHandler.handle_httpx_retry(ex, self._request_context)
+ if err is None:
+ continue
+ await self._request_context.shutdown(type(ex), ex, ex.__traceback__)
+ raise err from None
+ except ReadTimeout as ex:
+ # we set the read timeout to the query timeout, so if we get a ReadTimeout,
+ # it means the request timed out from the httpx client
+ await self._request_context.shutdown(type(ex), ex, ex.__traceback__)
+ raise TimeoutError(
+ message='Request timed out.', context=str(self._request_context.error_context)
+ ) from None
+ except (CookieConflict, HTTPError, StreamError, InvalidURL) as ex:
+ # these are not retriable errors, so we just shutdown the request context and raise the error
+ await self._request_context.shutdown(type(ex), ex, ex.__traceback__)
+ raise AnalyticsError(
+ cause=ex, message=str(ex), context=str(self._request_context.error_context)
+ ) from None
+ except AnalyticsError:
+ # if an AnalyticsError is raised, we have already shut down the request context
+ raise
+ except RuntimeError as ex:
+ await self._request_context.shutdown(type(ex), ex, ex.__traceback__)
+ if self._request_context.timed_out:
+ raise TimeoutError(
+ message='Request timeout.', context=str(self._request_context.error_context)
+ ) from None
+ if self._request_context.cancelled:
+ raise CancelledError('Request was cancelled.') from None
+ raise ex
+ except BaseException as ex:
+ await self._request_context.shutdown(type(ex), ex, ex.__traceback__)
+ if self._request_context.timed_out:
+ raise TimeoutError(
+ message='Request timed out.', context=str(self._request_context.error_context)
+ ) from None
+ if self._request_context.cancelled:
+ raise CancelledError('Request was cancelled.') from None
+ if self._request_context.request_error is not None:
+ raise self._request_context.request_error from None
+ if isinstance(ex, Exception):
+ # If the exception is an Exception, we raise it as an InternalSDKError as this is
+ # an unexpected error in the SDK
+ raise InternalSDKError(
+ cause=ex, message=str(ex), context=str(self._request_context.error_context)
+ ) from None
+ # we should have handled CancelledError and TimeoutError above, so if we get here,
+ # raise the BaseException as is (most likely a KeyboardInterrupt)
+ raise ex
+ finally:
+ if not RequestState.is_okay(self._request_context.request_state):
+ await self.close()
+
+ return wrapped_fn
diff --git a/acouchbase_analytics/protocol/cluster.py b/acouchbase_analytics/protocol/cluster.py
new file mode 100644
index 0000000..8e4ef32
--- /dev/null
+++ b/acouchbase_analytics/protocol/cluster.py
@@ -0,0 +1,123 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING, Awaitable, Optional
+from uuid import uuid4
+
+if sys.version_info < (3, 10):
+ from typing_extensions import TypeAlias
+else:
+ from typing import TypeAlias
+
+from acouchbase_analytics.protocol._core.anyio_utils import current_async_library
+from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter
+from acouchbase_analytics.protocol._core.request_context import AsyncRequestContext
+from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse
+from couchbase_analytics.common.result import AsyncQueryResult
+from couchbase_analytics.protocol._core.request import _RequestBuilder
+
+if TYPE_CHECKING:
+ from couchbase_analytics.common.credential import Credential
+ from couchbase_analytics.options import ClusterOptions
+
+
+class AsyncCluster:
+ def __init__(
+ self, connstr: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object
+ ) -> None:
+ self._cluster_id = str(uuid4())
+ kwargs['cluster_id'] = self._cluster_id
+ self._client_adapter = _AsyncClientAdapter(connstr, credential, options, **kwargs)
+ self._request_builder = _RequestBuilder(self._client_adapter)
+ self._backend = current_async_library()
+
+ @property
+ def client_adapter(self) -> _AsyncClientAdapter:
+ """
+ **INTERNAL**
+ """
+ return self._client_adapter
+
+ @property
+ def cluster_id(self) -> str:
+ """
+ **INTERNAL**
+ """
+ return self._cluster_id
+
+ @property
+ def has_client(self) -> bool:
+ """
+ bool: Indicator on if the cluster HTTP client has been created or not.
+ """
+ return self._client_adapter.has_client
+
+ async def _shutdown(self) -> None:
+ """
+ **INTERNAL**
+ """
+ await self._client_adapter.close_client()
+ self._client_adapter.reset_client()
+
+ async def _create_client(self) -> None:
+ """
+ **INTERNAL**
+ """
+ await self._client_adapter.create_client()
+
+ async def shutdown(self) -> None:
+ """Shuts down this cluster instance. Cleaning up all resources associated with it.
+
+ .. warning::
+ Use of this method is almost *always* unnecessary. Cluster resources should be cleaned
+ up once the cluster instance falls out of scope. However, in some applications tuning resources
+ is necessary and in those types of applications, this method might be beneficial.
+
+ """
+ if self.has_client:
+ await self._shutdown()
+ else:
+ # TODO: log warning
+ print('Cluster does not have a connection. Ignoring')
+
+ async def _execute_query(self, http_resp: AsyncHttpStreamingResponse) -> AsyncQueryResult:
+ if not self.has_client:
+ # TODO: add log message??
+ await self._create_client()
+ await http_resp.send_request()
+ return AsyncQueryResult(http_resp)
+
+ def execute_query(self, statement: str, *args: object, **kwargs: object) -> Awaitable[AsyncQueryResult]:
+ base_req = self._request_builder.build_base_query_request(statement, *args, is_async=True, **kwargs)
+ stream_config = base_req.options.pop('stream_config', None)
+ request_context = AsyncRequestContext(
+ client_adapter=self.client_adapter, request=base_req, stream_config=stream_config, backend=self._backend
+ )
+ resp = AsyncHttpStreamingResponse(request_context)
+ if self._backend.backend_lib == 'asyncio':
+ return request_context.create_response_task(self._execute_query, resp)
+ return self._execute_query(resp)
+
+ @classmethod
+ def create_instance(
+ cls, connstr: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object
+ ) -> AsyncCluster:
+ return cls(connstr, credential, options, **kwargs)
+
+
+Cluster: TypeAlias = AsyncCluster
diff --git a/acouchbase_analytics/protocol/cluster.pyi b/acouchbase_analytics/protocol/cluster.pyi
new file mode 100644
index 0000000..46d9767
--- /dev/null
+++ b/acouchbase_analytics/protocol/cluster.pyi
@@ -0,0 +1,82 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+from typing import Awaitable, overload
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Unpack
+else:
+ from typing import Unpack
+
+from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter
+from acouchbase_analytics.protocol.database import AsyncDatabase
+from couchbase_analytics.common.credential import Credential
+from couchbase_analytics.common.result import AsyncQueryResult
+from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs
+
+class AsyncCluster:
+ @overload
+ def __init__(self, connstr: str, credential: Credential) -> None: ...
+ @overload
+ def __init__(self, connstr: str, credential: Credential, options: ClusterOptions) -> None: ...
+ @overload
+ def __init__(self, connstr: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ...
+ @overload
+ def __init__(
+ self, connstr: str, credential: Credential, options: ClusterOptions, **kwargs: Unpack[ClusterOptionsKwargs]
+ ) -> None: ...
+ @property
+ def client_adapter(self) -> _AsyncClientAdapter: ...
+ @property
+ def connected(self) -> bool: ...
+ def shutdown(self) -> None: ...
+ def database(self, name: str) -> AsyncDatabase: ...
+ @overload
+ def execute_query(self, statement: str) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, options: QueryOptions) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: str, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: str, **kwargs: str
+ ) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, *args: str, **kwargs: str) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ @classmethod
+ def create_instance(cls, connstr: str, credential: Credential) -> AsyncCluster: ...
+ @overload
+ @classmethod
+ def create_instance(cls, connstr: str, credential: Credential, options: ClusterOptions) -> AsyncCluster: ...
+ @overload
+ @classmethod
+ def create_instance(
+ cls, connstr: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs]
+ ) -> AsyncCluster: ...
+ @overload
+ @classmethod
+ def create_instance(
+ cls, connstr: str, credential: Credential, options: ClusterOptions, **kwargs: Unpack[ClusterOptionsKwargs]
+ ) -> AsyncCluster: ...
diff --git a/acouchbase_analytics/protocol/database.py b/acouchbase_analytics/protocol/database.py
new file mode 100644
index 0000000..5d653f4
--- /dev/null
+++ b/acouchbase_analytics/protocol/database.py
@@ -0,0 +1,56 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING
+
+if sys.version_info < (3, 10):
+ from typing_extensions import TypeAlias
+else:
+ from typing import TypeAlias
+
+from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter
+from acouchbase_analytics.protocol.scope import AsyncScope
+
+if TYPE_CHECKING:
+ from acouchbase_analytics.protocol.cluster import AsyncCluster
+
+
+class AsyncDatabase:
+ def __init__(self, cluster: AsyncCluster, database_name: str) -> None:
+ self._database_name = database_name
+ self._cluster = cluster
+
+ @property
+ def client_adapter(self) -> _AsyncClientAdapter:
+ """
+ **INTERNAL**
+ """
+ return self._cluster.client_adapter
+
+ @property
+ def name(self) -> str:
+ """
+ str: The name of this :class:`~acouchbase_analytics.protocol.database.Database` instance.
+ """
+ return self._database_name
+
+ def scope(self, scope_name: str) -> AsyncScope:
+ return AsyncScope(self, scope_name)
+
+
+Database: TypeAlias = AsyncDatabase
diff --git a/acouchbase_analytics/protocol/database.pyi b/acouchbase_analytics/protocol/database.pyi
new file mode 100644
index 0000000..14d555b
--- /dev/null
+++ b/acouchbase_analytics/protocol/database.pyi
@@ -0,0 +1,26 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter
+from acouchbase_analytics.protocol.cluster import AsyncCluster as AsyncCluster
+from couchbase_analytics.protocol.scope import Scope
+
+class AsyncDatabase:
+ def __init__(self, cluster: AsyncCluster, database_name: str) -> None: ...
+ @property
+ def client_adapter(self) -> _AsyncClientAdapter: ...
+ @property
+ def name(self) -> str: ...
+ def scope(self, scope_name: str) -> Scope: ...
diff --git a/acouchbase_analytics/protocol/errors.py b/acouchbase_analytics/protocol/errors.py
new file mode 100644
index 0000000..5cd0e2e
--- /dev/null
+++ b/acouchbase_analytics/protocol/errors.py
@@ -0,0 +1,42 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import socket
+from functools import wraps
+from typing import Any, Callable, Coroutine, Optional
+
+from couchbase_analytics.common.errors import AnalyticsError
+from couchbase_analytics.common.logging import LogLevel
+from couchbase_analytics.protocol.errors import WrappedError
+
+
+class ErrorMapper:
+ @staticmethod
+ def handle_socket_error_async(
+ fn: Callable[[str, int, Optional[Callable[..., None]]], Coroutine[Any, Any, str]],
+ ) -> Callable[[str, int, Optional[Callable[..., None]]], Coroutine[Any, Any, str]]:
+ @wraps(fn)
+ async def wrapped_fn(host: str, port: int, logger_handler: Optional[Callable[..., None]] = None) -> str:
+ try:
+ return await fn(host, port, logger_handler)
+ except socket.gaierror as ex:
+ if logger_handler:
+ logger_handler(f'getaddrinfo() failed for {host}:{port} with error: {ex}', LogLevel.ERROR)
+ msg = 'Connection error occurred while sending request.'
+ raise WrappedError(AnalyticsError(cause=ex, message=msg), retriable=True) from None
+
+ return wrapped_fn
diff --git a/acouchbase_analytics/protocol/scope.py b/acouchbase_analytics/protocol/scope.py
new file mode 100644
index 0000000..631f32e
--- /dev/null
+++ b/acouchbase_analytics/protocol/scope.py
@@ -0,0 +1,83 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING, Awaitable
+
+if sys.version_info < (3, 10):
+ from typing_extensions import TypeAlias
+else:
+ from typing import TypeAlias
+
+from acouchbase_analytics.protocol._core.anyio_utils import current_async_library
+from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter
+from acouchbase_analytics.protocol._core.request_context import AsyncRequestContext
+from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse
+from couchbase_analytics.common.result import AsyncQueryResult
+from couchbase_analytics.protocol._core.request import _RequestBuilder
+
+if TYPE_CHECKING:
+ from acouchbase_analytics.protocol.database import AsyncDatabase
+
+
+class AsyncScope:
+ def __init__(self, database: AsyncDatabase, scope_name: str) -> None:
+ self._database = database
+ self._scope_name = scope_name
+ self._request_builder = _RequestBuilder(self.client_adapter, self._database.name, self.name)
+ self._backend = current_async_library()
+
+ @property
+ def client_adapter(self) -> _AsyncClientAdapter:
+ """
+ **INTERNAL**
+ """
+ return self._database.client_adapter
+
+ @property
+ def name(self) -> str:
+ """
+ str: The name of this :class:`~acouchbase_analytics.protocol.scope.Scope` instance.
+ """
+ return self._scope_name
+
+ async def _create_client(self) -> None:
+ """
+ **INTERNAL**
+ """
+ await self.client_adapter.create_client()
+
+ async def _execute_query(self, http_resp: AsyncHttpStreamingResponse) -> AsyncQueryResult:
+ if not self.client_adapter.has_client:
+ # TODO: add log message??
+ await self._create_client()
+ await http_resp.send_request()
+ return AsyncQueryResult(http_resp)
+
+ def execute_query(self, statement: str, *args: object, **kwargs: object) -> Awaitable[AsyncQueryResult]:
+ base_req = self._request_builder.build_base_query_request(statement, *args, is_async=True, **kwargs)
+ stream_config = base_req.options.pop('stream_config', None)
+ request_context = AsyncRequestContext(
+ client_adapter=self.client_adapter, request=base_req, stream_config=stream_config, backend=self._backend
+ )
+ resp = AsyncHttpStreamingResponse(request_context)
+ if self._backend.backend_lib == 'asyncio':
+ return request_context.create_response_task(self._execute_query, resp)
+ return self._execute_query(resp)
+
+
+Scope: TypeAlias = AsyncScope
diff --git a/acouchbase_analytics/protocol/scope.pyi b/acouchbase_analytics/protocol/scope.pyi
new file mode 100644
index 0000000..d86a2ea
--- /dev/null
+++ b/acouchbase_analytics/protocol/scope.pyi
@@ -0,0 +1,54 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+from typing import Awaitable, overload
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Unpack
+else:
+ from typing import Unpack
+
+from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter
+from acouchbase_analytics.protocol.database import AsyncDatabase as AsyncDatabase
+from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs
+from couchbase_analytics.result import AsyncQueryResult
+
+class AsyncScope:
+ def __init__(self, database: AsyncDatabase, scope_name: str) -> None: ...
+ @property
+ def client_adapter(self) -> _AsyncClientAdapter: ...
+ @property
+ def name(self) -> str: ...
+ @overload
+ def execute_query(self, statement: str) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, options: QueryOptions) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: str, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: str, **kwargs: str
+ ) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, *args: str, **kwargs: str) -> Awaitable[AsyncQueryResult]: ...
diff --git a/acouchbase_analytics/protocol/streaming.py b/acouchbase_analytics/protocol/streaming.py
new file mode 100644
index 0000000..b3c51d3
--- /dev/null
+++ b/acouchbase_analytics/protocol/streaming.py
@@ -0,0 +1,175 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from typing import Any, Optional
+
+from httpx import Response as HttpCoreResponse
+
+from acouchbase_analytics.protocol._core.request_context import AsyncRequestContext
+from acouchbase_analytics.protocol._core.retries import AsyncRetryHandler
+from couchbase_analytics.common._core import ParsedResult, ParsedResultType
+from couchbase_analytics.common._core.query import build_query_metadata
+from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError
+from couchbase_analytics.common.logging import LogLevel
+from couchbase_analytics.common.query import QueryMetadata
+
+
+class AsyncHttpStreamingResponse:
+ def __init__(self, request_context: AsyncRequestContext) -> None:
+ self._metadata: Optional[QueryMetadata] = None
+ self._core_response: HttpCoreResponse
+ # Goal is to treat the AsyncHttpStreamingResponse as a "task group"
+ self._request_context = request_context
+
+ async def _close_in_background(self) -> None:
+ """
+ **INTERNAL**
+ """
+ await self.close()
+
+ async def _handle_iteration_abort(self) -> None:
+ """
+ **INTERNAL**
+ """
+ await self.close()
+ if self._request_context.cancelled:
+ self._request_context.log_message('Request canceled, aborting iteration', LogLevel.DEBUG)
+ await self._request_context.shutdown()
+ raise StopAsyncIteration
+ elif self._request_context.timed_out:
+ err = TimeoutError(
+ message='Unable to complete iteration. Request timed out.',
+ context=str(self._request_context.error_context),
+ )
+ await self._request_context.reraise_after_shutdown(err)
+ else:
+ self._request_context.log_message('Aborting iteration', LogLevel.DEBUG)
+ await self._request_context.shutdown()
+ raise StopAsyncIteration
+
+ async def _process_response(
+ self, raw_response: Optional[ParsedResult] = None, handle_context_shutdown: Optional[bool] = False
+ ) -> None:
+ """
+ **INTERNAL**
+ """
+ json_response = await self._request_context.process_response(
+ self.close, raw_response=raw_response, handle_context_shutdown=handle_context_shutdown
+ )
+ await self.set_metadata(json_data=json_response)
+
+ async def close(self) -> None:
+ """
+ **INTERNAL**
+ """
+ if hasattr(self, '_core_response'):
+ await self._core_response.aclose()
+ self._request_context.log_message('HTTP core response closed', LogLevel.INFO)
+ del self._core_response
+
+ def cancel(self) -> None:
+ """
+ **INTERNAL**
+ """
+ self._request_context.log_message('AsyncHttpStreamingResponse cancelling request in background', LogLevel.DEBUG)
+ self._request_context.cancel_request(self._close_in_background)
+
+ async def cancel_async(self) -> None:
+ """
+ **INTERNAL**
+ """
+ self._request_context.log_message('AsyncHttpStreamingResponse cancelling request', LogLevel.DEBUG)
+ await self.close()
+ self._request_context.cancel_request()
+ await self._request_context.shutdown()
+
+ def get_metadata(self) -> QueryMetadata:
+ """
+ **INTERNAL**
+ """
+ if self._metadata is None:
+ raise RuntimeError('Query metadata is only available after all rows have been iterated.')
+ return self._metadata
+
+ async def set_metadata(self, json_data: Optional[Any] = None, raw_metadata: Optional[bytes] = None) -> None:
+ """
+ **INTERNAL**
+ """
+ try:
+ self._metadata = QueryMetadata(build_query_metadata(json_data=json_data, raw_metadata=raw_metadata))
+ await self._request_context.shutdown()
+ except (AnalyticsError, ValueError) as err:
+ await self._request_context.reraise_after_shutdown(err)
+ except Exception as ex:
+ internal_err = InternalSDKError(cause=ex, message=str(ex), context=str(self._request_context.error_context))
+ await self._request_context.reraise_after_shutdown(internal_err)
+ finally:
+ await self.close()
+
+ async def get_next_row(self) -> Any:
+ """
+ **INTERNAL**
+ """
+ if not (
+ hasattr(self, '_core_response')
+ and self._core_response is not None
+ and self._request_context.okay_to_iterate
+ ):
+ await self._handle_iteration_abort()
+
+ self._request_context.maybe_continue_to_process_stream()
+ raw_response = await self._request_context.get_result_from_stream()
+ if raw_response.result_type == ParsedResultType.ROW:
+ if raw_response.value is None:
+ await self.close()
+ raise AnalyticsError(
+ message='Unexpected empty row response while streaming.',
+ context=str(self._request_context.error_context),
+ )
+ return self._request_context.deserialize_result(raw_response.value)
+ elif raw_response.result_type in [ParsedResultType.ERROR, ParsedResultType.UNKNOWN]:
+ await self._process_response(raw_response=raw_response, handle_context_shutdown=True)
+ elif raw_response.result_type == ParsedResultType.END:
+ await self.set_metadata(raw_metadata=raw_response.value)
+ raise StopAsyncIteration
+ else:
+ await self._process_response(raw_response=raw_response, handle_context_shutdown=True)
+
+ @AsyncRetryHandler.with_retries
+ async def send_request(self) -> None:
+ """
+ **INTERNAL**
+ """
+ if not self._request_context.okay_to_stream:
+ raise RuntimeError('Query has been canceled or previously executed.')
+
+ # start cancel scope
+ await self._request_context.initialize()
+ self._core_response = await self._request_context.send_request()
+ self._request_context.start_stream(self._core_response)
+ # block until we either know we have rows or we have an error
+ await self._request_context.wait_for_results_or_errors()
+ if not self._request_context.okay_to_iterate:
+ await self._request_context.finish_processing_stream()
+ await self._process_response()
+
+ async def shutdown(self) -> None:
+ """
+ **INTERNAL**
+ """
+ await self.close()
+ await self._request_context.shutdown()
diff --git a/acouchbase_analytics/query.py b/acouchbase_analytics/query.py
new file mode 100644
index 0000000..6d6520e
--- /dev/null
+++ b/acouchbase_analytics/query.py
@@ -0,0 +1,19 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common.enums import QueryScanConsistency as QueryScanConsistency # noqa: F401
+from couchbase_analytics.common.query import QueryMetadata as QueryMetadata # noqa: F401
+from couchbase_analytics.common.query import QueryMetrics as QueryMetrics # noqa: F401
+from couchbase_analytics.common.query import QueryWarning as QueryWarning # noqa: F401
diff --git a/acouchbase_analytics/result.py b/acouchbase_analytics/result.py
new file mode 100644
index 0000000..dedca6d
--- /dev/null
+++ b/acouchbase_analytics/result.py
@@ -0,0 +1,17 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common.result import AsyncQueryResult as AsyncQueryResult # noqa: F401
+from couchbase_analytics.common.result import QueryResult as QueryResult # noqa: F401
diff --git a/acouchbase_analytics/scope.py b/acouchbase_analytics/scope.py
new file mode 100644
index 0000000..7febf95
--- /dev/null
+++ b/acouchbase_analytics/scope.py
@@ -0,0 +1,113 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import sys
+from asyncio import Future
+from typing import TYPE_CHECKING
+
+if sys.version_info < (3, 10):
+ from typing_extensions import TypeAlias
+else:
+ from typing import TypeAlias
+
+from couchbase_analytics.result import AsyncQueryResult
+
+if TYPE_CHECKING:
+ from acouchbase_analytics.protocol.database import AsyncDatabase
+
+
+class AsyncScope:
+ def __init__(self, database: AsyncDatabase, scope_name: str) -> None:
+ from acouchbase_analytics.protocol.scope import AsyncScope as _AsyncScope
+
+ self._impl = _AsyncScope(database, scope_name)
+
+ @property
+ def name(self) -> str:
+ """
+ str: The name of this :class:`~acouchbase_analytics.scope.AsyncScope` instance.
+ """
+ return self._impl.name
+
+ def execute_query(self, statement: str, *args: object, **kwargs: object) -> Future[AsyncQueryResult]:
+ """Executes a query against a Capella Columnar scope.
+
+ .. note::
+ A departure from the operational SDK, the query is *NOT* executed lazily.
+
+ .. seealso::
+ * :meth:`acouchbase_analytics.Cluster.execute_query`: For how to execute cluster-level queries.
+
+ Args:
+ statement (str): The N1QL statement to execute.
+ options (:class:`~acouchbase_analytics.options.QueryOptions`): Optional parameters for the query operation.
+ **kwargs (Dict[str, Any]): keyword arguments that can be used in place or to override provided :class:`~acouchbase_analytics.options.QueryOptions`
+
+ Returns:
+ Future[:class:`~couchbase_analytics.result.AsyncQueryResult`]: A :class:`~asyncio.Future` is returned.
+ Once the :class:`~asyncio.Future` completes, an instance of a :class:`~acouchbase_analytics.result.AsyncQueryResult`
+ is available to provide access to iterate over the query results and access metadata and metrics about the query.
+
+ Examples:
+ Simple query::
+
+ q_str = 'SELECT * FROM airline WHERE country LIKE 'United%' LIMIT 2;'
+ q_res = scope.execute_query(q_str)
+ async for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ Simple query with positional parameters::
+
+ from acouchbase_analytics.options import QueryOptions
+
+ # ... other code ...
+
+ q_str = 'SELECT * FROM airline WHERE country LIKE $1 LIMIT $2;'
+ q_res = scope.execute_query(q_str, QueryOptions(positional_parameters=['United%', 5]))
+ async for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ Simple query with named parameters::
+
+ from acouchbase_analytics.options import QueryOptions
+
+ # ... other code ...
+
+ q_str = 'SELECT * FROM airline WHERE country LIKE $country LIMIT $lim;'
+ q_res = scope.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2}))
+ async for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ Retrieve metadata and/or metrics from query::
+
+ from acouchbase_analytics.options import QueryOptions
+
+ # ... other code ...
+
+ q_str = 'SELECT * FROM `travel-sample` WHERE country LIKE $country LIMIT $lim;'
+ q_res = scope.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2}))
+ async for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ print(f'Query metadata: {q_res.metadata()}')
+ print(f'Query metrics: {q_res.metadata().metrics()}')
+
+ """ # noqa: E501
+ return self._impl.execute_query(statement, *args, **kwargs)
+
+
+Scope: TypeAlias = AsyncScope
diff --git a/acouchbase_analytics/scope.pyi b/acouchbase_analytics/scope.pyi
new file mode 100644
index 0000000..29c8a8b
--- /dev/null
+++ b/acouchbase_analytics/scope.pyi
@@ -0,0 +1,51 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+from typing import Awaitable, overload
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Unpack
+else:
+ from typing import Unpack
+
+from acouchbase_analytics.protocol.database import AsyncDatabase as AsyncDatabase
+from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs
+from couchbase_analytics.result import AsyncQueryResult
+
+class AsyncScope:
+ def __init__(self, database: AsyncDatabase, scope_name: str) -> None: ...
+ @property
+ def name(self) -> str: ...
+ @overload
+ def execute_query(self, statement: str) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, options: QueryOptions) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: str, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: str, **kwargs: str
+ ) -> Awaitable[AsyncQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, *args: str, **kwargs: str) -> Awaitable[AsyncQueryResult]: ...
diff --git a/acouchbase_analytics/tests/__init__.py b/acouchbase_analytics/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/acouchbase_analytics/tests/connect_integration_t.py b/acouchbase_analytics/tests/connect_integration_t.py
new file mode 100644
index 0000000..a9c15be
--- /dev/null
+++ b/acouchbase_analytics/tests/connect_integration_t.py
@@ -0,0 +1,94 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import TYPE_CHECKING
+
+import pytest
+
+from acouchbase_analytics.cluster import AsyncCluster
+from acouchbase_analytics.credential import Credential
+from acouchbase_analytics.errors import AnalyticsError, TimeoutError
+from acouchbase_analytics.options import QueryOptions
+from tests import AsyncYieldFixture
+
+if TYPE_CHECKING:
+ from tests.environments.base_environment import AsyncTestEnvironment
+
+
+class ConnectTestSuite:
+ TEST_MANIFEST = [
+ 'test_connect_timeout_max_retry_limit',
+ 'test_connect_timeout_query_timeout',
+ ]
+
+ async def test_connect_timeout_max_retry_limit(self, test_env: AsyncTestEnvironment) -> None:
+ statement = 'SELECT sleep("some value", 10000) AS some_field;'
+
+ username, pw = test_env.config.get_username_and_pw()
+ cred = Credential.from_username_and_password(username, pw)
+ # ignoring the port enables the failure
+ connstr = test_env.config.get_connection_string(ignore_port=True)
+ cluster = AsyncCluster.create_instance(connstr, cred)
+
+ allowed_retries = 5
+ q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10))
+ with pytest.raises(AnalyticsError) as ex:
+ await cluster.execute_query(statement, q_opts)
+
+ assert ex.value._message is not None
+ assert 'Retry limit exceeded' in ex.value._message
+ test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context)
+
+ async def test_connect_timeout_query_timeout(self, test_env: AsyncTestEnvironment) -> None:
+ statement = 'SELECT sleep("some value", 10000) AS some_field;'
+
+ username, pw = test_env.config.get_username_and_pw()
+ cred = Credential.from_username_and_password(username, pw)
+ # ignoring the port enables the failure
+ connstr = test_env.config.get_connection_string(ignore_port=True)
+ cluster = AsyncCluster.create_instance(connstr, cred)
+
+ # increase the max retries to ensure that the timeout is hit
+ q_opts = QueryOptions(max_retries=20, timeout=timedelta(seconds=3))
+ with pytest.raises(TimeoutError) as ex:
+ await cluster.execute_query(statement, q_opts)
+
+ assert ex.value._message is not None
+ assert 'Request timed out during retry delay' in ex.value._message
+ test_env.assert_error_context_num_attempts(2, ex.value._context, exact=False)
+
+
+class ConnectTests(ConnectTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ConnectTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ConnectTests) if valid_test_method(meth)]
+ test_list = set(ConnectTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='test_env')
+ async def couchbase_test_environment(
+ self, async_test_env: AsyncTestEnvironment
+ ) -> AsyncYieldFixture[AsyncTestEnvironment]:
+ await async_test_env.setup()
+ yield async_test_env
+ await async_test_env.teardown()
diff --git a/acouchbase_analytics/tests/connection_t.py b/acouchbase_analytics/tests/connection_t.py
new file mode 100644
index 0000000..ca13c38
--- /dev/null
+++ b/acouchbase_analytics/tests/connection_t.py
@@ -0,0 +1,258 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from typing import Dict
+from urllib.parse import urlparse
+
+import pytest
+
+from acouchbase_analytics.cluster import AsyncCluster
+from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.protocol._core.request import _RequestBuilder
+from tests.utils import get_test_cert_path, to_query_str
+
+TEST_CERT_PATH = get_test_cert_path()
+
+
+class ConnectionTestSuite:
+ TEST_MANIFEST = [
+ 'test_connstr_options_fail',
+ 'test_connstr_options_max_retries',
+ 'test_connstr_options_timeout',
+ 'test_connstr_options_timeout_fail',
+ 'test_connstr_options_timeout_invalid_duration',
+ 'test_connstr_options_security',
+ 'test_connstr_options_security_fail',
+ 'test_invalid_connection_strings',
+ 'test_valid_connection_strings',
+ ]
+
+ @pytest.mark.parametrize(
+ 'connstr_opt',
+ [
+ 'invalid_op=10',
+ 'connect_timeout=2500ms',
+ 'dispatch_timeout=2500ms',
+ 'query_timeout=2500ms',
+ 'socket_connect_timeout=2500ms',
+ 'trust_only_pem_file=/path/to/file',
+ 'disable_server_certificate_verification=True',
+ ],
+ )
+ def test_connstr_options_fail(self, connstr_opt: str) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ connstr = f'https://localhost?{connstr_opt}'
+ with pytest.raises(ValueError):
+ _AsyncClientAdapter(connstr, cred)
+
+ def test_connstr_options_max_retries(self) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ max_retries = 10
+ connstr = f'https://localhost?max_retries={max_retries}'
+ client = _AsyncClientAdapter(connstr, cred)
+ req_builder = _RequestBuilder(client)
+ req = req_builder.build_base_query_request('SELECT 1=1')
+ assert req.max_retries == max_retries
+
+ @pytest.mark.parametrize(
+ 'duration, expected_seconds',
+ [
+ ('1h', '3600'),
+ ('+1h', '3600'),
+ ('+1h', '3600'),
+ ('1h10m', '4200'),
+ ('1.h10m', '4200'),
+ ('.1h10m', '960'),
+ ('0001h00010m', '4200'),
+ ('2m3s4ms', '123.004'),
+ (('100ns', '1e-7')),
+ (('100us', '1e-4')),
+ (('100μs', '1e-4')),
+ (('1000000ns', '.001')),
+ (('1000us', '.001')),
+ (('1000μs', '.001')),
+ ('4ms3s2m', '123.004'),
+ ('4ms3s2m5s', '128.004'),
+ ('2m3.125s', '123.125'),
+ ],
+ )
+ def test_connstr_options_timeout(self, duration: str, expected_seconds: str) -> None:
+ opt_keys = ['timeout.connect_timeout', 'timeout.query_timeout']
+ # opts = {k: duration for k in opt_keys}
+ opts = dict.fromkeys(opt_keys, duration)
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ connstr = f'https://localhost?{to_query_str(opts)}'
+ client = _AsyncClientAdapter(connstr, cred)
+ req_builder = _RequestBuilder(client)
+ req = req_builder.build_base_query_request('SELECT 1=1')
+ expected = float(expected_seconds)
+ returned_timeout_opts = req.get_request_timeouts()
+ assert isinstance(returned_timeout_opts, dict)
+ for k in opts.keys():
+ opt_key = k.split('.')[1]
+ if opt_key.startswith('connect'):
+ pool_timeout = returned_timeout_opts.get('pool')
+ assert pool_timeout is not None
+ assert abs(pool_timeout - expected) < 1e-9
+ connect_timeout = returned_timeout_opts.get('connect')
+ assert connect_timeout is not None
+ assert abs(connect_timeout - expected) < 1e-9
+ else:
+ read_timeout = returned_timeout_opts.get('read')
+ assert read_timeout is not None
+ assert abs(read_timeout - expected) < 1e-9
+
+ @pytest.mark.parametrize(
+ 'invalid_opt_name',
+ ['connect_timeout', 'dispatch_timeout', 'query_timeout', 'resolve_timeout', 'socket_connect_timeout'],
+ )
+ def test_connstr_options_timeout_fail(self, invalid_opt_name: str) -> None:
+ opts = {invalid_opt_name: '2500s'}
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ connstr = f'https://localhost?{to_query_str(opts)}'
+ with pytest.raises(ValueError):
+ _AsyncClientAdapter(connstr, cred)
+
+ @pytest.mark.parametrize(
+ 'bad_duration',
+ [
+ '123',
+ '00',
+ ' 1h',
+ '1h ',
+ '1h 2m+-3h',
+ '-+3h',
+ '-',
+ '-.',
+ '.',
+ '.h',
+ '2.3.4h',
+ '3x',
+ '3',
+ '3h4x',
+ '1H',
+ '1h-2m',
+ '-1h',
+ '-1m',
+ '-1s',
+ ],
+ )
+ def test_connstr_options_timeout_invalid_duration(self, bad_duration: str) -> None:
+ opt_keys = ['timeout.connect_timeout', 'timeout.query_timeout']
+ for key in opt_keys:
+ opts = {key: bad_duration}
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ connstr = f'https://localhost?{to_query_str(opts)}'
+ with pytest.raises(ValueError):
+ _AsyncClientAdapter(connstr, cred)
+
+ @pytest.mark.parametrize(
+ 'connstr_opts, expected_opts',
+ [
+ (
+ {'security.trust_only_pem_file': TEST_CERT_PATH},
+ {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False},
+ ),
+ (
+ {'security.disable_server_certificate_verification': 'true'},
+ {'disable_server_certificate_verification': True},
+ ),
+ ],
+ )
+ def test_connstr_options_security(self, connstr_opts: Dict[str, object], expected_opts: Dict[str, object]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ connstr = f'https://localhost?{to_query_str(connstr_opts)}'
+ client = _AsyncClientAdapter(connstr, cred)
+ sec_opts = client.connection_details.cluster_options.get('security_options', {})
+ assert sec_opts == expected_opts
+
+ @pytest.mark.parametrize(
+ 'invalid_opt_name',
+ [
+ 'trust_only_capella',
+ 'trust_only_pem_file',
+ 'trust_only_pem_str',
+ 'trust_only_certificates',
+ 'disable_server_certificate_verification',
+ ],
+ )
+ def test_connstr_options_security_fail(self, invalid_opt_name: str) -> None:
+ opts = {invalid_opt_name: 'True'}
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ connstr = f'https://localhost?{to_query_str(opts)}'
+ with pytest.raises(ValueError):
+ _AsyncClientAdapter(connstr, cred)
+
+ @pytest.mark.parametrize(
+ 'connstr',
+ [
+ '10.0.0.1:8091',
+ 'http://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207',
+ 'http://10.0.0.1;10.0.0.2:11210;10.0.0.3',
+ 'http://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207',
+ 'https://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207',
+ 'https://10.0.0.1;10.0.0.2:11210;10.0.0.3',
+ 'https://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207',
+ 'couchbase://10.0.0.1',
+ 'couchbases://10.0.0.1',
+ ],
+ )
+ def test_invalid_connection_strings(self, connstr: str) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ with pytest.raises(ValueError):
+ AsyncCluster.create_instance(connstr, cred)
+
+ @pytest.mark.parametrize(
+ 'connstr',
+ [
+ 'http://10.0.0.1',
+ 'http://10.0.0.1:11222',
+ 'http://[3ffe:2a00:100:7031::1]',
+ 'http://[::ffff:192.168.0.1]:11207',
+ 'http://test.local:11210',
+ 'http://fqdn',
+ 'https://10.0.0.1',
+ 'https://10.0.0.1:11222',
+ 'https://[3ffe:2a00:100:7031::1]',
+ 'https://[::ffff:192.168.0.1]:11207',
+ 'https://test.local:11210',
+ 'https://fqdn',
+ ],
+ )
+ def test_valid_connection_strings(self, connstr: str) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _AsyncClientAdapter(connstr, cred)
+ # options should be empty
+ assert {} == client.connection_details.cluster_options
+ parsed_connstr = urlparse(connstr)
+ parsed_port = parsed_connstr.port or (80 if parsed_connstr.scheme == 'http' else 443)
+ url = client.connection_details.url.get_formatted_url()
+ assert f'{parsed_connstr.scheme}://{parsed_connstr.hostname}:{parsed_port}' == url
+
+
+class ConnectionTests(ConnectionTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ConnectionTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ConnectionTests) if valid_test_method(meth)]
+ test_list = set(ConnectionTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
diff --git a/acouchbase_analytics/tests/json_parsing_t.py b/acouchbase_analytics/tests/json_parsing_t.py
new file mode 100644
index 0000000..dc26a88
--- /dev/null
+++ b/acouchbase_analytics/tests/json_parsing_t.py
@@ -0,0 +1,509 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import json
+from time import time
+from typing import TYPE_CHECKING, Dict
+
+import pytest
+
+from acouchbase_analytics.protocol._core.async_json_stream import AsyncJsonStream
+from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType
+from couchbase_analytics.common.errors import AnalyticsError
+from tests.environments.simple_environment import JsonDataType
+from tests.utils import AsyncBytesIterator
+from tests.utils._async_utils import TaskGroupResultCollector
+
+if TYPE_CHECKING:
+ from tests.environments.simple_environment import AsyncSimpleEnvironment
+
+
+class JsonParsingTestSuite:
+ TEST_MANIFEST = [
+ 'test_analytics_error',
+ 'test_analytics_error_mid_stream',
+ 'test_analytics_many_rows',
+ 'test_analytics_many_rows_raw',
+ 'test_analytics_multiple_errors',
+ 'test_analytics_parses_async',
+ 'test_analytics_simple_result',
+ 'test_array',
+ 'test_array_empty',
+ 'test_array_mixed_types',
+ 'test_array_of_objects',
+ 'test_invalid_empty',
+ 'test_invalid_garbage_between_objects',
+ 'test_invalid_leading_garbage',
+ 'test_invalid_trailing_garbage',
+ 'test_invalid_whitespace_only',
+ 'test_object',
+ 'test_object_complex_nested_structure',
+ 'test_object_empty',
+ 'test_object_simple_nested',
+ 'test_object_with_empty_key_and_value',
+ 'test_object_with_unicode',
+ 'test_value_bool',
+ 'test_value_null',
+ ]
+
+ @pytest.mark.parametrize('buffered_result', [True, False])
+ async def test_analytics_error(self, async_test_env: AsyncSimpleEnvironment, buffered_result: bool) -> None:
+ json_object, bytes_data = async_test_env.get_json_data(JsonDataType.FAILED_REQUEST)
+ if buffered_result:
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ else:
+ parser = AsyncJsonStream(AsyncBytesIterator(bytes_data))
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.ERROR
+ assert isinstance(result.value, bytes)
+ assert json.loads(result.value.decode('utf-8')) == json_object
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ async def test_analytics_error_mid_stream(self, async_test_env: AsyncSimpleEnvironment) -> None:
+ json_object, bytes_data = async_test_env.get_json_data(JsonDataType.FAILED_REQUEST_MID_STREAM)
+ parser = AsyncJsonStream(AsyncBytesIterator(bytes_data))
+ await parser.start_parsing()
+ row_idx = 0
+ while True:
+ result = await parser.get_result()
+ if result is None and not parser.token_stream_exhausted:
+ await parser.continue_parsing()
+ continue
+ assert isinstance(result, ParsedResult)
+ assert result.result_type in [ParsedResultType.ROW, ParsedResultType.ERROR]
+ assert isinstance(result.value, bytes)
+ if result.result_type == ParsedResultType.ROW:
+ assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx]
+ row_idx += 1
+ else:
+ final_result = result.value.decode('utf-8')
+ break
+
+ # if we are not buffering the entire result, the final result will exclude the results key
+ json_object.pop('results')
+ assert json.loads(final_result) == json_object
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ async def test_analytics_many_rows(self, async_test_env: AsyncSimpleEnvironment) -> None:
+ json_object, bytes_data = async_test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS)
+ parser = AsyncJsonStream(AsyncBytesIterator(bytes_data))
+ await parser.start_parsing()
+ row_idx = 0
+ while row_idx < 36:
+ result = await parser.get_result()
+ if result is None and not parser.token_stream_exhausted:
+ await parser.continue_parsing()
+ continue
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.ROW
+ assert isinstance(result.value, bytes)
+ assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx]
+ row_idx += 1
+
+ final_result = await parser.get_result()
+ assert isinstance(final_result, ParsedResult)
+ assert final_result.result_type == ParsedResultType.END
+ assert isinstance(final_result.value, bytes)
+ # if we are not buffering the entire result, the final result will exclude the results key
+ json_object.pop('results')
+ assert json.loads(final_result.value.decode('utf-8')) == json_object
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.parametrize('buffered_result', [True, False])
+ async def test_analytics_many_rows_raw(self, async_test_env: AsyncSimpleEnvironment, buffered_result: bool) -> None:
+ json_object, bytes_data = async_test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS_RAW)
+ if buffered_result:
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ else:
+ parser = AsyncJsonStream(AsyncBytesIterator(bytes_data))
+
+ await parser.start_parsing()
+ if not buffered_result:
+ row_idx = 0
+ while row_idx < 10:
+ result = await parser.get_result()
+ if result is None and not parser.token_stream_exhausted:
+ await parser.continue_parsing()
+ continue
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.ROW
+ assert isinstance(result.value, bytes)
+ assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx]
+ row_idx += 1
+
+ final_result = await parser.get_result()
+ assert isinstance(final_result, ParsedResult)
+ assert final_result.result_type == ParsedResultType.END
+ assert isinstance(final_result.value, bytes)
+ if not buffered_result:
+ # if we are not buffering the entire result, the final result will exclude the results key
+ json_object.pop('results')
+ assert json.loads(final_result.value.decode('utf-8')) == json_object
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.parametrize('buffered_result', [True, False])
+ async def test_analytics_multiple_errors(
+ self, async_test_env: AsyncSimpleEnvironment, buffered_result: bool
+ ) -> None:
+ json_object, bytes_data = async_test_env.get_json_data(JsonDataType.FAILED_REQUEST_MULTI_ERRORS)
+ if buffered_result:
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ else:
+ parser = AsyncJsonStream(AsyncBytesIterator(bytes_data))
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.ERROR
+ assert isinstance(result.value, bytes)
+ assert json.loads(result.value.decode('utf-8')) == json_object
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ async def test_analytics_parses_async(self, async_test_env: AsyncSimpleEnvironment) -> None:
+ json_object, bytes_data = async_test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS)
+
+ async def _run_async(idx: int) -> Dict[float, int]:
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes_data, simulate_delay=True, simulate_delay_range=(0.01, 0.1))
+ )
+ await parser.start_parsing()
+ row_idx = 0
+ while row_idx < 36:
+ result = await parser.get_result()
+ if result is None and not parser.token_stream_exhausted:
+ await parser.continue_parsing()
+ continue
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.ROW
+ assert isinstance(result.value, bytes)
+ assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx]
+ row_idx += 1
+
+ return {time(): idx}
+
+ async with TaskGroupResultCollector() as tg:
+ for idx in range(10):
+ tg.start_soon(_run_async, idx)
+ ordered_results = dict(sorted({k: v for r in tg.results for k, v in r.items()}.items()))
+ assert list(ordered_results.values()) != list(range(10))
+
+ @pytest.mark.parametrize('buffered_result', [True, False])
+ async def test_analytics_simple_result(self, async_test_env: AsyncSimpleEnvironment, buffered_result: bool) -> None:
+ json_object, bytes_data = async_test_env.get_json_data(JsonDataType.SIMPLE_REQUEST)
+ if buffered_result:
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ else:
+ parser = AsyncJsonStream(AsyncBytesIterator(bytes_data))
+ await parser.start_parsing()
+ # check for individual rows when not buffering the result
+ if not buffered_result:
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.ROW
+ assert isinstance(result.value, bytes)
+ assert json.loads(result.value.decode('utf-8')) == json_object['results'][0]
+
+ final_result = await parser.get_result()
+ assert isinstance(final_result, ParsedResult)
+ assert final_result.result_type == ParsedResultType.END
+ assert isinstance(final_result.value, bytes)
+ # we don't store the 'results' if buffering is not enabled
+ if not buffered_result:
+ json_object.pop('results')
+ assert json.loads(final_result.value.decode('utf-8')) == json_object
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.anyio
+ async def test_array(self) -> None:
+ data = '[1,2,"three"]'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.anyio
+ async def test_array_empty(self) -> None:
+ data = '[]'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.anyio
+ async def test_array_mixed_types(self) -> None:
+ data = '[123,"text",true,null,{"key":"value"}]'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.anyio
+ async def test_array_of_objects(self) -> None:
+ data = '[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.anyio
+ async def test_invalid_empty(self) -> None:
+ data = ''
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ res = await parser.get_result()
+ assert res.result_type == ParsedResultType.ERROR
+ assert res.value is not None
+ decoded_value = res.value.decode('utf-8')
+ assert ('parse error' in decoded_value or 'Incomplete JSON content' in decoded_value) is True
+
+ @pytest.mark.anyio
+ async def test_invalid_garbage_between_objects(self) -> None:
+ data = '[{"id":1,"name":"Alice"},garbage,{"id":2,"name":"Bob"}]'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ res = await parser.get_result()
+ assert res.result_type == ParsedResultType.ERROR
+ assert res.value is not None
+ decoded_value = res.value.decode('utf-8')
+ assert ('lexical error' in decoded_value or 'Unexpected symbol' in decoded_value) is True
+
+ @pytest.mark.anyio
+ async def test_invalid_leading_garbage(self) -> None:
+ data = 'garbage{"key":"value"}'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ res = await parser.get_result()
+ assert res.result_type == ParsedResultType.ERROR
+ assert res.value is not None
+ decoded_value = res.value.decode('utf-8')
+ assert ('lexical error' in decoded_value or 'Unexpected symbol' in decoded_value) is True
+
+ @pytest.mark.anyio
+ async def test_invalid_trailing_garbage(self) -> None:
+ data = '{"key":"value"}garbage'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ res = await parser.get_result()
+ assert res.result_type == ParsedResultType.ERROR
+ assert res.value is not None
+ decoded_value = res.value.decode('utf-8')
+ assert ('parse error' in decoded_value or 'Additional data found' in decoded_value) is True
+
+ @pytest.mark.anyio
+ async def test_invalid_whitespace_only(self) -> None:
+ data = ' \n\t '
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ res = await parser.get_result()
+ assert res.result_type == ParsedResultType.ERROR
+ assert res.value is not None
+ decoded_value = res.value.decode('utf-8')
+ assert ('parse error' in decoded_value or 'Incomplete JSON content' in decoded_value) is True
+
+ @pytest.mark.anyio
+ async def test_value_bool(self) -> None:
+ data = 'true'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.anyio
+ async def test_value_null(self) -> None:
+ data = 'null'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.anyio
+ async def test_object(self) -> None:
+ data = '{"name":"John","age":30,"city":"New York"}'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.anyio
+ async def test_object_complex_nested_structure(self) -> None:
+ data_list = [
+ '{"users":[{"id":1,"name":"Alice","roles":["admin","editor"]},{"id":2,"name":"Bob","roles":["viewer"]}],',
+ '"meta":{"count":2,"status":"success"}}',
+ ]
+ data = ''.join(data_list)
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.anyio
+ async def test_object_empty(self) -> None:
+ data = '{}'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.anyio
+ async def test_object_simple_nested(self) -> None:
+ data = '{"outer":{"inner":{"key":"value"}}}'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.anyio
+ async def test_object_with_empty_key_and_value(self) -> None:
+ data = '{"":""}'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+ @pytest.mark.anyio
+ async def test_object_with_unicode(self) -> None:
+ data = '{"name":"ä½ å¥½","city":"Denver"}'
+ parser = AsyncJsonStream(
+ AsyncBytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ await parser.start_parsing()
+ result = await parser.get_result()
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ with pytest.raises(AnalyticsError):
+ await parser.get_result()
+
+
+class JsonParsingTests(JsonParsingTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(JsonParsingTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(JsonParsingTests) if valid_test_method(meth)]
+ test_list = set(JsonParsingTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='async_test_env')
+ def acouchbase_test_environment(self, simple_async_test_env: AsyncSimpleEnvironment) -> AsyncSimpleEnvironment:
+ return simple_async_test_env
diff --git a/acouchbase_analytics/tests/options_t.py b/acouchbase_analytics/tests/options_t.py
new file mode 100644
index 0000000..8fd67c5
--- /dev/null
+++ b/acouchbase_analytics/tests/options_t.py
@@ -0,0 +1,245 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import Dict, Optional, Type
+
+import pytest
+
+from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.deserializer import DefaultJsonDeserializer, Deserializer, PassthroughDeserializer
+from couchbase_analytics.options import (
+ ClusterOptions,
+ SecurityOptions,
+ SecurityOptionsKwargs,
+ TimeoutOptions,
+ TimeoutOptionsKwargs,
+)
+from tests.utils import get_test_cert_list, get_test_cert_path, get_test_cert_str
+
+TEST_CERT_PATH = get_test_cert_path()
+TEST_CERT_LIST = get_test_cert_list()
+TEST_CERT_STR = get_test_cert_str()
+
+
+class ClusterOptionsTestSuite:
+ TEST_MANIFEST = [
+ 'test_options_deserializer',
+ 'test_options_deserializer_kwargs',
+ 'test_options_max_retries',
+ 'test_options_max_retries_kwargs',
+ 'test_security_options',
+ 'test_security_options_classmethods',
+ 'test_security_options_kwargs',
+ 'test_security_options_invalid',
+ 'test_security_options_invalid_kwargs',
+ 'test_timeout_options',
+ 'test_timeout_options_kwargs',
+ 'test_timeout_options_must_be_positive',
+ 'test_timeout_options_must_be_positive_kwargs',
+ ]
+
+ @pytest.mark.parametrize('deserializer_cls', [DefaultJsonDeserializer, PassthroughDeserializer])
+ def test_options_deserializer(self, deserializer_cls: Type[Deserializer]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ deserializer_instance = deserializer_cls()
+ client = _AsyncClientAdapter('https://localhost', cred, ClusterOptions(deserializer=deserializer_instance))
+ assert isinstance(client.connection_details.default_deserializer, deserializer_cls)
+
+ @pytest.mark.parametrize('deserializer_cls', [DefaultJsonDeserializer, PassthroughDeserializer])
+ def test_options_deserializer_kwargs(self, deserializer_cls: Type[Deserializer]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ deserializer_instance = deserializer_cls()
+ client = _AsyncClientAdapter('https://localhost', cred, **{'deserializer': deserializer_instance})
+ assert isinstance(client.connection_details.default_deserializer, deserializer_cls)
+
+ @pytest.mark.parametrize('max_retries', [5, 10, None])
+ def test_options_max_retries(self, max_retries: Optional[int]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _AsyncClientAdapter('https://localhost', cred, ClusterOptions(max_retries=max_retries))
+ if max_retries is None:
+ assert client.connection_details.get_max_retries() == 7
+ else:
+ assert client.connection_details.get_max_retries() == max_retries
+
+ @pytest.mark.parametrize('max_retries', [5, 10, None])
+ def test_options_max_retries_kwargs(self, max_retries: Optional[int]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ if max_retries is None:
+ client = _AsyncClientAdapter('https://localhost', cred)
+ assert client.connection_details.get_max_retries() == 7
+ else:
+ client = _AsyncClientAdapter('https://localhost', cred, **{'max_retries': max_retries})
+ assert client.connection_details.get_max_retries() == max_retries
+
+ @pytest.mark.parametrize(
+ 'opts, expected_opts',
+ [
+ ({}, None),
+ ({'trust_only_capella': True}, {'trust_only_capella': True}),
+ (
+ {'trust_only_pem_file': TEST_CERT_PATH},
+ {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False},
+ ),
+ ({'trust_only_pem_str': TEST_CERT_STR}, {'trust_only_pem_str': TEST_CERT_STR, 'trust_only_capella': False}),
+ (
+ {'trust_only_certificates': TEST_CERT_LIST},
+ {'trust_only_certificates': TEST_CERT_LIST, 'trust_only_capella': False},
+ ),
+ ({'disable_server_certificate_verification': True}, {'disable_server_certificate_verification': True}),
+ ],
+ )
+ def test_security_options(self, opts: SecurityOptionsKwargs, expected_opts: SecurityOptionsKwargs) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _AsyncClientAdapter(
+ 'https://localhost', cred, ClusterOptions(security_options=SecurityOptions(**opts))
+ )
+ assert expected_opts == client.connection_details.cluster_options.get('security_options')
+
+ @pytest.mark.parametrize(
+ 'opts, expected_opts',
+ [
+ (SecurityOptions.trust_only_capella(), {'trust_only_capella': True}),
+ (
+ SecurityOptions.trust_only_pem_file(TEST_CERT_PATH),
+ {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False},
+ ),
+ (
+ SecurityOptions.trust_only_pem_str(TEST_CERT_STR),
+ {'trust_only_pem_str': TEST_CERT_STR, 'trust_only_capella': False},
+ ),
+ (
+ SecurityOptions.trust_only_certificates(TEST_CERT_LIST),
+ {'trust_only_certificates': TEST_CERT_LIST, 'trust_only_capella': False},
+ ),
+ ],
+ )
+ def test_security_options_classmethods(self, opts: SecurityOptions, expected_opts: Dict[str, object]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _AsyncClientAdapter('https://localhost', cred, ClusterOptions(security_options=opts))
+ assert expected_opts == client.connection_details.cluster_options.get('security_options')
+
+ @pytest.mark.parametrize(
+ 'opts, expected_opts',
+ [
+ ({}, None),
+ ({'trust_only_capella': True}, {'trust_only_capella': True}),
+ (
+ {'trust_only_pem_file': TEST_CERT_PATH},
+ {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False},
+ ),
+ ({'trust_only_pem_str': TEST_CERT_STR}, {'trust_only_pem_str': TEST_CERT_STR, 'trust_only_capella': False}),
+ (
+ {'trust_only_certificates': TEST_CERT_LIST},
+ {'trust_only_certificates': TEST_CERT_LIST, 'trust_only_capella': False},
+ ),
+ ({'disable_server_certificate_verification': True}, {'disable_server_certificate_verification': True}),
+ ],
+ )
+ def test_security_options_kwargs(self, opts: Dict[str, object], expected_opts: Dict[str, object]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _AsyncClientAdapter('https://localhost', cred, **opts)
+ assert expected_opts == client.connection_details.cluster_options.get('security_options')
+
+ @pytest.mark.parametrize(
+ 'opts',
+ [
+ {'trust_only_capella': True, 'trust_only_pem_file': TEST_CERT_PATH},
+ {'trust_only_capella': True, 'trust_only_pem_str': TEST_CERT_STR},
+ {'trust_only_capella': True, 'trust_only_certificates': TEST_CERT_LIST},
+ ],
+ )
+ def test_security_options_invalid(self, opts: SecurityOptionsKwargs) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ with pytest.raises(ValueError):
+ _AsyncClientAdapter('https://localhost', cred, ClusterOptions(security_options=SecurityOptions(**opts)))
+
+ @pytest.mark.parametrize(
+ 'opts',
+ [
+ {'trust_only_capella': True, 'trust_only_pem_file': TEST_CERT_PATH},
+ {'trust_only_capella': True, 'trust_only_pem_str': TEST_CERT_STR},
+ {'trust_only_capella': True, 'trust_only_certificates': TEST_CERT_LIST},
+ ],
+ )
+ def test_security_options_invalid_kwargs(self, opts: Dict[str, object]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ with pytest.raises(ValueError):
+ _AsyncClientAdapter('https://localhost', cred, **opts)
+
+ @pytest.mark.parametrize(
+ 'opts, expected_opts',
+ [
+ ({}, None),
+ ({'connect_timeout': timedelta(seconds=30)}, {'connect_timeout': 30}),
+ ({'query_timeout': timedelta(seconds=30)}, {'query_timeout': 30}),
+ (
+ {'connect_timeout': timedelta(seconds=60), 'query_timeout': timedelta(seconds=30)},
+ {'connect_timeout': 60, 'query_timeout': 30},
+ ),
+ ],
+ )
+ def test_timeout_options(self, opts: TimeoutOptionsKwargs, expected_opts: TimeoutOptionsKwargs) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _AsyncClientAdapter('https://localhost', cred, ClusterOptions(timeout_options=TimeoutOptions(**opts)))
+ assert expected_opts == client.connection_details.cluster_options.get('timeout_options')
+
+ @pytest.mark.parametrize(
+ 'opts, expected_opts',
+ [
+ ({'connect_timeout': timedelta(seconds=30)}, {'connect_timeout': 30}),
+ ({'query_timeout': timedelta(seconds=30)}, {'query_timeout': 30}),
+ (
+ {'connect_timeout': timedelta(seconds=60), 'query_timeout': timedelta(seconds=30)},
+ {'connect_timeout': 60, 'query_timeout': 30},
+ ),
+ ],
+ )
+ def test_timeout_options_kwargs(self, opts: Dict[str, object], expected_opts: Dict[str, object]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _AsyncClientAdapter('https://localhost', cred, **opts)
+ assert expected_opts == client.connection_details.cluster_options.get('timeout_options')
+
+ @pytest.mark.parametrize(
+ 'opts', [{'connect_timeout': timedelta(seconds=-1)}, {'query_timeout': timedelta(seconds=-1)}]
+ )
+ def test_timeout_options_must_be_positive(self, opts: TimeoutOptionsKwargs) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ with pytest.raises(ValueError):
+ _AsyncClientAdapter('https://localhost', cred, ClusterOptions(timeout_options=TimeoutOptions(**opts)))
+
+ @pytest.mark.parametrize(
+ 'opts', [{'connect_timeout': timedelta(seconds=-1)}, {'query_timeout': timedelta(seconds=-1)}]
+ )
+ def test_timeout_options_must_be_positive_kwargs(self, opts: Dict[str, object]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ with pytest.raises(ValueError):
+ _AsyncClientAdapter('https://localhost', cred, **opts)
+
+
+class ClusterOptionsTests(ClusterOptionsTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ClusterOptionsTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ClusterOptionsTests) if valid_test_method(meth)]
+ test_list = set(ClusterOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
diff --git a/acouchbase_analytics/tests/query_integration_t.py b/acouchbase_analytics/tests/query_integration_t.py
new file mode 100644
index 0000000..baddf08
--- /dev/null
+++ b/acouchbase_analytics/tests/query_integration_t.py
@@ -0,0 +1,349 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import json
+from asyncio import CancelledError, Task
+from datetime import timedelta
+from typing import TYPE_CHECKING
+
+import pytest
+
+from acouchbase_analytics.deserializer import PassthroughDeserializer
+from acouchbase_analytics.errors import QueryError, TimeoutError
+from acouchbase_analytics.options import QueryOptions
+from acouchbase_analytics.result import AsyncQueryResult
+from couchbase_analytics.common.request import RequestState
+from tests import AsyncYieldFixture
+
+if TYPE_CHECKING:
+ from tests.environments.base_environment import AsyncTestEnvironment
+
+
+class QueryTestSuite:
+ TEST_MANIFEST = [
+ 'test_query_cancel_prior_iterating',
+ 'test_query_cancel_async_while_iterating',
+ 'test_query_cancel_while_iterating',
+ 'test_query_metadata',
+ 'test_query_metadata_not_available',
+ 'test_query_named_parameters',
+ 'test_query_named_parameters_no_options',
+ 'test_query_named_parameters_override',
+ 'test_query_passthrough_deserializer',
+ 'test_query_positional_params',
+ 'test_query_positional_params_no_option',
+ 'test_query_positional_params_override',
+ 'test_query_raises_exception_prior_to_iterating',
+ 'test_query_raw_options',
+ 'test_query_timeout',
+ 'test_query_timeout_while_streaming',
+ 'test_simple_query',
+ ]
+
+ @pytest.fixture(scope='class')
+ def query_statement_limit2(self, test_env: AsyncTestEnvironment) -> str:
+ if test_env.use_scope:
+ return f'SELECT * FROM {test_env.collection_name} LIMIT 2;'
+ else:
+ return f'SELECT * FROM {test_env.fqdn} LIMIT 2;'
+
+ @pytest.fixture(scope='class')
+ def query_statement_pos_params_limit2(self, test_env: AsyncTestEnvironment) -> str:
+ if test_env.use_scope:
+ return f'SELECT * FROM {test_env.collection_name} WHERE country = $1 LIMIT 2;'
+ else:
+ return f'SELECT * FROM {test_env.fqdn} WHERE country = $1 LIMIT 2;'
+
+ @pytest.fixture(scope='class')
+ def query_statement_named_params_limit2(self, test_env: AsyncTestEnvironment) -> str:
+ if test_env.use_scope:
+ return f'SELECT * FROM {test_env.collection_name} WHERE country = $country LIMIT 2;'
+ else:
+ return f'SELECT * FROM {test_env.fqdn} WHERE country = $country LIMIT 2;'
+
+ @pytest.fixture(scope='class')
+ def query_statement_limit5(self, test_env: AsyncTestEnvironment) -> str:
+ if test_env.use_scope:
+ return f'SELECT * FROM {test_env.collection_name} LIMIT 5;'
+ else:
+ return f'SELECT * FROM {test_env.fqdn} LIMIT 5;'
+
+ async def test_query_cancel_prior_iterating(self, test_env: AsyncTestEnvironment) -> None:
+ # simulate query that takes time to return
+ statement = 'SELECT sleep("some value", 10000) AS some_field;'
+ qtask = test_env.cluster_or_scope.execute_query(statement)
+ assert isinstance(qtask, Task)
+ await test_env.sleep(1)
+ qtask.cancel()
+ with pytest.raises(CancelledError):
+ await qtask
+
+ async def test_query_cancel_async_while_iterating(
+ self, test_env: AsyncTestEnvironment, query_statement_limit5: str
+ ) -> None:
+ qtask = test_env.cluster_or_scope.execute_query(query_statement_limit5)
+ assert isinstance(qtask, Task)
+ res = await qtask
+ assert isinstance(res, AsyncQueryResult)
+ expected_state = RequestState.StreamingResults
+ assert res._http_response._request_context.request_state == expected_state
+ rows = []
+ count = 0
+ async for row in res.rows():
+ if count == 2:
+ await res.cancel_async()
+ assert row is not None
+ rows.append(row)
+ count += 1
+
+ assert len(rows) == count
+ expected_state = RequestState.Cancelled
+ assert res._http_response._request_context.request_state == expected_state
+ with pytest.raises(RuntimeError):
+ res.metadata()
+ test_env.assert_streaming_response_state(res)
+
+ async def test_query_cancel_while_iterating(
+ self, test_env: AsyncTestEnvironment, query_statement_limit5: str
+ ) -> None:
+ qtask = test_env.cluster_or_scope.execute_query(query_statement_limit5)
+ assert isinstance(qtask, Task)
+ res = await qtask
+ assert isinstance(res, AsyncQueryResult)
+ expected_state = RequestState.StreamingResults
+ assert res._http_response._request_context.request_state == expected_state
+ rows = []
+ count = 0
+ async for row in res.rows():
+ if count == 2:
+ res.cancel()
+ assert row is not None
+ rows.append(row)
+ count += 1
+
+ assert len(rows) == count
+ expected_state = RequestState.Cancelled
+ assert res._http_response._request_context.request_state == expected_state
+ with pytest.raises(RuntimeError):
+ res.metadata()
+ # if we don't cancel via the async path, we want to ensure the stream/response is shutdown appropriately
+ await res.shutdown()
+ test_env.assert_streaming_response_state(res)
+
+ async def test_query_metadata(self, test_env: AsyncTestEnvironment, query_statement_limit5: str) -> None:
+ result = await test_env.cluster_or_scope.execute_query(query_statement_limit5)
+ expected_count = 5
+ await test_env.assert_rows(result, expected_count)
+
+ metadata = result.metadata()
+
+ assert len(metadata.warnings()) == 0
+ assert len(metadata.request_id()) > 0
+
+ metrics = metadata.metrics()
+
+ assert metrics.result_size() > 0
+ assert metrics.result_count() == expected_count
+ assert metrics.processed_objects() > 0
+ assert metrics.elapsed_time() > timedelta(0)
+ assert metrics.execution_time() > timedelta(0)
+ test_env.assert_streaming_response_state(result)
+
+ async def test_query_metadata_not_available(
+ self, test_env: AsyncTestEnvironment, query_statement_limit5: str
+ ) -> None:
+ result = await test_env.cluster_or_scope.execute_query(query_statement_limit5)
+
+ with pytest.raises(RuntimeError):
+ result.metadata()
+
+ # Read one row -- NOTE: anext()/aiter() add in Python 3.10
+ aiter = result.rows()
+ row = await aiter.__anext__()
+ assert row is not None
+ assert isinstance(row, dict)
+
+ with pytest.raises(RuntimeError):
+ result.metadata()
+
+ # Iterate the rest of the rows
+ rows = [r async for r in result.rows()]
+ assert len(rows) == 4
+
+ metadata = result.metadata()
+ assert len(metadata.warnings()) == 0
+ assert len(metadata.request_id()) > 0
+ test_env.assert_streaming_response_state(result)
+
+ async def test_query_named_parameters(
+ self,
+ test_env: AsyncTestEnvironment,
+ query_statement_named_params_limit2: str,
+ ) -> None:
+ q_opts = QueryOptions(named_parameters={'country': 'United States'})
+ result = await test_env.cluster_or_scope.execute_query(query_statement_named_params_limit2, q_opts)
+ await test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ async def test_query_named_parameters_no_options(
+ self, test_env: AsyncTestEnvironment, query_statement_named_params_limit2: str
+ ) -> None:
+ result = await test_env.cluster_or_scope.execute_query(
+ query_statement_named_params_limit2, country='United States'
+ )
+ await test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ async def test_query_named_parameters_override(
+ self, test_env: AsyncTestEnvironment, query_statement_named_params_limit2: str
+ ) -> None:
+ q_opts = QueryOptions(named_parameters={'country': 'abcdefg'})
+ result = await test_env.cluster_or_scope.execute_query(
+ query_statement_named_params_limit2, q_opts, country='United States'
+ )
+ await test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ async def test_query_passthrough_deserializer(self, test_env: AsyncTestEnvironment) -> None:
+ statement = 'FROM range(0, 10) AS num SELECT *'
+ result = await test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(deserializer=PassthroughDeserializer())
+ )
+ idx = 0
+ async for row in result.rows():
+ assert isinstance(row, bytes)
+ assert json.loads(row) == {'num': idx}
+ idx += 1
+ test_env.assert_streaming_response_state(result)
+
+ async def test_query_positional_params(
+ self, test_env: AsyncTestEnvironment, query_statement_pos_params_limit2: str
+ ) -> None:
+ q_opts = QueryOptions(positional_parameters=['United States'])
+ result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, q_opts)
+ await test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ async def test_query_positional_params_no_option(
+ self, test_env: AsyncTestEnvironment, query_statement_pos_params_limit2: str
+ ) -> None:
+ result = await test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, 'United States')
+ await test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ async def test_query_positional_params_override(
+ self, test_env: AsyncTestEnvironment, query_statement_pos_params_limit2: str
+ ) -> None:
+ q_opts = QueryOptions(positional_parameters=['abcdefg'])
+ result = await test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2, q_opts, 'United States'
+ )
+ await test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ async def test_query_raises_exception_prior_to_iterating(self, test_env: AsyncTestEnvironment) -> None:
+ statement = "I'm not N1QL!"
+ with pytest.raises(QueryError):
+ await test_env.cluster_or_scope.execute_query(statement)
+
+ async def test_query_raw_options(
+ self, test_env: AsyncTestEnvironment, query_statement_pos_params_limit2: str
+ ) -> None:
+ # via raw, we should be able to pass any option
+ # if using named params, need to match full name param in query
+ # which is different for when we pass in name_parameters via their specific
+ # query option (i.e. include the $ when using raw)
+ if test_env.use_scope:
+ statement = f'SELECT * FROM {test_env.collection_name} WHERE country = $country LIMIT $1;'
+ else:
+ statement = f'SELECT * FROM {test_env.fqdn} WHERE country = $country LIMIT $1;'
+
+ q_opts = QueryOptions(raw={'$country': 'United States', 'args': [2]})
+ result = await test_env.cluster_or_scope.execute_query(statement, q_opts)
+ await test_env.assert_rows(result, 2)
+
+ result = await test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2, QueryOptions(raw={'args': ['United States']})
+ )
+ await test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ async def test_query_timeout(self, test_env: AsyncTestEnvironment) -> None:
+ statement = 'SELECT sleep("some value", 10000) AS some_field;'
+
+ with pytest.raises(TimeoutError):
+ await test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2)))
+
+ async def test_query_timeout_while_streaming(self, test_env: AsyncTestEnvironment) -> None:
+ statement = 'SELECT {"x1": 1, "x2": 2, "x3": 3} FROM range(1, 100000) r;'
+ res = test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2)))
+ assert isinstance(res, Task)
+ result = await res
+
+ with pytest.raises(TimeoutError):
+ async for _ in result.rows():
+ pass
+ test_env.assert_streaming_response_state(result)
+
+ async def test_simple_query(self, test_env: AsyncTestEnvironment, query_statement_limit2: str) -> None:
+ result = await test_env.cluster_or_scope.execute_query(query_statement_limit2)
+ await test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+
+class ClusterQueryTests(QueryTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ClusterQueryTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ClusterQueryTests) if valid_test_method(meth)]
+ test_list = set(QueryTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='test_env')
+ async def couchbase_test_environment(
+ self, async_test_env: AsyncTestEnvironment
+ ) -> AsyncYieldFixture[AsyncTestEnvironment]:
+ await async_test_env.setup()
+ yield async_test_env
+ await async_test_env.teardown()
+
+
+class ScopeQueryTests(QueryTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ScopeQueryTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ScopeQueryTests) if valid_test_method(meth)]
+ test_list = set(QueryTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='test_env')
+ async def couchbase_test_environment(
+ self, async_test_env: AsyncTestEnvironment
+ ) -> AsyncYieldFixture[AsyncTestEnvironment]:
+ await async_test_env.setup()
+ test_env = async_test_env.enable_scope()
+ yield test_env
+ test_env.disable_scope()
+ await test_env.teardown()
diff --git a/acouchbase_analytics/tests/query_options_t.py b/acouchbase_analytics/tests/query_options_t.py
new file mode 100644
index 0000000..23b1de1
--- /dev/null
+++ b/acouchbase_analytics/tests/query_options_t.py
@@ -0,0 +1,301 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import timedelta
+from typing import Any, Dict, List, Optional, Union
+
+import pytest
+
+from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter
+from couchbase_analytics import JSONType
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs
+from couchbase_analytics.protocol._core.request import _RequestBuilder
+from couchbase_analytics.protocol.options import QueryOptionsTransformedKwargs
+
+
+@dataclass
+class QueryContext:
+ database_name: Optional[str] = None
+ scope_name: Optional[str] = None
+
+ def validate_query_context(self, body: Dict[str, Union[str, object]]) -> None:
+ if self.database_name is None or self.scope_name is None:
+ with pytest.raises(KeyError):
+ body['query_context']
+ else:
+ assert body['query_context'] == f'default:`{self.database_name}`.`{self.scope_name}`'
+
+
+class QueryOptionsTestSuite:
+ TEST_MANIFEST = [
+ 'test_options_deserializer',
+ 'test_options_deserializer_kwargs',
+ 'test_options_max_retries',
+ 'test_options_max_retries_kwargs',
+ 'test_options_named_parameters',
+ 'test_options_named_parameters_kwargs',
+ 'test_options_positional_parameters',
+ 'test_options_positional_parameters_kwargs',
+ 'test_options_raw',
+ 'test_options_raw_kwargs',
+ 'test_options_readonly',
+ 'test_options_readonly_kwargs',
+ 'test_options_scan_consistency',
+ 'test_options_scan_consistency_kwargs',
+ 'test_options_timeout',
+ 'test_options_timeout_kwargs',
+ 'test_options_timeout_must_be_positive',
+ 'test_options_timeout_must_be_positive_kwargs',
+ ]
+
+ @pytest.fixture(scope='class')
+ def query_statment(self) -> str:
+ return 'SELECT * FROM default'
+
+ def test_options_deserializer(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ from couchbase_analytics.deserializer import DefaultJsonDeserializer
+
+ deserializer = DefaultJsonDeserializer()
+ q_opts = QueryOptions(deserializer=deserializer)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {}
+ assert req.options == exp_opts
+ assert req.deserializer == deserializer
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_deserializer_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ from couchbase_analytics.deserializer import DefaultJsonDeserializer
+
+ deserializer = DefaultJsonDeserializer()
+ kwargs: QueryOptionsKwargs = {'deserializer': deserializer}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {}
+ assert req.options == exp_opts
+ assert req.deserializer == deserializer
+ query_ctx.validate_query_context(req.body)
+
+ @pytest.mark.parametrize('max_retries', [5, 10, None])
+ def test_options_max_retries(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext, max_retries: Optional[int]
+ ) -> None:
+ if max_retries is not None:
+ q_opts = QueryOptions(max_retries=max_retries)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ else:
+ req = request_builder.build_base_query_request(query_statment)
+ exp_opts: QueryOptionsTransformedKwargs = {}
+ assert req.options == exp_opts
+ assert req.max_retries == (max_retries if max_retries is not None else 7)
+ query_ctx.validate_query_context(req.body)
+
+ @pytest.mark.parametrize('max_retries', [5, 10, None])
+ def test_options_max_retries_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext, max_retries: Optional[int]
+ ) -> None:
+ if max_retries is not None:
+ kwargs: QueryOptionsKwargs = {'max_retries': max_retries}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ else:
+ req = request_builder.build_base_query_request(query_statment)
+ exp_opts: QueryOptionsTransformedKwargs = {}
+ assert req.options == exp_opts
+ assert req.max_retries == (max_retries if max_retries is not None else 7)
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_named_parameters(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ params: Dict[str, JSONType] = {'foo': 'bar', 'baz': 1, 'quz': False}
+ q_opts = QueryOptions(named_parameters=params)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {'named_parameters': params}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_named_parameters_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ params: Dict[str, JSONType] = {'foo': 'bar', 'baz': 1, 'quz': False}
+ kwargs: QueryOptionsKwargs = {'named_parameters': params}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {'named_parameters': params}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_positional_parameters(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ params: List[JSONType] = ['foo', 'bar', 1, False]
+ q_opts = QueryOptions(positional_parameters=params)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {'positional_parameters': params}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_positional_parameters_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ params: List[JSONType] = ['foo', 'bar', 1, False]
+ kwargs: QueryOptionsKwargs = {'positional_parameters': params}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {'positional_parameters': params}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_raw(self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext) -> None:
+ pos_params: List[JSONType] = ['foo', 'bar', 1, False]
+ params: Dict[str, Any] = {'readonly': True, 'positional_params': pos_params}
+ q_opts = QueryOptions(raw=params)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {'raw': params}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_raw_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ pos_params: List[JSONType] = ['foo', 'bar', 1, False]
+ params: Dict[str, Any] = {'readonly': True, 'positional_params': pos_params}
+ kwargs: QueryOptionsKwargs = {'raw': params}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {'raw': params}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_readonly(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ q_opts = QueryOptions(readonly=True)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {'readonly': True}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_readonly_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ kwargs: QueryOptionsKwargs = {'readonly': True}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {'readonly': True}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_scan_consistency(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ from couchbase_analytics.query import QueryScanConsistency
+
+ q_opts = QueryOptions(scan_consistency=QueryScanConsistency.REQUEST_PLUS)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_scan_consistency_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ from couchbase_analytics.query import QueryScanConsistency
+
+ kwargs: QueryOptionsKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_timeout(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ q_opts = QueryOptions(timeout=timedelta(seconds=20))
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {'timeout': 20.0}
+ assert req.options == exp_opts
+ # NOTE: we add time to the server timeout to ensure a client side timeout
+ assert req.body['timeout'] == '25000.0ms'
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_timeout_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ kwargs: QueryOptionsKwargs = {'timeout': timedelta(seconds=20)}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {'timeout': 20.0}
+ assert req.options == exp_opts
+ # NOTE: we add time to the server timeout to ensure a client side timeout
+ assert req.body['timeout'] == '25000.0ms'
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_timeout_must_be_positive(self, query_statment: str, request_builder: _RequestBuilder) -> None:
+ q_opts = QueryOptions(timeout=timedelta(seconds=-1))
+ with pytest.raises(ValueError):
+ request_builder.build_base_query_request(query_statment, q_opts)
+
+ def test_options_timeout_must_be_positive_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder
+ ) -> None:
+ kwargs: QueryOptionsKwargs = {'timeout': timedelta(seconds=-1)}
+ with pytest.raises(ValueError):
+ request_builder.build_base_query_request(query_statment, **kwargs)
+
+
+class ClusterQueryOptionsTests(QueryOptionsTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ClusterQueryOptionsTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ClusterQueryOptionsTests) if valid_test_method(meth)]
+ test_list = set(QueryOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='query_ctx')
+ def query_context(self) -> QueryContext:
+ return QueryContext()
+
+ @pytest.fixture(scope='class')
+ def request_builder(self) -> _RequestBuilder:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ return _RequestBuilder(_AsyncClientAdapter('https://localhost', cred))
+
+
+class ScopeQueryOptionsTests(QueryOptionsTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ScopeQueryOptionsTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ScopeQueryOptionsTests) if valid_test_method(meth)]
+ test_list = set(QueryOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='query_ctx')
+ def query_context(self) -> QueryContext:
+ return QueryContext('test-database', 'test-scope')
+
+ @pytest.fixture(scope='class')
+ def request_builder(self) -> _RequestBuilder:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ return _RequestBuilder(_AsyncClientAdapter('https://localhost', cred), 'test-database', 'test-scope')
diff --git a/acouchbase_analytics/tests/test_server_t.py b/acouchbase_analytics/tests/test_server_t.py
new file mode 100644
index 0000000..0657dc7
--- /dev/null
+++ b/acouchbase_analytics/tests/test_server_t.py
@@ -0,0 +1,228 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import TYPE_CHECKING, Union
+
+import pytest
+
+from acouchbase_analytics.errors import AnalyticsError, InvalidCredentialError, QueryError, TimeoutError
+from acouchbase_analytics.options import QueryOptions
+from acouchbase_analytics.result import AsyncQueryResult
+from tests import AsyncYieldFixture
+from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType
+
+if TYPE_CHECKING:
+ from tests.environments.base_environment import AsyncTestEnvironment
+
+
+class TestServerTestSuite:
+ TEST_MANIFEST = [
+ 'test_auth_error_unauthorized',
+ 'test_auth_error_insufficient_permissions',
+ 'test_error_non_retriable_response',
+ 'test_error_retriable_response_timeout',
+ 'test_error_retriable_response_retries_exceeded',
+ 'test_error_retriable_http503',
+ 'test_error_timeout',
+ 'test_results_object_values',
+ 'test_results_raw_values',
+ ]
+
+ async def test_auth_error_unauthorized(self, test_env: AsyncTestEnvironment) -> None:
+ test_env.set_url_path('/test_error')
+ test_env.update_request_json({'error_type': ErrorType.Unauthorized.value})
+ statement = 'SELECT "Hello, data!" AS greeting'
+ with pytest.raises(InvalidCredentialError) as ex:
+ await test_env.cluster_or_scope.execute_query(statement)
+ test_env.assert_error_context_num_attempts(1, ex.value._context)
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+
+ async def test_auth_error_insufficient_permissions(self, test_env: AsyncTestEnvironment) -> None:
+ test_env.set_url_path('/test_error')
+ test_env.update_request_json({'error_type': ErrorType.InsufficientPermissions.value})
+ statement = 'SELECT "Hello, data!" AS greeting'
+ with pytest.raises(QueryError) as ex:
+ await test_env.cluster_or_scope.execute_query(statement)
+ assert ex.value.code == 20001
+ assert 'Insufficient permissions' in ex.value.server_message
+ test_env.assert_error_context_num_attempts(1, ex.value._context)
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+
+ @pytest.mark.parametrize(
+ 'retry_group_type',
+ [RetriableGroupType.Zero, RetriableGroupType.First, RetriableGroupType.Middle, RetriableGroupType.Last],
+ )
+ @pytest.mark.parametrize(
+ 'non_retriable_spec',
+ [
+ NonRetriableSpecificationType.AllEmpty,
+ NonRetriableSpecificationType.AllFalse,
+ NonRetriableSpecificationType.Random,
+ ],
+ )
+ async def test_error_non_retriable_response(
+ self,
+ test_env: AsyncTestEnvironment,
+ retry_group_type: RetriableGroupType,
+ non_retriable_spec: NonRetriableSpecificationType,
+ ) -> None:
+ test_env.set_url_path('/test_error')
+ test_env.update_request_json(
+ {
+ 'error_type': ErrorType.Retriable.value,
+ 'retry_group_type': retry_group_type.value,
+ 'non_retriable_spec': non_retriable_spec.value,
+ }
+ )
+ statement = 'SELECT "Hello, data!" AS greeting'
+ with pytest.raises(QueryError) as ex:
+ await test_env.cluster_or_scope.execute_query(statement)
+ test_env.assert_error_context_num_attempts(1, ex.value._context)
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+
+ async def test_error_retriable_response_timeout(self, test_env: AsyncTestEnvironment) -> None:
+ test_env.set_url_path('/test_error')
+ test_env.update_request_json(
+ {'error_type': ErrorType.Retriable.value, 'retry_group_type': RetriableGroupType.All.value}
+ )
+ statement = 'SELECT "Hello, data!" AS greeting'
+ with pytest.raises(TimeoutError) as ex:
+ # just-in-case, increase the max_retries to ensure we hit the timeout
+ await test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(max_retries=10, timeout=timedelta(seconds=1.5))
+ )
+
+ test_env.assert_error_context_num_attempts(4, ex.value._context, exact=False)
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+
+ async def test_error_retriable_response_retries_exceeded(self, test_env: AsyncTestEnvironment) -> None:
+ test_env.set_url_path('/test_error')
+ test_env.update_request_json(
+ {'error_type': ErrorType.Retriable.value, 'retry_group_type': RetriableGroupType.All.value}
+ )
+ statement = 'SELECT "Hello, data!" AS greeting'
+ allowed_retries = 5
+ q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10))
+ with pytest.raises(QueryError) as ex:
+ await test_env.cluster_or_scope.execute_query(statement, q_opts)
+
+ test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context)
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+
+ @pytest.mark.parametrize('analytics_error', [False, True])
+ async def test_error_retriable_http503(self, test_env: AsyncTestEnvironment, analytics_error: bool) -> None:
+ test_env.set_url_path('/test_error')
+ test_env.update_request_json({'error_type': ErrorType.Http503.value, 'analytics_error': analytics_error})
+ statement = 'SELECT "Hello, data!" AS greeting'
+ allowed_retries = 5
+ q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10))
+ ex: Union[pytest.ExceptionInfo[AnalyticsError], pytest.ExceptionInfo[QueryError]]
+ if analytics_error:
+ with pytest.raises(QueryError) as ex:
+ await test_env.cluster_or_scope.execute_query(statement, q_opts)
+ else:
+ with pytest.raises(AnalyticsError) as ex:
+ await test_env.cluster_or_scope.execute_query(statement, q_opts)
+
+ test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context)
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+
+ @pytest.mark.parametrize('server_side', [False, True])
+ async def test_error_timeout(self, test_env: AsyncTestEnvironment, server_side: bool) -> None:
+ test_env.set_url_path('/test_error')
+ if server_side:
+ req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 1, 'server_side': True}
+ else:
+ req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 3}
+
+ test_env.update_request_json(req_json)
+ statement = 'SELECT "Hello, data!" AS greeting'
+ with pytest.raises(TimeoutError) as ex:
+ await test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2)))
+ test_env.assert_error_context_num_attempts(1, ex.value._context)
+ if server_side:
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+ else:
+ test_env.assert_error_context_missing_last_dispatch(ex.value._context)
+
+ @pytest.mark.parametrize('stream', [False, True])
+ async def test_results_object_values(self, test_env: AsyncTestEnvironment, stream: bool) -> None:
+ expected_rows = 50
+ test_env.set_url_path('/test_results')
+ test_env.update_request_json(
+ {'result_type': ResultType.Object.value, 'row_count': expected_rows, 'stream': stream}
+ )
+ statement = 'SELECT "Hello, data!" AS greeting'
+ result = await test_env.cluster_or_scope.execute_query(statement)
+ assert isinstance(result, AsyncQueryResult)
+ await test_env.assert_rows(result, expected_rows)
+
+ @pytest.mark.parametrize('stream', [False, True])
+ async def test_results_raw_values(self, test_env: AsyncTestEnvironment, stream: bool) -> None:
+ expected_rows = 50
+ test_env.set_url_path('/test_results')
+ test_env.update_request_json(
+ {'result_type': ResultType.Raw.value, 'row_count': expected_rows, 'stream': stream}
+ )
+ statement = 'SELECT "Hello, data!" AS greeting'
+ result = await test_env.cluster_or_scope.execute_query(statement)
+ assert isinstance(result, AsyncQueryResult)
+ await test_env.assert_rows(result, expected_rows)
+
+
+class ClusterTestServerTests(TestServerTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ClusterTestServerTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ClusterTestServerTests) if valid_test_method(meth)]
+ test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='test_env')
+ async def couchbase_test_environment(
+ self, async_test_env_with_server: AsyncTestEnvironment
+ ) -> AsyncYieldFixture[AsyncTestEnvironment]:
+ test_env = await async_test_env_with_server.enable_test_server()
+ yield test_env
+ test_env.disable_test_server()
+
+
+class ScopeTestServerTests(TestServerTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ScopeTestServerTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ScopeTestServerTests) if valid_test_method(meth)]
+ test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='test_env')
+ async def couchbase_test_environment(
+ self, async_test_env_with_server: AsyncTestEnvironment
+ ) -> AsyncYieldFixture[AsyncTestEnvironment]:
+ test_env = await async_test_env_with_server.enable_test_server()
+ test_env.enable_scope()
+ yield test_env
+ test_env.disable_scope().disable_test_server()
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 0000000..d7b108a
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,74 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import List
+
+import pytest
+
+pytest_plugins = [
+ 'tests.analytics_config',
+ 'tests.environments.base_environment',
+ 'tests.environments.simple_environment',
+]
+
+_UNIT_TESTS = [
+ 'acouchbase_analytics/tests/connection_t.py::ConnectionTests',
+ 'acouchbase_analytics/tests/json_parsing_t.py::JsonParsingTests',
+ 'acouchbase_analytics/tests/options_t.py::ClusterOptionsTests',
+ 'acouchbase_analytics/tests/query_options_t.py::ClusterQueryOptionsTests',
+ 'acouchbase_analytics/tests/query_options_t.py::ScopeQueryOptionsTests',
+ 'acouchbase_analytics/tests/test_server_t.py::ClusterTestServerTests',
+ 'acouchbase_analytics/tests/test_server_t.py::ScopeTestServerTests',
+ 'couchbase_analytics/tests/connection_t.py::ConnectionTests',
+ 'couchbase_analytics/tests/duration_parsing_t.py::DurationParsingTests',
+ 'couchbase_analytics/tests/json_parsing_t.py::JsonParsingTests',
+ 'couchbase_analytics/tests/options_t.py::ClusterOptionsTests',
+ 'couchbase_analytics/tests/query_options_t.py::ClusterQueryOptionsTests',
+ 'couchbase_analytics/tests/query_options_t.py::ScopeQueryOptionsTests',
+ 'couchbase_analytics/tests/test_server_t.py::ClusterTestServerTests',
+ 'couchbase_analytics/tests/test_server_t.py::ScopeTestServerTests',
+]
+
+_INTEGRATRION_TESTS = [
+ 'acouchbase_analytics/tests/connect_integration_t.py::ConnectTests',
+ 'acouchbase_analytics/tests/query_integration_t.py::ClusterQueryTests',
+ 'acouchbase_analytics/tests/query_integration_t.py::ScopeQueryTests',
+ 'couchbase_analytics/tests/connect_integration_t.py::ConnectTests',
+ 'couchbase_analytics/tests/query_integration_t.py::ClusterQueryTests',
+ 'couchbase_analytics/tests/query_integration_t.py::ScopeQueryTests',
+]
+
+
+@pytest.fixture(scope='class')
+def anyio_backend() -> str:
+ return 'asyncio'
+
+
+# https://docs.pytest.org/en/stable/reference/reference.html#std-hook-pytest_collection_modifyitems
+def pytest_collection_modifyitems(session: pytest.Session, config: pytest.Config, items: List[pytest.Item]) -> None: # noqa: C901
+ for item in items:
+ item_details = item.nodeid.split('::')
+
+ item_api = item_details[0].split('/')
+ if item_api[0] == 'couchbase_analytics':
+ item.add_marker(pytest.mark.pycbac_couchbase)
+ elif item_api[0] == 'acouchbase_analytics':
+ item.add_marker(pytest.mark.pycbac_acouchbase)
+
+ test_class_path = '::'.join(item_details[:-1])
+ if test_class_path in _UNIT_TESTS:
+ item.add_marker(pytest.mark.pycbac_unit)
+ elif test_class_path in _INTEGRATRION_TESTS:
+ item.add_marker(pytest.mark.pycbac_integration)
diff --git a/couchbase_analytics/__init__.py b/couchbase_analytics/__init__.py
new file mode 100644
index 0000000..7ed5405
--- /dev/null
+++ b/couchbase_analytics/__init__.py
@@ -0,0 +1,18 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common import LOG_DATE_FORMAT as LOG_DATE_FORMAT # noqa: F401
+from couchbase_analytics.common import LOG_FORMAT as LOG_FORMAT # noqa: F401
+from couchbase_analytics.common import JSONType as JSONType # noqa: F401
diff --git a/couchbase_analytics/_version.py b/couchbase_analytics/_version.py
new file mode 100644
index 0000000..081f83c
--- /dev/null
+++ b/couchbase_analytics/_version.py
@@ -0,0 +1,5 @@
+# This file automatically generated by
+# /Users/jaredcasey/GIT/couchbase/clients/python/analytics-python-client/couchbase_analytics_version.py
+# at
+# 2025-07-24 17:08:38.315069
+__version__ = '0.0.1'
diff --git a/couchbase_analytics/cluster.py b/couchbase_analytics/cluster.py
new file mode 100644
index 0000000..8c728b9
--- /dev/null
+++ b/couchbase_analytics/cluster.py
@@ -0,0 +1,199 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from concurrent.futures import Future
+from typing import TYPE_CHECKING, Optional, Union
+
+from couchbase_analytics.database import Database
+from couchbase_analytics.result import BlockingQueryResult
+
+if TYPE_CHECKING:
+ from couchbase_analytics.credential import Credential
+ from couchbase_analytics.options import ClusterOptions
+
+
+class Cluster:
+ """Create a Cluster instance.
+
+ The cluster instance exposes the operations which are available to be performed against an Analytics cluster.
+
+ .. important::
+ Use the static :meth:`.Cluster.create_instance` method to create a Cluster.
+
+ Args:
+ http_endpoint:
+ The HTTP endpoint to use for sending requests to the Analytics server.
+ The format of the endpoint string is the *scheme* (``http`` or ``https`` is _required_), followed a hostname
+ credential: User credentials.
+ options: Global options to set for the cluster.
+ Some operations allow the global options to be overriden by passing in options to the operation.
+ **kwargs: keyword arguments that can be used in place or to overrride provided :class:`~couchbase_analytics.options.ClusterOptions`
+
+ Raises:
+ ValueError: If incorrect connstr is provided.
+ ValueError: If incorrect options are provided.
+
+ """ # noqa: E501
+
+ def __init__(
+ self, http_endpoint: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object
+ ) -> None:
+ from couchbase_analytics.protocol.cluster import Cluster as _Cluster
+
+ self._impl = _Cluster(http_endpoint, credential, options, **kwargs)
+
+ def database(self, name: str) -> Database:
+ """Creates a database instance.
+
+ .. seealso::
+ :class:`~couchbase_analytics.database.Database`
+
+ Args:
+ name: Name of the database
+
+ Returns:
+ A Database instance.
+
+ """
+ return Database(self._impl, name)
+
+ def execute_query(
+ self, statement: str, *args: object, **kwargs: object
+ ) -> Union[Future[BlockingQueryResult], BlockingQueryResult]:
+ """Executes a query against an Analytics cluster.
+
+ .. note::
+ A departure from the operational SDK, the query is *NOT* executed lazily.
+
+ .. seealso::
+ :meth:`couchbase_analytics.Scope.execute_query`: For how to execute scope-level queries.
+
+ Args:
+ statement: The SQL++ statement to execute.
+ options (:class:`~couchbase_analytics.options.QueryOptions`): Optional parameters for the query operation.
+ **kwargs (Dict[str, Any]): keyword arguments that can be used in place or to override provided :class:`~couchbase_analytics.options.QueryOptions`
+
+ Returns:
+ :class:`~couchbase_analytics.result.BlockingQueryResult`: An instance of a :class:`~couchbase_analytics.result.BlockingQueryResult` which
+ provides access to iterate over the query results and access metadata and metrics about the query.
+
+ Examples:
+ Simple query::
+
+ q_str = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country LIKE 'United%' LIMIT 2;'
+ q_res = cluster.execute_query(q_str)
+ for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ Simple query with positional parameters::
+
+ from couchbase_analytics.options import QueryOptions
+
+ # ... other code ...
+
+ q_str = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country LIKE $1 LIMIT $2;'
+ q_res = cluster.execute_query(q_str, QueryOptions(positional_parameters=['United%', 5]))
+ for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ Simple query with named parameters::
+
+ from couchbase_analytics.options import QueryOptions
+
+ # ... other code ...
+
+ q_str = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country LIKE $country LIMIT $lim;'
+ q_res = cluster.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2}))
+ for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ Retrieve metadata and/or metrics from query::
+
+ from couchbase_analytics.options import QueryOptions
+
+ # ... other code ...
+
+ q_str = 'SELECT * FROM `travel-sample` WHERE country LIKE $country LIMIT $lim;'
+ q_res = cluster.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2}))
+ for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ print(f'Query metadata: {q_res.metadata()}')
+ print(f'Query metrics: {q_res.metadata().metrics()}')
+
+ """ # noqa: E501
+ return self._impl.execute_query(statement, *args, **kwargs)
+
+ def shutdown(self) -> None:
+ """Shuts down this cluster instance. Cleaning up all resources associated with it.
+
+ .. warning::
+ Use of this method is almost *always* unnecessary. Cluster resources should be cleaned
+ up once the cluster instance falls out of scope. However, in some applications tuning resources
+ is necessary and in those types of applications, this method might be beneficial.
+
+ """
+ return self._impl.shutdown()
+
+ @classmethod
+ def create_instance(
+ cls, http_endpoint: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object
+ ) -> Cluster:
+ """Create a Cluster instance
+
+ Args:
+ http_endpoint:
+ The HTTP endpoint to use for sending requests to the Analytics server.
+ The format of the endpoint string is the *scheme* (``http`` or ``https`` is _required_), followed a hostname
+ credential: User credentials.
+ options: Global options to set for the cluster.
+ Some operations allow the global options to be overriden by passing in options to the operation.
+ **kwargs: Keyword arguments that can be used in place or to overrride provided :class:`~couchbase_analytics.options.ClusterOptions`
+
+
+ Returns:
+ An Analytics Cluster instance.
+
+ Raises:
+ ValueError: If incorrect connstr is provided.
+ ValueError: If incorrect options are provided.
+
+
+ Examples:
+ Initialize cluster using default options::
+
+ from couchbase_analytics.cluster import Cluster
+ from couchbase_analytics.credential import Credential
+
+ cred = Credential.from_username_and_password('username', 'password')
+ cluster = Cluster.create_instance('https://hostname', cred)
+
+
+ Initialize cluster using with global timeout options::
+
+ from datetime import timedelta
+
+ from couchbase_analytics.cluster import Cluster
+ from couchbase_analytics.credential import Credential
+ from couchbase_analytics.options import ClusterOptions, ClusterTimeoutOptions
+
+ cred = Credential.from_username_and_password('username', 'password')
+ opts = ClusterOptions(timeout_options=ClusterTimeoutOptions(query_timeout=timedelta(seconds=120)))
+ cluster = Cluster.create_instance('https://hostname', cred, opts)
+
+ """ # noqa: E501
+ return cls(http_endpoint, credential, options, **kwargs)
diff --git a/couchbase_analytics/cluster.pyi b/couchbase_analytics/cluster.pyi
new file mode 100644
index 0000000..9dcbfba
--- /dev/null
+++ b/couchbase_analytics/cluster.pyi
@@ -0,0 +1,133 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+from concurrent.futures import Future
+from typing import overload
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Unpack
+else:
+ from typing import Unpack
+
+from couchbase_analytics import JSONType
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.database import Database
+from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs
+from couchbase_analytics.result import BlockingQueryResult
+
+class Cluster:
+ @overload
+ def __init__(self, http_endpoint: str, credential: Credential) -> None: ...
+ @overload
+ def __init__(self, http_endpoint: str, credential: Credential, options: ClusterOptions) -> None: ...
+ @overload
+ def __init__(self, http_endpoint: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ...
+ @overload
+ def __init__(
+ self,
+ http_endpoint: str,
+ credential: Credential,
+ options: ClusterOptions,
+ **kwargs: Unpack[ClusterOptionsKwargs],
+ ) -> None: ...
+ def database(self, name: str) -> Database: ...
+ @overload
+ def execute_query(self, statement: str) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, options: QueryOptions) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: str
+ ) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, *args: JSONType, **kwargs: str) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, enable_cancel: bool) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, enable_cancel: bool, *args: JSONType) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, enable_cancel: bool
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self,
+ statement: str,
+ options: QueryOptions,
+ enable_cancel: bool,
+ *args: JSONType,
+ **kwargs: Unpack[QueryOptionsKwargs],
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self,
+ statement: str,
+ options: QueryOptions,
+ *args: JSONType,
+ enable_cancel: bool,
+ **kwargs: Unpack[QueryOptionsKwargs],
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, enable_cancel: bool, *args: JSONType, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: JSONType, enable_cancel: bool, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, enable_cancel: bool, *args: JSONType, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, *args: JSONType, enable_cancel: bool, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ def shutdown(self) -> None: ...
+ @overload
+ @classmethod
+ def create_instance(cls, http_endpoint: str, credential: Credential) -> Cluster: ...
+ @overload
+ @classmethod
+ def create_instance(cls, http_endpoint: str, credential: Credential, options: ClusterOptions) -> Cluster: ...
+ @overload
+ @classmethod
+ def create_instance(
+ cls, http_endpoint: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs]
+ ) -> Cluster: ...
+ @overload
+ @classmethod
+ def create_instance(
+ cls, http_endpoint: str, credential: Credential, options: ClusterOptions, **kwargs: Unpack[ClusterOptionsKwargs]
+ ) -> Cluster: ...
diff --git a/couchbase_analytics/common/__init__.py b/couchbase_analytics/common/__init__.py
new file mode 100644
index 0000000..c14c1f9
--- /dev/null
+++ b/couchbase_analytics/common/__init__.py
@@ -0,0 +1,21 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import Any, Dict, List, Union
+
+from .logging import LOG_DATE_FORMAT as LOG_DATE_FORMAT # noqa: F401
+from .logging import LOG_FORMAT as LOG_FORMAT # noqa: F401
+
+JSONType = Union[str, int, float, bool, None, Dict[str, Any], List[Any]]
diff --git a/couchbase_analytics/common/_core/__init__.py b/couchbase_analytics/common/_core/__init__.py
new file mode 100644
index 0000000..d1d6ccb
--- /dev/null
+++ b/couchbase_analytics/common/_core/__init__.py
@@ -0,0 +1,18 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from .json_parsing import JsonStreamConfig as JsonStreamConfig # noqa: F401
+from .json_parsing import ParsedResult as ParsedResult # noqa: F401
+from .json_parsing import ParsedResultType as ParsedResultType # noqa: F401
diff --git a/couchbase_analytics/common/_core/capella_certificates/capella.pem b/couchbase_analytics/common/_core/capella_certificates/capella.pem
new file mode 100644
index 0000000..87984fe
--- /dev/null
+++ b/couchbase_analytics/common/_core/capella_certificates/capella.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDFTCCAf2gAwIBAgIRANLVkgOvtaXiQJi0V6qeNtswDQYJKoZIhvcNAQELBQAw
+JDESMBAGA1UECgwJQ291Y2hiYXNlMQ4wDAYDVQQLDAVDbG91ZDAeFw0xOTEyMDYy
+MjEyNTlaFw0yOTEyMDYyMzEyNTlaMCQxEjAQBgNVBAoMCUNvdWNoYmFzZTEOMAwG
+A1UECwwFQ2xvdWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCfvOIi
+enG4Dp+hJu9asdxEMRmH70hDyMXv5ZjBhbo39a42QwR59y/rC/sahLLQuNwqif85
+Fod1DkqgO6Ng3vecSAwyYVkj5NKdycQu5tzsZkghlpSDAyI0xlIPSQjoORA/pCOU
+WOpymA9dOjC1bo6rDyw0yWP2nFAI/KA4Z806XeqLREuB7292UnSsgFs4/5lqeil6
+rL3ooAw/i0uxr/TQSaxi1l8t4iMt4/gU+W52+8Yol0JbXBTFX6itg62ppb/Eugmn
+mQRMgL67ccZs7cJ9/A0wlXencX2ohZQOR3mtknfol3FH4+glQFn27Q4xBCzVkY9j
+KQ20T1LgmGSngBInAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE
+FJQOBPvrkU2In1Sjoxt97Xy8+cKNMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0B
+AQsFAAOCAQEARgM6XwcXPLSpFdSf0w8PtpNGehmdWijPM3wHb7WZiS47iNen3oq8
+m2mm6V3Z57wbboPpfI+VEzbhiDcFfVnK1CXMC0tkF3fnOG1BDDvwt4jU95vBiNjY
+xdzlTP/Z+qr0cnVbGBSZ+fbXstSiRaaAVcqQyv3BRvBadKBkCyPwo+7svQnScQ5P
+Js7HEHKVms5tZTgKIw1fbmgR2XHleah1AcANB+MAPBCcTgqurqr5G7W2aPSBLLGA
+fRIiVzm7VFLc7kWbp7ENH39HVG6TZzKnfl9zJYeiklo5vQQhGSMhzBsO70z4RRzi
+DPFAN/4qZAgD5q3AFNIq2WWADFQGSwVJhg==
+-----END CERTIFICATE-----
diff --git a/couchbase_analytics/common/_core/certificates.py b/couchbase_analytics/common/_core/certificates.py
new file mode 100644
index 0000000..6210130
--- /dev/null
+++ b/couchbase_analytics/common/_core/certificates.py
@@ -0,0 +1,73 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import List
+
+
+class _Certificates:
+ """**INTERNAL**"""
+
+ @staticmethod
+ def get_certificate_from_file(certpath: str) -> str:
+ """
+ **INTERNAL** Convenience method for access to the certificate file. NOT part of the public API.
+
+ Returns:
+ str: The contents of the certificate file.
+ """
+ cert_file = Path(certpath)
+ if not cert_file.exists():
+ raise FileNotFoundError(f'Certificate file not found: {cert_file}')
+ return cert_file.read_text()
+
+ @staticmethod
+ def get_capella_certificates() -> List[str]:
+ """
+ **INTERNAL** Convenience method for access to Capella certificates. NOT part of the public API.
+ Returns:
+ List[str]: List of Capella certificates.
+ """
+ nonprod_cert_dir = Path(Path(__file__).resolve().parent, 'capella_certificates')
+ nonprod_certs: List[str] = []
+ for cert in nonprod_cert_dir.iterdir():
+ if os.path.isdir(cert) or cert.suffix != '.pem':
+ continue
+ nonprod_certs.append(cert.read_text())
+ return nonprod_certs
+
+ @staticmethod
+ def get_nonprod_certificates() -> List[str]:
+ """
+ **INTERNAL** Convenience method for access to non-prod Capella certificates. NOT
+ part of the public API.
+
+ Returns:
+ List[str]: List of nonprod Capella certificates.
+ """
+ import warnings
+
+ warnings.warn('Only use non-prod certificate in DEVELOPMENT environments.', ResourceWarning, stacklevel=2)
+ nonprod_cert_dir = Path(Path(__file__).resolve().parent, 'nonprod_certificates')
+ nonprod_certs: List[str] = []
+ for cert in nonprod_cert_dir.iterdir():
+ if os.path.isdir(cert) or cert.suffix != '.pem':
+ continue
+ nonprod_certs.append(cert.read_text())
+ return nonprod_certs
diff --git a/couchbase_analytics/common/_core/duration_str_utils.py b/couchbase_analytics/common/_core/duration_str_utils.py
new file mode 100644
index 0000000..5b582d0
--- /dev/null
+++ b/couchbase_analytics/common/_core/duration_str_utils.py
@@ -0,0 +1,92 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import re
+from typing import Optional
+
+from couchbase_analytics.common._core.utils import is_null_or_empty
+
+# TODO: Apparently Go does not allow a leading decimal point without a leading zero, e.g., ".5s" is invalid.
+# We allowed this in the Columnar SDK due to how the C++ client parsed durations
+DURATION_PATTERN = re.compile(r'^([-+]?)((\d*(\.\d*)?){1}(?:ns|us|µs|μs|ms|s|m|h){1})+$')
+DURATION_PAIRS_PATTERN = re.compile(r'(\d*(?:\.\d*)?)(ns|us|ms|s|m|h)')
+
+
+def check_valid_duration_str(duration_str: str) -> None:
+ """
+ Validates if the given string is a valid duration string.
+
+ :param value: The duration string to validate.
+ :return: True if valid, False otherwise.
+ """
+ if not isinstance(duration_str, str):
+ raise ValueError(f'Expected a string, got {type(duration_str).__name__} instead.')
+
+ if is_null_or_empty(duration_str):
+ raise ValueError('Duration string cannot be empty.')
+
+ if duration_str.startswith('-'):
+ raise ValueError('Negative durations are not supported.')
+
+ # Special case: "0" duration
+ if duration_str == '0':
+ return
+
+ match = DURATION_PATTERN.fullmatch(duration_str)
+
+ if not match:
+ raise ValueError('Duration string has invalid format')
+
+
+def parse_duration_str(duration_str: str, in_millis: Optional[bool] = False) -> float:
+ check_valid_duration_str(duration_str)
+
+ # Special case: "0" duration
+ if duration_str == '0':
+ return 0.0
+
+ # Normalize 'µs' (micro)
+ duration_str = duration_str.replace('µs', 'us').replace('μs', 'us')
+
+ # Mapping of units to their multiplier to convert to seconds
+ unit_multipliers = {
+ 'ns': 1e-9, # nanoseconds
+ 'us': 1e-6, # microseconds
+ 'ms': 1e-3, # milliseconds
+ 's': 1.0, # seconds
+ 'm': 60.0, # minutes
+ 'h': 3600.0, # hours
+ }
+
+ segments = DURATION_PAIRS_PATTERN.findall(duration_str)
+ total_seconds = 0.0
+ for num_str, unit_str in segments:
+ try:
+ value = float(num_str)
+ total_seconds += value * unit_multipliers[unit_str]
+ except OverflowError as e:
+ raise ValueError(
+ (f'Invalid duration. Overflow error while parsing number "{num_str}{unit_str}". Error details: {e}')
+ ) from None
+ except ValueError as e:
+ raise ValueError(
+ (f'Invalid duration. Parsing error while parsing number "{num_str}{unit_str}". Error details: {e}')
+ ) from None
+ except KeyError:
+ raise ValueError(f'Invalid duration. Unknown unit "{unit_str}"') from None
+
+ if in_millis:
+ total_seconds *= 1e3
+ return total_seconds
diff --git a/couchbase_analytics/common/_core/error_context.py b/couchbase_analytics/common/_core/error_context.py
new file mode 100644
index 0000000..6270fea
--- /dev/null
+++ b/couchbase_analytics/common/_core/error_context.py
@@ -0,0 +1,90 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional
+
+from httpx import Response as HttpCoreResponse
+
+from couchbase_analytics.protocol._core.request import QueryRequest
+
+
+@dataclass
+class ErrorContext:
+ num_attempts: int = 0
+ path: Optional[str] = None
+ method: Optional[str] = None
+ status_code: Optional[int] = None
+ statement: Optional[str] = None
+ last_dispatched_to: Optional[str] = None
+ last_dispatched_from: Optional[str] = None
+ errors: Optional[List[Dict[str, Any]]] = None
+ first_error: Optional[Dict[str, Any]] = None
+
+ def set_errors(self, errors: List[Dict[str, Any]]) -> None:
+ self.errors: List[Dict[str, Any]] = errors
+
+ def set_first_error(self, error: Dict[str, Any]) -> None:
+ self.first_error = error
+
+ def maybe_update_errors(self) -> None:
+ if self.errors is not None and len(self.errors) > 0:
+ return
+ if self.first_error is not None:
+ self.errors = [self.first_error]
+
+ def update_num_attempts(self) -> None:
+ self.num_attempts += 1
+
+ def update_request_context(self, request: QueryRequest) -> None:
+ self.path = request.url.path
+
+ def update_response_context(self, response: HttpCoreResponse) -> None:
+ network_stream = response.extensions.get('network_stream', None)
+ if network_stream is not None:
+ addr, port = network_stream.get_extra_info('client_addr')
+ self.last_dispatched_from = f'{addr}:{port}'
+ addr, port = network_stream.get_extra_info('server_addr')
+ self.last_dispatched_to = f'{addr}:{port}'
+ self.status_code = response.status_code
+
+ def _ctx_details(self) -> Dict[str, str]:
+ details: Dict[str, str] = {
+ 'num_attempts': str(self.num_attempts),
+ }
+ if self.path is not None:
+ details['path'] = self.path
+ if self.method is not None:
+ details['method'] = self.method
+ if self.status_code is not None:
+ details['status_code'] = str(self.status_code)
+ if self.statement is not None:
+ details['statement'] = self.statement
+ if self.last_dispatched_to is not None:
+ details['last_dispatched_to'] = self.last_dispatched_to
+ if self.last_dispatched_from is not None:
+ details['last_dispatched_from'] = self.last_dispatched_from
+ if self.errors is not None:
+ errors = ', '.join(str(e) for e in self.errors)
+ details['errors'] = f'[{errors}]'
+ return details
+
+ def __repr__(self) -> str:
+ return f'{type(self).__name__}({self._ctx_details()})'
+
+ def __str__(self) -> str:
+ return str(self._ctx_details())
diff --git a/couchbase_analytics/common/_core/json_parsing.py b/couchbase_analytics/common/_core/json_parsing.py
new file mode 100644
index 0000000..43a1c4c
--- /dev/null
+++ b/couchbase_analytics/common/_core/json_parsing.py
@@ -0,0 +1,53 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import IntEnum
+from typing import NamedTuple, Optional
+
+# buffer size in httpcore is 2 ** 16 (65kiB) which matches the default buffer size in ijson
+# passing in a chunk_size is only applying an abstraction over the httpcore stream
+DEFAULT_HTTP_STREAM_BUFFER_SIZE = 2**16
+
+
+@dataclass
+class JsonStreamConfig:
+ http_stream_buffer_size: int = DEFAULT_HTTP_STREAM_BUFFER_SIZE
+ buffer_entire_result: bool = False
+ buffered_row_max: int = 100
+ buffered_row_threshold_percent: float = 0.75
+ queue_timeout: float = 0.25
+
+
+class ParsedResultType(IntEnum):
+ """
+ **INTERNAL**
+ """
+
+ ROW = 0
+ ERROR = 1
+ END = 2
+ UNKNOWN = 3
+
+
+class ParsedResult(NamedTuple):
+ """
+ **INTERNAL**
+ """
+
+ value: Optional[bytes]
+ result_type: ParsedResultType
diff --git a/couchbase_analytics/common/_core/json_token_parser_base.py b/couchbase_analytics/common/_core/json_token_parser_base.py
new file mode 100644
index 0000000..4548a2f
--- /dev/null
+++ b/couchbase_analytics/common/_core/json_token_parser_base.py
@@ -0,0 +1,273 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from collections import deque
+from enum import Enum
+from typing import Deque, NamedTuple, Optional
+
+
+class ParsingState(Enum):
+ PROCESSING = 'processing'
+ START_RESULTS_PROCESSING = 'start_results_processing'
+ PROCESSING_RESULTS = 'processing_results'
+ PROCESSING_RESULT = 'processing_result'
+ START_ERRORS_PROCESSING = 'start_errors_processing'
+ PROCESSING_ERRORS = 'processing_errors'
+ PROCESSING_ERROR = 'processing_error'
+ UNDEFINED = 'undefined'
+
+ @staticmethod
+ def okay_to_emit(state: ParsingState, previous_state: ParsingState) -> bool:
+ if state == ParsingState.PROCESSING_RESULTS:
+ return True
+ return previous_state == ParsingState.PROCESSING_RESULTS and state == ParsingState.PROCESSING
+
+ @staticmethod
+ def should_pop_results_key(state: ParsingState, previous_state: ParsingState) -> bool:
+ return previous_state == ParsingState.PROCESSING_RESULTS and state == ParsingState.PROCESSING
+
+ def __str__(self) -> str:
+ return self.value
+
+
+class TokenState(Enum):
+ RESULTS_START = 'results_start'
+ RESULT_START = 'result_start'
+ ERRORS_START = 'errors_start'
+ ERROR_START = 'error_start'
+ UNDEFINED = 'undefined'
+
+ def __str__(self) -> str:
+ return self.value
+
+
+class TokenType(Enum):
+ START_MAP = 'start_map'
+ END_MAP = 'end_map'
+ START_ARRAY = 'start_array'
+ END_ARRAY = 'end_array'
+ MAP_KEY = 'map_key'
+ STRING = 'string'
+ BOOLEAN = 'boolean'
+ NULL = 'null'
+ INTEGER = 'integer'
+ DOUBLE = 'double'
+ NUMBER = 'number'
+ PAIR = 'pair'
+ VALUE = 'value'
+ OBJECT = 'object'
+ UNKNOWN = 'unknown'
+
+ @classmethod
+ def from_str(cls, value: str) -> TokenType:
+ try:
+ return cls[value.upper()]
+ except KeyError:
+ raise ValueError(f'Invalid token type: {value}') from None
+
+ def __str__(self) -> str:
+ return self.value
+
+
+class Token(NamedTuple):
+ type: TokenType
+ value: str
+ state: Optional[TokenState] = None
+
+
+VALUE_TOKENS = [
+ TokenType.STRING,
+ TokenType.BOOLEAN,
+ TokenType.NULL,
+ TokenType.INTEGER,
+ TokenType.DOUBLE,
+ TokenType.NUMBER,
+]
+
+EVENT_TOKENS = {
+ TokenType.START_ARRAY: Token(TokenType.START_ARRAY, '['),
+ TokenType.END_ARRAY: Token(TokenType.END_ARRAY, ']'),
+ TokenType.START_MAP: Token(TokenType.START_MAP, '{'),
+ TokenType.END_MAP: Token(TokenType.END_MAP, '}'),
+}
+
+POP_EVENTS = [TokenType.END_ARRAY, TokenType.END_MAP]
+
+START_EVENTS = [TokenType.START_ARRAY, TokenType.START_MAP]
+
+START_EVENT_TRANSITION_STATES = [
+ ParsingState.START_RESULTS_PROCESSING,
+ ParsingState.START_ERRORS_PROCESSING,
+ ParsingState.PROCESSING_RESULTS,
+]
+
+
+class JsonTokenParsingError(Exception):
+ """
+ Exception raised when there is an error parsing JSON tokens.
+ """
+
+ def __init__(self, message: str) -> None:
+ super().__init__(message)
+ self.message = message
+
+ def __str__(self) -> str:
+ return f'JsonTokenParsingError: {self.message}'
+
+
+class JsonTokenParserBase:
+ def __init__(self, emit_results_enabled: bool) -> None:
+ self._stack: Deque[Token] = deque()
+ self._state = ParsingState.PROCESSING
+ self._previous_state = ParsingState.UNDEFINED
+ self._emit_results_enabled = emit_results_enabled
+ self._results_type = TokenType.UNKNOWN
+ self._has_errors = False
+
+ @property
+ def has_errors(self) -> bool:
+ return self._has_errors
+
+ @property
+ def results_type(self) -> TokenType:
+ return self._results_type
+
+ def _check_results_in_raw_array(self) -> None:
+ if self._results_type != TokenType.UNKNOWN:
+ return
+ if self._state == ParsingState.PROCESSING:
+ return
+ if self._state == ParsingState.PROCESSING_RESULTS:
+ self._results_type = TokenType.VALUE
+ else:
+ self._results_type = TokenType.OBJECT
+
+ def _get_matching_token(self, token_type: TokenType) -> Token:
+ if token_type == TokenType.END_ARRAY:
+ return EVENT_TOKENS[TokenType.START_ARRAY]
+ elif token_type == TokenType.END_MAP:
+ return EVENT_TOKENS[TokenType.START_MAP]
+ else:
+ raise JsonTokenParsingError(f'Invalid token type (cannot match): {token_type}')
+
+ def _handle_map_key_token(self, value: str) -> None:
+ if self._state == ParsingState.PROCESSING:
+ if value == 'results':
+ self._state = ParsingState.START_RESULTS_PROCESSING
+ self._previous_state = ParsingState.PROCESSING
+ elif value == 'errors':
+ self._has_errors = True
+ self._state = ParsingState.START_ERRORS_PROCESSING
+ self._previous_state = ParsingState.PROCESSING
+ self._push(TokenType.MAP_KEY, f'"{value}"')
+
+ def _handle_pop_transition(self, token_state: Optional[TokenState] = None) -> bool:
+ if token_state is not None:
+ if token_state == TokenState.RESULTS_START:
+ self._previous_state = self._state
+ self._state = ParsingState.PROCESSING
+ elif token_state == TokenState.ERRORS_START:
+ self._previous_state = self._state
+ self._state = ParsingState.PROCESSING
+ elif token_state == TokenState.RESULT_START:
+ self._previous_state = self._state
+ self._state = ParsingState.PROCESSING_RESULTS
+ return True
+ return False
+
+ def _handle_push_transition(self) -> Optional[TokenState]:
+ if self._state == ParsingState.START_RESULTS_PROCESSING:
+ self._previous_state = self._state
+ self._state = ParsingState.PROCESSING_RESULTS
+ return TokenState.RESULTS_START
+ elif self._state == ParsingState.START_ERRORS_PROCESSING:
+ self._previous_state = self._state
+ self._state = ParsingState.PROCESSING_ERRORS
+ return TokenState.ERRORS_START
+ elif self._state == ParsingState.PROCESSING_RESULTS:
+ self._previous_state = self._state
+ self._state = ParsingState.PROCESSING_RESULT
+ return TokenState.RESULT_START
+ elif self._state == ParsingState.PROCESSING_ERRORS:
+ self._previous_state = self._state
+ self._state = ParsingState.PROCESSING_ERROR
+ return TokenState.ERROR_START
+ raise JsonTokenParsingError(f'Invalid state for push transition: {self._state}')
+
+ def _handle_start_event(self, token_type: TokenType) -> None:
+ transition = False
+ if self._state in START_EVENT_TRANSITION_STATES:
+ transition = True
+
+ self._push(token_type, EVENT_TOKENS[token_type].value, transition)
+
+ def _handle_value_token(self, token_type: TokenType, value: str) -> Optional[str]:
+ self._check_results_in_raw_array()
+ pair_key = val = None
+ if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY:
+ # no state transitions for a map_key token
+ pair_key = self._pop().value
+ if token_type == TokenType.STRING:
+ if '"' in value:
+ value = value.replace('"', '\\"')
+ if "\\'" in value:
+ value = value.replace("\\'", "\\\\'")
+ val = f'"{value}"'
+ elif token_type == TokenType.NULL:
+ val = 'null'
+ elif token_type == TokenType.BOOLEAN:
+ val = f'{value}'.lower()
+ else:
+ val = f'{value}'
+ if pair_key is not None:
+ if self.results_type == TokenType.VALUE and self._state != ParsingState.PROCESSING:
+ raise JsonTokenParsingError('Cannot return value when pair_key is present.')
+ self._push(TokenType.PAIR, f'{pair_key}:{val}')
+ else:
+ if self._emit_results_enabled is True and self.results_type == TokenType.VALUE:
+ return val
+ self._push(TokenType.VALUE, val)
+ return None
+
+ def _push(self, token_type: TokenType, value: str, transition: Optional[bool] = False) -> None:
+ token_state = None
+ if transition is True:
+ token_state = self._handle_push_transition()
+
+ self._stack.append(Token(token_type, value, token_state))
+
+ def _pop(self) -> Token:
+ if self._stack:
+ return self._stack.pop()
+ raise JsonTokenParsingError('Stack is empty')
+
+ def _should_push_pair(self, token: Token) -> bool:
+ # when a results object is complete, the state will have transactioned back to PROCESSING
+ # if we are not emitting rows or errors, we want to keep the results/errors object on the stack
+ if (
+ self._previous_state == ParsingState.PROCESSING_RESULTS
+ and self._state == ParsingState.PROCESSING
+ and self._emit_results_enabled is False
+ ):
+ return True
+
+ # the initial results object token will have a state of RESULTS_START
+ # and we don't want to push them onto the stack
+ if token.state != TokenState.RESULTS_START:
+ return True
+
+ return False
diff --git a/couchbase_analytics/common/_core/nonprod_certificates/nonprod.pem b/couchbase_analytics/common/_core/nonprod_certificates/nonprod.pem
new file mode 100644
index 0000000..2bd5744
--- /dev/null
+++ b/couchbase_analytics/common/_core/nonprod_certificates/nonprod.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDFTCCAf2gAwIBAgIRANguFcFZ7eVLTF2mnPqkkhYwDQYJKoZIhvcNAQELBQAw
+JDESMBAGA1UECgwJQ291Y2hiYXNlMQ4wDAYDVQQLDAVDbG91ZDAeFw0xOTEwMTgx
+NDUzMzRaFw0yOTEwMTgxNTUzMzRaMCQxEjAQBgNVBAoMCUNvdWNoYmFzZTEOMAwG
+A1UECwwFQ2xvdWQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDMoL2G
+1yR4XKOL5KrAZbgJI11NkcooxqCSqoibr5nSM+GNARlou42XbopRhkLQlSMlmH7U
+ZreI7xq2MqmCaQvP1jdS5al/GwuwAP+2kU2nz4IHzliCVV6YvYqNy0fygNpYky9/
+wjCu32n8Ae0AZuxcsAzPUtJBvIIGHum08WlLYS3gNrYkfyds6LfvZvqMk703RL5X
+Ny/RXWmbbBXAXh0chsavEK7EsDLI4t4WI2Iv8+lwS7Wo7Vh6NnEmJLPAAp7udNK4
+U3nwjkL5p/yINROT7CxUE9x0IB2l2rZwZiJhgHCpee77J8QesDut+jZu38ZYY3le
+PS38S81T6I6bSSgtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE
+FLlocLdzgAeibrlCmEO4OH5Buf3vMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0B
+AQsFAAOCAQEAkoVX5CJ7rGx2ALfzy5C7Z+tmEmrZ6jdHjDtw4XwWNhlrsgMuuboU
+Y9XMinSSm1TVfvIz4ru82MVMRxq4v1tPwPdZabbzKYclHkwSMxK5BkyEKWzF1Hoq
+UcinTaT68lVzkTc0D8T+gkRzwXIqxjML2ZdruD1foHNzCgeGHzKzdsjYqrnHv17b
+J+f5tqoa5CKbnyWl3HP0k7r3HHQP0GQequoqXcL3XlERX3Ne20Chck9mftNnHhKw
+Dby7ylZaP97sphqOZQ/W/gza7x1JYylrLXvjfdv3Nmu7oSMKO/2cDyWwcbVGkpbk
+8JOQtFENWmr9u2S0cQfwoCSYBWaK0ofivA==
+-----END CERTIFICATE-----
diff --git a/couchbase_analytics/common/_core/query.py b/couchbase_analytics/common/_core/query.py
new file mode 100644
index 0000000..2e798bd
--- /dev/null
+++ b/couchbase_analytics/common/_core/query.py
@@ -0,0 +1,111 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import json
+from typing import Any, List, Optional, TypedDict
+
+from couchbase_analytics.common._core.duration_str_utils import parse_duration_str
+
+
+class QueryMetricsCore(TypedDict, total=False):
+ """
+ **INTERNAL**
+ """
+
+ elapsed_time: float
+ execution_time: float
+ compile_time: float
+ queue_wait_time: float
+ result_count: int
+ result_size: int
+ processed_objects: int
+ buffer_cache_hit_ratio: str
+ buffer_cache_page_read_count: int
+
+
+class QueryWarningCore(TypedDict, total=False):
+ """
+ **INTERNAL**
+ """
+
+ code: int
+ message: str
+
+
+class QueryMetadataCore(TypedDict, total=False):
+ """
+ **INTERNAL**
+ """
+
+ request_id: str
+ client_context_id: str
+ warnings: List[QueryWarningCore]
+ metrics: QueryMetricsCore
+ status: Optional[str]
+
+
+def build_query_metadata(json_data: Optional[Any] = None, raw_metadata: Optional[bytes] = None) -> QueryMetadataCore:
+ """
+ Builds the query metadata from the raw bytes.
+
+ Args:
+ metadata (bytes): The raw metadata bytes.
+
+ Returns:
+ QueryMetadataCore: The parsed query metadata.
+ """
+ if json_data is None and raw_metadata is None:
+ raise ValueError('No metadata provided.')
+
+ if json_data is None and raw_metadata is not None:
+ json_data = json.loads(raw_metadata.decode('utf-8'))
+
+ if json_data is None or not isinstance(json_data, dict):
+ raise ValueError('Invalid query metadata format. Expected a JSON object.')
+
+ warnings: List[QueryWarningCore] = []
+ for warning in json_data.get('warnings', []):
+ warnings.append({'code': warning.get('code', 0), 'message': warning.get('msg', '')})
+
+ metadata: QueryMetadataCore = {
+ 'request_id': json_data.get('requestID', ''),
+ 'client_context_id': json_data.get('clientContextID', ''),
+ 'warnings': warnings,
+ }
+
+ # TODO: include status in metadata?? Seems to only be populated in error scenario
+ if 'status' in json_data:
+ metadata['status'] = json_data.get('status', '')
+
+ if 'metrics' not in json_data:
+ metadata['metrics'] = {}
+ return metadata
+
+ metrics: QueryMetricsCore = {
+ 'elapsed_time': parse_duration_str(json_data['metrics'].get('elapsedTime', '0'), in_millis=True),
+ 'execution_time': parse_duration_str(json_data['metrics'].get('executionTime', '0'), in_millis=True),
+ 'compile_time': parse_duration_str(json_data['metrics'].get('compileTime', '0'), in_millis=True),
+ 'queue_wait_time': parse_duration_str(json_data['metrics'].get('queueWaitTime', '0'), in_millis=True),
+ 'result_count': json_data['metrics'].get('resultCount', 0),
+ 'result_size': json_data['metrics'].get('resultSize', 0),
+ 'processed_objects': json_data['metrics'].get('processedObjects', 0),
+ 'buffer_cache_hit_ratio': json_data['metrics'].get('bufferCacheHitRatio', ''),
+ 'buffer_cache_page_read_count': json_data['metrics'].get('bufferCachePageReadCount', 0),
+ }
+
+ metadata['metrics'] = metrics
+ return metadata
diff --git a/couchbase_analytics/common/_core/result.py b/couchbase_analytics/common/_core/result.py
new file mode 100644
index 0000000..018b8c5
--- /dev/null
+++ b/couchbase_analytics/common/_core/result.py
@@ -0,0 +1,57 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import sys
+from abc import ABC, abstractmethod
+from typing import Any, Coroutine, List, Optional, Union
+
+if sys.version_info < (3, 9):
+ from typing import AsyncIterator as PyAsyncIterator
+ from typing import Iterator
+else:
+ from collections.abc import AsyncIterator as PyAsyncIterator
+ from collections.abc import Iterator
+
+from couchbase_analytics.common.query import QueryMetadata
+
+
+class QueryResult(ABC):
+ """Abstract base class for query results."""
+
+ @abstractmethod
+ def cancel(self) -> Union[Coroutine[Any, Any, None], None]:
+ """
+ Cancel streaming the query results.
+
+ **VOLATILE** This API is subject to change at any time.
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ def get_all_rows(self) -> Union[Coroutine[Any, Any, List[Any]], List[Any]]:
+ """Convenience method to load all query results into memory."""
+ raise NotImplementedError
+
+ @abstractmethod
+ def metadata(self) -> Optional[QueryMetadata]:
+ """Get the query metadata."""
+ raise NotImplementedError
+
+ @abstractmethod
+ def rows(self) -> Union[PyAsyncIterator[Any], Iterator[Any]]:
+ """Retrieve the rows which have been returned by the query."""
+ raise NotImplementedError
diff --git a/couchbase_analytics/common/_core/utils.py b/couchbase_analytics/common/_core/utils.py
new file mode 100644
index 0000000..8c785c4
--- /dev/null
+++ b/couchbase_analytics/common/_core/utils.py
@@ -0,0 +1,152 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from datetime import timedelta
+from enum import Enum
+from os import path
+from typing import Any, Dict, Generic, List, Optional, TypeVar, Union
+
+from couchbase_analytics.common.deserializer import Deserializer
+
+T = TypeVar('T')
+E = TypeVar('E', bound=Enum)
+
+
+def is_null_or_empty(value: Optional[str]) -> bool:
+ return not value or value.isspace()
+
+
+def timedelta_as_seconds(duration: timedelta) -> int:
+ if duration and not isinstance(duration, timedelta):
+ raise ValueError(f'Expected timedelta instead of {duration}')
+ if duration.total_seconds() < 0:
+ raise ValueError('Timeout must be non-negative.')
+ return int(duration.total_seconds() if duration else 0)
+
+
+def to_microseconds(value: Union[timedelta, float, int]) -> int:
+ if value and not isinstance(value, (timedelta, float, int)):
+ raise ValueError(f'Excepted value to be of type Union[timedelta, float, int] instead of {value}')
+ if not value:
+ total_us = 0
+ elif isinstance(value, timedelta):
+ if value.total_seconds() < 0:
+ raise ValueError('Timeout must be non-negative.')
+ total_us = int(value.total_seconds() * 1e6)
+ else:
+ if value < 0:
+ raise ValueError('Timeout must be non-negative.')
+ total_us = int(value * 1e6)
+
+ return total_us
+
+
+def to_seconds(value: Union[timedelta, float, int]) -> float:
+ if value and not isinstance(value, (timedelta, float, int)):
+ raise ValueError(f'Excepted value to be of type Union[timedelta, float, int] instead of {type(value)}')
+ if not value:
+ total_secs = float(0)
+ elif isinstance(value, timedelta):
+ if value.total_seconds() < 0:
+ raise ValueError('Timeout must be non-negative.')
+ total_secs = float(value.total_seconds())
+ else:
+ if value < 0:
+ raise ValueError('Timeout must be non-negative.')
+ total_secs = float(value)
+
+ return total_secs
+
+
+def validate_raw_dict(value: Dict[str, Any]) -> Dict[str, Any]:
+ if not isinstance(value, dict):
+ raise ValueError('Raw option must be of type Dict[str, Any].')
+ if not all((isinstance(k, str) for k in value.keys())):
+ raise ValueError('All keys in raw dict must be a str.')
+ return value
+
+
+def validate_path(value: str) -> str:
+ if not isinstance(value, str):
+ raise ValueError('Path option must be str.')
+ if not path.exists(value):
+ raise FileNotFoundError('Provided path does not exist.')
+
+ return value
+
+
+class ValidateBaseClass(Generic[T]):
+ """**INTERNAL**"""
+
+ def __call__(self, value: Any) -> T:
+ expected_base_class = self.__orig_class__.__args__[0] # type: ignore[attr-defined]
+ # this will pass w/ duck-typing which is okay
+ if not issubclass(value.__class__, expected_base_class):
+ raise ValueError(
+ (
+ f'Expected value to be subclass of {expected_base_class} '
+ '(or implement necessary functionality for the '
+ f'{expected_base_class} base class).'
+ )
+ )
+ return value # type: ignore[no-any-return]
+
+
+class EnumToStr(Generic[E]):
+ def __call__(self, value: Any) -> str:
+ expected_type = self.__orig_class__.__args__[0] # type: ignore[attr-defined]
+
+ if isinstance(value, str):
+ if value in (x.value for x in expected_type):
+ # TODO: use warning -- maybe don't want to allow str representation?
+ return value
+ raise ValueError(f"Invalid str representation of {expected_type}. Received '{value}'.")
+
+ if not isinstance(value, expected_type):
+ raise ValueError(f'Expected value to be of type {expected_type} instead of {type(value)}')
+
+ return value.value # type: ignore[no-any-return]
+
+
+class ValidateType(Generic[T]):
+ def __call__(self, value: Any) -> T:
+ expected_type = self.__orig_class__.__args__[0] # type: ignore[attr-defined]
+ if not isinstance(value, expected_type):
+ raise ValueError(f'Expected value to be of type {expected_type} instead of {type(value)}')
+ return value # type: ignore[no-any-return]
+
+
+class ValidateList(Generic[T]):
+ def __call__(self, value: Any) -> List[T]:
+ expected_type = self.__orig_class__.__args__[0] # type: ignore[attr-defined]
+ if not isinstance(value, list):
+ raise ValueError('Expected value to be a list.')
+ if not all((isinstance(v, expected_type) for v in value)):
+ item_types = [type(x) for x in value]
+ raise ValueError(
+ (f'Expected all items in list to be of type {expected_type}. Provided item types {item_types}.')
+ )
+ # we are returning List[T]
+ return value
+
+
+VALIDATE_BOOL = ValidateType[bool]()
+VALIDATE_INT = ValidateType[int]()
+VALIDATE_FLOAT = ValidateType[float]()
+VALIDATE_STR = ValidateType[str]()
+VALIDATE_DESERIALIZER = ValidateBaseClass[Deserializer]()
+VALIDATE_STR_LIST = ValidateList[str]()
diff --git a/couchbase_analytics/common/backoff_calculator.py b/couchbase_analytics/common/backoff_calculator.py
new file mode 100644
index 0000000..a071d15
--- /dev/null
+++ b/couchbase_analytics/common/backoff_calculator.py
@@ -0,0 +1,42 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from abc import ABC, abstractmethod
+from random import uniform
+from typing import Optional
+
+
+class BackoffCalculator(ABC):
+ @abstractmethod
+ def calculate_backoff(self, retry_count: int) -> float:
+ raise NotImplementedError
+
+
+class DefaultBackoffCalculator(BackoffCalculator):
+ MIN = 100
+ MAX = 60 * 1000
+ EXPONENT_BASE = 2
+
+ def __init__(
+ self, min: Optional[int] = None, max: Optional[int] = None, exponent_base: Optional[int] = None
+ ) -> None:
+ self._min = min or self.MIN
+ self._max = max or self.MAX
+ self._exp = exponent_base or self.EXPONENT_BASE
+
+ def calculate_backoff(self, retry_count: int) -> float:
+ delay_ms = self._min * self._exp ** (retry_count - 1)
+ capped_ms = min(self._max, delay_ms)
+ return uniform(0, capped_ms) # nosec B311
diff --git a/couchbase_analytics/common/credential.py b/couchbase_analytics/common/credential.py
new file mode 100644
index 0000000..7abbdaa
--- /dev/null
+++ b/couchbase_analytics/common/credential.py
@@ -0,0 +1,102 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from typing import Callable, Dict, Tuple
+
+
+class Credential:
+ """Create a Credential instance.
+
+ A Credential is required in order to connect to a Analytics endpoint.
+
+ .. important::
+ Use the the provided classmethods to create a :class:`.Credential` instance.
+
+ """
+
+ def __init__(self, **kwargs: str) -> None:
+ username = kwargs.pop('username', None)
+ password = kwargs.pop('password', None)
+
+ if username is None:
+ raise ValueError('Must provide a username.')
+ if not isinstance(username, str):
+ raise ValueError('The username must be a str.')
+
+ if password is None:
+ raise ValueError('Must provide a password.')
+ if not isinstance(password, str):
+ raise ValueError('The password must be a str.')
+
+ self._username = username
+ self._password = password
+
+ def asdict(self) -> Dict[str, str]:
+ """
+ **INTERNAL**
+ """
+ return {'username': self._username, 'password': self._password}
+
+ def astuple(self) -> Tuple[bytes, bytes]:
+ """
+ **INTERNAL**
+ """
+ return self._username.encode(), self._password.encode()
+
+ @classmethod
+ def from_username_and_password(cls, username: str, password: str) -> Credential:
+ """Create a :class:`.Credential` from a username and password.
+
+ Args:
+ username: The username for the Analytics endpoint.
+ password: The password for the Analytics endpoint.
+
+ Returns:
+ A Credential instance.
+ """
+ return Credential(username=username, password=password)
+
+ @classmethod
+ def from_callable(cls, callback: Callable[[], Credential]) -> Credential:
+ """Create a :class:`.Credential` from provided callback.
+
+ The callback is
+
+ Args:
+ callback: Callback that returns a :class:`.Credential`.
+
+ Returns:
+ A Credential instance.
+
+ Example:
+ Retrieve credentials from environment variables::
+
+ def _cred_from_env() -> Credential:
+ from os import getenv
+ return Credential.from_username_and_password(getenv('PYCBCC_USERNAME'),
+ getenv('PYCBCC_PW'))
+
+ cred = Credential.from_callable(_cred_from_env)
+
+ """
+ return Credential(**callback().asdict())
+
+ def __repr__(self) -> str:
+ return f'Credential(username={self._username}, password=****)'
+
+ def __str__(self) -> str:
+ return self.__repr__()
diff --git a/couchbase_analytics/common/deserializer.py b/couchbase_analytics/common/deserializer.py
new file mode 100644
index 0000000..01aee2c
--- /dev/null
+++ b/couchbase_analytics/common/deserializer.py
@@ -0,0 +1,68 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import json
+from abc import ABC, abstractmethod
+from typing import Any
+
+
+class Deserializer(ABC):
+ """
+ Interface a Custom Deserializer must implement
+ """
+
+ @abstractmethod
+ def deserialize(self, value: bytes) -> Any:
+ raise NotImplementedError
+
+ @classmethod
+ def __subclasshook__(cls, subclass: type) -> bool:
+ return hasattr(subclass, 'deserialize') and callable(subclass.deserialize)
+
+
+class DefaultJsonDeserializer(Deserializer):
+ """
+ Deserializer using the default Python json library.
+ """
+
+ def deserialize(self, value: bytes) -> Any:
+ """Decodes the received bytes into a utf-8 string and deserializes using Python's json library.
+
+ Args:
+ value: The bytes to deserialize.
+
+ Returns:
+ The deserialized Python object.
+ """
+ return json.loads(value.decode('utf-8'))
+
+
+class PassthroughDeserializer(Deserializer):
+ """
+ Deserializer used in order to skip deserializing rows and simply pass the bytes along.
+ """
+
+ def deserialize(self, value: bytes) -> bytes:
+ """Needed to abide by the :class:`.Deserializer` abstract class. No deserializing is done.
+
+ Args:
+ value: The bytes to passthrough.
+
+ Returns:
+ The received bytes.
+ """
+ return value
diff --git a/couchbase_analytics/common/enums.py b/couchbase_analytics/common/enums.py
new file mode 100644
index 0000000..5e1d12e
--- /dev/null
+++ b/couchbase_analytics/common/enums.py
@@ -0,0 +1,42 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from enum import Enum
+
+
+class QueryScanConsistency(Enum):
+ """
+ Represents the various scan consistency options that are available.
+ """
+
+ NOT_BOUNDED = 'not_bounded'
+ REQUEST_PLUS = 'request_plus'
+
+
+# This is unfortunate, but Enum is 'special' and this is one of the least invasive manners to document the members
+QueryScanConsistency.NOT_BOUNDED.__doc__ = (
+ 'Indicates that no specific consistency is required, '
+ 'this is the fastest options, but results may not include '
+ 'the most recent operations which have been performed.'
+)
+QueryScanConsistency.REQUEST_PLUS.__doc__ = (
+ 'Indicates that the results to the query should include '
+ 'all operations that have occurred up until the query was started. '
+ 'This incurs a performance penalty of waiting for the index to catch '
+ 'up to the most recent operations, but provides the highest level '
+ 'of consistency.'
+)
diff --git a/couchbase_analytics/common/errors.py b/couchbase_analytics/common/errors.py
new file mode 100644
index 0000000..a6b94f1
--- /dev/null
+++ b/couchbase_analytics/common/errors.py
@@ -0,0 +1,189 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from typing import Dict, Optional, Union
+
+"""
+
+Error Classes
+
+"""
+
+
+class AnalyticsError(Exception):
+ """
+ Generic base error. Analytics specific errors inherit from this base error.
+ """
+
+ def __init__(
+ self,
+ cause: Optional[Union[BaseException, Exception]] = None,
+ message: Optional[str] = None,
+ context: Optional[str] = None,
+ ) -> None:
+ self._cause = cause
+ self._message = message
+ self._context = context
+ super().__init__(message)
+
+ def _err_details(self) -> Dict[str, str]:
+ details: Dict[str, str] = {}
+ if self._message is not None and not self._message.isspace():
+ details['message'] = self._message
+ if self._context is not None:
+ details['context'] = self._context
+ if self._cause is not None:
+ details['cause'] = self._cause.__repr__()
+ return details
+
+ def __repr__(self) -> str:
+ details = self._err_details()
+ if details:
+ return f'{type(self).__name__}({details})'
+ return f'{type(self).__name__}()'
+
+ def __str__(self) -> str:
+ return self.__repr__()
+
+
+class InvalidCredentialError(AnalyticsError):
+ """
+ Indicates that an error occurred authenticating the user to the cluster.
+ """
+
+ def __init__(
+ self,
+ cause: Optional[Union[BaseException, Exception]] = None,
+ context: Optional[str] = None,
+ message: Optional[str] = None,
+ ) -> None:
+ super().__init__(cause=cause, context=context, message=message)
+
+ def __repr__(self) -> str:
+ details = self._err_details()
+ if details:
+ return f'{type(self).__name__}({details})'
+ return f'{type(self).__name__}()'
+
+ def __str__(self) -> str:
+ return self.__repr__()
+
+
+class QueryError(AnalyticsError):
+ """
+ Indicates that an query request received an error from the Analytics server.
+ """
+
+ def __init__(self, code: int, server_message: str, context: str, message: Optional[str] = None) -> None:
+ super().__init__(message=message, context=context)
+ self._code = code
+ self._server_message = server_message
+
+ @property
+ def code(self) -> int:
+ """
+ Returns:
+ Error code from Analytics server
+ """
+ return self._code
+
+ @property
+ def server_message(self) -> str:
+ """
+ Returns:
+ Error message from Analytics server
+ """
+ return self._server_message
+
+ def __repr__(self) -> str:
+ details: Dict[str, str] = {
+ 'code': str(self._code),
+ 'server_message': self._server_message,
+ 'context': self._context or '',
+ }
+ return f'{type(self).__name__}({details})'
+
+ def __str__(self) -> str:
+ return self.__repr__()
+
+
+class TimeoutError(AnalyticsError):
+ """
+ Indicates that a request was unable to complete prior to reaching the deadline specified for the reqest.
+ """
+
+ def __init__(
+ self,
+ cause: Optional[Union[BaseException, Exception]] = None,
+ context: Optional[str] = None,
+ message: Optional[str] = None,
+ ) -> None:
+ super().__init__(cause=cause, context=context, message=message)
+
+ def __repr__(self) -> str:
+ details = self._err_details()
+ if details:
+ return f'{type(self).__name__}({details})'
+ return f'{type(self).__name__}()'
+
+ def __str__(self) -> str:
+ return self.__repr__()
+
+
+class FeatureUnavailableError(Exception):
+ """
+ Raised when feature that is not available with the current server version is used.
+ """
+
+ def __repr__(self) -> str:
+ return f'{type(self).__name__}({super().__repr__()})'
+
+ def __str__(self) -> str:
+ return self.__repr__()
+
+
+class InternalSDKError(Exception):
+ """
+ This means the SDK has done something wrong. Get support.
+ (this doesn't mean *you* didn't do anything wrong, it does mean you should not be seeing this message)
+ """
+
+ def __init__(
+ self,
+ cause: Optional[Union[BaseException, Exception]] = None,
+ context: Optional[str] = None,
+ message: Optional[str] = None,
+ ) -> None:
+ self._cause = cause
+ self._message = message
+ self._context = context
+ super().__init__(message)
+
+ def __repr__(self) -> str:
+ details: Dict[str, str] = {}
+ if self._message is not None and not self._message.isspace():
+ details['message'] = self._message
+ if self._context is not None:
+ details['context'] = self._context
+ if self._cause is not None:
+ details['cause'] = self._cause.__repr__()
+ if details:
+ return f'{type(self).__name__}({details})'
+ return f'{type(self).__name__}()'
+
+ def __str__(self) -> str:
+ return self.__repr__()
diff --git a/couchbase_analytics/common/logging.py b/couchbase_analytics/common/logging.py
new file mode 100644
index 0000000..110fb97
--- /dev/null
+++ b/couchbase_analytics/common/logging.py
@@ -0,0 +1,51 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+from enum import Enum
+
+LOG_FORMAT_ARR = [
+ '[%(asctime)s.%(msecs)03d]',
+ '%(relativeCreated)dms',
+ '[%(levelname)s]',
+ '[%(process)d, %(threadName)s (%(thread)d)] %(name)s',
+ '- %(message)s',
+]
+LOG_FORMAT = ' '.join(LOG_FORMAT_ARR)
+LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
+
+
+class LogLevel(Enum):
+ DEBUG = logging.DEBUG
+ INFO = logging.INFO
+ WARNING = logging.WARNING
+ ERROR = logging.ERROR
+ CRITICAL = logging.CRITICAL
+
+
+def log_message(logger: logging.Logger, message: str, log_level: LogLevel) -> None:
+ if not logger or not logger.hasHandlers():
+ return
+
+ if log_level == LogLevel.DEBUG:
+ logger.debug(message)
+ elif log_level == LogLevel.INFO:
+ logger.info(message)
+ elif log_level == LogLevel.WARNING:
+ logger.warning(message)
+ elif log_level == LogLevel.ERROR:
+ logger.error(message)
+ elif log_level == LogLevel.CRITICAL:
+ logger.critical(message)
diff --git a/couchbase_analytics/common/options.py b/couchbase_analytics/common/options.py
new file mode 100644
index 0000000..387b2b3
--- /dev/null
+++ b/couchbase_analytics/common/options.py
@@ -0,0 +1,170 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from __future__ import annotations
+
+import sys
+from typing import List, Union
+
+if sys.version_info < (3, 10):
+ from typing_extensions import TypeAlias
+else:
+ from typing import TypeAlias
+
+from couchbase_analytics.common.options_base import (
+ ClusterOptionsBase,
+ QueryOptionsBase,
+ SecurityOptionsBase,
+ TimeoutOptionsBase,
+)
+from couchbase_analytics.common.options_base import ClusterOptionsKwargs as ClusterOptionsKwargs # noqa: F401
+from couchbase_analytics.common.options_base import QueryOptionsKwargs as QueryOptionsKwargs # noqa: F401
+from couchbase_analytics.common.options_base import SecurityOptionsKwargs as SecurityOptionsKwargs # noqa: F401
+from couchbase_analytics.common.options_base import TimeoutOptionsKwargs as TimeoutOptionsKwargs # noqa: F401
+
+"""
+ Python SDK Cluster Options Classes
+"""
+
+
+class ClusterOptions(ClusterOptionsBase):
+ """Available options to set when creating a cluster.
+
+ Cluster options enable the configuration of various global cluster settings.
+ Some options can be set globally for the cluster, but overridden for a specific request (i.e. :class:`.TimeoutOptions`).
+
+ .. note::
+ Options and methods marked **VOLATILE** are subject to change at any time.
+
+ Args:
+ deserializer (Optional[Deserializer]): Set to configure global serializer to translate JSON to Python objects. Defaults to `None` (:class:`~couchbase_analytics.deserializer.DefaultJsonDeserializer`).
+ max_retries (Optional[int]): Set to configure the maximum number of retries for a request. Defaults to 7.
+ security_options (Optional[:class:`.SecurityOptions`]): Security options for SDK connection.
+ timeout_options (Optional[:class:`.TimeoutOptions`]): Timeout options for the various stages of a request. See :class:`.TimeoutOptions` for details.
+ """ # noqa: E501
+
+
+class SecurityOptions(SecurityOptionsBase):
+ """Available security options to set when creating a cluster.
+
+ All options are optional and not required to be specified. By default the SDK will trust only the Capella CA certificate(s).
+ Only a single option related to which certificate(s) the SDK should trust can be used.
+ The `disable_server_certificate_verification` option can either be enabled or disabled for any of the specified trust settings.
+
+ Args:
+ trust_only_capella (Optional[bool]): If enabled, SDK will trust only the Capella CA certificate(s). Defaults to `True` (enabled).
+ trust_only_pem_file (Optional[str]): If set, SDK will trust only the PEM-encoded certificate(s) at the specified file path. Defaults to `None`.
+ trust_only_pem_str (Optional[str]): If set, SDK will trust only the PEM-encoded certificate(s) in the specified str. Defaults to `None`.
+ trust_only_certificates (Optional[List[str]]): If set, SDK will trust only the PEM-encoded certificate(s) specified. Defaults to `None`.
+ disable_server_certificate_verification (Optional[bool]): If disabled, SDK will trust any certificate regardless of validity.
+ Should not be disabled in production environments. Defaults to `True` (enabled).
+ """ # noqa: E501
+
+ @classmethod
+ def trust_only_capella(cls) -> SecurityOptions:
+ """
+ Convenience method that returns `SecurityOptions` instance with `trust_only_capella=True`.
+
+ Returns:
+ :class:`~couchbase_analytics.common.options.SecurityOptions`
+ """
+ return cls(trust_only_capella=True)
+
+ @classmethod
+ def trust_only_pem_file(cls, pem_file: str) -> SecurityOptions:
+ """
+ Convenience method that returns `SecurityOptions` instance with `trust_only_pem_file` set to provided certificate(s) path.
+
+ Args:
+ pem_file (str): Path to PEM-encoded certificate(s) the SDK should trust.
+
+ Returns:
+ :class:`~couchbase_analytics.common.options.SecurityOptions`
+ """ # noqa: E501
+ return cls(trust_only_capella=False, trust_only_pem_file=pem_file)
+
+ @classmethod
+ def trust_only_pem_str(cls, pem_str: str) -> SecurityOptions:
+ """
+ Convenience method that returns `SecurityOptions` instance with `trust_only_pem_str` set to provided certificate(s) str.
+
+ Args:
+ pem_str (str): PEM-encoded certificate(s) the SDK should trust.
+
+ Returns:
+ :class:`~couchbase_analytics.common.options.SecurityOptions`
+ """ # noqa: E501
+ return cls(trust_only_capella=False, trust_only_pem_str=pem_str)
+
+ @classmethod
+ def trust_only_certificates(cls, certificates: List[str]) -> SecurityOptions:
+ """
+ Convenience method that returns `SecurityOptions` instance with `trust_only_certificates` set to provided certificates.
+
+ Args:
+ trust_only_certificates (List[str]): List of PEM-encoded certificate(s) the SDK should trust.
+
+ Returns:
+ :class:`~couchbase_analytics.common.options.SecurityOptions`
+ """ # noqa: E501
+ return cls(trust_only_capella=False, trust_only_certificates=certificates)
+
+
+class TimeoutOptions(TimeoutOptionsBase):
+ """Available timeout options to set when creating a cluster.
+
+ These options set the default timeouts for operations for the cluster. Some operations allow the timeout to be overridden on a per operation basis.
+ All options are optional and default to `None`.
+
+ .. note::
+ Options marked **VOLATILE** are subject to change at any time.
+
+ Args:
+ connect_timeout (Optional[timedelta]): Set to configure the period of time allowed to make a connection. Defaults to `None` (10s).
+ query_timeout (Optional[timedelta]): Set to configure the period of time allowed for query operations. Defaults to `None` (10m).
+ """ # noqa: E501
+
+
+class QueryOptions(QueryOptionsBase):
+ """Available options for Analytics query operation.
+
+ Timeout will default to cluster setting if not set for the operation.
+
+ .. note::
+ Options marked **VOLATILE** are subject to change at any time.
+
+ Args:
+ client_context_id (Optional[str]): Set to configure a unique identifier for this query request. Defaults to `None` (autogenerated by client).
+ deserializer (Optional[Deserializer]): Specifies a :class:`~couchbase_analytics.deserializer.Deserializer` to apply to results. Defaults to `None` (:class:`~couchbase_analytics.deserializer.DefaultJsonDeserializer`).
+ lazy_execute (Optional[bool]): **VOLATILE** If enabled, the query will not execute until the application begins to iterate over results. Defaulst to `None` (disabled).
+ max_retries (Optional[int]): Set to configure the maximum number of retries for a request.
+ named_parameters (Optional[Dict[str, :py:type:`~couchbase_analytics.JSONType`]]): Values to use for positional placeholders in query.
+ positional_parameters (Optional[List[:py:type:`~couchbase_analytics.JSONType`]]):, optional): Values to use for named placeholders in query.
+ query_context (Optional[str]): Specifies the context within which this query should be executed.
+ raw (Optional[Dict[str, Any]]): Specifies any additional parameters which should be passed to the Analytics engine when executing the query.
+ readonly (Optional[bool]): Specifies that this query should be executed in read-only mode, disabling the ability for the query to make any changes to the data.
+ scan_consistency (Optional[QueryScanConsistency]): Specifies the consistency requirements when executing the query.
+ timeout (Optional[timedelta]): Set to configure allowed time for operation to complete. Defaults to `None` (75s).
+ stream_config (Optional[JsonStreamConfig]): **VOLATILE** Configuration for JSON stream processing. Defaults to `None` (default configuration). See :class:`~couchbase_analytics.common.json_parsing.JsonStreamConfig` for details.
+ """ # noqa: E501
+
+
+OptionsClass: TypeAlias = Union[
+ ClusterOptions,
+ SecurityOptions,
+ TimeoutOptions,
+ QueryOptions,
+]
diff --git a/couchbase_analytics/common/options_base.py b/couchbase_analytics/common/options_base.py
new file mode 100644
index 0000000..2714956
--- /dev/null
+++ b/couchbase_analytics/common/options_base.py
@@ -0,0 +1,185 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from __future__ import annotations
+
+import sys
+from datetime import timedelta
+from typing import Any, Dict, Iterable, List, Literal, Optional, TypedDict, Union
+
+if sys.version_info < (3, 10):
+ from typing_extensions import TypeAlias, Unpack
+else:
+ if sys.version_info < (3, 11):
+ from typing import TypeAlias
+
+ from typing_extensions import Unpack
+ else:
+ from typing import TypeAlias, Unpack
+
+from couchbase_analytics.common import JSONType
+from couchbase_analytics.common._core import JsonStreamConfig
+from couchbase_analytics.common.deserializer import Deserializer
+from couchbase_analytics.common.enums import QueryScanConsistency
+
+"""
+ Python Analytics SDK Cluster Options Classes
+"""
+
+
+class ClusterOptionsKwargs(TypedDict, total=False):
+ deserializer: Optional[Deserializer]
+ max_retries: Optional[int]
+ security_options: Optional[SecurityOptionsBase]
+ timeout_options: Optional[TimeoutOptionsBase]
+
+
+ClusterOptionsValidKeys: TypeAlias = Literal[
+ 'deserializer',
+ 'max_retries',
+ 'security_options',
+ 'timeout_options',
+]
+
+
+class ClusterOptionsBase(Dict[str, Any]):
+ """
+ **INTERNAL**
+ """
+
+ VALID_OPTION_KEYS: List[ClusterOptionsValidKeys] = [
+ 'deserializer',
+ 'max_retries',
+ 'security_options',
+ 'timeout_options',
+ ]
+
+ def __init__(self, **kwargs: Unpack[ClusterOptionsKwargs]) -> None:
+ filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None}
+ super().__init__(**filtered_kwargs)
+
+
+class SecurityOptionsKwargs(TypedDict, total=False):
+ trust_only_capella: Optional[bool]
+ trust_only_pem_file: Optional[str]
+ trust_only_pem_str: Optional[str]
+ trust_only_certificates: Optional[List[str]]
+ disable_server_certificate_verification: Optional[bool]
+
+
+SecurityOptionsValidKeys: TypeAlias = Literal[
+ 'trust_only_capella',
+ 'trust_only_pem_file',
+ 'trust_only_pem_str',
+ 'trust_only_certificates',
+ 'disable_server_certificate_verification',
+]
+
+
+class SecurityOptionsBase(Dict[str, object]):
+ """
+ **INTERNAL**
+ """
+
+ VALID_OPTION_KEYS: List[SecurityOptionsValidKeys] = [
+ 'trust_only_capella',
+ 'trust_only_pem_file',
+ 'trust_only_pem_str',
+ 'trust_only_certificates',
+ 'disable_server_certificate_verification',
+ ]
+
+ def __init__(self, **kwargs: Unpack[SecurityOptionsKwargs]) -> None:
+ filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None}
+ super().__init__(**filtered_kwargs)
+
+
+class TimeoutOptionsKwargs(TypedDict, total=False):
+ connect_timeout: Optional[timedelta]
+ query_timeout: Optional[timedelta]
+
+
+TimeoutOptionsValidKeys: TypeAlias = Literal[
+ 'connect_timeout',
+ 'query_timeout',
+]
+
+
+class TimeoutOptionsBase(Dict[str, object]):
+ """
+ **INTERNAL**
+ """
+
+ VALID_OPTION_KEYS: List[TimeoutOptionsValidKeys] = [
+ 'connect_timeout',
+ 'query_timeout',
+ ]
+
+ def __init__(self, **kwargs: Unpack[TimeoutOptionsKwargs]) -> None:
+ filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None}
+ super().__init__(**filtered_kwargs)
+
+
+class QueryOptionsKwargs(TypedDict, total=False):
+ client_context_id: Optional[str]
+ deserializer: Optional[Deserializer]
+ lazy_execute: Optional[bool]
+ max_retries: Optional[int]
+ named_parameters: Optional[Dict[str, JSONType]]
+ positional_parameters: Optional[Iterable[JSONType]]
+ query_context: Optional[str]
+ raw: Optional[Dict[str, Any]]
+ readonly: Optional[bool]
+ scan_consistency: Optional[Union[QueryScanConsistency, str]]
+ stream_config: Optional[JsonStreamConfig]
+ timeout: Optional[timedelta]
+
+
+QueryOptionsValidKeys: TypeAlias = Literal[
+ 'client_context_id',
+ 'deserializer',
+ 'lazy_execute',
+ 'max_retries',
+ 'named_parameters',
+ 'positional_parameters',
+ 'query_context',
+ 'raw',
+ 'readonly',
+ 'scan_consistency',
+ 'stream_config',
+ 'timeout',
+]
+
+
+class QueryOptionsBase(Dict[str, object]):
+ VALID_OPTION_KEYS: List[QueryOptionsValidKeys] = [
+ 'client_context_id',
+ 'deserializer',
+ 'lazy_execute',
+ 'max_retries',
+ 'named_parameters',
+ 'positional_parameters',
+ 'query_context',
+ 'raw',
+ 'readonly',
+ 'scan_consistency',
+ 'stream_config',
+ 'timeout',
+ ]
+
+ def __init__(self, **kwargs: Unpack[QueryOptionsKwargs]) -> None:
+ filtered_kwargs = {k: v for k, v in kwargs.items() if v is not None}
+ super().__init__(**filtered_kwargs)
diff --git a/couchbase_analytics/common/query.py b/couchbase_analytics/common/query.py
new file mode 100644
index 0000000..c25930a
--- /dev/null
+++ b/couchbase_analytics/common/query.py
@@ -0,0 +1,125 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import List, Optional
+
+from couchbase_analytics.common._core.query import QueryMetadataCore, QueryMetricsCore, QueryWarningCore
+
+
+class QueryWarning:
+ def __init__(self, raw: QueryWarningCore) -> None:
+ self._raw = raw
+
+ def code(self) -> int:
+ """
+ Returns:
+ The query warning code.
+ """
+ return self._raw['code']
+
+ def message(self) -> str:
+ """
+ Returns:
+ The query warning message.
+ """
+ return self._raw['message']
+
+ def __repr__(self) -> str:
+ return 'QueryWarning:{}'.format(self._raw)
+
+
+class QueryMetrics:
+ def __init__(self, raw: QueryMetricsCore) -> None:
+ self._raw = raw
+
+ def elapsed_time(self) -> timedelta:
+ """Get the total amount of time spent running the query.
+
+ Returns:
+ The total amount of time spent running the query.
+ """
+ us = (self._raw.get('elapsed_time') or 0) * 1000
+ return timedelta(microseconds=us)
+
+ def execution_time(self) -> timedelta:
+ """Get the total amount of time spent executing the query.
+
+ Returns:
+ The total amount of time spent executing the query.
+ """
+ us = (self._raw.get('execution_time') or 0) * 1000
+ return timedelta(microseconds=us)
+
+ def result_count(self) -> int:
+ """Get the total number of rows which were part of the result set.
+
+ Returns:
+ The total number of rows which were part of the result set.
+ """
+ return self._raw.get('result_count') or 0
+
+ def result_size(self) -> int:
+ """Get the total number of bytes which were generated as part of the result set.
+
+ Returns:
+ The total number of bytes which were generated as part of the result set.
+ """ # noqa: E501
+ return self._raw.get('result_size') or 0
+
+ def processed_objects(self) -> int:
+ """Get the total number of objects that were processed to create the result set.
+
+ Returns:
+ The total number of objects that were processed to create the result set.
+ """
+ return self._raw.get('processed_objects') or 0
+
+ def __repr__(self) -> str:
+ return 'QueryMetrics:{}'.format(self._raw)
+
+
+class QueryMetadata:
+ def __init__(self, raw: Optional[QueryMetadataCore]) -> None:
+ self._raw = raw if raw is not None else {}
+
+ def request_id(self) -> str:
+ """Get the request ID which is associated with the executed query.
+
+ Returns:
+ The request ID which is associated with the executed query.
+ """
+ return self._raw['request_id']
+
+ def warnings(self) -> List[QueryWarning]:
+ """Get warnings that occurred during the execution of the query.
+
+ Returns:
+ Any warnings that occurred during the execution of the query.
+ """
+ return list(map(QueryWarning, self._raw['warnings']))
+
+ def metrics(self) -> QueryMetrics:
+ """Get the various metrics which are made available by the query engine.
+
+ Returns:
+ A :class:`~couchbase_analytics.query.QueryMetrics` instance.
+ """
+ return QueryMetrics(self._raw['metrics'])
+
+ def __repr__(self) -> str:
+ return 'QueryMetadata:{}'.format(self._raw)
diff --git a/couchbase_analytics/common/request.py b/couchbase_analytics/common/request.py
new file mode 100644
index 0000000..098dbf9
--- /dev/null
+++ b/couchbase_analytics/common/request.py
@@ -0,0 +1,98 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import IntEnum
+from typing import Dict, Optional
+
+
+class RequestState(IntEnum):
+ """
+ **INTERNAL
+ """
+
+ NotStarted = 0
+ ResetAndNotStarted = 1
+ Started = 2
+ Cancelled = 3
+ Completed = 4
+ StreamingResults = 5
+ Error = 6
+ Timeout = 7
+ AsyncCancelledPriorToTimeout = 8
+ SyncCancelledPriorToTimeout = 9
+
+ @staticmethod
+ def okay_to_stream(state: RequestState) -> bool:
+ """
+ **INTERNAL
+ """
+ return state in [RequestState.NotStarted, RequestState.ResetAndNotStarted]
+
+ @staticmethod
+ def okay_to_iterate(state: RequestState) -> bool:
+ """
+ **INTERNAL
+ """
+ return state == RequestState.StreamingResults
+
+ @staticmethod
+ def is_okay(state: RequestState) -> bool:
+ """
+ **INTERNAL
+ """
+ return state not in [RequestState.Cancelled, RequestState.Error, RequestState.Timeout]
+
+ @staticmethod
+ def is_timeout_or_cancelled(state: RequestState) -> bool:
+ """
+ **INTERNAL
+ """
+ return state in [
+ RequestState.Cancelled,
+ RequestState.Timeout,
+ RequestState.AsyncCancelledPriorToTimeout,
+ RequestState.SyncCancelledPriorToTimeout,
+ ]
+
+
+@dataclass
+class RequestURL:
+ scheme: str
+ host: str
+ port: int
+ ip: Optional[str] = None
+ path: Optional[str] = None
+
+ def get_formatted_url(self) -> str:
+ """Get the formatted URL for this request."""
+ host = self.ip if self.ip else self.host
+ if self.path is None:
+ return f'{self.scheme}://{host}:{self.port}'
+ return f'{self.scheme}://{host}:{self.port}{self.path}'
+
+ def __repr__(self) -> str:
+ details: Dict[str, str] = {
+ 'scheme': self.scheme,
+ 'host': self.host,
+ 'port': str(self.port),
+ 'path': self.path if self.path else '',
+ }
+ return f'{type(self).__name__}({details})'
+
+ def __str__(self) -> str:
+ return self.__repr__()
diff --git a/couchbase_analytics/common/result.py b/couchbase_analytics/common/result.py
new file mode 100644
index 0000000..c35e139
--- /dev/null
+++ b/couchbase_analytics/common/result.py
@@ -0,0 +1,146 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, List, Optional
+
+from couchbase_analytics.common._core.result import QueryResult as QueryResult
+from couchbase_analytics.common.query import QueryMetadata
+from couchbase_analytics.common.streaming import AsyncIterator, BlockingIterator
+
+if TYPE_CHECKING:
+ from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse
+ from couchbase_analytics.protocol.streaming import HttpStreamingResponse
+
+
+class BlockingQueryResult(QueryResult):
+ def __init__(self, http_response: HttpStreamingResponse, lazy_execute: Optional[bool] = None) -> None:
+ self._http_response = http_response
+ self._lazy_execute = lazy_execute
+
+ def cancel(self) -> None:
+ """Cancel streaming the query results.
+
+ **VOLATILE** This API is subject to change at any time.
+ """
+ self._http_response.cancel()
+
+ def get_all_rows(self) -> List[Any]:
+ """Convenience method to load all query results into memory.
+
+ Returns:
+ A list of query results.
+
+ Example:
+ Read all rows from simple query::
+
+ q_str = 'SELECT * FROM `travel-sample`.inventory WHERE country LIKE 'United%' LIMIT 2;'
+ q_rows = cluster.execute_query(q_str).all_rows()
+
+ """
+ return BlockingIterator(self._http_response).get_all_rows()
+
+ def metadata(self) -> QueryMetadata:
+ """Get the query metadata.
+
+ Returns:
+ A QueryMetadata instance (if available).
+
+ Raises:
+ RuntimeError: When the metadata is not available. Metadata is only available once all rows have been iterated.
+ """ # noqa: E501
+ return self._http_response.get_metadata()
+
+ def rows(self) -> BlockingIterator:
+ """Retrieve the rows which have been returned by the query.
+
+ Returns:
+ A blocking iterator for iterating over query results.
+ """
+ return BlockingIterator(self._http_response)
+
+ def __iter__(self) -> BlockingIterator:
+ return iter(BlockingIterator(self._http_response))
+
+ def __repr__(self) -> str:
+ return 'BlockingQueryResult()'
+
+
+class AsyncQueryResult(QueryResult):
+ def __init__(self, http_response: AsyncHttpStreamingResponse) -> None:
+ self._http_response = http_response
+
+ def cancel(self) -> None:
+ """Cancel streaming the query results.
+
+ **VOLATILE** This API is subject to change at any time.
+ """
+ self._http_response.cancel()
+
+ async def cancel_async(self) -> None:
+ """Cancel streaming the query results.
+
+ **VOLATILE** This API is subject to change at any time.
+ """
+ await self._http_response.cancel_async()
+
+ async def get_all_rows(self) -> List[Any]:
+ """Convenience method to load all query results into memory.
+
+ Returns:
+ A list of query results.
+
+ Example:
+
+ Read all rows from simple query::
+
+ q_str = 'SELECT * FROM `travel-sample`.inventory WHERE country LIKE 'United%' LIMIT 2;'
+ q_rows = await cluster.execute_query(q_str).all_rows()
+
+ """
+ return await AsyncIterator(self._http_response).get_all_rows()
+
+ def metadata(self) -> QueryMetadata:
+ """The meta-data which has been returned by the query.
+
+ Returns:
+ A QueryMetadata instance (if available).
+
+ Raises:
+ RuntimeError: When the metadata is not available. Metadata is only available once all rows have been iterated.
+ """ # noqa: E501
+ return self._http_response.get_metadata()
+
+ def rows(self) -> AsyncIterator:
+ """Retrieve the rows which have been returned by the query.
+
+ .. note::
+ Bee sure to use ``async for`` when looping over rows.
+
+ Returns:
+ An async iterator for iterating over query results.
+ """
+ return AsyncIterator(self._http_response)
+
+ async def shutdown(self) -> None:
+ """Shutdown the streaming connection."""
+ await self._http_response.shutdown()
+
+ def __aiter__(self) -> AsyncIterator:
+ return AsyncIterator(self._http_response).__aiter__()
+
+ def __repr__(self) -> str:
+ return 'AsyncQueryResult()'
diff --git a/couchbase_analytics/common/streaming.py b/couchbase_analytics/common/streaming.py
new file mode 100644
index 0000000..5ccbfc5
--- /dev/null
+++ b/couchbase_analytics/common/streaming.py
@@ -0,0 +1,117 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator as PyAsyncIterator
+from collections.abc import Iterator
+from enum import IntEnum
+from typing import TYPE_CHECKING, Any, List, NamedTuple
+
+from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError
+
+if TYPE_CHECKING:
+ from acouchbase_analytics.protocol.streaming import AsyncHttpStreamingResponse
+ from couchbase_analytics.protocol.streaming import HttpStreamingResponse
+
+
+class BlockingIterator(Iterator[Any]):
+ """
+ **INTERNAL
+ """
+
+ def __init__(self, http_response: HttpStreamingResponse) -> None:
+ self._http_response = http_response
+
+ def get_all_rows(self) -> List[Any]:
+ """
+ **INTERNAL
+ """
+ return list(self)
+
+ def __iter__(self) -> BlockingIterator:
+ """
+ **INTERNAL
+ """
+ if self._http_response.lazy_execute is True:
+ self._http_response.send_request()
+
+ return self
+
+ def __next__(self) -> Any:
+ """
+ **INTERNAL
+ """
+ try:
+ return self._http_response.get_next_row()
+ except StopIteration:
+ raise
+ except AnalyticsError as err:
+ raise err
+ except Exception as ex:
+ raise InternalSDKError(cause=ex, message='Error attempting to obtain next row.') from None
+
+
+class AsyncIterator(PyAsyncIterator[Any]):
+ """
+ **INTERNAL
+ """
+
+ def __init__(self, http_response: AsyncHttpStreamingResponse) -> None:
+ self._http_response = http_response
+
+ async def get_all_rows(self) -> List[Any]:
+ """
+ **INTERNAL
+ """
+ return [r async for r in self]
+
+ def __aiter__(self) -> AsyncIterator:
+ """
+ **INTERNAL
+ """
+ return self
+
+ async def __anext__(self) -> Any:
+ """
+ **INTERNAL
+ """
+ try:
+ return await self._http_response.get_next_row()
+ except StopAsyncIteration:
+ raise
+ except AnalyticsError as err:
+ raise err
+ except Exception as ex:
+ raise InternalSDKError(cause=ex, message='Error attempting to obtain next row.') from None
+
+
+class HttpResponseType(IntEnum):
+ """
+ **INTERNAL**
+ """
+
+ ROW = 0
+ ERROR = 1
+ END = 2
+
+
+class ParsedResult(NamedTuple):
+ """
+ **INTERNAL**
+ """
+
+ result: str
+ result_type: HttpResponseType
diff --git a/couchbase_analytics/credential.py b/couchbase_analytics/credential.py
new file mode 100644
index 0000000..c3aa770
--- /dev/null
+++ b/couchbase_analytics/credential.py
@@ -0,0 +1,16 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common.credential import Credential as Credential # noqa: F401
diff --git a/couchbase_analytics/database.py b/couchbase_analytics/database.py
new file mode 100644
index 0000000..36fc2a4
--- /dev/null
+++ b/couchbase_analytics/database.py
@@ -0,0 +1,59 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from couchbase_analytics.scope import Scope
+
+if TYPE_CHECKING:
+ from couchbase_analytics.protocol.cluster import Cluster
+
+
+class Database:
+ """Create a Database instance.
+
+ The database instance exposes the operations which are available to be performed against an Analytics database.
+
+ Args:
+ cluster (:class:`~couchbase_analytics.cluster.Cluster`): A :class:`~couchbase_analytics.cluster.Cluster` instance.
+ database_name (str): The database name.
+
+ """ # noqa: E501
+
+ def __init__(self, cluster: Cluster, database_name: str) -> None:
+ from couchbase_analytics.protocol.database import Database as _Database
+
+ self._impl = _Database(cluster, database_name)
+
+ @property
+ def name(self) -> str:
+ """
+ str: The name of this :class:`~couchbase_analytics.database.Database` instance.
+ """
+ return self._impl.name
+
+ def scope(self, scope_name: str) -> Scope:
+ """Creates a :class:`~couchbase_analytics.scope.Scope` instance.
+
+ Args:
+ scope_name (str): Name of the scope.
+
+ Returns:
+ :class:`~couchbase_analytics.scope.Scope`
+
+ """
+ return Scope(self._impl, scope_name)
diff --git a/couchbase_analytics/database.pyi b/couchbase_analytics/database.pyi
new file mode 100644
index 0000000..cfda42f
--- /dev/null
+++ b/couchbase_analytics/database.pyi
@@ -0,0 +1,23 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.protocol.cluster import Cluster
+from couchbase_analytics.scope import Scope
+
+class Database:
+ def __init__(self, cluster: Cluster, database_name: str) -> None: ...
+ @property
+ def name(self) -> str: ...
+ def scope(self, scope_name: str) -> Scope: ...
diff --git a/couchbase_analytics/deserializer.py b/couchbase_analytics/deserializer.py
new file mode 100644
index 0000000..d5aed73
--- /dev/null
+++ b/couchbase_analytics/deserializer.py
@@ -0,0 +1,18 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common.deserializer import DefaultJsonDeserializer as DefaultJsonDeserializer # noqa: F401
+from couchbase_analytics.common.deserializer import Deserializer as Deserializer # noqa: F401
+from couchbase_analytics.common.deserializer import PassthroughDeserializer as PassthroughDeserializer # noqa: F401
diff --git a/couchbase_analytics/errors.py b/couchbase_analytics/errors.py
new file mode 100644
index 0000000..3b18f30
--- /dev/null
+++ b/couchbase_analytics/errors.py
@@ -0,0 +1,20 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common.errors import AnalyticsError as AnalyticsError # noqa: F401
+from couchbase_analytics.common.errors import InternalSDKError as InternalSDKError # noqa: F401
+from couchbase_analytics.common.errors import InvalidCredentialError as InvalidCredentialError # noqa: F401
+from couchbase_analytics.common.errors import QueryError as QueryError # noqa: F401
+from couchbase_analytics.common.errors import TimeoutError as TimeoutError # noqa: F401
diff --git a/couchbase_analytics/options.py b/couchbase_analytics/options.py
new file mode 100644
index 0000000..ef2074d
--- /dev/null
+++ b/couchbase_analytics/options.py
@@ -0,0 +1,23 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common.options import ClusterOptions as ClusterOptions # noqa: F401
+from couchbase_analytics.common.options import ClusterOptionsKwargs as ClusterOptionsKwargs # noqa: F401
+from couchbase_analytics.common.options import QueryOptions as QueryOptions # noqa: F401
+from couchbase_analytics.common.options import QueryOptionsKwargs as QueryOptionsKwargs # noqa: F401
+from couchbase_analytics.common.options import SecurityOptions as SecurityOptions # noqa: F401
+from couchbase_analytics.common.options import SecurityOptionsKwargs as SecurityOptionsKwargs # noqa: F401
+from couchbase_analytics.common.options import TimeoutOptions as TimeoutOptions # noqa: F401
+from couchbase_analytics.common.options import TimeoutOptionsKwargs as TimeoutOptionsKwargs # noqa: F401
diff --git a/couchbase_analytics/protocol/__init__.py b/couchbase_analytics/protocol/__init__.py
new file mode 100644
index 0000000..8c4f837
--- /dev/null
+++ b/couchbase_analytics/protocol/__init__.py
@@ -0,0 +1,47 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import logging
+import sys
+
+try:
+ from couchbase_analytics._version import __version__
+except ImportError:
+ __version__ = '0.0.0-could-not-find-version'
+
+PYCBAC_VERSION = f'pycbac/{__version__}'
+
+try:
+ python_version_info = sys.version.split(' ')
+ if len(python_version_info) > 0:
+ PYCBAC_VERSION = f'{PYCBAC_VERSION} (python/{python_version_info[0]})'
+except Exception: # nosec
+ pass
+
+
+def configure_logger() -> None:
+ import os
+
+ log_level = os.getenv('PYCBAC_LOG_LEVEL', None)
+ if log_level:
+ logger = logging.getLogger()
+ if not logger.hasHandlers():
+ from couchbase_analytics.common.logging import LOG_DATE_FORMAT, LOG_FORMAT
+
+ logging.basicConfig(format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT, level=log_level.upper())
+ logger.info(f'Python Couchbase Analytics Client ({PYCBAC_VERSION})')
+
+
+configure_logger()
diff --git a/couchbase_analytics/protocol/_core/__init__.py b/couchbase_analytics/protocol/_core/__init__.py
new file mode 100644
index 0000000..72df2de
--- /dev/null
+++ b/couchbase_analytics/protocol/_core/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/couchbase_analytics/protocol/_core/client_adapter.py b/couchbase_analytics/protocol/_core/client_adapter.py
new file mode 100644
index 0000000..09f8bcd
--- /dev/null
+++ b/couchbase_analytics/protocol/_core/client_adapter.py
@@ -0,0 +1,183 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Optional, cast
+from uuid import uuid4
+
+from httpx import URL, BasicAuth, Client, Response
+
+from couchbase_analytics.common.credential import Credential
+from couchbase_analytics.common.deserializer import Deserializer
+from couchbase_analytics.common.logging import LogLevel, log_message
+from couchbase_analytics.protocol.connection import _ConnectionDetails
+from couchbase_analytics.protocol.options import OptionsBuilder
+
+if TYPE_CHECKING:
+ from couchbase_analytics.protocol._core.request import QueryRequest
+
+
+class _ClientAdapter:
+ """
+ **INTERNAL**
+ """
+
+ ANALYTICS_PATH = '/api/v1/request'
+ LOGGER_NAME = 'couchbase_analytics'
+
+ def __init__(
+ self, http_endpoint: str, credential: Credential, options: Optional[object] = None, **kwargs: object
+ ) -> None:
+ self._client_id = str(uuid4())
+ self._prefix = ''
+ self._cluster_id = cast(str, kwargs.pop('cluster_id', ''))
+ self._opts_builder = OptionsBuilder()
+ # PYCO-67: Do we want to allow supporting custom HTTP transports?
+ self._http_transport_cls = None
+ kwargs['logger_name'] = self.logger_name
+ self._conn_details = _ConnectionDetails.create(self._opts_builder, http_endpoint, credential, options, **kwargs)
+
+ @property
+ def analytics_path(self) -> str:
+ """
+ **INTERNAL**
+ """
+ return self.ANALYTICS_PATH
+
+ @property
+ def client(self) -> Client:
+ """
+ **INTERNAL**
+ """
+ return self._client
+
+ @property
+ def client_id(self) -> str:
+ """
+ **INTERNAL**
+ """
+ return self._client_id
+
+ @property
+ def connection_details(self) -> _ConnectionDetails:
+ """
+ **INTERNAL**
+ """
+ return self._conn_details
+
+ @property
+ def default_deserializer(self) -> Deserializer:
+ """
+ **INTERNAL**
+ """
+ return self._conn_details.default_deserializer
+
+ @property
+ def has_client(self) -> bool:
+ """
+ **INTERNAL**
+ """
+ return hasattr(self, '_client')
+
+ @property
+ def log_prefix(self) -> str:
+ """
+ **INTERNAL**
+ """
+ if self._prefix:
+ return self._prefix
+ self._prefix = f'[{self._cluster_id}'
+ if self.has_client:
+ self._prefix += f'/{self._client_id}'
+ if self.connection_details.is_secure():
+ self._prefix += '/https]'
+ else:
+ self._prefix += '/http]'
+
+ return self._prefix
+
+ @property
+ def logger_name(self) -> str:
+ """
+ **INTERNAL**
+ """
+ return self.LOGGER_NAME
+
+ @property
+ def options_builder(self) -> OptionsBuilder:
+ """
+ **INTERNAL**
+ """
+ return self._opts_builder
+
+ def close_client(self) -> None:
+ """
+ **INTERNAL**
+ """
+ if hasattr(self, '_client'):
+ self._client.close()
+ self.log_message('Cluster HTTP client closed', LogLevel.INFO)
+
+ def create_client(self) -> None:
+ """
+ **INTERNAL**
+ """
+ if not hasattr(self, '_client'):
+ auth = BasicAuth(*self._conn_details.credential)
+ if self._conn_details.is_secure():
+ if self._conn_details.ssl_context is None:
+ raise ValueError('SSL context is required for secure connections.')
+ transport = None
+ if self._http_transport_cls is not None:
+ transport = self._http_transport_cls(verify=self._conn_details.ssl_context)
+ self._client = Client(verify=self._conn_details.ssl_context, auth=auth, transport=transport)
+ else:
+ transport = None
+ if self._http_transport_cls is not None:
+ transport = self._http_transport_cls()
+ self._client = Client(auth=auth, transport=transport)
+
+ self.log_message(
+ (f'Cluster HTTP client created: connection_details={self._conn_details.get_init_details()}'),
+ LogLevel.INFO,
+ )
+ else:
+ self.log_message('Cluster HTTP client already exists, skipping creation.', LogLevel.INFO)
+
+ def log_message(self, message: str, log_level: LogLevel) -> None:
+ log_message(logger, f'{self.log_prefix} {message}', log_level)
+
+ def send_request(self, request: QueryRequest) -> Response:
+ """
+ **INTERNAL**
+ """
+ if not hasattr(self, '_client'):
+ raise RuntimeError('Client not created yet')
+
+ url = URL(scheme=request.url.scheme, host=request.url.ip, port=request.url.port, path=request.url.path)
+ req = self._client.build_request(request.method, url, json=request.body, extensions=request.extensions)
+ return self._client.send(req, stream=True)
+
+ def reset_client(self) -> None:
+ """
+ **INTERNAL**
+ """
+ if hasattr(self, '_client'):
+ del self._client
+
+
+logger = logging.getLogger(_ClientAdapter.LOGGER_NAME)
diff --git a/couchbase_analytics/protocol/_core/json_stream.py b/couchbase_analytics/protocol/_core/json_stream.py
new file mode 100644
index 0000000..b598c2f
--- /dev/null
+++ b/couchbase_analytics/protocol/_core/json_stream.py
@@ -0,0 +1,222 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from concurrent.futures import Future
+from queue import Empty as QueueEmpty
+from queue import Full as QueueFull
+from queue import Queue
+from typing import TYPE_CHECKING, Callable, Iterator, Optional
+
+import ijson
+
+from couchbase_analytics.common._core.json_parsing import JsonStreamConfig, ParsedResult, ParsedResultType
+from couchbase_analytics.common._core.json_token_parser_base import JsonTokenParsingError
+from couchbase_analytics.common.logging import LogLevel
+from couchbase_analytics.protocol._core.json_token_parser import JsonTokenParser
+
+if TYPE_CHECKING:
+ from couchbase_analytics.protocol._core.request_context import RequestContext
+
+
+class JsonStream:
+ DEFAULT_HTTP_STREAM_BUFFER_SIZE = 2**16
+
+ def __init__(
+ self,
+ http_stream_iter: Iterator[bytes],
+ *,
+ stream_config: Optional[JsonStreamConfig] = None,
+ logger_handler: Optional[Callable[[str, LogLevel], None]] = None,
+ ) -> None:
+ # HTTP stream handling
+ if stream_config is None:
+ stream_config = JsonStreamConfig()
+ self._http_stream_iter = http_stream_iter
+ self._http_stream_buffer_size = stream_config.http_stream_buffer_size
+ self._http_response_buffer = bytearray()
+ self._http_stream_exhausted = False
+
+ # logging
+ self._log_handler = logger_handler
+
+ # results handling
+ self._buffered_row_max = stream_config.buffered_row_max
+ self._buffered_row_threshold = int(self._buffered_row_max * stream_config.buffered_row_threshold_percent)
+ self._json_stream_parser = None
+ self._buffer_entire_result = stream_config.buffer_entire_result
+ handler = None if self._buffer_entire_result is True else self._handle_json_result
+ self._json_token_parser = JsonTokenParser(handler)
+ self._token_stream_exhausted = False
+ self._results_queue: Queue[ParsedResult] = Queue()
+ self._queue_timeout = stream_config.queue_timeout
+ self._notify_on_results_or_error: Optional[Future[ParsedResultType]] = None
+
+ @property
+ def http_stream_exhausted(self) -> bool:
+ """
+ **INTERNAL**
+ """
+ return self._http_stream_exhausted
+
+ @property
+ def token_stream_exhausted(self) -> bool:
+ """
+ **INTERNAL**
+ """
+ return self._token_stream_exhausted
+
+ def _continue_processing(self, request_context: Optional[RequestContext] = None) -> bool:
+ """
+ **INTERNAL**
+ """
+ if self._token_stream_exhausted:
+ return False
+ if self._buffer_entire_result:
+ return True
+ if request_context is not None and (request_context.cancelled or request_context.timed_out):
+ return False
+ if self._results_queue.qsize() >= self._buffered_row_threshold:
+ return False
+ return True
+
+ def _put(self, result: ParsedResult) -> None:
+ """
+ **INTERNAL**
+ """
+ while True:
+ try:
+ self._results_queue.put(result, timeout=self._queue_timeout)
+ break
+ except QueueFull:
+ self._log_message('Encountered QueueFull error', LogLevel.ERROR)
+ pass
+
+ def _handle_json_result(self, row: bytes) -> None:
+ """
+ **INTERNAL**
+ """
+ if self._notify_on_results_or_error is not None and not self._notify_on_results_or_error.done():
+ self._handle_notification(ParsedResultType.ROW)
+
+ self._put(ParsedResult(row, ParsedResultType.ROW))
+
+ def _handle_notification(self, result_type: ParsedResultType) -> None:
+ if self._notify_on_results_or_error is None or self._notify_on_results_or_error.done():
+ return
+
+ self._notify_on_results_or_error.set_result(result_type)
+
+ def _log_message(self, message: str, level: LogLevel) -> None:
+ if self._log_handler is not None:
+ self._log_handler(message, level)
+
+ def _process_token_stream(self, request_context: Optional[RequestContext] = None) -> None:
+ """
+ **INTERNAL**
+ """
+ if self._json_stream_parser is None:
+ self._json_stream_parser = ijson.parse(self, buf_size=self._http_stream_buffer_size)
+
+ while self._continue_processing(request_context=request_context):
+ try:
+ _, event, value = next(self._json_stream_parser) # type: ignore[call-overload]
+ self._json_token_parser.parse_token(event, value)
+ except StopIteration:
+ self._token_stream_exhausted = True
+ except JsonTokenParsingError as ex:
+ ex_str = str(ex)
+ self._log_message(f'JSON token parsing error encountered: {ex_str}', LogLevel.ERROR)
+ self._token_stream_exhausted = True
+ self._put(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR))
+ self._handle_notification(ParsedResultType.ERROR)
+ return
+ except ijson.common.IncompleteJSONError as ex:
+ ex_str = str(ex)
+ self._log_message(f'Incomplete JSON error encountered: {ex_str}', LogLevel.ERROR)
+ self._token_stream_exhausted = True
+ self._put(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR))
+ self._handle_notification(ParsedResultType.ERROR)
+ return
+ except ijson.common.JSONError as ex:
+ ex_str = str(ex)
+ self._log_message(f'JSON error encountered: {ex_str}', LogLevel.ERROR)
+ self._token_stream_exhausted = True
+ self._put(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR))
+ self._handle_notification(ParsedResultType.ERROR)
+ return
+ except ijson.backends.python.UnexpectedSymbol as ex:
+ ex_str = str(ex)
+ self._log_message(f'Unexpected symbol encountered: {ex_str}', LogLevel.ERROR)
+ self._token_stream_exhausted = True
+ self._put(ParsedResult(ex_str.encode('utf-8'), ParsedResultType.ERROR))
+ self._handle_notification(ParsedResultType.ERROR)
+ return
+
+ if self._token_stream_exhausted:
+ result_type = ParsedResultType.ERROR if self._json_token_parser.has_errors else ParsedResultType.END
+ self._put(ParsedResult(self._json_token_parser.get_result(), result_type))
+ self._handle_notification(result_type)
+
+ def read(self, size: Optional[int] = -1) -> bytes:
+ """
+ **INTERNAL**
+ """
+ if size is None or size == 0 or self._http_stream_exhausted:
+ return b''
+
+ while not self._http_stream_exhausted:
+ if size >= 0 and len(self._http_response_buffer) > size:
+ break
+ try:
+ chunk = next(self._http_stream_iter)
+ self._http_response_buffer += chunk
+ except StopIteration:
+ self._http_stream_exhausted = True
+ break
+
+ if size == -1:
+ data = bytes(self._http_response_buffer[:])
+ del self._http_response_buffer[:]
+ else:
+ end = min(size, len(self._http_response_buffer))
+ data = bytes(self._http_response_buffer[:end])
+ del self._http_response_buffer[:end]
+ return data
+
+ def get_result(self, timeout: float) -> Optional[ParsedResult]:
+ try:
+ return self._results_queue.get(timeout=timeout)
+ except QueueEmpty:
+ self._log_message(f'Results queue empty after waiting {timeout} seconds', LogLevel.WARNING)
+ return None
+
+ def start_parsing(
+ self,
+ request_context: Optional[RequestContext] = None,
+ notify_on_results_or_error: Optional[Future[ParsedResultType]] = None,
+ ) -> None:
+ if self._json_stream_parser is not None:
+ self._log_message('JSON stream parser already exists', LogLevel.WARNING)
+ return
+ self._notify_on_results_or_error = notify_on_results_or_error
+ self._process_token_stream(request_context=request_context)
+
+ def continue_parsing(
+ self,
+ request_context: Optional[RequestContext] = None,
+ ) -> None:
+ self._process_token_stream(request_context=request_context)
diff --git a/couchbase_analytics/protocol/_core/json_token_parser.py b/couchbase_analytics/protocol/_core/json_token_parser.py
new file mode 100644
index 0000000..ebe6e2c
--- /dev/null
+++ b/couchbase_analytics/protocol/_core/json_token_parser.py
@@ -0,0 +1,91 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from typing import Callable, List, Optional
+
+from couchbase_analytics.common._core.json_token_parser_base import (
+ POP_EVENTS,
+ START_EVENTS,
+ VALUE_TOKENS,
+ JsonTokenParserBase,
+ JsonTokenParsingError,
+ ParsingState,
+ TokenType,
+)
+
+
+class JsonTokenParser(JsonTokenParserBase):
+ def __init__(self, result_handler: Optional[Callable[[bytes], None]] = None) -> None:
+ self._result_handler = result_handler
+ super().__init__(emit_results_enabled=result_handler is not None)
+
+ def _handle_obj_emit(self, obj: str) -> bool:
+ if (
+ self._emit_results_enabled
+ and self._result_handler is not None
+ and ParsingState.okay_to_emit(self._state, self._previous_state)
+ ):
+ self._result_handler(bytes(obj, 'utf-8'))
+ return True
+ return False
+
+ def _handle_pop_event(self, token_type: TokenType) -> None:
+ matching_token = self._get_matching_token(token_type)
+ obj_pairs: List[str] = []
+ while self._stack:
+ next_token = self._pop()
+ if next_token.type == matching_token.type:
+ should_emit = self._handle_pop_transition(next_token.state)
+ # NOTE: obj_pairs.reverse() vs. reversed(obj_pairs) are essentially the same _because_ we convert
+ # the obj_pairs to a string (e.g. ",".join(...)); using reversed() in this case is slightly
+ # more convenient as it returns an iterator
+ if matching_token.type == TokenType.START_ARRAY:
+ obj = f'[{",".join(reversed(obj_pairs))}]'
+ else:
+ obj = f'{{{",".join(reversed(obj_pairs))}}}'
+ if should_emit and self._handle_obj_emit(obj):
+ break # this means we emiited the result/error, so stop processing the stack
+
+ if len(self._stack) > 0 and self._stack[-1].type == TokenType.MAP_KEY:
+ map_key = self._pop()
+ # If we are emitting rows and/or errors,
+ # we don't keep them in the stack and therefore don't need to return the results
+ if self._should_push_pair(next_token):
+ self._push(TokenType.PAIR, f'{map_key.value}:{obj}')
+ else:
+ self._push(TokenType.OBJECT, obj)
+
+ break
+ obj_pairs.append(next_token.value)
+
+ def get_result(self) -> Optional[bytes]:
+ return bytes(self._stack.pop().value, 'utf-8') if self._stack else None
+
+ def parse_token(self, token: str, value: str) -> None:
+ token_type = TokenType.from_str(token)
+ if token_type in VALUE_TOKENS:
+ val = self._handle_value_token(token_type, value)
+ if val is not None:
+ self._handle_obj_emit(val)
+ elif token_type == TokenType.MAP_KEY:
+ self._handle_map_key_token(value)
+ elif token_type in START_EVENTS:
+ self._handle_start_event(token_type)
+ elif token_type in POP_EVENTS:
+ self._handle_pop_event(token_type)
+ else:
+ raise JsonTokenParsingError(f'Invalid token type: {token_type}; {value=}')
diff --git a/couchbase_analytics/protocol/_core/net_utils.py b/couchbase_analytics/protocol/_core/net_utils.py
new file mode 100644
index 0000000..b311058
--- /dev/null
+++ b/couchbase_analytics/protocol/_core/net_utils.py
@@ -0,0 +1,50 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import socket
+from ipaddress import IPv4Address, IPv6Address, ip_address
+from random import choice
+from typing import Callable, Optional, Union
+
+from couchbase_analytics.common.logging import LogLevel
+from couchbase_analytics.protocol.errors import ErrorMapper
+
+
+@ErrorMapper.handle_socket_error
+def get_request_ip(host: str, port: int, logger_handler: Optional[Callable[..., None]] = None) -> str:
+ # Lets not call getaddrinfo, if the host is already an IP address
+ try:
+ ip: Optional[Union[IPv4Address, IPv6Address, str]] = ip_address(host)
+ except ValueError:
+ ip = None
+
+ # if we have localhost, httpx does not seem to be able to resolve IPv6 localhost (::1) properly
+ # TODO: IPv6 support for localhost??
+ if host == 'localhost':
+ ip = '127.0.0.1'
+
+ if not ip:
+ result = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM, family=socket.AF_UNSPEC)
+ res_ip = choice([addr[4][0] for addr in result]) # nosec B311
+ ip = str(res_ip)
+ if logger_handler:
+ message_data = {'results': [f'{addr[4][0]}' for addr in result], 'selected_ip': ip}
+ logger_handler(f'getaddrinfo() returned {len(result)} results', LogLevel.DEBUG, message_data=message_data)
+ else:
+ ip = str(ip)
+
+ return ip
diff --git a/couchbase_analytics/protocol/_core/request.py b/couchbase_analytics/protocol/_core/request.py
new file mode 100644
index 0000000..29b9e9e
--- /dev/null
+++ b/couchbase_analytics/protocol/_core/request.py
@@ -0,0 +1,202 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from copy import deepcopy
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Callable, Coroutine, Dict, Optional, TypedDict, Union, cast
+from uuid import uuid4
+
+from couchbase_analytics.common.deserializer import Deserializer
+from couchbase_analytics.common.options import QueryOptions
+from couchbase_analytics.common.request import RequestURL
+from couchbase_analytics.protocol.options import QueryOptionsTransformedKwargs
+from couchbase_analytics.query import QueryScanConsistency
+
+if TYPE_CHECKING:
+ from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter as AsyncClientAdapter
+ from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter as BlockingClientAdapter
+
+
+class RequestTimeoutExtensions(TypedDict, total=False):
+ pool: Optional[float] # Timeout for acquiring a connection from the pool
+ connect: Optional[float] # Timeout for establishing a socket connection
+ read: Optional[float] # Timeout for reading data from the socket connection
+ write: Optional[float] # Timeout for writing data to the socket connection
+
+
+class RequestExtensions(TypedDict, total=False):
+ timeout: RequestTimeoutExtensions
+ sni_hostname: Optional[str]
+ trace: Optional[Callable[[str, str], Union[None, Coroutine[Any, Any, None]]]]
+
+
+@dataclass
+class QueryRequest:
+ url: RequestURL
+ deserializer: Deserializer
+ body: Dict[str, Union[str, object]]
+ extensions: RequestExtensions
+ max_retries: int
+ method: str = 'POST'
+
+ options: Optional[QueryOptionsTransformedKwargs] = None
+ enable_cancel: Optional[bool] = None
+
+ def add_trace_to_extensions(
+ self, handler: Callable[[str, str], Union[None, Coroutine[Any, Any, None]]]
+ ) -> QueryRequest:
+ """
+ **INTERNAL**
+ """
+ if self.extensions is None:
+ self.extensions = {}
+ self.extensions['trace'] = handler
+ return self
+
+ def get_request_statement(self) -> Optional[str]:
+ """
+ **INTERNAL**
+ """
+ if 'statement' in self.body:
+ return cast(str, self.body['statement'])
+ return None
+
+ def get_request_timeouts(self) -> Optional[RequestTimeoutExtensions]:
+ """
+ **INTERNAL**
+ """
+ if self.extensions is None or 'timeout' not in self.extensions:
+ return {}
+ return self.extensions['timeout']
+
+ def update_url(self, ip: str, path: str) -> QueryRequest:
+ """
+ **INTERNAL**
+ """
+ self.url.ip = ip
+ self.url.path = path
+ return self
+
+
+class _RequestBuilder:
+ def __init__(
+ self,
+ client: Union[AsyncClientAdapter, BlockingClientAdapter],
+ database_name: Optional[str] = None,
+ scope_name: Optional[str] = None,
+ ) -> None:
+ self._conn_details = client.connection_details
+ self._opts_builder = client.options_builder
+ self._database_name = database_name
+ self._scope_name = scope_name
+
+ connect_timeout = self._conn_details.get_connect_timeout()
+ self._default_query_timeout = self._conn_details.get_query_timeout()
+ self._extensions: RequestExtensions = {
+ 'timeout': {'pool': connect_timeout, 'connect': connect_timeout, 'read': self._default_query_timeout}
+ }
+ # TODO: warning if we have a secure connection, but the sni_hostname is not set?
+ if self._conn_details.is_secure() and self._conn_details.sni_hostname is not None:
+ self._extensions['sni_hostname'] = self._conn_details.sni_hostname
+
+ def build_base_query_request( # noqa: C901
+ self,
+ statement: str,
+ *args: object,
+ is_async: Optional[bool] = False,
+ **kwargs: object,
+ ) -> QueryRequest: # noqa: C901
+ enable_cancel: Optional[bool] = None
+ cancel_kwarg_token = kwargs.pop('enable_cancel', None)
+ if isinstance(cancel_kwarg_token, bool):
+ enable_cancel = cancel_kwarg_token
+
+ # default if no options provided
+ opts = QueryOptions()
+ args_list = list(args)
+ parsed_args_list = []
+ for arg in args_list:
+ if isinstance(arg, QueryOptions):
+ # we have options passed in
+ opts = arg
+ elif enable_cancel is None and isinstance(arg, bool):
+ enable_cancel = arg
+ else:
+ parsed_args_list.append(arg)
+
+ # need to pop out named params prior to sending options to the builder
+ named_param_keys = list(filter(lambda k: k not in QueryOptions.VALID_OPTION_KEYS, kwargs.keys()))
+ named_params = {}
+ for key in named_param_keys:
+ named_params[key] = kwargs.pop(key)
+
+ q_opts = self._opts_builder.build_options(QueryOptions, QueryOptionsTransformedKwargs, kwargs, opts)
+ # positional params and named params passed in outside of QueryOptions serve as overrides
+ if parsed_args_list and len(parsed_args_list) > 0:
+ q_opts['positional_parameters'] = parsed_args_list
+ if named_params and len(named_params) > 0:
+ q_opts['named_parameters'] = named_params
+ # handle deserializer and max_retries
+ deserializer = q_opts.pop('deserializer', None) or self._conn_details.default_deserializer
+ max_retries = q_opts.pop('max_retries', None) or self._conn_details.get_max_retries()
+
+ body: Dict[str, Union[str, object]] = {
+ 'statement': statement,
+ 'client_context_id': q_opts.get('client_context_id', None) or str(uuid4()),
+ }
+
+ if self._database_name is not None and self._scope_name is not None:
+ body['query_context'] = f'default:`{self._database_name}`.`{self._scope_name}`'
+
+ # handle timeouts
+ timeout = q_opts.get('timeout', None) or self._default_query_timeout
+ extensions = deepcopy(self._extensions)
+ if timeout is not None and timeout != self._default_query_timeout:
+ extensions['timeout']['read'] = timeout
+ # we add 5 seconds to the server timeout to ensure we always trigger a client side timeout
+ timeout_ms = (timeout + 5) * 1e3 # convert to milliseconds
+ body['timeout'] = f'{timeout_ms}ms'
+
+ for opt_key, opt_val in q_opts.items():
+ if opt_key == 'deserializer':
+ continue
+ elif opt_key == 'raw':
+ for k, v in opt_val.items(): # type: ignore[attr-defined]
+ body[k] = v
+ elif opt_key == 'positional_parameters':
+ body['args'] = list(opt_val) # type: ignore[call-overload]
+ elif opt_key == 'named_parameters':
+ for k, v in opt_val.items(): # type: ignore[attr-defined]
+ key = f'${k}' if not k.startswith('$') else k
+ body[key] = v
+ elif opt_key == 'readonly':
+ body['readonly'] = opt_val
+ elif opt_key == 'scan_consistency':
+ if isinstance(opt_val, QueryScanConsistency):
+ body['scan_consistency'] = opt_val.value
+ else:
+ body['scan_consistency'] = opt_val
+
+ return QueryRequest(
+ self._conn_details.url,
+ deserializer,
+ body,
+ extensions=extensions,
+ max_retries=max_retries,
+ options=q_opts,
+ enable_cancel=enable_cancel,
+ )
diff --git a/couchbase_analytics/protocol/_core/request_context.py b/couchbase_analytics/protocol/_core/request_context.py
new file mode 100644
index 0000000..991f2a4
--- /dev/null
+++ b/couchbase_analytics/protocol/_core/request_context.py
@@ -0,0 +1,462 @@
+from __future__ import annotations
+
+import json
+import math
+import time
+from concurrent.futures import CancelledError, Future, ThreadPoolExecutor
+from threading import Event, Lock
+from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union
+from uuid import uuid4
+
+from httpx import Response as HttpCoreResponse
+
+from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType
+from couchbase_analytics.common._core.error_context import ErrorContext
+from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator
+from couchbase_analytics.common.errors import AnalyticsError, TimeoutError
+from couchbase_analytics.common.logging import LogLevel
+from couchbase_analytics.common.request import RequestState
+from couchbase_analytics.common.result import BlockingQueryResult
+from couchbase_analytics.protocol._core.json_stream import JsonStream
+from couchbase_analytics.protocol._core.net_utils import get_request_ip
+from couchbase_analytics.protocol.connection import DEFAULT_TIMEOUTS
+from couchbase_analytics.protocol.errors import ErrorMapper, WrappedError
+
+if TYPE_CHECKING:
+ from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter
+ from couchbase_analytics.protocol._core.request import QueryRequest
+
+
+# TODO: might not be needed; need to validate httpx iterator behavior
+class ThreadSafeBytesIterator:
+ def __init__(self, iterator: Iterator[bytes]):
+ if not hasattr(iterator, '__next__'):
+ raise TypeError('Provided object is not an iterator (missing __next__ method).')
+ self._iterator = iterator
+ self._lock = Lock()
+
+ def __iter__(self) -> ThreadSafeBytesIterator:
+ return self
+
+ def __next__(self) -> bytes:
+ with self._lock: # Acquire the lock before accessing the iterator
+ try:
+ item = next(self._iterator)
+ return item
+ except StopIteration:
+ # Always re-raise StopIteration to signal the end of iteration
+ raise
+
+
+class BackgroundRequest:
+ def __init__(
+ self, bg_future: Future[BlockingQueryResult], user_future: Future[BlockingQueryResult], cancel_event: Event
+ ) -> None:
+ self._background_work_ft = bg_future
+ self._user_ft = user_future
+ self._cancel_event = cancel_event
+ self._background_work_ft.add_done_callback(self._background_work_done)
+ self._user_ft.add_done_callback(self._user_done)
+
+ @property
+ def user_cancelled(self) -> bool:
+ return self._user_ft.cancelled()
+
+ def _background_work_done(self, ft: Future[BlockingQueryResult]) -> None:
+ """
+ Callback to handle when the background work future is done.
+ """
+ if self._user_ft.done():
+ return
+ if self._cancel_event.is_set():
+ self._user_ft.cancel()
+ return
+ try:
+ result = ft.result()
+ self._user_ft.set_result(result)
+ except Exception as ex:
+ self._user_ft.set_exception(ex)
+
+ def _user_done(self, ft: Future[BlockingQueryResult]) -> None:
+ """
+ Callback to handle when the user future is done.
+ """
+ if self._background_work_ft.done():
+ # If the background work future is already done, we don't need to do anything
+ return
+ if ft.cancelled():
+ self._cancel_event.set()
+ self._background_work_ft.cancel()
+ return
+
+
+class RequestContext:
+ def __init__(
+ self,
+ client_adapter: _ClientAdapter,
+ request: QueryRequest,
+ tp_executor: ThreadPoolExecutor,
+ stream_config: Optional[JsonStreamConfig] = None,
+ ) -> None:
+ self._id = str(uuid4())
+ self._client_adapter = client_adapter
+ self._request = request
+ self._backoff_calc = DefaultBackoffCalculator()
+ self._error_ctx = ErrorContext(num_attempts=0, method=request.method, statement=request.get_request_statement())
+ self._request_state = RequestState.NotStarted
+ self._stream_config = stream_config or JsonStreamConfig()
+ self._json_stream: JsonStream
+ self._cancel_event = Event()
+ self._tp_executor = tp_executor
+ self._stage_completed_ft: Optional[Future[Any]] = None
+ self._stage_notification_ft: Optional[Future[ParsedResultType]] = None
+ self._request_deadline = math.inf
+ self._background_request: Optional[BackgroundRequest] = None
+ self._shutdown = False
+
+ @property
+ def cancel_enabled(self) -> Optional[bool]:
+ return self._request.enable_cancel
+
+ @property
+ def cancelled(self) -> bool:
+ self._check_cancelled_or_timed_out()
+ return self._request_state in [RequestState.Cancelled, RequestState.SyncCancelledPriorToTimeout]
+
+ @property
+ def error_context(self) -> ErrorContext:
+ return self._error_ctx
+
+ @property
+ def has_stage_completed(self) -> bool:
+ return self._stage_completed_ft is not None and self._stage_completed_ft.done()
+
+ @property
+ def is_shutdown(self) -> bool:
+ return self._shutdown
+
+ @property
+ def okay_to_iterate(self) -> bool:
+ # NOTE: Called prior to upstream logic attempting to iterate over results from HTTP client
+ self._check_cancelled_or_timed_out()
+ return RequestState.okay_to_iterate(self._request_state)
+
+ @property
+ def okay_to_stream(self) -> bool:
+ # NOTE: Called prior to upstream logic attempting to send request to HTTP client
+ self._check_cancelled_or_timed_out()
+ return RequestState.okay_to_stream(self._request_state)
+
+ @property
+ def request_state(self) -> RequestState:
+ return self._request_state
+
+ @property
+ def retry_limit_exceeded(self) -> bool:
+ return self.error_context.num_attempts > self._request.max_retries
+
+ @property
+ def timed_out(self) -> bool:
+ self._check_cancelled_or_timed_out()
+ return self._request_state == RequestState.Timeout
+
+ def _check_cancelled_or_timed_out(self) -> None:
+ if self._request_state in [RequestState.Timeout, RequestState.Cancelled, RequestState.Error]:
+ return
+
+ if self._cancel_event.is_set() or (
+ self._background_request is not None and self._background_request.user_cancelled
+ ):
+ self._request_state = RequestState.Cancelled
+ if self._cancel_event.is_set():
+ self.log_message('Request has been cancelled', LogLevel.DEBUG)
+ elif self._background_request is not None and self._background_request.user_cancelled:
+ self.log_message('Request has been cancelled via user background request', LogLevel.DEBUG)
+ return
+
+ current_time = time.monotonic()
+ timed_out = current_time >= self._request_deadline
+ if timed_out:
+ message_data = {'current_time': f'{current_time}', 'request_deadline': f'{self._request_deadline}'}
+ self.log_message('Request has timed out', LogLevel.DEBUG, message_data=message_data)
+ if self._request_state == RequestState.Cancelled:
+ self._request_state = RequestState.SyncCancelledPriorToTimeout
+ else:
+ self._request_state = RequestState.Timeout
+
+ def _create_stage_notification_future(self) -> None:
+ # TODO: custom ThreadPoolExecutor, to get a "plain" future
+ if self._stage_notification_ft is not None:
+ raise RuntimeError('Stage notification future already created for this context.')
+ self._stage_notification_ft = Future[ParsedResultType]()
+
+ def _process_error(
+ self, json_data: Union[str, List[Dict[str, Any]]], handle_context_shutdown: Optional[bool] = False
+ ) -> None:
+ self._request_state = RequestState.Error
+ request_error: Union[AnalyticsError, WrappedError]
+ if isinstance(json_data, str):
+ request_error = ErrorMapper.build_error_from_http_status_code(json_data, self._error_ctx)
+ elif not isinstance(json_data, list):
+ request_error = AnalyticsError(
+ message='Cannot parse error response; expected JSON array', context=str(self._error_ctx)
+ )
+ else:
+ request_error = ErrorMapper.build_error_from_json(json_data, self._error_ctx)
+ if handle_context_shutdown is True:
+ self.shutdown()
+ raise request_error
+
+ def _reset_stream(self) -> None:
+ if hasattr(self, '_json_stream'):
+ del self._json_stream
+ self._request_state = RequestState.ResetAndNotStarted
+ self._stage_notification_ft = None
+ self.log_message('Request state has been reset', LogLevel.DEBUG)
+
+ def _start_next_stage(
+ self,
+ fn: Callable[..., Any],
+ *args: object,
+ create_notification: Optional[bool] = False,
+ reset_previous_stage: Optional[bool] = False,
+ ) -> None:
+ if reset_previous_stage is True:
+ if self._stage_completed_ft is not None:
+ self._stage_completed_ft = None
+ elif self._stage_completed_ft is not None and not self._stage_completed_ft.done():
+ raise RuntimeError('Future already running in this context.')
+
+ kwargs: Dict[str, Union[RequestContext, Future[ParsedResultType]]] = {'request_context': self}
+ if create_notification is True:
+ self._create_stage_notification_future()
+ if self._stage_notification_ft is None:
+ raise RuntimeError('Unable to create stage notification future.')
+ kwargs['notify_on_results_or_error'] = self._stage_notification_ft
+
+ self._stage_completed_ft = self._tp_executor.submit(fn, *args, **kwargs)
+
+ def _trace_handler(self, event_name: str, _: str) -> None:
+ if event_name == 'connection.connect_tcp.complete':
+ pass
+
+ def _wait_for_stage_completed(self) -> None:
+ if self._stage_completed_ft is None:
+ raise RuntimeError('Stage completed future not created for this context.')
+ self._stage_completed_ft.result()
+
+ def calculate_backoff(self) -> float:
+ return self._backoff_calc.calculate_backoff(self._error_ctx.num_attempts) / 1000
+
+ def cancel_request(self) -> None:
+ if self._request_state == RequestState.Timeout:
+ return
+ self._request_state = RequestState.Cancelled
+
+ def deserialize_result(self, result: bytes) -> Any:
+ return self._request.deserializer.deserialize(result)
+
+ def finish_processing_stream(self) -> None:
+ if not self.has_stage_completed:
+ self._wait_for_stage_completed()
+
+ if self.cancelled:
+ return
+
+ while not self._json_stream.token_stream_exhausted:
+ self._json_stream.continue_parsing()
+
+ def get_result_from_stream(self) -> Optional[ParsedResult]:
+ return self._json_stream.get_result(self._stream_config.queue_timeout)
+
+ def initialize(self) -> None:
+ if self._request_state == RequestState.ResetAndNotStarted:
+ self.log_message(
+ 'Request is a retry, skipping initialization',
+ LogLevel.DEBUG,
+ message_data={'request_deadline': f'{self._request_deadline}'},
+ )
+ return
+ self._request_state = RequestState.Started
+ timeouts = self._request.get_request_timeouts() or {}
+ current_time = time.monotonic()
+ self._request_deadline = current_time + (timeouts.get('read', None) or DEFAULT_TIMEOUTS['query_timeout'])
+ message_data = {'current_time': f'{current_time}', 'request_deadline': f'{self._request_deadline}'}
+ self.log_message('Request context initialized', LogLevel.DEBUG, message_data=message_data)
+
+ def log_message(
+ self,
+ message: str,
+ log_level: LogLevel,
+ message_data: Optional[Dict[str, str]] = None,
+ append_ctx: Optional[bool] = True,
+ ) -> None:
+ if append_ctx is True:
+ message = f'{message}: ctx={self._id}'
+ if message_data is not None:
+ message_data_str = ', '.join(f'{k}={v}' for k, v in message_data.items())
+ message = f'{message}, {message_data_str}'
+ self._client_adapter.log_message(message, log_level)
+
+ def maybe_continue_to_process_stream(self) -> None:
+ if not self.has_stage_completed:
+ return
+
+ if self._json_stream.token_stream_exhausted:
+ return
+
+ if self.cancelled:
+ return
+
+ # NOTE: _start_next_stage injects the request context into args
+ self._start_next_stage(self._json_stream.continue_parsing, reset_previous_stage=True)
+
+ def okay_to_delay_and_retry(self, delay: float) -> bool:
+ self._check_cancelled_or_timed_out()
+ if self._request_state in [RequestState.Timeout, RequestState.Cancelled]:
+ return False
+
+ current_time = time.monotonic()
+ delay_time = current_time + delay
+ will_time_out = self._request_deadline < delay_time
+ if will_time_out:
+ self._request_state = RequestState.Timeout
+ message_data = {
+ 'current_time': f'{current_time}',
+ 'delay_time': f'{delay_time}',
+ 'request_deadline': f'{self._request_deadline}',
+ }
+ self.log_message('Request will timeout after delay', LogLevel.DEBUG, message_data=message_data)
+ return False
+ elif self.retry_limit_exceeded:
+ self._request_state = RequestState.Error
+ message_data = {
+ 'num_attempts': f'{self.error_context.num_attempts}',
+ 'max_retries': f'{self._request.max_retries}',
+ }
+ self.log_message('Request has exceeded max retries', LogLevel.DEBUG, message_data=message_data)
+ return False
+ else:
+ self._reset_stream()
+ return True
+
+ def process_response(
+ self,
+ close_handler: Callable[[], None],
+ raw_response: Optional[ParsedResult] = None,
+ handle_context_shutdown: Optional[bool] = False,
+ ) -> Any:
+ if raw_response is None:
+ raw_response = self._json_stream.get_result(self._stream_config.queue_timeout)
+ if raw_response is None:
+ close_handler()
+ raise AnalyticsError(
+ message='Received unexpected empty result from JsonStream.', context=str(self._error_ctx)
+ )
+
+ if raw_response.value is None:
+ close_handler()
+ raise AnalyticsError(
+ message='Received unexpected empty response value from JsonStream.', context=str(self._error_ctx)
+ )
+
+ # we have all the data, close the core response/stream
+ close_handler()
+ try:
+ json_response = json.loads(raw_response.value)
+ except json.JSONDecodeError:
+ self._process_error(str(raw_response.value), handle_context_shutdown=handle_context_shutdown)
+ else:
+ if 'errors' in json_response:
+ self._process_error(json_response['errors'], handle_context_shutdown=handle_context_shutdown)
+ return json_response
+
+ def send_request(self, enable_trace_handling: Optional[bool] = False) -> HttpCoreResponse:
+ self._error_ctx.update_num_attempts()
+ ip = get_request_ip(self._request.url.host, self._request.url.port, self.log_message)
+ if enable_trace_handling is True:
+ (
+ self._request.update_url(ip, self._client_adapter.analytics_path).add_trace_to_extensions(
+ self._trace_handler
+ )
+ )
+ else:
+ self._request.update_url(ip, self._client_adapter.analytics_path)
+ self._error_ctx.update_request_context(self._request)
+ message_data = {
+ 'url': f'{self._request.url.get_formatted_url()}',
+ 'body': f'{self._request.body}',
+ 'request_deadline': f'{self._request_deadline}',
+ }
+ self.log_message('HTTP request', LogLevel.DEBUG, message_data=message_data)
+ response = self._client_adapter.send_request(self._request)
+ self._error_ctx.update_response_context(response)
+ message_data = {
+ 'status_code': f'{response.status_code}',
+ 'last_dispatched_to': f'{self._error_ctx.last_dispatched_to}',
+ 'last_dispatched_from': f'{self._error_ctx.last_dispatched_from}',
+ 'request_deadline': f'{self._request_deadline}',
+ }
+ self.log_message('HTTP response', LogLevel.DEBUG, message_data=message_data)
+ return response
+
+ def send_request_in_background(
+ self,
+ fn: Callable[..., BlockingQueryResult],
+ *args: object,
+ ) -> Future[BlockingQueryResult]:
+ if self._background_request is not None:
+ raise RuntimeError('Background reqeust already created for this context.')
+ # TODO: custom ThreadPoolExecutor, to get a "plain" future
+ user_ft = Future[BlockingQueryResult]()
+ background_work_ft = self._tp_executor.submit(fn, *args)
+ self._background_request = BackgroundRequest(background_work_ft, user_ft, self._cancel_event)
+ return user_ft
+
+ def set_state_to_streaming(self) -> None:
+ self._request_state = RequestState.StreamingResults
+
+ def shutdown(self, exc_val: Optional[BaseException] = None) -> None:
+ if self.is_shutdown:
+ self.log_message('Request context already shutdown', LogLevel.WARNING)
+ return
+ if isinstance(exc_val, CancelledError):
+ self._request_state = RequestState.Cancelled
+ elif exc_val is not None:
+ self._check_cancelled_or_timed_out()
+ if self._request_state not in [
+ RequestState.Timeout,
+ RequestState.Cancelled,
+ RequestState.SyncCancelledPriorToTimeout,
+ ]:
+ self._request_state = RequestState.Error
+
+ if RequestState.is_okay(self._request_state):
+ self._request_state = RequestState.Completed
+ self._shutdown = True
+ self.log_message('Request context shutdown complete', LogLevel.INFO)
+
+ def start_stream(self, core_response: HttpCoreResponse) -> None:
+ if hasattr(self, '_json_stream'):
+ self.log_message('JSON stream already exists', LogLevel.WARNING)
+ return
+
+ # TODO: need to confirm if the httpx Response iterator is thread-safe
+ self._json_stream = JsonStream(
+ core_response.iter_bytes(), stream_config=self._stream_config, logger_handler=self.log_message
+ )
+ self._start_next_stage(self._json_stream.start_parsing, create_notification=True)
+
+ def wait_for_stage_notification(self) -> None:
+ if self._stage_notification_ft is None:
+ raise RuntimeError('Stage notification future not created for this context.')
+ deadline = round(self._request_deadline - time.monotonic(), 6) # round to microseconds
+ if deadline <= 0:
+ raise TimeoutError(message='Request timed out waiting for stage notification', context=str(self._error_ctx))
+ result_type = self._stage_notification_ft.result(timeout=deadline)
+ if result_type == ParsedResultType.ROW:
+ self.log_message('Received row, setting status to streaming', LogLevel.DEBUG)
+ # we move to iterating rows
+ self._request_state = RequestState.StreamingResults
+ else:
+ self.log_message(f'Received result type {result_type.name}', LogLevel.DEBUG)
diff --git a/couchbase_analytics/protocol/_core/retries.py b/couchbase_analytics/protocol/_core/retries.py
new file mode 100644
index 0000000..b135e80
--- /dev/null
+++ b/couchbase_analytics/protocol/_core/retries.py
@@ -0,0 +1,158 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from concurrent.futures import CancelledError
+from functools import wraps
+from time import sleep
+from typing import TYPE_CHECKING, Callable, Optional, Union
+
+from httpx import ConnectError, ConnectTimeout, CookieConflict, HTTPError, InvalidURL, ReadTimeout, StreamError
+
+from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError
+from couchbase_analytics.common.logging import LogLevel
+from couchbase_analytics.common.request import RequestState
+from couchbase_analytics.protocol.errors import WrappedError
+
+if TYPE_CHECKING:
+ from couchbase_analytics.protocol._core.request_context import RequestContext
+ from couchbase_analytics.protocol.streaming import HttpStreamingResponse
+
+
+class RetryHandler:
+ """
+ **INTERNAL**
+ """
+
+ @staticmethod
+ def handle_httpx_retry(ex: Union[ConnectError, ConnectTimeout], ctx: RequestContext) -> Optional[Exception]:
+ err_str = str(ex)
+ if 'SSL:' in err_str:
+ message = 'TLS connection error occurred.'
+ return AnalyticsError(cause=ex, message=message, context=str(ctx.error_context))
+ delay = ctx.calculate_backoff()
+ err: Optional[Exception] = None
+ if not ctx.okay_to_delay_and_retry(delay):
+ if ctx.retry_limit_exceeded:
+ err = AnalyticsError(cause=ex, message='Retry limit exceeded.', context=str(ctx.error_context))
+ else:
+ err = TimeoutError(message='Request timed out during retry delay.', context=str(ctx.error_context))
+ if err:
+ return err
+ sleep(delay)
+ ctx.log_message(
+ 'Retrying request',
+ LogLevel.DEBUG,
+ {'num_attempts': f'{ctx.error_context.num_attempts}', 'delay': f'{delay}s'},
+ )
+ return None
+
+ @staticmethod
+ def handle_retry(ex: WrappedError, ctx: RequestContext) -> Optional[Union[BaseException, Exception]]:
+ if ex.retriable is True:
+ delay = ctx.calculate_backoff()
+ err: Optional[Union[BaseException, Exception]] = None
+ if not ctx.okay_to_delay_and_retry(delay):
+ if ctx.retry_limit_exceeded:
+ if ex.is_cause_query_err:
+ ex.maybe_set_cause_context(ctx.error_context)
+ err = ex.unwrap()
+ else:
+ err = AnalyticsError(
+ cause=ex.unwrap(), message='Retry limit exceeded.', context=str(ctx.error_context)
+ )
+ else:
+ err = TimeoutError(message='Request timed out during retry delay.', context=str(ctx.error_context))
+
+ if err:
+ return err
+ sleep(delay)
+ ctx.log_message(
+ 'Retrying request',
+ LogLevel.DEBUG,
+ {'num_attempts': f'{ctx.error_context.num_attempts}', 'delay': f'{delay}s'},
+ )
+ return None
+ ex.maybe_set_cause_context(ctx.error_context)
+ return ex.unwrap()
+
+ @staticmethod
+ def with_retries(fn: Callable[[HttpStreamingResponse], None]) -> Callable[[HttpStreamingResponse], None]: # noqa: C901
+ @wraps(fn)
+ def wrapped_fn(self: HttpStreamingResponse) -> None: # noqa: C901
+ while True:
+ try:
+ fn(self)
+ break
+ except WrappedError as ex:
+ err = RetryHandler.handle_retry(ex, self._request_context)
+ if err is None:
+ continue
+ self._request_context.shutdown(ex)
+ raise err from None
+ except (ConnectError, ConnectTimeout) as ex:
+ err = RetryHandler.handle_httpx_retry(ex, self._request_context)
+ if err is None:
+ continue
+ self._request_context.shutdown(ex)
+ raise err from None
+ except ReadTimeout as ex:
+ # we set the read timeout to the query timeout, so if we get a ReadTimeout,
+ # it means the request timed out from the httpx client
+ self._request_context.shutdown(ex)
+ raise TimeoutError(
+ message='Request timed out.', context=str(self._request_context.error_context)
+ ) from None
+ except (CookieConflict, HTTPError, StreamError, InvalidURL) as ex:
+ # these are not retriable errors, so we just shutdown the request context and raise the error
+ self._request_context.shutdown(ex)
+ raise AnalyticsError(
+ cause=ex, message=str(ex), context=str(self._request_context.error_context)
+ ) from None
+ except AnalyticsError:
+ # if an AnalyticsError is raised, we have already shut down the request context
+ raise
+ except RuntimeError as ex:
+ self._request_context.shutdown(ex)
+ if self._request_context.timed_out:
+ raise TimeoutError(
+ message='Request timeout.', context=str(self._request_context.error_context)
+ ) from None
+ if self._request_context.cancelled:
+ raise CancelledError('Request was cancelled.') from None
+ raise ex
+ except BaseException as ex:
+ self._request_context.shutdown(ex)
+ if self._request_context.timed_out:
+ raise TimeoutError(
+ message='Request timeout.', context=str(self._request_context.error_context)
+ ) from None
+ if self._request_context.cancelled:
+ raise CancelledError('Request was cancelled.') from None
+ if isinstance(ex, Exception):
+ # If the exception is an Exception, we raise it as an InternalSDKError as this is
+ # an unexpected error in the SDK
+ raise InternalSDKError(
+ cause=ex, message=str(ex), context=str(self._request_context.error_context)
+ ) from None
+ # we should have handled CancelledError and TimeoutError above, so if we get here,
+ # raise the BaseException as is (most likely a KeyboardInterrupt)
+ raise ex
+ finally:
+ if not RequestState.is_okay(self._request_context.request_state):
+ self.close()
+
+ return wrapped_fn
diff --git a/couchbase_analytics/protocol/_core/utils.py b/couchbase_analytics/protocol/_core/utils.py
new file mode 100644
index 0000000..a44106c
--- /dev/null
+++ b/couchbase_analytics/protocol/_core/utils.py
@@ -0,0 +1,35 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from datetime import timedelta
+from time import time
+
+THIRTY_DAYS_IN_SECONDS = 30 * 24 * 60 * 60
+
+
+def timedelta_as_timestamp(duration: timedelta) -> int:
+ if not isinstance(duration, timedelta):
+ raise ValueError(f'Expected timedelta instead of {duration}')
+
+ # PYCBC-1177 remove deprecated heuristic from PYCBC-948:
+ seconds = int(duration.total_seconds())
+ if seconds < 0:
+ raise ValueError(f'Expected expiry seconds of zero (for no expiry) or greater, got {seconds}.')
+
+ if seconds < THIRTY_DAYS_IN_SECONDS:
+ return seconds
+
+ return seconds + int(time())
diff --git a/couchbase_analytics/protocol/cluster.py b/couchbase_analytics/protocol/cluster.py
new file mode 100644
index 0000000..f1e676d
--- /dev/null
+++ b/couchbase_analytics/protocol/cluster.py
@@ -0,0 +1,149 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import atexit
+from concurrent.futures import Future, ThreadPoolExecutor
+from typing import TYPE_CHECKING, Optional, Union
+from uuid import uuid4
+
+from couchbase_analytics.common.logging import LogLevel
+from couchbase_analytics.common.result import BlockingQueryResult
+from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter
+from couchbase_analytics.protocol._core.request import _RequestBuilder
+from couchbase_analytics.protocol._core.request_context import RequestContext
+from couchbase_analytics.protocol.streaming import HttpStreamingResponse
+
+if TYPE_CHECKING:
+ from couchbase_analytics.common.credential import Credential
+ from couchbase_analytics.options import ClusterOptions
+
+
+class Cluster:
+ def __init__(
+ self, http_endpoint: str, credential: Credential, options: Optional[ClusterOptions] = None, **kwargs: object
+ ) -> None:
+ self._cluster_id = str(uuid4())
+ kwargs['cluster_id'] = self._cluster_id
+ self._client_adapter = _ClientAdapter(http_endpoint, credential, options, **kwargs)
+ self._request_builder = _RequestBuilder(self._client_adapter)
+ self._create_client()
+ # TODO: make a custom ThreadPoolExecutor, so that we can override submit and have a way to get
+ # a "plain" future as the docs say we should create a future via an executor
+ # The RequestContext generates a future that enables some background processing
+ # Allow the default max_workers which is (as of Python 3.8): min(32, os.cpu_count() + 4).
+ # We can add an option later if we see a need
+ self._tp_executor = ThreadPoolExecutor()
+ self._tp_executor_shutdown_called = False
+ atexit.register(self._shutdown_executor)
+
+ @property
+ def client_adapter(self) -> _ClientAdapter:
+ """
+ **INTERNAL**
+ """
+ return self._client_adapter
+
+ @property
+ def cluster_id(self) -> str:
+ """
+ **INTERNAL**
+ """
+ return self._cluster_id
+
+ @property
+ def has_client(self) -> bool:
+ """
+ bool: Indicator on if the cluster HTTP client has been created or not.
+ """
+ return self._client_adapter.has_client
+
+ @property
+ def threadpool_executor(self) -> ThreadPoolExecutor:
+ """
+ **INTERNAL**
+ """
+ return self._tp_executor
+
+ def _shutdown(self) -> None:
+ """
+ **INTERNAL**
+ """
+ self._client_adapter.close_client()
+ self._client_adapter.reset_client()
+ if self._tp_executor_shutdown_called is False:
+ self._tp_executor.shutdown()
+
+ def _create_client(self) -> None:
+ """
+ **INTERNAL**
+ """
+ self._client_adapter.create_client()
+
+ def _shutdown_executor(self) -> None:
+ if self._tp_executor_shutdown_called is False:
+ self._tp_executor.shutdown()
+ self._tp_executor_shutdown_called = True
+
+ def shutdown(self) -> None:
+ """Shuts down this cluster instance. Cleaning up all resources associated with it.
+
+ .. warning::
+ Use of this method is almost *always* unnecessary. Cluster resources should be cleaned
+ up once the cluster instance falls out of scope. However, in some applications tuning resources
+ is necessary and in those types of applications, this method might be beneficial.
+
+ """
+ if self.has_client:
+ self._shutdown()
+ else:
+ self._client_adapter.log_message('Cluster does not have a connection, no need to shutdown.', LogLevel.INFO)
+
+ def execute_query(
+ self, statement: str, *args: object, **kwargs: object
+ ) -> Union[BlockingQueryResult, Future[BlockingQueryResult]]:
+ base_req = self._request_builder.build_base_query_request(statement, *args, **kwargs)
+ lazy_execute = base_req.options.pop('lazy_execute', None)
+ stream_config = base_req.options.pop('stream_config', None)
+ request_context = RequestContext(
+ self.client_adapter, base_req, self.threadpool_executor, stream_config=stream_config
+ )
+ resp = HttpStreamingResponse(request_context, lazy_execute=lazy_execute)
+
+ def _execute_query(http_response: HttpStreamingResponse) -> BlockingQueryResult:
+ http_response.send_request()
+ return BlockingQueryResult(http_response)
+
+ if request_context.cancel_enabled is True:
+ if lazy_execute is True:
+ raise RuntimeError(
+ (
+ 'Cannot cancel, via cancel token, a query that is executed lazily.'
+ ' Queries executed lazily can be cancelled only after iteration begins.'
+ )
+ )
+
+ return request_context.send_request_in_background(_execute_query, resp)
+ else:
+ if lazy_execute is not True:
+ resp.send_request()
+ return BlockingQueryResult(resp)
+
+ @classmethod
+ def create_instance(
+ cls, http_endpoint: str, credential: Credential, options: Optional[ClusterOptions], **kwargs: object
+ ) -> Cluster:
+ return cls(http_endpoint, credential, options, **kwargs)
diff --git a/couchbase_analytics/protocol/cluster.pyi b/couchbase_analytics/protocol/cluster.pyi
new file mode 100644
index 0000000..d552809
--- /dev/null
+++ b/couchbase_analytics/protocol/cluster.pyi
@@ -0,0 +1,138 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+from concurrent.futures import Future, ThreadPoolExecutor
+from typing import overload
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Unpack
+else:
+ from typing import Unpack
+
+from couchbase_analytics import JSONType
+from couchbase_analytics.common.credential import Credential
+from couchbase_analytics.common.result import BlockingQueryResult
+from couchbase_analytics.options import ClusterOptions, ClusterOptionsKwargs, QueryOptions, QueryOptionsKwargs
+from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter
+
+class Cluster:
+ @overload
+ def __init__(self, http_endpoint: str, credential: Credential) -> None: ...
+ @overload
+ def __init__(self, http_endpoint: str, credential: Credential, options: ClusterOptions) -> None: ...
+ @overload
+ def __init__(self, http_endpoint: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs]) -> None: ...
+ @overload
+ def __init__(
+ self,
+ http_endpoint: str,
+ credential: Credential,
+ options: ClusterOptions,
+ **kwargs: Unpack[ClusterOptionsKwargs],
+ ) -> None: ...
+ @property
+ def client_adapter(self) -> _ClientAdapter: ...
+ @property
+ def connected(self) -> bool: ...
+ @property
+ def threadpool_executor(self) -> ThreadPoolExecutor: ...
+ @overload
+ def execute_query(self, statement: str) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, options: QueryOptions) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: str
+ ) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, *args: JSONType, **kwargs: str) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, enable_cancel: bool) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, enable_cancel: bool, *args: JSONType) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, enable_cancel: bool
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self,
+ statement: str,
+ options: QueryOptions,
+ enable_cancel: bool,
+ *args: JSONType,
+ **kwargs: Unpack[QueryOptionsKwargs],
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self,
+ statement: str,
+ options: QueryOptions,
+ *args: JSONType,
+ enable_cancel: bool,
+ **kwargs: Unpack[QueryOptionsKwargs],
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, enable_cancel: bool, *args: JSONType, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: JSONType, enable_cancel: bool, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, enable_cancel: bool, *args: JSONType, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, *args: JSONType, enable_cancel: bool, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ def shutdown(self) -> None: ...
+ @overload
+ @classmethod
+ def create_instance(cls, http_endpoint: str, credential: Credential) -> Cluster: ...
+ @overload
+ @classmethod
+ def create_instance(cls, http_endpoint: str, credential: Credential, options: ClusterOptions) -> Cluster: ...
+ @overload
+ @classmethod
+ def create_instance(
+ cls, http_endpoint: str, credential: Credential, **kwargs: Unpack[ClusterOptionsKwargs]
+ ) -> Cluster: ...
+ @overload
+ @classmethod
+ def create_instance(
+ cls, http_endpoint: str, credential: Credential, options: ClusterOptions, **kwargs: Unpack[ClusterOptionsKwargs]
+ ) -> Cluster: ...
diff --git a/couchbase_analytics/protocol/connection.py b/couchbase_analytics/protocol/connection.py
new file mode 100644
index 0000000..c90948b
--- /dev/null
+++ b/couchbase_analytics/protocol/connection.py
@@ -0,0 +1,268 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import logging
+import ssl
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, TypedDict, cast
+from urllib.parse import parse_qs, urlparse
+
+from couchbase_analytics.common._core.certificates import _Certificates
+from couchbase_analytics.common._core.duration_str_utils import parse_duration_str
+from couchbase_analytics.common._core.utils import is_null_or_empty
+from couchbase_analytics.common.credential import Credential
+from couchbase_analytics.common.deserializer import DefaultJsonDeserializer, Deserializer
+from couchbase_analytics.common.options import ClusterOptions, SecurityOptions, TimeoutOptions
+from couchbase_analytics.common.request import RequestURL
+from couchbase_analytics.protocol.options import (
+ ClusterOptionsTransformedKwargs,
+ QueryStrVal,
+ SecurityOptionsTransformedKwargs,
+ TimeoutOptionsTransformedKwargs,
+)
+
+if TYPE_CHECKING:
+ from couchbase_analytics.protocol.options import OptionsBuilder
+
+
+class StreamingTimeouts(TypedDict, total=False):
+ query_timeout: Optional[float]
+
+
+class DefaultTimeouts(TypedDict):
+ connect_timeout: float
+ query_timeout: float
+
+
+DEFAULT_TIMEOUTS: DefaultTimeouts = {
+ 'connect_timeout': 10,
+ 'query_timeout': 60 * 10,
+}
+
+DEFAULT_MAX_RETRIES: int = 7
+
+
+def parse_http_endpoint(http_endpoint: str) -> Tuple[RequestURL, Dict[str, List[str]]]:
+ """**INTERNAL**
+
+ Parse the provided HTTP endpoint
+
+ The provided connection string will be parsed to split the connection string
+ and the the query options. Query options will be split into legacy options
+ and 'current' options.
+
+ Args:
+ http_endpoint (str): The HTTP endpoint to use for requests.
+
+ Returns:
+ Tuple[str, Dict[str, Any], Dict[str, Any]]: The parsed HTTP URL and options dict.
+ """
+ parsed_endpoint = urlparse(http_endpoint)
+ if parsed_endpoint.scheme is None or parsed_endpoint.scheme not in ['http', 'https']:
+ raise ValueError(f"The endpoint scheme must be 'http[s]'. Found: {parsed_endpoint.scheme}.")
+
+ host = parsed_endpoint.hostname
+ if host is None:
+ host = ''
+
+ if len(host.split(',')) > 1:
+ raise ValueError('The endpoint must not contain multiple hosts.')
+
+ port = parsed_endpoint.port
+ if parsed_endpoint.port is None:
+ port = 80 if parsed_endpoint.scheme == 'http' else 443
+
+ if port is None:
+ raise ValueError('The URL must have a port specified.')
+
+ if not is_null_or_empty(parsed_endpoint.path):
+ raise ValueError('The SDK does not currently support HTTP endpoint paths.')
+
+ url = RequestURL(scheme=parsed_endpoint.scheme, host=host, port=port)
+
+ return url, parse_qs(parsed_endpoint.query)
+
+
+def parse_query_string_value(value: List[str], enforce_str: Optional[bool] = False) -> QueryStrVal:
+ """Parse a query string value
+
+ The provided value is a list of at least one element. Returns either a list of strings or a single element
+ which might be cast to an integer or a boolean if that's appropriate.
+
+ Args:
+ value (List[str]): The query string value.
+
+ Returns:
+ Union[List[str], str, bool, int]: The parsed current options and legacy options.
+ """
+
+ if len(value) > 1:
+ return value
+ v = value[0]
+ if v.isnumeric() and not enforce_str:
+ return int(v)
+ elif v.lower() in ['true', 'false']:
+ return v.lower() == 'true'
+ return v
+
+
+def parse_query_str_options(query_str_opts: Dict[str, List[str]]) -> Dict[str, QueryStrVal]:
+ final_opts: Dict[str, QueryStrVal] = {}
+ for k, v in query_str_opts.items():
+ tokens = k.split('.')
+ if len(tokens) == 2:
+ if tokens[0] == 'security':
+ final_opts[tokens[1]] = parse_query_string_value(v)
+ elif tokens[0] == 'timeout':
+ val = parse_query_string_value(v, enforce_str=True)
+ final_opts[tokens[1]] = parse_duration_str(cast(str, val))
+ else:
+ print('Warning: Unrecognized query string option:', k)
+ # TODO: exceptions -- this means the user passed in an invalid option
+ pass
+ else:
+ if k in SecurityOptions.VALID_OPTION_KEYS:
+ msg = f'Invalid query string option: {k}.'
+ if k not in ['trust_only_pem_str', 'trust_only_certificates']:
+ msg += f' Use "security.{k}" instead.'
+ raise ValueError(msg)
+ elif k in TimeoutOptions.VALID_OPTION_KEYS:
+ raise ValueError(f'Invalid query string option: {k}. Use "timeout.{k}" instead.')
+ final_opts[k] = parse_query_string_value(v)
+
+ return final_opts
+
+
+@dataclass
+class _ConnectionDetails:
+ """
+ **INTERNAL**
+ """
+
+ url: RequestURL
+ cluster_options: ClusterOptionsTransformedKwargs
+ credential: Tuple[bytes, bytes]
+ default_deserializer: Deserializer
+ ssl_context: Optional[ssl.SSLContext] = None
+ sni_hostname: Optional[str] = None
+ logger_name: Optional[str] = None
+
+ def get_connect_timeout(self) -> float:
+ timeout_opts: Optional[TimeoutOptionsTransformedKwargs] = self.cluster_options.get('timeout_options')
+ if timeout_opts is not None:
+ connect_timeout = timeout_opts.get('connect_timeout', None)
+ if connect_timeout is not None:
+ return connect_timeout
+ return DEFAULT_TIMEOUTS['connect_timeout']
+
+ def get_max_retries(self) -> int:
+ return self.cluster_options.get('max_retries', None) or DEFAULT_MAX_RETRIES
+
+ def get_init_details(self) -> str:
+ details = {'url': self.url.get_formatted_url(), 'cluster_options': self.cluster_options}
+ return f'{details}'
+
+ def get_query_timeout(self) -> float:
+ timeout_opts: Optional[TimeoutOptionsTransformedKwargs] = self.cluster_options.get('timeout_options')
+ if timeout_opts is not None:
+ query_timeout = timeout_opts.get('query_timeout', None)
+ if query_timeout is not None:
+ return query_timeout
+ return DEFAULT_TIMEOUTS['query_timeout']
+
+ def is_secure(self) -> bool:
+ return self.url.scheme == 'https'
+
+ def validate_security_options(self) -> None: # noqa: C901
+ security_opts: Optional[SecurityOptionsTransformedKwargs] = self.cluster_options.get('security_options')
+ # TODO: security settings
+ if security_opts is not None:
+ # separate between value options and boolean option (trust_only_capella)
+ solo_security_opts = ['trust_only_pem_file', 'trust_only_pem_str', 'trust_only_certificates']
+ trust_capella = security_opts.get('trust_only_capella', None)
+ security_opt_count = sum(
+ (1 if security_opts.get(opt, None) is not None else 0 for opt in solo_security_opts)
+ )
+ if security_opt_count > 1 or (security_opt_count == 1 and trust_capella is True):
+ raise ValueError(
+ (
+ 'Can only set one of the following options: '
+ f'[{", ".join(["trust_only_capella"] + solo_security_opts)}]'
+ )
+ )
+
+ if not self.is_secure():
+ return
+
+ self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ self.sni_hostname = self.url.host
+
+ if security_opts is None:
+ self.ssl_context.set_default_verify_paths()
+ capalla_certs = _Certificates.get_capella_certificates()
+ self.ssl_context.load_verify_locations(cadata='\n'.join(capalla_certs))
+ elif security_opts.get('trust_only_capella', False):
+ capalla_certs = _Certificates.get_capella_certificates()
+ self.ssl_context.load_verify_locations(cadata='\n'.join(capalla_certs))
+ elif (certpath := security_opts.get('trust_only_pem_file', None)) is not None:
+ self.ssl_context.load_verify_locations(cafile=certpath)
+ security_opts['trust_only_capella'] = False
+ elif (certstr := security_opts.get('trust_only_pem_str', None)) is not None:
+ self.ssl_context.load_verify_locations(cadata=certstr)
+ security_opts['trust_only_capella'] = False
+ elif (certificates := security_opts.get('trust_only_certificates', None)) is not None:
+ self.ssl_context.load_verify_locations(cadata='\n'.join(certificates))
+ security_opts['trust_only_capella'] = False
+
+ if security_opts is not None and security_opts.get('disable_server_certificate_verification', False):
+ if self.logger_name is not None:
+ logger = logging.getLogger(self.logger_name)
+ msg = 'Server certificate verification is disabled. This is not recommended for production use.'
+ logger.warning(msg)
+ self.ssl_context.check_hostname = False
+ self.ssl_context.verify_mode = ssl.CERT_NONE
+ else:
+ self.ssl_context.check_hostname = True
+ self.ssl_context.verify_mode = ssl.CERT_REQUIRED
+
+ @classmethod
+ def create(
+ cls,
+ opts_builder: OptionsBuilder,
+ http_endpoint: str,
+ credential: Credential,
+ options: Optional[object] = None,
+ **kwargs: object,
+ ) -> _ConnectionDetails:
+ url, query_str_opts = parse_http_endpoint(http_endpoint)
+
+ logger_name = cast(Optional[str], kwargs.pop('logger_name', None))
+ cluster_opts = opts_builder.build_cluster_options(
+ ClusterOptions,
+ ClusterOptionsTransformedKwargs,
+ kwargs,
+ options,
+ query_str_opts=parse_query_str_options(query_str_opts),
+ )
+
+ default_deserializer = cluster_opts.pop('deserializer', None)
+ if default_deserializer is None:
+ default_deserializer = DefaultJsonDeserializer()
+
+ conn_dtls = cls(url, cluster_opts, credential.astuple(), default_deserializer, logger_name=logger_name)
+ conn_dtls.validate_security_options()
+ return conn_dtls
diff --git a/couchbase_analytics/protocol/database.py b/couchbase_analytics/protocol/database.py
new file mode 100644
index 0000000..c1e9b34
--- /dev/null
+++ b/couchbase_analytics/protocol/database.py
@@ -0,0 +1,55 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from concurrent.futures import ThreadPoolExecutor
+from typing import TYPE_CHECKING
+
+from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter
+from couchbase_analytics.protocol.scope import Scope
+
+if TYPE_CHECKING:
+ from couchbase_analytics.protocol.cluster import Cluster
+
+
+class Database:
+ def __init__(self, cluster: Cluster, database_name: str) -> None:
+ self._database_name = database_name
+ self._cluster = cluster
+
+ @property
+ def client_adapter(self) -> _ClientAdapter:
+ """
+ **INTERNAL**
+ """
+ return self._cluster.client_adapter
+
+ @property
+ def name(self) -> str:
+ """
+ str: The name of this :class:`~couchbase_analytics.protocol.database.Database` instance.
+ """
+ return self._database_name
+
+ @property
+ def threadpool_executor(self) -> ThreadPoolExecutor:
+ """
+ **INTERNAL**
+ """
+ return self._cluster.threadpool_executor
+
+ def scope(self, scope_name: str) -> Scope:
+ return Scope(self, scope_name)
diff --git a/couchbase_analytics/protocol/database.pyi b/couchbase_analytics/protocol/database.pyi
new file mode 100644
index 0000000..f5d21ef
--- /dev/null
+++ b/couchbase_analytics/protocol/database.pyi
@@ -0,0 +1,30 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from concurrent.futures import ThreadPoolExecutor
+
+from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter
+from couchbase_analytics.protocol.cluster import Cluster as Cluster
+from couchbase_analytics.protocol.scope import Scope
+
+class Database:
+ def __init__(self, cluster: Cluster, database_name: str) -> None: ...
+ @property
+ def client_adapter(self) -> _ClientAdapter: ...
+ @property
+ def name(self) -> str: ...
+ @property
+ def threadpool_executor(self) -> ThreadPoolExecutor: ...
+ def scope(self, scope_name: str) -> Scope: ...
diff --git a/couchbase_analytics/protocol/errors.py b/couchbase_analytics/protocol/errors.py
new file mode 100644
index 0000000..afb67cf
--- /dev/null
+++ b/couchbase_analytics/protocol/errors.py
@@ -0,0 +1,172 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import socket
+from functools import wraps
+from typing import Any, Callable, Dict, List, NamedTuple, Optional, Union
+
+from couchbase_analytics.common._core.error_context import ErrorContext
+from couchbase_analytics.common.errors import (
+ AnalyticsError,
+ InvalidCredentialError,
+ QueryError,
+ TimeoutError,
+)
+from couchbase_analytics.common.logging import LogLevel
+
+
+class ServerQueryError(NamedTuple):
+ """
+ **INTERNAL**
+ """
+
+ code: int
+ message: str
+ retriable: bool = False
+
+ def to_dict(self) -> Dict[str, Any]:
+ output: Dict[str, Any] = {
+ 'code': self.code,
+ 'msg': self.message,
+ }
+ if self.retriable is not None:
+ output['retriable'] = self.retriable
+ return output
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, ServerQueryError):
+ return False
+ return self.code == other.code and self.message == other.message
+
+ def __repr__(self) -> str:
+ return f'ServerQueryError(code={self.code}, message={self.message}, retriable={self.retriable})'
+
+ @classmethod
+ def from_json(cls, json_data: Dict[str, Any]) -> ServerQueryError:
+ """
+ **INTERNAL**
+ """
+ code = json_data.get('code', 0)
+ message = json_data.get('msg', 'Unknown error')
+ retriable = bool(json_data.get('retriable', False))
+ return cls(code=code, message=message, retriable=retriable)
+
+
+class WrappedError(Exception):
+ def __init__(self, cause: Union[BaseException, Exception], retriable: bool = False) -> None:
+ super().__init__()
+ self._cause = cause
+ self._retriable = retriable
+
+ @property
+ def is_cause_query_err(self) -> bool:
+ return isinstance(self._cause, QueryError)
+
+ @property
+ def retriable(self) -> bool:
+ return self._retriable
+
+ @retriable.setter
+ def retriable(self, value: bool) -> None:
+ self._retriable = value
+
+ def maybe_set_cause_context(self, context: ErrorContext) -> None:
+ if not isinstance(self._cause, (AnalyticsError, InvalidCredentialError, QueryError, TimeoutError)):
+ return
+
+ if hasattr(self._cause, '_context') and self._cause._context is None:
+ self._cause._context = str(context)
+
+ def unwrap(self) -> Union[BaseException, Exception]:
+ """
+ Unwraps the cause of the error, returning the original exception.
+ """
+ return self._cause
+
+ def __repr__(self) -> str:
+ return f'{type(self).__name__}(cause={self._cause!r}, retriable={self._retriable})'
+
+ def __str__(self) -> str:
+ return self.__repr__()
+
+
+# Python does not specify which socket errors are retriable or not, although there is a EAI_AGAIN error
+# that is commented to be temporary. The current version of the RFC has connect failures as retriable.
+# https://github.com/python/cpython/blob/0f866cbfefd797b4dae25962457c5579bb90dde5/Modules/addrinfo.h#L58-L71
+
+
+class ErrorMapper:
+ @staticmethod
+ def build_error_from_http_status_code(message: str, context: ErrorContext) -> WrappedError:
+ if context.status_code == 503:
+ return WrappedError(AnalyticsError(context=str(context), message=message), retriable=True)
+
+ return WrappedError(AnalyticsError(context=str(context), message=message))
+
+ @staticmethod # noqa: C901
+ def build_error_from_json(json_data: List[Dict[str, Any]], context: ErrorContext) -> WrappedError:
+ if context.status_code is None:
+ return WrappedError(AnalyticsError(context=str(context), message='Unknown error occurred.'))
+ if context.status_code == 401:
+ return WrappedError(InvalidCredentialError(context=str(context), message='Invalid credentials provided.'))
+
+ first_non_retriable_error: Optional[ServerQueryError] = None
+ first_retriable_error: Optional[ServerQueryError] = None
+ errs: List[ServerQueryError] = []
+ for err_data in json_data:
+ err = ServerQueryError.from_json(err_data)
+ errs.append(err)
+ retriable = bool(err_data.get('retriable', False)) or False
+ if not retriable and first_non_retriable_error is None:
+ first_non_retriable_error = err
+
+ if retriable and first_retriable_error is None:
+ first_retriable_error = err
+
+ first_err = first_non_retriable_error or first_retriable_error
+ context.set_errors([e.to_dict() for e in errs])
+ if first_err is None:
+ err_msg = 'Could not parse errors from server response (expected JSON array).'
+ return WrappedError(AnalyticsError(context=str(context), message=err_msg))
+
+ if first_err.code == 20000:
+ return WrappedError(InvalidCredentialError(context=str(context)))
+ if first_err.code == 21002:
+ return WrappedError(TimeoutError(context=str(context), message='Received timeout error from server.'))
+
+ q_err = QueryError(code=first_err.code, server_message=first_err.message, context=str(context))
+ if context.status_code == 503:
+ return WrappedError(q_err, retriable=True)
+
+ retriable = first_non_retriable_error is None and first_retriable_error is not None
+ return WrappedError(q_err, retriable=retriable)
+
+ @staticmethod
+ def handle_socket_error(
+ fn: Callable[[str, int, Optional[Callable[..., None]]], str],
+ ) -> Callable[[str, int, Optional[Callable[..., None]]], str]:
+ @wraps(fn)
+ def wrapped_fn(host: str, port: int, logger_handler: Optional[Callable[..., None]] = None) -> str:
+ try:
+ return fn(host, port, logger_handler)
+ except socket.gaierror as ex:
+ if logger_handler:
+ logger_handler(f'getaddrinfo() failed for {host}:{port} with error: {ex}', LogLevel.ERROR)
+ msg = 'Connection error occurred while sending request.'
+ raise WrappedError(AnalyticsError(cause=ex, message=msg), retriable=True) from None
+
+ return wrapped_fn
diff --git a/couchbase_analytics/protocol/options.py b/couchbase_analytics/protocol/options.py
new file mode 100644
index 0000000..e6830e8
--- /dev/null
+++ b/couchbase_analytics/protocol/options.py
@@ -0,0 +1,310 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from copy import copy
+from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, TypedDict, TypeVar, Union
+
+from couchbase_analytics.common._core import JsonStreamConfig
+from couchbase_analytics.common._core.utils import (
+ VALIDATE_BOOL,
+ VALIDATE_DESERIALIZER,
+ VALIDATE_INT,
+ VALIDATE_STR,
+ VALIDATE_STR_LIST,
+ EnumToStr,
+ to_seconds,
+ validate_path,
+ validate_raw_dict,
+)
+from couchbase_analytics.common.deserializer import Deserializer
+from couchbase_analytics.common.enums import QueryScanConsistency
+from couchbase_analytics.common.options import (
+ ClusterOptions,
+ OptionsClass,
+ QueryOptions,
+ SecurityOptions,
+ TimeoutOptions,
+)
+from couchbase_analytics.common.options_base import (
+ ClusterOptionsValidKeys,
+ QueryOptionsValidKeys,
+ SecurityOptionsValidKeys,
+ TimeoutOptionsValidKeys,
+)
+
+QUERY_CONSISTENCY_TO_STR = EnumToStr[QueryScanConsistency]()
+
+QueryStrVal = Union[List[str], str, bool, int, float]
+
+
+class ClusterOptionsTransforms(TypedDict):
+ deserializer: Dict[Literal['deserializer'], Callable[[Any], Deserializer]]
+ max_retries: Dict[Literal['max_retries'], Callable[[Any], int]]
+ security_options: Dict[Literal['security_options'], Callable[[Any], Any]]
+ timeout_options: Dict[Literal['timeout_options'], Callable[[Any], Any]]
+
+
+CLUSTER_OPTIONS_TRANSFORMS: ClusterOptionsTransforms = {
+ 'deserializer': {'deserializer': VALIDATE_DESERIALIZER},
+ 'max_retries': {'max_retries': VALIDATE_INT},
+ 'security_options': {'security_options': lambda x: x},
+ 'timeout_options': {'timeout_options': lambda x: x},
+}
+
+
+class ClusterOptionsTransformedKwargs(TypedDict, total=False):
+ deserializer: Optional[Deserializer]
+ max_retries: Optional[int]
+ security_options: Optional[SecurityOptionsTransformedKwargs]
+ timeout_options: Optional[TimeoutOptionsTransformedKwargs]
+
+
+class SecurityOptionsTransforms(TypedDict):
+ trust_only_capella: Dict[Literal['trust_only_capella'], Callable[[Any], bool]]
+ trust_only_pem_file: Dict[Literal['trust_only_pem_file'], Callable[[Any], str]]
+ trust_only_pem_str: Dict[Literal['trust_only_pem_str'], Callable[[Any], str]]
+ trust_only_certificates: Dict[Literal['trust_only_certificates'], Callable[[Any], List[str]]]
+ disable_server_certificate_verification: Dict[
+ Literal['disable_server_certificate_verification'], Callable[[Any], bool]
+ ]
+
+
+SECURITY_OPTIONS_TRANSFORMS: SecurityOptionsTransforms = {
+ 'trust_only_capella': {'trust_only_capella': VALIDATE_BOOL},
+ 'trust_only_pem_file': {'trust_only_pem_file': validate_path},
+ 'trust_only_pem_str': {'trust_only_pem_str': VALIDATE_STR},
+ 'trust_only_certificates': {'trust_only_certificates': VALIDATE_STR_LIST},
+ 'disable_server_certificate_verification': {'disable_server_certificate_verification': VALIDATE_BOOL},
+}
+
+
+class SecurityOptionsTransformedKwargs(TypedDict, total=False):
+ trust_only_capella: Optional[bool]
+ trust_only_pem_file: Optional[str]
+ trust_only_pem_str: Optional[str]
+ trust_only_certificates: Optional[List[str]]
+ disable_server_certificate_verification: Optional[bool]
+
+
+class TimeoutOptionsTransforms(TypedDict):
+ connect_timeout: Dict[Literal['connect_timeout'], Callable[[Any], float]]
+ query_timeout: Dict[Literal['query_timeout'], Callable[[Any], float]]
+
+
+TIMEOUT_OPTIONS_TRANSFORMS: TimeoutOptionsTransforms = {
+ 'connect_timeout': {'connect_timeout': to_seconds},
+ 'query_timeout': {'query_timeout': to_seconds},
+}
+
+
+class TimeoutOptionsTransformedKwargs(TypedDict, total=False):
+ connect_timeout: Optional[int]
+ query_timeout: Optional[int]
+
+
+class QueryOptionsTransforms(TypedDict):
+ client_context_id: Dict[Literal['client_context_id'], Callable[[Any], str]]
+ deserializer: Dict[Literal['deserializer'], Callable[[Any], Deserializer]]
+ lazy_execute: Dict[Literal['lazy_execute'], Callable[[Any], bool]]
+ max_retries: Dict[Literal['max_retries'], Callable[[Any], int]]
+ named_parameters: Dict[Literal['named_parameters'], Callable[[Any], Any]]
+ positional_parameters: Dict[Literal['positional_parameters'], Callable[[Any], Any]]
+ query_context: Dict[Literal['query_context'], Callable[[Any], str]]
+ raw: Dict[Literal['raw'], Callable[[Any], Dict[str, Any]]]
+ readonly: Dict[Literal['readonly'], Callable[[Any], bool]]
+ scan_consistency: Dict[Literal['scan_consistency'], Callable[[Any], str]]
+ stream_config: Dict[Literal['stream_config'], Callable[[Any], JsonStreamConfig]]
+ timeout: Dict[Literal['timeout'], Callable[[Any], float]]
+
+
+QUERY_OPTIONS_TRANSFORMS: QueryOptionsTransforms = {
+ 'client_context_id': {'client_context_id': VALIDATE_STR},
+ 'deserializer': {'deserializer': VALIDATE_DESERIALIZER},
+ 'lazy_execute': {'lazy_execute': VALIDATE_BOOL},
+ 'max_retries': {'max_retries': VALIDATE_INT},
+ 'named_parameters': {'named_parameters': lambda x: x},
+ 'positional_parameters': {'positional_parameters': lambda x: x},
+ 'query_context': {'query_context': VALIDATE_STR},
+ 'raw': {'raw': validate_raw_dict},
+ 'readonly': {'readonly': VALIDATE_BOOL},
+ 'scan_consistency': {'scan_consistency': QUERY_CONSISTENCY_TO_STR},
+ 'stream_config': {'stream_config': lambda x: x},
+ 'timeout': {'timeout': to_seconds},
+}
+
+
+class QueryOptionsTransformedKwargs(TypedDict, total=False):
+ client_context_id: Optional[str]
+ deserializer: Optional[Deserializer]
+ lazy_execute: Optional[bool]
+ max_retries: Optional[int]
+ named_parameters: Optional[Any]
+ positional_parameters: Optional[Any]
+ priority: Optional[bool]
+ query_context: Optional[str]
+ raw: Optional[Dict[str, Any]]
+ readonly: Optional[bool]
+ scan_consistency: Optional[str]
+ stream_config: Optional[JsonStreamConfig]
+ timeout: Optional[float]
+
+
+TransformedOptionKwargs = TypeVar(
+ 'TransformedOptionKwargs',
+ QueryOptionsTransformedKwargs,
+ ClusterOptionsTransformedKwargs,
+ SecurityOptionsTransformedKwargs,
+ TimeoutOptionsTransformedKwargs,
+)
+
+TransformedClusterOptionKwargs = TypeVar(
+ 'TransformedClusterOptionKwargs',
+ ClusterOptionsTransformedKwargs,
+ SecurityOptionsTransformedKwargs,
+ TimeoutOptionsTransformedKwargs,
+)
+
+TransformDetailsPair = Union[
+ Tuple[List[QueryOptionsValidKeys], QueryOptionsTransforms],
+ Tuple[List[ClusterOptionsValidKeys], ClusterOptionsTransforms],
+ Tuple[List[SecurityOptionsValidKeys], SecurityOptionsTransforms],
+ Tuple[List[TimeoutOptionsValidKeys], TimeoutOptionsTransforms],
+]
+
+
+class OptionsBuilder:
+ """
+ **INTERNAL**
+ """
+
+ def _get_options_copy(
+ self, options_class: type[OptionsClass], orig_kwargs: Dict[str, object], options: Optional[object] = None
+ ) -> Dict[str, object]:
+ orig_kwargs = copy(orig_kwargs) if orig_kwargs else {}
+ # set our options base dict()
+ temp_options: Dict[str, object] = {}
+ if options and isinstance(options, (options_class, dict)):
+ # mypy cannot recognize that all our options classes are dicts
+ temp_options = options_class(**options) # type: ignore[arg-type]
+ else:
+ temp_options = {}
+ temp_options.update(orig_kwargs)
+
+ return temp_options
+
+ def _get_transform_details(self, option_type: str) -> TransformDetailsPair: # noqa: C901
+ if option_type == 'ClusterOptions':
+ return ClusterOptions.VALID_OPTION_KEYS, CLUSTER_OPTIONS_TRANSFORMS
+ elif option_type == 'SecurityOptions':
+ return SecurityOptions.VALID_OPTION_KEYS, SECURITY_OPTIONS_TRANSFORMS
+ elif option_type == 'TimeoutOptions':
+ return TimeoutOptions.VALID_OPTION_KEYS, TIMEOUT_OPTIONS_TRANSFORMS
+ elif option_type == 'QueryOptions':
+ return QueryOptions.VALID_OPTION_KEYS, QUERY_OPTIONS_TRANSFORMS
+ else:
+ raise ValueError('Invalid OptionType.')
+
+ def build_cluster_options( # noqa: C901
+ self,
+ option_type: type[OptionsClass],
+ output_type: type[TransformedClusterOptionKwargs],
+ orig_kwargs: Dict[str, object],
+ options: Optional[object] = None,
+ query_str_opts: Optional[Dict[str, QueryStrVal]] = None,
+ ) -> TransformedClusterOptionKwargs:
+ temp_options = self._get_options_copy(option_type, orig_kwargs, options)
+
+ # we flatten all the nested options (timeout_options & security_options)
+ # so that we can combine the nested options w/ potential query string options
+ # when parsing the various nested options we pass in keys that are okay to be ignored as
+ # we know they are included in the overall "cluster options" umbrella (mainly due to handling
+ # the query string options).
+
+ security_opts = temp_options.pop('security_options', {})
+ if security_opts and isinstance(security_opts, dict):
+ for k, v in security_opts.items():
+ if k not in temp_options:
+ temp_options[k] = v
+
+ timeout_opts = temp_options.pop('timeout_options', {})
+ if timeout_opts and isinstance(timeout_opts, dict):
+ for k, v in timeout_opts.items():
+ if k not in temp_options:
+ temp_options[k] = v
+
+ if query_str_opts:
+ # query string options override the options passed in via ClusterOptions
+ for k, v in query_str_opts.items():
+ temp_options[k] = v
+
+ keys_to_ignore: List[str] = [*ClusterOptions.VALID_OPTION_KEYS, *TimeoutOptions.VALID_OPTION_KEYS]
+
+ # not going to be able to make mypy happy w/ keys_to_ignore :/
+ transformed_security_opts = self.build_options(
+ SecurityOptions, SecurityOptionsTransformedKwargs, temp_options, keys_to_ignore=keys_to_ignore
+ )
+ if transformed_security_opts:
+ temp_options['security_options'] = transformed_security_opts
+
+ keys_to_ignore = [*ClusterOptions.VALID_OPTION_KEYS, *SecurityOptions.VALID_OPTION_KEYS]
+
+ # not going to be able to make mypy happy w/ keys_to_ignore :/
+ transformed_timeout_opts = self.build_options(
+ TimeoutOptions, TimeoutOptionsTransformedKwargs, temp_options, keys_to_ignore=keys_to_ignore
+ )
+ if transformed_timeout_opts:
+ temp_options['timeout_options'] = transformed_timeout_opts
+
+ # transform final ClusterOptions
+ transformed_opts = self.build_options(option_type, output_type, temp_options)
+
+ return transformed_opts
+
+ def build_options(
+ self,
+ option_type: type[OptionsClass],
+ output_type: type[TransformedOptionKwargs],
+ orig_kwargs: Dict[str, object],
+ options: Optional[object] = None,
+ keys_to_ignore: Optional[List[str]] = None,
+ ) -> TransformedOptionKwargs:
+ temp_options = self._get_options_copy(option_type, orig_kwargs, options)
+ transformed_opts: TransformedOptionKwargs = {}
+ # Option 1 satisfies mypy, but we want temp_options to be the limiting factor for the loop.
+ # Option 2. Also makes providing warnings/exceptions for users not using static type checking easier,
+ # but unfortunately we need to use some type: ignore comments
+
+ # Option 1:
+ # for k in option_type.VALID_OPTION_KEYS:
+ # if k in ALLOWED_TRANSFORM_KEYS and k in temp_options:
+ # for nk, cfn in tf_dict[k].items():
+ # conv = cfn(temp_options[k])
+ # transformed_opts[nk] = conv # type: ignore
+
+ # Option 2:
+ allowed_keys, option_transforms = self._get_transform_details(option_type.__name__)
+ for k, v in temp_options.items():
+ if k in allowed_keys:
+ transforms = option_transforms[k] # type: ignore[literal-required]
+ for nk, cfn in transforms.items():
+ conv = cfn(v)
+ if conv is not None:
+ transformed_opts[nk] = conv # type: ignore[literal-required]
+ elif keys_to_ignore and k not in keys_to_ignore:
+ raise ValueError(f'Invalid key provided (key={k}).')
+
+ return transformed_opts
diff --git a/couchbase_analytics/protocol/result.py b/couchbase_analytics/protocol/result.py
new file mode 100644
index 0000000..6837555
--- /dev/null
+++ b/couchbase_analytics/protocol/result.py
@@ -0,0 +1,16 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
diff --git a/couchbase_analytics/protocol/scope.py b/couchbase_analytics/protocol/scope.py
new file mode 100644
index 0000000..6037268
--- /dev/null
+++ b/couchbase_analytics/protocol/scope.py
@@ -0,0 +1,85 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from concurrent.futures import Future, ThreadPoolExecutor
+from typing import TYPE_CHECKING, Union
+
+from couchbase_analytics.common.result import BlockingQueryResult
+from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter
+from couchbase_analytics.protocol._core.request import _RequestBuilder
+from couchbase_analytics.protocol._core.request_context import RequestContext
+from couchbase_analytics.protocol.streaming import HttpStreamingResponse
+
+if TYPE_CHECKING:
+ from couchbase_analytics.protocol.database import Database
+
+
+class Scope:
+ def __init__(self, database: Database, scope_name: str) -> None:
+ self._database = database
+ self._scope_name = scope_name
+ self._request_builder = _RequestBuilder(self.client_adapter, self._database.name, self.name)
+
+ @property
+ def client_adapter(self) -> _ClientAdapter:
+ """
+ **INTERNAL**
+ """
+ return self._database.client_adapter
+
+ @property
+ def name(self) -> str:
+ """
+ str: The name of this :class:`~couchbase_analytics.protocol.scope.Scope` instance.
+ """
+ return self._scope_name
+
+ @property
+ def threadpool_executor(self) -> ThreadPoolExecutor:
+ """
+ **INTERNAL**
+ """
+ return self._database.threadpool_executor
+
+ def execute_query(
+ self, statement: str, *args: object, **kwargs: object
+ ) -> Union[BlockingQueryResult, Future[BlockingQueryResult]]:
+ base_req = self._request_builder.build_base_query_request(statement, *args, **kwargs)
+ lazy_execute = base_req.options.pop('lazy_execute', None)
+ stream_config = base_req.options.pop('stream_config', None)
+ request_context = RequestContext(
+ self.client_adapter, base_req, self.threadpool_executor, stream_config=stream_config
+ )
+ resp = HttpStreamingResponse(request_context, lazy_execute=lazy_execute)
+
+ def _execute_query(http_response: HttpStreamingResponse) -> BlockingQueryResult:
+ http_response.send_request()
+ return BlockingQueryResult(http_response)
+
+ if request_context.cancel_enabled is True:
+ if lazy_execute is True:
+ raise RuntimeError(
+ (
+ 'Cannot cancel, via cancel token, a query that is executed lazily.'
+ ' Queries executed lazily can be cancelled only after iteration begins.'
+ )
+ )
+ return request_context.send_request_in_background(_execute_query, resp)
+ else:
+ if lazy_execute is not True:
+ resp.send_request()
+ return BlockingQueryResult(resp)
diff --git a/couchbase_analytics/protocol/scope.pyi b/couchbase_analytics/protocol/scope.pyi
new file mode 100644
index 0000000..4e4914f
--- /dev/null
+++ b/couchbase_analytics/protocol/scope.pyi
@@ -0,0 +1,108 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+from concurrent.futures import Future, ThreadPoolExecutor
+from typing import overload
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Unpack
+else:
+ from typing import Unpack
+
+from couchbase_analytics import JSONType
+from couchbase_analytics.common.result import BlockingQueryResult
+from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs
+from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter
+from couchbase_analytics.protocol.database import Database as Database
+
+class Scope:
+ def __init__(self, database: Database, scope_name: str) -> None: ...
+ @property
+ def client_adapter(self) -> _ClientAdapter: ...
+ @property
+ def name(self) -> str: ...
+ @property
+ def threadpool_executor(self) -> ThreadPoolExecutor: ...
+ @overload
+ def execute_query(self, statement: str) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, options: QueryOptions) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: str
+ ) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, *args: JSONType, **kwargs: str) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, enable_cancel: bool) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, enable_cancel: bool, *args: JSONType) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, enable_cancel: bool
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self,
+ statement: str,
+ options: QueryOptions,
+ enable_cancel: bool,
+ *args: JSONType,
+ **kwargs: Unpack[QueryOptionsKwargs],
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self,
+ statement: str,
+ options: QueryOptions,
+ *args: JSONType,
+ enable_cancel: bool,
+ **kwargs: Unpack[QueryOptionsKwargs],
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, enable_cancel: bool, *args: JSONType, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: JSONType, enable_cancel: bool, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, enable_cancel: bool, *args: JSONType, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, *args: JSONType, enable_cancel: bool, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
diff --git a/couchbase_analytics/protocol/streaming.py b/couchbase_analytics/protocol/streaming.py
new file mode 100644
index 0000000..6f27bbf
--- /dev/null
+++ b/couchbase_analytics/protocol/streaming.py
@@ -0,0 +1,162 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from concurrent.futures import CancelledError
+from typing import Any, Optional
+
+from httpx import Response as HttpCoreResponse
+
+from couchbase_analytics.common._core import ParsedResult, ParsedResultType
+from couchbase_analytics.common._core.query import build_query_metadata
+from couchbase_analytics.common.errors import AnalyticsError, InternalSDKError, TimeoutError
+from couchbase_analytics.common.logging import LogLevel
+from couchbase_analytics.common.query import QueryMetadata
+from couchbase_analytics.protocol._core.request_context import RequestContext
+from couchbase_analytics.protocol._core.retries import RetryHandler
+
+
+class HttpStreamingResponse:
+ def __init__(self, request_context: RequestContext, lazy_execute: Optional[bool] = None) -> None:
+ self._request_context = request_context
+ if lazy_execute is not None:
+ self._lazy_execute = lazy_execute
+ else:
+ self._lazy_execute = False
+ self._metadata: Optional[QueryMetadata] = None
+ self._core_response: HttpCoreResponse
+
+ @property
+ def lazy_execute(self) -> bool:
+ """
+ **INTERNAL**
+ """
+ return self._lazy_execute
+
+ def _handle_iteration_abort(self) -> None:
+ self.close()
+ if self._request_context.cancelled:
+ self._request_context.log_message('Request canceled, aborting iteration', LogLevel.DEBUG)
+ self._request_context.shutdown()
+ raise StopIteration
+ elif self._request_context.timed_out:
+ err = TimeoutError(
+ message='Unable to complete iteration. Request timed out.',
+ context=str(self._request_context.error_context),
+ )
+ self._request_context.shutdown(err)
+ raise err
+ else:
+ self._request_context.log_message('Aborting iteration', LogLevel.DEBUG)
+ self._request_context.shutdown()
+ raise StopIteration
+
+ def _process_response(
+ self, raw_response: Optional[ParsedResult] = None, handle_context_shutdown: Optional[bool] = False
+ ) -> None:
+ json_response = self._request_context.process_response(
+ self.close, raw_response=raw_response, handle_context_shutdown=handle_context_shutdown
+ )
+ self.set_metadata(json_data=json_response)
+
+ def close(self) -> None:
+ """
+ **INTERNAL**
+ """
+ if hasattr(self, '_core_response'):
+ self._core_response.close()
+ self._request_context.log_message('HTTP core response closed', LogLevel.INFO)
+ del self._core_response
+
+ def cancel(self) -> None:
+ """
+ **INTERNAL**
+ """
+ self._request_context.log_message('HttpStreamingResponse cancelling request', LogLevel.DEBUG)
+ self.close()
+ self._request_context.cancel_request()
+ self._request_context.shutdown()
+
+ def get_metadata(self) -> QueryMetadata:
+ if self._metadata is None:
+ raise RuntimeError('Query metadata is only available after all rows have been iterated.')
+ return self._metadata
+
+ def set_metadata(self, json_data: Optional[Any] = None, raw_metadata: Optional[bytes] = None) -> None:
+ try:
+ self._metadata = QueryMetadata(build_query_metadata(json_data=json_data, raw_metadata=raw_metadata))
+ self._request_context.shutdown()
+ except (AnalyticsError, ValueError) as err:
+ self._request_context.shutdown(err)
+ raise err
+ except Exception as ex:
+ internal_err = InternalSDKError(cause=ex, message=str(ex), context=str(self._request_context.error_context))
+ self._request_context.shutdown(internal_err)
+ finally:
+ self.close()
+
+ def get_next_row(self) -> Any:
+ """
+ **INTERNAL**
+ """
+ if not (
+ hasattr(self, '_core_response')
+ and self._core_response is not None
+ and self._request_context.okay_to_iterate
+ ):
+ self._handle_iteration_abort()
+
+ self._request_context.maybe_continue_to_process_stream()
+ check_state = False
+ while True:
+ if check_state and not self._request_context.okay_to_iterate:
+ self._handle_iteration_abort()
+
+ raw_response = self._request_context.get_result_from_stream()
+ if raw_response is None:
+ check_state = True
+ continue
+ if raw_response.result_type == ParsedResultType.ROW:
+ if raw_response.value is None:
+ err = AnalyticsError(
+ message='Unexpected empty row response while streaming.',
+ context=str(self._request_context.error_context),
+ )
+ self._request_context.shutdown(err)
+ self.close()
+ raise err
+ return self._request_context.deserialize_result(raw_response.value)
+ elif raw_response.result_type in [ParsedResultType.ERROR, ParsedResultType.UNKNOWN]:
+ self._process_response(raw_response=raw_response, handle_context_shutdown=True)
+ elif raw_response.result_type == ParsedResultType.END:
+ self.set_metadata(raw_metadata=raw_response.value)
+ raise StopIteration
+
+ @RetryHandler.with_retries
+ def send_request(self) -> None:
+ if not self._request_context.okay_to_stream:
+ raise RuntimeError('Query has been canceled or previously executed.')
+
+ self._request_context.initialize()
+ self._core_response = self._request_context.send_request()
+ if self._request_context.cancelled:
+ raise CancelledError('Request was cancelled.')
+ self._request_context.start_stream(self._core_response)
+ # block until we either know we have rows or errors
+ self._request_context.wait_for_stage_notification()
+ if not self._request_context.okay_to_iterate:
+ self._request_context.finish_processing_stream()
+ self._process_response()
diff --git a/couchbase_analytics/query.py b/couchbase_analytics/query.py
new file mode 100644
index 0000000..6d6520e
--- /dev/null
+++ b/couchbase_analytics/query.py
@@ -0,0 +1,19 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common.enums import QueryScanConsistency as QueryScanConsistency # noqa: F401
+from couchbase_analytics.common.query import QueryMetadata as QueryMetadata # noqa: F401
+from couchbase_analytics.common.query import QueryMetrics as QueryMetrics # noqa: F401
+from couchbase_analytics.common.query import QueryWarning as QueryWarning # noqa: F401
diff --git a/couchbase_analytics/result.py b/couchbase_analytics/result.py
new file mode 100644
index 0000000..78712e1
--- /dev/null
+++ b/couchbase_analytics/result.py
@@ -0,0 +1,18 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from couchbase_analytics.common.result import AsyncQueryResult as AsyncQueryResult # noqa: F401
+from couchbase_analytics.common.result import BlockingQueryResult as BlockingQueryResult # noqa: F401
+from couchbase_analytics.common.result import QueryResult as QueryResult # noqa: F401
diff --git a/couchbase_analytics/scope.py b/couchbase_analytics/scope.py
new file mode 100644
index 0000000..02ba341
--- /dev/null
+++ b/couchbase_analytics/scope.py
@@ -0,0 +1,115 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from concurrent.futures import Future
+from typing import TYPE_CHECKING, Union
+
+from couchbase_analytics.result import BlockingQueryResult
+
+if TYPE_CHECKING:
+ from couchbase_analytics.protocol.database import Database
+
+
+class Scope:
+ """Create a Scope instance.
+
+ The Scope instance exposes the operations which are available to be performed against an Analytics scope.
+
+ Args:
+ database (:class:`~couchbase_analytics.database.Database`): A :class:`~couchbase_analytics.database.Database` instance.
+ scope_name (str): The scope name.
+
+ """ # noqa: E501
+
+ def __init__(self, database: Database, scope_name: str) -> None:
+ from couchbase_analytics.protocol.scope import Scope as _Scope
+
+ self._impl = _Scope(database, scope_name)
+
+ @property
+ def name(self) -> str:
+ """
+ str: The name of this :class:`~couchbase_analytics.scope.Scope` instance.
+ """
+ return self._impl.name
+
+ def execute_query(
+ self, statement: str, *args: object, **kwargs: object
+ ) -> Union[Future[BlockingQueryResult], BlockingQueryResult]:
+ """Executes a query against an Analytics scope.
+
+ .. note::
+ A departure from the operational SDK, the query is *NOT* executed lazily.
+
+ .. seealso::
+ * :meth:`couchbase_analytics.Cluster.execute_query`: For how to execute cluster-level queries.
+
+ Args:
+ statement (str): The N1QL statement to execute.
+ options (:class:`~couchbase_analytics.options.QueryOptions`): Optional parameters for the query operation.
+ **kwargs (Dict[str, Any]): keyword arguments that can be used in place or to override provided :class:`~couchbase_analytics.options.QueryOptions`
+
+ Returns:
+ :class:`~couchbase_analytics.result.BlockingQueryResult`: An instance of a :class:`~couchbase_analytics.result.BlockingQueryResult` which
+ provides access to iterate over the query results and access metadata and metrics about the query.
+
+ Examples:
+ Simple query::
+
+ q_str = 'SELECT * FROM airline WHERE country LIKE 'United%' LIMIT 2;'
+ q_res = scope.execute_query(q_str)
+ for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ Simple query with positional parameters::
+
+ from couchbase_analytics.options import QueryOptions
+
+ # ... other code ...
+
+ q_str = 'SELECT * FROM airline WHERE country LIKE $1 LIMIT $2;'
+ q_res = scope.execute_query(q_str, QueryOptions(positional_parameters=['United%', 5]))
+ for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ Simple query with named parameters::
+
+ from couchbase_analytics.options import QueryOptions
+
+ # ... other code ...
+
+ q_str = 'SELECT * FROM airline WHERE country LIKE $country LIMIT $lim;'
+ q_res = scope.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2}))
+ for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ Retrieve metadata and/or metrics from query::
+
+ from couchbase_analytics.options import QueryOptions
+
+ # ... other code ...
+
+ q_str = 'SELECT * FROM `travel-sample` WHERE country LIKE $country LIMIT $lim;'
+ q_res = scope.execute_query(q_str, QueryOptions(named_parameters={'country': 'United%', 'lim':2}))
+ for row in q_res.rows():
+ print(f'Found row: {row}')
+
+ print(f'Query metadata: {q_res.metadata()}')
+ print(f'Query metrics: {q_res.metadata().metrics()}')
+
+ """ # noqa: E501
+ return self._impl.execute_query(statement, *args, **kwargs)
diff --git a/couchbase_analytics/scope.pyi b/couchbase_analytics/scope.pyi
new file mode 100644
index 0000000..3486c4e
--- /dev/null
+++ b/couchbase_analytics/scope.pyi
@@ -0,0 +1,103 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import sys
+from concurrent.futures import Future
+from typing import overload
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Unpack
+else:
+ from typing import Unpack
+
+from couchbase_analytics import JSONType
+from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs
+from couchbase_analytics.protocol.database import Database as Database
+from couchbase_analytics.result import BlockingQueryResult
+
+class Scope:
+ def __init__(self, database: Database, scope_name: str) -> None: ...
+ @property
+ def name(self) -> str: ...
+ @overload
+ def execute_query(self, statement: str) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, options: QueryOptions) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, **kwargs: Unpack[QueryOptionsKwargs]) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: JSONType, **kwargs: str
+ ) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, *args: JSONType, **kwargs: str) -> BlockingQueryResult: ...
+ @overload
+ def execute_query(self, statement: str, enable_cancel: bool) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(self, statement: str, enable_cancel: bool, *args: JSONType) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, enable_cancel: bool
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, enable_cancel: bool, **kwargs: Unpack[QueryOptionsKwargs]
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self,
+ statement: str,
+ options: QueryOptions,
+ enable_cancel: bool,
+ *args: JSONType,
+ **kwargs: Unpack[QueryOptionsKwargs],
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self,
+ statement: str,
+ options: QueryOptions,
+ *args: JSONType,
+ enable_cancel: bool,
+ **kwargs: Unpack[QueryOptionsKwargs],
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, enable_cancel: bool, *args: JSONType, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, options: QueryOptions, *args: JSONType, enable_cancel: bool, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, enable_cancel: bool, *args: JSONType, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
+ @overload
+ def execute_query(
+ self, statement: str, *args: JSONType, enable_cancel: bool, **kwargs: str
+ ) -> Future[BlockingQueryResult]: ...
diff --git a/couchbase_analytics/tests/__init__.py b/couchbase_analytics/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/couchbase_analytics/tests/backoff_calc_t.py b/couchbase_analytics/tests/backoff_calc_t.py
new file mode 100644
index 0000000..5698b0f
--- /dev/null
+++ b/couchbase_analytics/tests/backoff_calc_t.py
@@ -0,0 +1,65 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import pytest
+
+from couchbase_analytics.common.backoff_calculator import DefaultBackoffCalculator
+
+MIN = 100
+MAX = 60 * 1000
+EXPONENT_BASE = 2
+
+
+class BackoffCalcTestSuite:
+ TEST_MANIFEST = [
+ 'test_backoff_calcs',
+ ]
+
+ @pytest.mark.parametrize(
+ 'retry_count, max_expected',
+ [
+ (1, MIN * EXPONENT_BASE**0),
+ (2, MIN * EXPONENT_BASE**1),
+ (3, MIN * EXPONENT_BASE**2),
+ (4, MIN * EXPONENT_BASE**3),
+ (5, MIN * EXPONENT_BASE**4),
+ (6, MIN * EXPONENT_BASE**5),
+ (7, MIN * EXPONENT_BASE**6),
+ (8, MIN * EXPONENT_BASE**7),
+ (9, MIN * EXPONENT_BASE**8),
+ (10, MIN * EXPONENT_BASE**9),
+ (1000, MAX),
+ ],
+ )
+ def test_backoff_calcs(self, retry_count: int, max_expected: float) -> None:
+ calc = DefaultBackoffCalculator()
+ for _ in range(10):
+ delay = calc.calculate_backoff(retry_count)
+ assert delay <= max_expected
+
+
+class BackoffCalcTests(BackoffCalcTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(BackoffCalcTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(BackoffCalcTests) if valid_test_method(meth)]
+ test_list = set(BackoffCalcTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
diff --git a/couchbase_analytics/tests/connect_integration_t.py b/couchbase_analytics/tests/connect_integration_t.py
new file mode 100644
index 0000000..76923bd
--- /dev/null
+++ b/couchbase_analytics/tests/connect_integration_t.py
@@ -0,0 +1,94 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import TYPE_CHECKING
+
+import pytest
+
+from couchbase_analytics.cluster import Cluster
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.errors import AnalyticsError, TimeoutError
+from couchbase_analytics.options import QueryOptions
+from tests import YieldFixture
+
+if TYPE_CHECKING:
+ from tests.environments.base_environment import BlockingTestEnvironment
+
+
+class ConnectTestSuite:
+ TEST_MANIFEST = [
+ 'test_connect_timeout_max_retry_limit',
+ 'test_connect_timeout_query_timeout',
+ ]
+
+ def test_connect_timeout_max_retry_limit(self, test_env: BlockingTestEnvironment) -> None:
+ statement = 'SELECT sleep("some value", 10000) AS some_field;'
+
+ username, pw = test_env.config.get_username_and_pw()
+ cred = Credential.from_username_and_password(username, pw)
+ # ignoring the port enables the failure
+ connstr = test_env.config.get_connection_string(ignore_port=True)
+ cluster = Cluster.create_instance(connstr, cred)
+
+ allowed_retries = 5
+ q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10))
+ with pytest.raises(AnalyticsError) as ex:
+ cluster.execute_query(statement, q_opts)
+
+ assert ex.value._message is not None
+ assert 'Retry limit exceeded' in ex.value._message
+ test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context)
+
+ def test_connect_timeout_query_timeout(self, test_env: BlockingTestEnvironment) -> None:
+ statement = 'SELECT sleep("some value", 10000) AS some_field;'
+
+ username, pw = test_env.config.get_username_and_pw()
+ cred = Credential.from_username_and_password(username, pw)
+ # ignoring the port enables the failure
+ connstr = test_env.config.get_connection_string(ignore_port=True)
+ cluster = Cluster.create_instance(connstr, cred)
+
+ # increase the max retries to ensure that the timeout is hit
+ q_opts = QueryOptions(max_retries=20, timeout=timedelta(seconds=3))
+ with pytest.raises(TimeoutError) as ex:
+ cluster.execute_query(statement, q_opts)
+
+ assert ex.value._message is not None
+ assert 'Request timed out during retry delay' in ex.value._message
+ test_env.assert_error_context_num_attempts(2, ex.value._context, exact=False)
+
+
+class ConnectTests(ConnectTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ConnectTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ConnectTests) if valid_test_method(meth)]
+ test_list = set(ConnectTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='test_env')
+ def couchbase_test_environment(
+ self, sync_test_env: BlockingTestEnvironment
+ ) -> YieldFixture[BlockingTestEnvironment]:
+ sync_test_env.setup()
+ yield sync_test_env
+ sync_test_env.teardown()
diff --git a/couchbase_analytics/tests/connection_t.py b/couchbase_analytics/tests/connection_t.py
new file mode 100644
index 0000000..9653063
--- /dev/null
+++ b/couchbase_analytics/tests/connection_t.py
@@ -0,0 +1,257 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from typing import Dict
+from urllib.parse import urlparse
+
+import pytest
+
+from couchbase_analytics.cluster import Cluster
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter
+from couchbase_analytics.protocol._core.request import _RequestBuilder
+from tests.utils import get_test_cert_path, to_query_str
+
+TEST_CERT_PATH = get_test_cert_path()
+
+
+class ConnectionTestSuite:
+ TEST_MANIFEST = [
+ 'test_connstr_options_fail',
+ 'test_connstr_options_max_retries',
+ 'test_connstr_options_timeout',
+ 'test_connstr_options_timeout_fail',
+ 'test_connstr_options_timeout_invalid_duration',
+ 'test_connstr_options_security',
+ 'test_connstr_options_security_fail',
+ 'test_invalid_connection_strings',
+ 'test_valid_connection_strings',
+ ]
+
+ @pytest.mark.parametrize(
+ 'connstr_opt',
+ [
+ 'invalid_op=10',
+ 'connect_timeout=2500ms',
+ 'dispatch_timeout=2500ms',
+ 'query_timeout=2500ms',
+ 'socket_connect_timeout=2500ms',
+ 'trust_only_pem_file=/path/to/file',
+ 'disable_server_certificate_verification=True',
+ ],
+ )
+ def test_connstr_options_fail(self, connstr_opt: str) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ connstr = f'https://localhost?{connstr_opt}'
+ with pytest.raises(ValueError):
+ _ClientAdapter(connstr, cred)
+
+ def test_connstr_options_max_retries(self) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ max_retries = 10
+ connstr = f'https://localhost?max_retries={max_retries}'
+ client = _ClientAdapter(connstr, cred)
+ req_builder = _RequestBuilder(client)
+ req = req_builder.build_base_query_request('SELECT 1=1')
+ assert req.max_retries == max_retries
+
+ @pytest.mark.parametrize(
+ 'duration, expected_seconds',
+ [
+ ('1h', '3600'),
+ ('+1h', '3600'),
+ ('+1h', '3600'),
+ ('1h10m', '4200'),
+ ('1.h10m', '4200'),
+ ('.1h10m', '960'),
+ ('0001h00010m', '4200'),
+ ('2m3s4ms', '123.004'),
+ (('100ns', '1e-7')),
+ (('100us', '1e-4')),
+ (('100μs', '1e-4')),
+ (('1000000ns', '.001')),
+ (('1000us', '.001')),
+ (('1000μs', '.001')),
+ ('4ms3s2m', '123.004'),
+ ('4ms3s2m5s', '128.004'),
+ ('2m3.125s', '123.125'),
+ ],
+ )
+ def test_connstr_options_timeout(self, duration: str, expected_seconds: str) -> None:
+ opt_keys = ['timeout.connect_timeout', 'timeout.query_timeout']
+ opts = dict.fromkeys(opt_keys, duration)
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ connstr = f'https://localhost?{to_query_str(opts)}'
+ client = _ClientAdapter(connstr, cred)
+ req_builder = _RequestBuilder(client)
+ req = req_builder.build_base_query_request('SELECT 1=1')
+ expected = float(expected_seconds)
+ returned_timeout_opts = req.get_request_timeouts()
+ assert isinstance(returned_timeout_opts, dict)
+ for k in opts.keys():
+ opt_key = k.split('.')[1]
+ if opt_key.startswith('connect'):
+ pool_timeout = returned_timeout_opts.get('pool')
+ assert pool_timeout is not None
+ assert abs(pool_timeout - expected) < 1e-9
+ connect_timeout = returned_timeout_opts.get('connect')
+ assert connect_timeout is not None
+ assert abs(connect_timeout - expected) < 1e-9
+ else:
+ read_timeout = returned_timeout_opts.get('read')
+ assert read_timeout is not None
+ assert abs(read_timeout - expected) < 1e-9
+
+ @pytest.mark.parametrize(
+ 'invalid_opt_name',
+ ['connect_timeout', 'dispatch_timeout', 'query_timeout', 'resolve_timeout', 'socket_connect_timeout'],
+ )
+ def test_connstr_options_timeout_fail(self, invalid_opt_name: str) -> None:
+ opts = {invalid_opt_name: '2500s'}
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ connstr = f'https://localhost?{to_query_str(opts)}'
+ with pytest.raises(ValueError):
+ _ClientAdapter(connstr, cred)
+
+ @pytest.mark.parametrize(
+ 'bad_duration',
+ [
+ '123',
+ '00',
+ ' 1h',
+ '1h ',
+ '1h 2m+-3h',
+ '-+3h',
+ '-',
+ '-.',
+ '.',
+ '.h',
+ '2.3.4h',
+ '3x',
+ '3',
+ '3h4x',
+ '1H',
+ '1h-2m',
+ '-1h',
+ '-1m',
+ '-1s',
+ ],
+ )
+ def test_connstr_options_timeout_invalid_duration(self, bad_duration: str) -> None:
+ opt_keys = ['timeout.connect_timeout', 'timeout.query_timeout']
+ for key in opt_keys:
+ opts = {key: bad_duration}
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ connstr = f'https://localhost?{to_query_str(opts)}'
+ with pytest.raises(ValueError):
+ _ClientAdapter(connstr, cred)
+
+ @pytest.mark.parametrize(
+ 'connstr_opts, expected_opts',
+ [
+ (
+ {'security.trust_only_pem_file': TEST_CERT_PATH},
+ {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False},
+ ),
+ (
+ {'security.disable_server_certificate_verification': 'true'},
+ {'disable_server_certificate_verification': True},
+ ),
+ ],
+ )
+ def test_connstr_options_security(self, connstr_opts: Dict[str, object], expected_opts: Dict[str, object]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ connstr = f'https://localhost?{to_query_str(connstr_opts)}'
+ client = _ClientAdapter(connstr, cred)
+ sec_opts = client.connection_details.cluster_options.get('security_options', {})
+ assert sec_opts == expected_opts
+
+ @pytest.mark.parametrize(
+ 'invalid_opt_name',
+ [
+ 'trust_only_capella',
+ 'trust_only_pem_file',
+ 'trust_only_pem_str',
+ 'trust_only_certificates',
+ 'disable_server_certificate_verification',
+ ],
+ )
+ def test_connstr_options_security_fail(self, invalid_opt_name: str) -> None:
+ opts = {invalid_opt_name: 'True'}
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ connstr = f'https://localhost?{to_query_str(opts)}'
+ with pytest.raises(ValueError):
+ _ClientAdapter(connstr, cred)
+
+ @pytest.mark.parametrize(
+ 'connstr',
+ [
+ '10.0.0.1:8091',
+ 'http://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207',
+ 'http://10.0.0.1;10.0.0.2:11210;10.0.0.3',
+ 'http://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207',
+ 'https://10.0.0.1:11222,10.0.0.2,10.0.0.3:11207',
+ 'https://10.0.0.1;10.0.0.2:11210;10.0.0.3',
+ 'https://[::ffff:192.168.0.1]:11207,[::ffff:192.168.0.2]:11207',
+ 'couchbase://10.0.0.1',
+ 'couchbases://10.0.0.1',
+ ],
+ )
+ def test_invalid_connection_strings(self, connstr: str) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ with pytest.raises(ValueError):
+ Cluster.create_instance(connstr, cred)
+
+ @pytest.mark.parametrize(
+ 'connstr',
+ [
+ 'http://10.0.0.1',
+ 'http://10.0.0.1:11222',
+ 'http://[3ffe:2a00:100:7031::1]',
+ 'http://[::ffff:192.168.0.1]:11207',
+ 'http://test.local:11210',
+ 'http://fqdn',
+ 'https://10.0.0.1',
+ 'https://10.0.0.1:11222',
+ 'https://[3ffe:2a00:100:7031::1]',
+ 'https://[::ffff:192.168.0.1]:11207',
+ 'https://test.local:11210',
+ 'https://fqdn',
+ ],
+ )
+ def test_valid_connection_strings(self, connstr: str) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _ClientAdapter(connstr, cred)
+ # options should be empty
+ assert {} == client.connection_details.cluster_options
+ parsed_connstr = urlparse(connstr)
+ parsed_port = parsed_connstr.port or (80 if parsed_connstr.scheme == 'http' else 443)
+ url = client.connection_details.url.get_formatted_url()
+ assert f'{parsed_connstr.scheme}://{parsed_connstr.hostname}:{parsed_port}' == url
+
+
+class ConnectionTests(ConnectionTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ConnectionTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ConnectionTests) if valid_test_method(meth)]
+ test_list = set(ConnectionTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
diff --git a/couchbase_analytics/tests/duration_parsing_t.py b/couchbase_analytics/tests/duration_parsing_t.py
new file mode 100644
index 0000000..7fce156
--- /dev/null
+++ b/couchbase_analytics/tests/duration_parsing_t.py
@@ -0,0 +1,100 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import pytest
+
+from couchbase_analytics.common._core.duration_str_utils import parse_duration_str
+
+
+class DurationParsingTestSuite:
+ TEST_MANIFEST = [
+ 'test_invalid_durations',
+ 'test_valid_durations',
+ ]
+
+ @pytest.mark.parametrize(
+ 'duration',
+ [
+ '',
+ '10',
+ '10Gs',
+ 'abc',
+ '-',
+ '+',
+ '1h-',
+ '1h 30m',
+ '1h_30m',
+ 'h1',
+ '-.5s',
+ '1.2.3s',
+ ],
+ )
+ def test_invalid_durations(self, duration: str) -> None:
+ with pytest.raises(ValueError):
+ parse_duration_str(duration)
+
+ @pytest.mark.parametrize(
+ 'duration, expected_millis',
+ [
+ ('0', 0),
+ ('0s', 0),
+ ('1h', 3.6e6),
+ ('+1h', 3.6e6),
+ ('1h10m', 4.2e6),
+ ('1.h10m', 4.2e6),
+ ('1.234h', 1.234 * 3.6e6),
+ ('1h30m0s', 5.4e6),
+ ('0.1h10m', 9.6e5),
+ # TODO: apparently this is invalid in Go, but was okay w/ C++ implementation
+ ('.1h10m', 9.6e5),
+ ('0001h00010m', 4.2e6),
+ ('100ns', 1e-4),
+ ('100us', 0.1),
+ ('100μs', 0.1),
+ ('100µs', 0.1),
+ ('1000000ns', 1),
+ ('1000us', 1),
+ ('1000μs', 1),
+ ('1000µs', 1),
+ ('3h15m10s500ms', 11710.5 * 1e3),
+ ('1h1m1s1ms1us1ns', 3.6e6 + 60e3 + 1e3 + 1 + 0.001 + 0.000001),
+ ('2m3s4ms', 123004),
+ ('4ms3s2m', 123004),
+ ('4ms3s2m5s', 128004),
+ ('2m3.125s', 123125),
+ ],
+ )
+ def test_valid_durations(self, duration: str, expected_millis: float) -> None:
+ actual = parse_duration_str(duration, in_millis=True)
+ # if we don't allow for a tolerance, we will have issues with float precision
+ # examples:
+ # 100us yields 0.09999999999999999 != 0.1
+ # 4ms3s2m5s yields 128004.00000000001 != 128004
+ assert abs(actual - expected_millis) < 1e-9
+
+
+class DurationParsingTests(DurationParsingTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(DurationParsingTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(DurationParsingTests) if valid_test_method(meth)]
+ test_list = set(DurationParsingTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
diff --git a/couchbase_analytics/tests/json_parsing_t.py b/couchbase_analytics/tests/json_parsing_t.py
new file mode 100644
index 0000000..d540682
--- /dev/null
+++ b/couchbase_analytics/tests/json_parsing_t.py
@@ -0,0 +1,437 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import json
+from typing import TYPE_CHECKING
+
+import pytest
+
+from couchbase_analytics.common._core import JsonStreamConfig, ParsedResult, ParsedResultType
+from couchbase_analytics.protocol._core.json_stream import JsonStream
+from tests.environments.simple_environment import JsonDataType
+from tests.utils import BytesIterator
+
+if TYPE_CHECKING:
+ from tests.environments.simple_environment import SimpleEnvironment
+
+
+class JsonParsingTestSuite:
+ TEST_MANIFEST = [
+ 'test_analytics_error',
+ 'test_analytics_error_mid_stream',
+ 'test_analytics_many_rows',
+ 'test_analytics_many_rows_raw',
+ 'test_analytics_multiple_errors',
+ 'test_analytics_simple_result',
+ 'test_array',
+ 'test_array_empty',
+ 'test_array_mixed_types',
+ 'test_array_of_objects',
+ 'test_invalid_empty',
+ 'test_invalid_garbage_between_objects',
+ 'test_invalid_leading_garbage',
+ 'test_invalid_trailing_garbage',
+ 'test_invalid_whitespace_only',
+ 'test_object',
+ 'test_object_complex_nested_structure',
+ 'test_object_empty',
+ 'test_object_simple_nested',
+ 'test_object_with_empty_key_and_value',
+ 'test_object_with_unicode',
+ 'test_value_bool',
+ 'test_value_null',
+ ]
+
+ @pytest.mark.parametrize('buffered_result', [True, False])
+ def test_analytics_error(self, test_env: SimpleEnvironment, buffered_result: bool) -> None:
+ json_object, bytes_data = test_env.get_json_data(JsonDataType.FAILED_REQUEST)
+ if buffered_result:
+ parser = JsonStream(BytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True))
+ else:
+ parser = JsonStream(BytesIterator(bytes_data))
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.ERROR
+ assert isinstance(result.value, bytes)
+ assert json.loads(result.value.decode('utf-8')) == json_object
+ assert parser.get_result(0.01) is None
+
+ def test_analytics_error_mid_stream(self, test_env: SimpleEnvironment) -> None:
+ json_object, bytes_data = test_env.get_json_data(JsonDataType.FAILED_REQUEST_MID_STREAM)
+ parser = JsonStream(BytesIterator(bytes_data))
+ parser.start_parsing()
+ row_idx = 0
+ while True:
+ result = parser.get_result(0.01)
+ if result is None and not parser.token_stream_exhausted:
+ parser.continue_parsing()
+ continue
+ assert isinstance(result, ParsedResult)
+ assert result.result_type in [ParsedResultType.ROW, ParsedResultType.ERROR]
+ assert isinstance(result.value, bytes)
+ if result.result_type == ParsedResultType.ROW:
+ assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx]
+ row_idx += 1
+ else:
+ final_result = result.value.decode('utf-8')
+ break
+
+ # if we are not buffering the entire result, the final result will exclude the results key
+ json_object.pop('results')
+ assert json.loads(final_result) == json_object
+ assert parser.get_result(0.01) is None
+
+ def test_analytics_many_rows(self, test_env: SimpleEnvironment) -> None:
+ json_object, bytes_data = test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS)
+ parser = JsonStream(BytesIterator(bytes_data))
+ parser.start_parsing()
+ row_idx = 0
+ while row_idx < 36:
+ result = parser.get_result(0.01)
+ if result is None and not parser.token_stream_exhausted:
+ parser.continue_parsing()
+ continue
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.ROW
+ assert isinstance(result.value, bytes)
+ assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx]
+ row_idx += 1
+
+ final_result = parser.get_result(0.01)
+ assert isinstance(final_result, ParsedResult)
+ assert final_result.result_type == ParsedResultType.END
+ assert isinstance(final_result.value, bytes)
+ # if we are not buffering the entire result, the final result will exclude the results key
+ json_object.pop('results')
+ assert json.loads(final_result.value.decode('utf-8')) == json_object
+ assert parser.get_result(0.01) is None
+
+ @pytest.mark.parametrize('buffered_result', [True, False])
+ def test_analytics_many_rows_raw(self, test_env: SimpleEnvironment, buffered_result: bool) -> None:
+ json_object, bytes_data = test_env.get_json_data(JsonDataType.MULTIPLE_RESULTS_RAW)
+ if buffered_result:
+ parser = JsonStream(BytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True))
+ else:
+ parser = JsonStream(BytesIterator(bytes_data))
+
+ parser.start_parsing()
+ if not buffered_result:
+ row_idx = 0
+ while row_idx < 10:
+ result = parser.get_result(0.01)
+ if result is None and not parser.token_stream_exhausted:
+ parser.continue_parsing()
+ continue
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.ROW
+ assert isinstance(result.value, bytes)
+ assert json.loads(result.value.decode('utf-8')) == json_object['results'][row_idx]
+ row_idx += 1
+
+ final_result = parser.get_result(0.01)
+ assert isinstance(final_result, ParsedResult)
+ assert final_result.result_type == ParsedResultType.END
+ assert isinstance(final_result.value, bytes)
+ if not buffered_result:
+ # if we are not buffering the entire result, the final result will exclude the results key
+ json_object.pop('results')
+ assert json.loads(final_result.value.decode('utf-8')) == json_object
+ assert parser.get_result(0.01) is None
+
+ @pytest.mark.parametrize('buffered_result', [True, False])
+ def test_analytics_multiple_errors(self, test_env: SimpleEnvironment, buffered_result: bool) -> None:
+ json_object, bytes_data = test_env.get_json_data(JsonDataType.FAILED_REQUEST_MULTI_ERRORS)
+ if buffered_result:
+ parser = JsonStream(BytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True))
+ else:
+ parser = JsonStream(BytesIterator(bytes_data))
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.ERROR
+ assert isinstance(result.value, bytes)
+ assert json.loads(result.value.decode('utf-8')) == json_object
+ assert parser.get_result(0.01) is None
+
+ @pytest.mark.parametrize('buffered_result', [True, False])
+ def test_analytics_simple_result(self, test_env: SimpleEnvironment, buffered_result: bool) -> None:
+ json_object, bytes_data = test_env.get_json_data(JsonDataType.SIMPLE_REQUEST)
+ if buffered_result:
+ parser = JsonStream(BytesIterator(bytes_data), stream_config=JsonStreamConfig(buffer_entire_result=True))
+ else:
+ parser = JsonStream(BytesIterator(bytes_data))
+ parser.start_parsing()
+ # check for individual rows when not buffering the result
+ if not buffered_result:
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.ROW
+ assert isinstance(result.value, bytes)
+ assert json.loads(result.value.decode('utf-8')) == json_object['results'][0]
+
+ final_result = parser.get_result(0.01)
+ assert isinstance(final_result, ParsedResult)
+ assert final_result.result_type == ParsedResultType.END
+ assert isinstance(final_result.value, bytes)
+ # we don't store the 'results' if buffering is not enabled
+ if not buffered_result:
+ json_object.pop('results')
+ assert json.loads(final_result.value.decode('utf-8')) == json_object
+ assert parser.get_result(0.01) is None
+
+ def test_array(self) -> None:
+ data = '[1,2,"three"]'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ assert parser.get_result(0.01) is None
+
+ def test_array_empty(self) -> None:
+ data = '[]'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ assert parser.get_result(0.01) is None
+
+ def test_array_mixed_types(self) -> None:
+ data = '[123,"text",true,null,{"key":"value"}]'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ assert parser.get_result(0.01) is None
+
+ def test_array_of_objects(self) -> None:
+ data = '[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ assert parser.get_result(0.01) is None
+
+ def test_invalid_empty(self) -> None:
+ data = ''
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ res = parser.get_result(0.01)
+ assert isinstance(res, ParsedResult)
+ assert res.result_type == ParsedResultType.ERROR
+ assert res.value is not None
+ decoded_value = res.value.decode('utf-8')
+ assert ('parse error' in decoded_value or 'Incomplete JSON content' in decoded_value) is True
+
+ def test_invalid_garbage_between_objects(self) -> None:
+ data = '[{"id":1,"name":"Alice"},garbage,{"id":2,"name":"Bob"}]'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ res = parser.get_result(0.01)
+ assert isinstance(res, ParsedResult)
+ assert res.result_type == ParsedResultType.ERROR
+ assert res.value is not None
+ decoded_value = res.value.decode('utf-8')
+ assert ('lexical error' in decoded_value or 'Unexpected symbol' in decoded_value) is True
+
+ def test_invalid_leading_garbage(self) -> None:
+ data = 'garbage{"key":"value"}'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ res = parser.get_result(0.01)
+ assert isinstance(res, ParsedResult)
+ assert res.result_type == ParsedResultType.ERROR
+ assert res.value is not None
+ decoded_value = res.value.decode('utf-8')
+ assert ('lexical error' in decoded_value or 'Unexpected symbol' in decoded_value) is True
+
+ def test_invalid_trailing_garbage(self) -> None:
+ data = '{"key":"value"}garbage'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ res = parser.get_result(0.01)
+ assert isinstance(res, ParsedResult)
+ assert res.result_type == ParsedResultType.ERROR
+ assert res.value is not None
+ decoded_value = res.value.decode('utf-8')
+ assert ('parse error' in decoded_value or 'Additional data found' in decoded_value) is True
+
+ def test_invalid_whitespace_only(self) -> None:
+ data = ' \n\t '
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ res = parser.get_result(0.01)
+ assert isinstance(res, ParsedResult)
+ assert res.result_type == ParsedResultType.ERROR
+ assert res.value is not None
+ decoded_value = res.value.decode('utf-8')
+ assert ('parse error' in decoded_value or 'Incomplete JSON content' in decoded_value) is True
+
+ def test_object(self) -> None:
+ data = '{"name":"John","age":30,"city":"New York"}'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ assert parser.get_result(0.01) is None
+
+ def test_object_complex_nested_structure(self) -> None:
+ data_list = [
+ '{"users":[{"id":1,"name":"Alice","roles":["admin","editor"]},{"id":2,"name":"Bob","roles":["viewer"]}],',
+ '"meta":{"count":2,"status":"success"}}',
+ ]
+ data = ''.join(data_list)
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ assert parser.get_result(0.01) is None
+
+ def test_object_empty(self) -> None:
+ data = '{}'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ assert parser.get_result(0.01) is None
+
+ def test_object_simple_nested(self) -> None:
+ data = '{"outer":{"inner":{"key":"value"}}}'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ assert parser.get_result(0.01) is None
+
+ def test_object_with_empty_key_and_value(self) -> None:
+ data = '{"":""}'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ assert parser.get_result(0.01) is None
+
+ def test_object_with_unicode(self) -> None:
+ data = '{"name":"ä½ å¥½","city":"Denver"}'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ assert parser.get_result(0.01) is None
+
+ def test_value_bool(self) -> None:
+ data = 'true'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ assert parser.get_result(0.01) is None
+
+ def test_value_null(self) -> None:
+ data = 'null'
+ parser = JsonStream(
+ BytesIterator(bytes(data, 'utf-8')), stream_config=JsonStreamConfig(buffer_entire_result=True)
+ )
+ parser.start_parsing()
+ result = parser.get_result(0.01)
+ assert isinstance(result, ParsedResult)
+ assert result.result_type == ParsedResultType.END
+ assert isinstance(result.value, bytes)
+ assert result.value.decode('utf-8') == data
+ assert parser.get_result(0.01) is None
+
+
+class JsonParsingTests(JsonParsingTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(JsonParsingTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(JsonParsingTests) if valid_test_method(meth)]
+ test_list = set(JsonParsingTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='test_env')
+ def couchbase_test_environment(self, simple_test_env: SimpleEnvironment) -> SimpleEnvironment:
+ return simple_test_env
diff --git a/couchbase_analytics/tests/options_t.py b/couchbase_analytics/tests/options_t.py
new file mode 100644
index 0000000..f2fd50d
--- /dev/null
+++ b/couchbase_analytics/tests/options_t.py
@@ -0,0 +1,243 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from datetime import timedelta
+from typing import Dict, Optional, Type
+
+import pytest
+
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.deserializer import DefaultJsonDeserializer, Deserializer, PassthroughDeserializer
+from couchbase_analytics.options import (
+ ClusterOptions,
+ SecurityOptions,
+ SecurityOptionsKwargs,
+ TimeoutOptions,
+ TimeoutOptionsKwargs,
+)
+from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter
+from tests.utils import get_test_cert_list, get_test_cert_path, get_test_cert_str
+
+TEST_CERT_PATH = get_test_cert_path()
+TEST_CERT_LIST = get_test_cert_list()
+TEST_CERT_STR = get_test_cert_str()
+
+
+class ClusterOptionsTestSuite:
+ TEST_MANIFEST = [
+ 'test_options_deserializer',
+ 'test_options_deserializer_kwargs',
+ 'test_options_max_retries',
+ 'test_options_max_retries_kwargs',
+ 'test_security_options',
+ 'test_security_options_classmethods',
+ 'test_security_options_kwargs',
+ 'test_security_options_invalid',
+ 'test_security_options_invalid_kwargs',
+ 'test_timeout_options',
+ 'test_timeout_options_kwargs',
+ 'test_timeout_options_must_be_positive',
+ 'test_timeout_options_must_be_positive_kwargs',
+ ]
+
+ @pytest.mark.parametrize('deserializer_cls', [DefaultJsonDeserializer, PassthroughDeserializer])
+ def test_options_deserializer(self, deserializer_cls: Type[Deserializer]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ deserializer_instance = deserializer_cls()
+ client = _ClientAdapter('https://localhost', cred, ClusterOptions(deserializer=deserializer_instance))
+ assert isinstance(client.connection_details.default_deserializer, deserializer_cls)
+
+ @pytest.mark.parametrize('deserializer_cls', [DefaultJsonDeserializer, PassthroughDeserializer])
+ def test_options_deserializer_kwargs(self, deserializer_cls: Type[Deserializer]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ deserializer_instance = deserializer_cls()
+ client = _ClientAdapter('https://localhost', cred, **{'deserializer': deserializer_instance})
+ assert isinstance(client.connection_details.default_deserializer, deserializer_cls)
+
+ @pytest.mark.parametrize('max_retries', [5, 10, None])
+ def test_options_max_retries(self, max_retries: Optional[int]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _ClientAdapter('https://localhost', cred, ClusterOptions(max_retries=max_retries))
+ if max_retries is None:
+ assert client.connection_details.get_max_retries() == 7
+ else:
+ assert client.connection_details.get_max_retries() == max_retries
+
+ @pytest.mark.parametrize('max_retries', [5, 10, None])
+ def test_options_max_retries_kwargs(self, max_retries: Optional[int]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ if max_retries is None:
+ client = _ClientAdapter('https://localhost', cred)
+ assert client.connection_details.get_max_retries() == 7
+ else:
+ client = _ClientAdapter('https://localhost', cred, **{'max_retries': max_retries})
+ assert client.connection_details.get_max_retries() == max_retries
+
+ @pytest.mark.parametrize(
+ 'opts, expected_opts',
+ [
+ ({}, None),
+ ({'trust_only_capella': True}, {'trust_only_capella': True}),
+ (
+ {'trust_only_pem_file': TEST_CERT_PATH},
+ {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False},
+ ),
+ ({'trust_only_pem_str': TEST_CERT_STR}, {'trust_only_pem_str': TEST_CERT_STR, 'trust_only_capella': False}),
+ (
+ {'trust_only_certificates': TEST_CERT_LIST},
+ {'trust_only_certificates': TEST_CERT_LIST, 'trust_only_capella': False},
+ ),
+ ({'disable_server_certificate_verification': True}, {'disable_server_certificate_verification': True}),
+ ],
+ )
+ def test_security_options(self, opts: SecurityOptionsKwargs, expected_opts: SecurityOptionsKwargs) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _ClientAdapter('https://localhost', cred, ClusterOptions(security_options=SecurityOptions(**opts)))
+ assert expected_opts == client.connection_details.cluster_options.get('security_options')
+
+ @pytest.mark.parametrize(
+ 'opts, expected_opts',
+ [
+ (SecurityOptions.trust_only_capella(), {'trust_only_capella': True}),
+ (
+ SecurityOptions.trust_only_pem_file(TEST_CERT_PATH),
+ {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False},
+ ),
+ (
+ SecurityOptions.trust_only_pem_str(TEST_CERT_STR),
+ {'trust_only_pem_str': TEST_CERT_STR, 'trust_only_capella': False},
+ ),
+ (
+ SecurityOptions.trust_only_certificates(TEST_CERT_LIST),
+ {'trust_only_certificates': TEST_CERT_LIST, 'trust_only_capella': False},
+ ),
+ ],
+ )
+ def test_security_options_classmethods(self, opts: SecurityOptions, expected_opts: Dict[str, object]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _ClientAdapter('https://localhost', cred, ClusterOptions(security_options=opts))
+ assert expected_opts == client.connection_details.cluster_options.get('security_options')
+
+ @pytest.mark.parametrize(
+ 'opts, expected_opts',
+ [
+ ({}, None),
+ ({'trust_only_capella': True}, {'trust_only_capella': True}),
+ (
+ {'trust_only_pem_file': TEST_CERT_PATH},
+ {'trust_only_pem_file': TEST_CERT_PATH, 'trust_only_capella': False},
+ ),
+ ({'trust_only_pem_str': TEST_CERT_STR}, {'trust_only_pem_str': TEST_CERT_STR, 'trust_only_capella': False}),
+ (
+ {'trust_only_certificates': TEST_CERT_LIST},
+ {'trust_only_certificates': TEST_CERT_LIST, 'trust_only_capella': False},
+ ),
+ ({'disable_server_certificate_verification': True}, {'disable_server_certificate_verification': True}),
+ ],
+ )
+ def test_security_options_kwargs(self, opts: Dict[str, object], expected_opts: Dict[str, object]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _ClientAdapter('https://localhost', cred, **opts)
+ assert expected_opts == client.connection_details.cluster_options.get('security_options')
+
+ @pytest.mark.parametrize(
+ 'opts',
+ [
+ {'trust_only_capella': True, 'trust_only_pem_file': TEST_CERT_PATH},
+ {'trust_only_capella': True, 'trust_only_pem_str': TEST_CERT_STR},
+ {'trust_only_capella': True, 'trust_only_certificates': TEST_CERT_LIST},
+ ],
+ )
+ def test_security_options_invalid(self, opts: SecurityOptionsKwargs) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ with pytest.raises(ValueError):
+ _ClientAdapter('https://localhost', cred, ClusterOptions(security_options=SecurityOptions(**opts)))
+
+ @pytest.mark.parametrize(
+ 'opts',
+ [
+ {'trust_only_capella': True, 'trust_only_pem_file': TEST_CERT_PATH},
+ {'trust_only_capella': True, 'trust_only_pem_str': TEST_CERT_STR},
+ {'trust_only_capella': True, 'trust_only_certificates': TEST_CERT_LIST},
+ ],
+ )
+ def test_security_options_invalid_kwargs(self, opts: Dict[str, object]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ with pytest.raises(ValueError):
+ _ClientAdapter('https://localhost', cred, **opts)
+
+ @pytest.mark.parametrize(
+ 'opts, expected_opts',
+ [
+ ({}, None),
+ ({'connect_timeout': timedelta(seconds=30)}, {'connect_timeout': 30}),
+ ({'query_timeout': timedelta(seconds=30)}, {'query_timeout': 30}),
+ (
+ {'connect_timeout': timedelta(seconds=60), 'query_timeout': timedelta(seconds=30)},
+ {'connect_timeout': 60, 'query_timeout': 30},
+ ),
+ ],
+ )
+ def test_timeout_options(self, opts: TimeoutOptionsKwargs, expected_opts: TimeoutOptionsKwargs) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _ClientAdapter('https://localhost', cred, ClusterOptions(timeout_options=TimeoutOptions(**opts)))
+ assert expected_opts == client.connection_details.cluster_options.get('timeout_options')
+
+ @pytest.mark.parametrize(
+ 'opts, expected_opts',
+ [
+ ({'connect_timeout': timedelta(seconds=30)}, {'connect_timeout': 30}),
+ ({'query_timeout': timedelta(seconds=30)}, {'query_timeout': 30}),
+ (
+ {'connect_timeout': timedelta(seconds=60), 'query_timeout': timedelta(seconds=30)},
+ {'connect_timeout': 60, 'query_timeout': 30},
+ ),
+ ],
+ )
+ def test_timeout_options_kwargs(self, opts: Dict[str, object], expected_opts: Dict[str, object]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ client = _ClientAdapter('https://localhost', cred, **opts)
+ assert expected_opts == client.connection_details.cluster_options.get('timeout_options')
+
+ @pytest.mark.parametrize(
+ 'opts', [{'connect_timeout': timedelta(seconds=-1)}, {'query_timeout': timedelta(seconds=-1)}]
+ )
+ def test_timeout_options_must_be_positive(self, opts: TimeoutOptionsKwargs) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ with pytest.raises(ValueError):
+ _ClientAdapter('https://localhost', cred, ClusterOptions(timeout_options=TimeoutOptions(**opts)))
+
+ @pytest.mark.parametrize(
+ 'opts', [{'connect_timeout': timedelta(seconds=-1)}, {'query_timeout': timedelta(seconds=-1)}]
+ )
+ def test_timeout_options_must_be_positive_kwargs(self, opts: Dict[str, object]) -> None:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ with pytest.raises(ValueError):
+ _ClientAdapter('https://localhost', cred, **opts)
+
+
+class ClusterOptionsTests(ClusterOptionsTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ClusterOptionsTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ClusterOptionsTests) if valid_test_method(meth)]
+ test_list = set(ClusterOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
diff --git a/couchbase_analytics/tests/query_integration_t.py b/couchbase_analytics/tests/query_integration_t.py
new file mode 100644
index 0000000..9b7effb
--- /dev/null
+++ b/couchbase_analytics/tests/query_integration_t.py
@@ -0,0 +1,723 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import json
+from concurrent.futures import CancelledError, Future
+from datetime import timedelta
+from typing import TYPE_CHECKING, Any, Dict, Optional
+
+import pytest
+
+from couchbase_analytics.common.request import RequestState
+from couchbase_analytics.deserializer import PassthroughDeserializer
+from couchbase_analytics.errors import QueryError, TimeoutError
+from couchbase_analytics.options import QueryOptions
+from couchbase_analytics.query import QueryScanConsistency
+from couchbase_analytics.result import BlockingQueryResult
+from tests import SyncQueryType, YieldFixture
+
+if TYPE_CHECKING:
+ from tests.environments.base_environment import BlockingTestEnvironment
+
+
+class QueryTestSuite:
+ TEST_MANIFEST = [
+ 'test_cancel_prior_iterating',
+ 'test_cancel_prior_iterating_positional_params',
+ 'test_cancel_prior_iterating_with_kwargs',
+ 'test_cancel_prior_iterating_with_options',
+ 'test_cancel_prior_iterating_with_opts_and_kwargs',
+ 'test_cancel_while_iterating',
+ 'test_query_cannot_set_both_cancel_and_lazy_execution',
+ 'test_query_metadata',
+ 'test_query_metadata_not_available',
+ 'test_query_named_parameters',
+ 'test_query_named_parameters_no_options',
+ 'test_query_named_parameters_override',
+ 'test_query_passthrough_deserializer',
+ 'test_query_positional_params',
+ 'test_query_positional_params_no_option',
+ 'test_query_positional_params_override',
+ 'test_query_raises_exception_prior_to_iterating',
+ 'test_query_raw_options',
+ 'test_query_timeout',
+ 'test_query_timeout_while_streaming',
+ 'test_simple_query',
+ 'test_query_with_lazy_execution',
+ 'test_query_with_lazy_execution_raises_exception',
+ ]
+
+ @pytest.fixture(scope='class')
+ def query_statement_limit2(self, test_env: BlockingTestEnvironment) -> str:
+ if test_env.use_scope:
+ return f'SELECT * FROM {test_env.collection_name} LIMIT 2;'
+ else:
+ return f'SELECT * FROM {test_env.fqdn} LIMIT 2;'
+
+ @pytest.fixture(scope='class')
+ def query_statement_pos_params_limit2(self, test_env: BlockingTestEnvironment) -> str:
+ if test_env.use_scope:
+ return f'SELECT * FROM {test_env.collection_name} WHERE country = $1 LIMIT 2;'
+ else:
+ return f'SELECT * FROM {test_env.fqdn} WHERE country = $1 LIMIT 2;'
+
+ @pytest.fixture(scope='class')
+ def query_statement_named_params_limit2(self, test_env: BlockingTestEnvironment) -> str:
+ if test_env.use_scope:
+ return f'SELECT * FROM {test_env.collection_name} WHERE country = $country LIMIT 2;'
+ else:
+ return f'SELECT * FROM {test_env.fqdn} WHERE country = $country LIMIT 2;'
+
+ @pytest.fixture(scope='class')
+ def query_statement_limit5(self, test_env: BlockingTestEnvironment) -> str:
+ if test_env.use_scope:
+ return f'SELECT * FROM {test_env.collection_name} LIMIT 5;'
+ else:
+ return f'SELECT * FROM {test_env.fqdn} LIMIT 5;'
+
+ @pytest.mark.parametrize('cancel_via_future', [False, True])
+ def test_cancel_prior_iterating(self, test_env: BlockingTestEnvironment, cancel_via_future: bool) -> None:
+ statement = 'FROM range(0, 100000) AS r SELECT *'
+ ft = test_env.cluster_or_scope.execute_query(statement, enable_cancel=True)
+ assert isinstance(ft, Future)
+ res: Optional[BlockingQueryResult] = None
+ rows = []
+ if cancel_via_future:
+ ft.cancel()
+ with pytest.raises(CancelledError):
+ res = ft.result()
+ for row in res.rows():
+ rows.append(row)
+
+ assert res is None
+ assert len(rows) == 0
+ else:
+ res = ft.result()
+ res.cancel()
+
+ assert isinstance(res, BlockingQueryResult)
+ assert res._http_response._request_context.request_state == RequestState.Cancelled
+
+ for row in res.rows():
+ rows.append(row)
+
+ with pytest.raises(RuntimeError):
+ res.metadata()
+
+ test_env.assert_streaming_response_state(res)
+
+ @pytest.mark.parametrize('cancel_via_future', [False, True])
+ def test_cancel_prior_iterating_positional_params(
+ self, test_env: BlockingTestEnvironment, query_statement_pos_params_limit2: str, cancel_via_future: bool
+ ) -> None:
+ ft = test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2, 'United States', enable_cancel=True
+ )
+ assert isinstance(ft, Future)
+ res: Optional[BlockingQueryResult] = None
+ rows = []
+ if cancel_via_future:
+ ft.cancel()
+ with pytest.raises(CancelledError):
+ res = ft.result()
+ for row in res.rows():
+ rows.append(row)
+
+ assert res is None
+ assert len(rows) == 0
+ else:
+ res = ft.result()
+ res.cancel()
+
+ assert isinstance(res, BlockingQueryResult)
+ assert res._http_response._request_context.request_state == RequestState.Cancelled
+
+ for row in res.rows():
+ rows.append(row)
+
+ with pytest.raises(RuntimeError):
+ res.metadata()
+
+ test_env.assert_streaming_response_state(res)
+
+ @pytest.mark.parametrize('cancel_via_future', [False, True])
+ def test_cancel_prior_iterating_with_kwargs(
+ self, test_env: BlockingTestEnvironment, cancel_via_future: bool
+ ) -> None:
+ statement = 'FROM range(0, 100000) AS r SELECT *'
+ ft = test_env.cluster_or_scope.execute_query(statement, timeout=timedelta(seconds=4), enable_cancel=True)
+ assert isinstance(ft, Future)
+ res: Optional[BlockingQueryResult] = None
+ rows = []
+ if cancel_via_future:
+ ft.cancel()
+ with pytest.raises(CancelledError):
+ res = ft.result()
+ for row in res.rows():
+ rows.append(row)
+
+ assert res is None
+ assert len(rows) == 0
+ else:
+ res = ft.result()
+ res.cancel()
+
+ assert isinstance(res, BlockingQueryResult)
+ assert res._http_response._request_context.request_state == RequestState.Cancelled
+
+ for row in res.rows():
+ rows.append(row)
+
+ with pytest.raises(RuntimeError):
+ res.metadata()
+
+ test_env.assert_streaming_response_state(res)
+
+ @pytest.mark.parametrize('cancel_via_future', [False, True])
+ def test_cancel_prior_iterating_with_options(
+ self, test_env: BlockingTestEnvironment, cancel_via_future: bool
+ ) -> None:
+ statement = 'FROM range(0, 100000) AS r SELECT *'
+ ft = test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(timeout=timedelta(seconds=4)), enable_cancel=True
+ )
+ assert isinstance(ft, Future)
+ res: Optional[BlockingQueryResult] = None
+ rows = []
+ if cancel_via_future:
+ ft.cancel()
+ with pytest.raises(CancelledError):
+ res = ft.result()
+ for row in res.rows():
+ rows.append(row)
+
+ assert res is None
+ assert len(rows) == 0
+ else:
+ res = ft.result()
+ res.cancel()
+
+ assert isinstance(res, BlockingQueryResult)
+ assert res._http_response._request_context.request_state == RequestState.Cancelled
+
+ for row in res.rows():
+ rows.append(row)
+
+ with pytest.raises(RuntimeError):
+ res.metadata()
+ test_env.assert_streaming_response_state(res)
+
+ @pytest.mark.parametrize('cancel_via_future', [False, True])
+ def test_cancel_prior_iterating_with_opts_and_kwargs(
+ self, test_env: BlockingTestEnvironment, cancel_via_future: bool
+ ) -> None:
+ statement = 'FROM range(0, 100000) AS r SELECT *'
+ ft = test_env.cluster_or_scope.execute_query(
+ statement,
+ QueryOptions(scan_consistency=QueryScanConsistency.NOT_BOUNDED),
+ timeout=timedelta(seconds=4),
+ enable_cancel=True,
+ )
+ assert isinstance(ft, Future)
+ res: Optional[BlockingQueryResult] = None
+ rows = []
+ if cancel_via_future:
+ ft.cancel()
+ with pytest.raises(CancelledError):
+ res = ft.result()
+ for row in res.rows():
+ rows.append(row)
+
+ assert res is None
+ assert len(rows) == 0
+ else:
+ res = ft.result()
+ res.cancel()
+
+ assert isinstance(res, BlockingQueryResult)
+ assert res._http_response._request_context.request_state == RequestState.Cancelled
+
+ for row in res.rows():
+ rows.append(row)
+
+ with pytest.raises(RuntimeError):
+ res.metadata()
+ test_env.assert_streaming_response_state(res)
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_cancel_while_iterating(
+ self, test_env: BlockingTestEnvironment, query_statement_limit5: str, query_type: SyncQueryType
+ ) -> None:
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(query_statement_limit5)
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(query_statement_limit5, QueryOptions(lazy_execute=True))
+ else:
+ res = test_env.cluster_or_scope.execute_query(query_statement_limit5, enable_cancel=True)
+ assert isinstance(res, Future)
+ result = res.result()
+
+ assert isinstance(result, BlockingQueryResult)
+ if query_type != SyncQueryType.LAZY:
+ expected_state = RequestState.StreamingResults
+ else:
+ expected_state = RequestState.NotStarted
+ assert result._http_response._request_context.request_state == expected_state
+ rows = []
+ count = 0
+ for row in result.rows():
+ if count == 2:
+ result.cancel()
+ assert row is not None
+ rows.append(row)
+ count += 1
+
+ assert len(rows) == count
+ expected_state = RequestState.Cancelled
+ assert result._http_response._request_context.request_state == expected_state
+ with pytest.raises(RuntimeError):
+ result.metadata()
+ test_env.assert_streaming_response_state(result)
+
+ def test_query_cannot_set_both_cancel_and_lazy_execution(self, test_env: BlockingTestEnvironment) -> None:
+ statement = 'SELECT 1=1'
+ with pytest.raises(RuntimeError):
+ test_env.cluster_or_scope.execute_query(statement, QueryOptions(lazy_execute=True), enable_cancel=True)
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_query_metadata(
+ self, test_env: BlockingTestEnvironment, query_statement_limit5: str, query_type: SyncQueryType
+ ) -> None:
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(query_statement_limit5)
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(query_statement_limit5, QueryOptions(lazy_execute=True))
+ else:
+ res = test_env.cluster_or_scope.execute_query(query_statement_limit5, enable_cancel=True)
+ assert isinstance(res, Future)
+ result = res.result()
+
+ expected_count = 5
+ test_env.assert_rows(result, expected_count)
+
+ metadata = result.metadata()
+
+ assert len(metadata.warnings()) == 0
+ assert len(metadata.request_id()) > 0
+
+ metrics = metadata.metrics()
+
+ assert metrics.result_size() > 0
+ assert metrics.result_count() == expected_count
+ assert metrics.processed_objects() > 0
+ assert metrics.elapsed_time() > timedelta(0)
+ assert metrics.execution_time() > timedelta(0)
+ test_env.assert_streaming_response_state(result)
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_query_metadata_not_available(
+ self, test_env: BlockingTestEnvironment, query_statement_limit5: str, query_type: SyncQueryType
+ ) -> None:
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(query_statement_limit5)
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(query_statement_limit5, QueryOptions(lazy_execute=True))
+ else:
+ res = test_env.cluster_or_scope.execute_query(query_statement_limit5, enable_cancel=True)
+ assert isinstance(res, Future)
+ result = res.result()
+
+ with pytest.raises(RuntimeError):
+ result.metadata()
+
+ # Read one row
+ next(iter(result.rows()))
+
+ with pytest.raises(RuntimeError):
+ result.metadata()
+
+ # This would attempt to send the request when using lazy execution
+ if query_type == SyncQueryType.LAZY:
+ with pytest.raises(RuntimeError):
+ list(result.rows())
+ return
+ # Iterate the rest of the rows
+ rows = list(result.rows())
+ assert len(rows) == 4
+
+ metadata = result.metadata()
+ assert len(metadata.warnings()) == 0
+ assert len(metadata.request_id()) > 0
+ test_env.assert_streaming_response_state(result)
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_query_named_parameters(
+ self, test_env: BlockingTestEnvironment, query_statement_named_params_limit2: str, query_type: SyncQueryType
+ ) -> None:
+ named_parameters: Dict[str, Any] = {'country': 'United States'}
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_named_params_limit2, QueryOptions(named_parameters=named_parameters)
+ )
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_named_params_limit2, QueryOptions(named_parameters=named_parameters, lazy_execute=True)
+ )
+ else:
+ res = test_env.cluster_or_scope.execute_query(
+ query_statement_named_params_limit2, QueryOptions(named_parameters=named_parameters), enable_cancel=True
+ )
+ assert isinstance(res, Future)
+ result = res.result()
+ test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_query_named_parameters_no_options(
+ self, test_env: BlockingTestEnvironment, query_statement_named_params_limit2: str, query_type: SyncQueryType
+ ) -> None:
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_named_params_limit2, country='United States'
+ )
+ elif query_type == SyncQueryType.LAZY:
+ # this format does not really make sense, if users are using static type checking it will prevent them
+ # but, technically viable so we test it
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_named_params_limit2, # type: ignore[call-overload]
+ lazy_execute=True,
+ country='United States',
+ )
+ else:
+ res = test_env.cluster_or_scope.execute_query(
+ query_statement_named_params_limit2, country='United States', enable_cancel=True
+ )
+ assert isinstance(res, Future)
+ result = res.result()
+ test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_query_named_parameters_override(
+ self, test_env: BlockingTestEnvironment, query_statement_named_params_limit2: str, query_type: SyncQueryType
+ ) -> None:
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_named_params_limit2,
+ QueryOptions(named_parameters={'country': 'abcdefg'}),
+ country='United States',
+ )
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_named_params_limit2,
+ QueryOptions(named_parameters={'country': 'abcdefg'}, lazy_execute=True),
+ country='United States',
+ )
+ else:
+ res = test_env.cluster_or_scope.execute_query(
+ query_statement_named_params_limit2,
+ QueryOptions(named_parameters={'country': 'abcdefg'}),
+ country='United States',
+ enable_cancel=True,
+ )
+ assert isinstance(res, Future)
+ result = res.result()
+ test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_query_passthrough_deserializer(self, test_env: BlockingTestEnvironment, query_type: SyncQueryType) -> None:
+ statement = 'FROM range(0, 10) AS num SELECT *'
+
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(deserializer=PassthroughDeserializer())
+ )
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(deserializer=PassthroughDeserializer(), lazy_execute=True)
+ )
+ else:
+ res = test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(deserializer=PassthroughDeserializer()), enable_cancel=True
+ )
+ assert isinstance(res, Future)
+ result = res.result()
+
+ for idx, row in enumerate(result.rows()):
+ assert isinstance(row, bytes)
+ assert json.loads(row) == {'num': idx}
+ test_env.assert_streaming_response_state(result)
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_query_positional_params(
+ self, test_env: BlockingTestEnvironment, query_statement_pos_params_limit2: str, query_type: SyncQueryType
+ ) -> None:
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2, QueryOptions(positional_parameters=['United States'])
+ )
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2,
+ QueryOptions(positional_parameters=['United States'], lazy_execute=True),
+ )
+ else:
+ res = test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2,
+ QueryOptions(positional_parameters=['United States']),
+ enable_cancel=True,
+ )
+ assert isinstance(res, Future)
+ result = res.result()
+ test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_query_positional_params_no_option(
+ self, test_env: BlockingTestEnvironment, query_statement_pos_params_limit2: str, query_type: SyncQueryType
+ ) -> None:
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(query_statement_pos_params_limit2, 'United States')
+ elif query_type == SyncQueryType.LAZY:
+ # this format does not really make sense, if users are using static type checking it will prevent them
+ # but, technically viable so we test it
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2, # type: ignore[call-overload]
+ 'United States',
+ lazy_execute=True,
+ )
+ else:
+ res = test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2, 'United States', enable_cancel=True
+ )
+ assert isinstance(res, Future)
+ result = res.result()
+ test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_query_positional_params_override(
+ self, test_env: BlockingTestEnvironment, query_statement_pos_params_limit2: str, query_type: SyncQueryType
+ ) -> None:
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2, QueryOptions(positional_parameters=['abcdefg']), 'United States'
+ )
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2,
+ QueryOptions(positional_parameters=['abcdefg'], lazy_execute=True),
+ 'United States',
+ )
+ else:
+ res = test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2,
+ QueryOptions(positional_parameters=['abcdefg']),
+ 'United States',
+ enable_cancel=True,
+ )
+ assert isinstance(res, Future)
+ result = res.result()
+ test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ # We test lazy execution in a separate test
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.CANCELLABLE])
+ def test_query_raises_exception_prior_to_iterating(
+ self, test_env: BlockingTestEnvironment, query_type: SyncQueryType
+ ) -> None:
+ statement = "I'm not N1QL!"
+ if query_type == SyncQueryType.NORMAL:
+ with pytest.raises(QueryError):
+ test_env.cluster_or_scope.execute_query(statement)
+ else:
+ res = test_env.cluster_or_scope.execute_query(statement, enable_cancel=True)
+ assert isinstance(res, Future)
+ with pytest.raises(QueryError):
+ res.result()
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_query_raw_options(
+ self, test_env: BlockingTestEnvironment, query_statement_pos_params_limit2: str, query_type: SyncQueryType
+ ) -> None:
+ # via raw, we should be able to pass any option
+ # if using named params, need to match full name param in query
+ # which is different for when we pass in name_parameters via their specific
+ # query option (i.e. include the $ when using raw)
+ if test_env.use_scope:
+ statement = f'SELECT * FROM {test_env.collection_name} WHERE country = $country LIMIT $1;'
+ else:
+ statement = f'SELECT * FROM {test_env.fqdn} WHERE country = $country LIMIT $1;'
+
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(raw={'$country': 'United States', 'args': [2]})
+ )
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(raw={'$country': 'United States', 'args': [2]}, lazy_execute=True)
+ )
+ else:
+ res = test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(raw={'$country': 'United States', 'args': [2]}), enable_cancel=True
+ )
+ assert isinstance(res, Future)
+ result = res.result()
+
+ test_env.assert_rows(result, 2)
+
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2, QueryOptions(raw={'args': ['United States']})
+ )
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2, QueryOptions(raw={'args': ['United States']}, lazy_execute=True)
+ )
+ else:
+ res = test_env.cluster_or_scope.execute_query(
+ query_statement_pos_params_limit2, QueryOptions(raw={'args': ['United States']}), enable_cancel=True
+ )
+ assert isinstance(res, Future)
+ result = res.result()
+ test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_query_timeout(self, test_env: BlockingTestEnvironment, query_type: SyncQueryType) -> None:
+ statement = 'SELECT sleep("some value", 10000) AS some_field;'
+
+ if query_type == SyncQueryType.NORMAL:
+ with pytest.raises(TimeoutError):
+ result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2)))
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(timeout=timedelta(seconds=2), lazy_execute=True)
+ )
+ with pytest.raises(TimeoutError):
+ for _ in result.rows():
+ pass
+ else:
+ res = test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(timeout=timedelta(seconds=2)), enable_cancel=True
+ )
+ assert isinstance(res, Future)
+ with pytest.raises(TimeoutError):
+ result = res.result()
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_query_timeout_while_streaming(self, test_env: BlockingTestEnvironment, query_type: SyncQueryType) -> None:
+ statement = 'SELECT {"x1": 1, "x2": 2, "x3": 3} FROM range(1, 100000) r;'
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2)))
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(timeout=timedelta(seconds=2), lazy_execute=True)
+ )
+ else:
+ res = test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(timeout=timedelta(seconds=2)), enable_cancel=True
+ )
+ assert isinstance(res, Future)
+ result = res.result()
+
+ with pytest.raises(TimeoutError):
+ for _ in result.rows():
+ pass
+ test_env.assert_streaming_response_state(result)
+
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_simple_query(
+ self, test_env: BlockingTestEnvironment, query_statement_limit2: str, query_type: SyncQueryType
+ ) -> None:
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(query_statement_limit2)
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(query_statement_limit2, lazy_execute=True)
+ else:
+ res = test_env.cluster_or_scope.execute_query(query_statement_limit2, enable_cancel=True)
+ assert isinstance(res, Future)
+ result = res.result()
+ test_env.assert_rows(result, 2)
+ test_env.assert_streaming_response_state(result)
+
+ def test_query_with_lazy_execution(self, test_env: BlockingTestEnvironment, query_statement_limit2: str) -> None:
+ result = test_env.cluster_or_scope.execute_query(query_statement_limit2, QueryOptions(lazy_execute=True))
+ expected_state = RequestState.NotStarted
+ assert result._http_response._request_context.request_state == expected_state
+ expected_state = RequestState.StreamingResults
+ count = 0
+ for row in result.rows():
+ assert result._http_response._request_context.request_state == expected_state
+ assert row is not None
+ count += 1
+ assert count == 2
+ test_env.assert_streaming_response_state(result)
+
+ def test_query_with_lazy_execution_raises_exception(self, test_env: BlockingTestEnvironment) -> None:
+ statement = "I'm not N1QL!"
+ result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(lazy_execute=True))
+ expected_state = RequestState.NotStarted
+ assert result._http_response._request_context.request_state == expected_state
+ with pytest.raises(QueryError):
+ list(result.rows())
+ test_env.assert_streaming_response_state(result)
+
+
+class ClusterQueryTests(QueryTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ClusterQueryTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ClusterQueryTests) if valid_test_method(meth)]
+ test_list = set(QueryTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='test_env')
+ def couchbase_test_environment(
+ self, sync_test_env: BlockingTestEnvironment
+ ) -> YieldFixture[BlockingTestEnvironment]:
+ sync_test_env.setup()
+ yield sync_test_env
+ sync_test_env.teardown()
+
+
+class ScopeQueryTests(QueryTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ScopeQueryTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ScopeQueryTests) if valid_test_method(meth)]
+ test_list = set(QueryTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='test_env')
+ def couchbase_test_environment(
+ self, sync_test_env: BlockingTestEnvironment
+ ) -> YieldFixture[BlockingTestEnvironment]:
+ sync_test_env.setup()
+ test_env = sync_test_env.enable_scope()
+ yield test_env
+ test_env.disable_scope()
+ test_env.teardown()
diff --git a/couchbase_analytics/tests/query_options_t.py b/couchbase_analytics/tests/query_options_t.py
new file mode 100644
index 0000000..dc4f25a
--- /dev/null
+++ b/couchbase_analytics/tests/query_options_t.py
@@ -0,0 +1,301 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import timedelta
+from typing import Any, Dict, List, Optional, Union
+
+import pytest
+
+from couchbase_analytics import JSONType
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.options import QueryOptions, QueryOptionsKwargs
+from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter
+from couchbase_analytics.protocol._core.request import _RequestBuilder
+from couchbase_analytics.protocol.options import QueryOptionsTransformedKwargs
+
+
+@dataclass
+class QueryContext:
+ database_name: Optional[str] = None
+ scope_name: Optional[str] = None
+
+ def validate_query_context(self, body: Dict[str, Union[str, object]]) -> None:
+ if self.database_name is None or self.scope_name is None:
+ with pytest.raises(KeyError):
+ body['query_context']
+ else:
+ assert body['query_context'] == f'default:`{self.database_name}`.`{self.scope_name}`'
+
+
+class QueryOptionsTestSuite:
+ TEST_MANIFEST = [
+ 'test_options_deserializer',
+ 'test_options_deserializer_kwargs',
+ 'test_options_max_retries',
+ 'test_options_max_retries_kwargs',
+ 'test_options_named_parameters',
+ 'test_options_named_parameters_kwargs',
+ 'test_options_positional_parameters',
+ 'test_options_positional_parameters_kwargs',
+ 'test_options_raw',
+ 'test_options_raw_kwargs',
+ 'test_options_readonly',
+ 'test_options_readonly_kwargs',
+ 'test_options_scan_consistency',
+ 'test_options_scan_consistency_kwargs',
+ 'test_options_timeout',
+ 'test_options_timeout_kwargs',
+ 'test_options_timeout_must_be_positive',
+ 'test_options_timeout_must_be_positive_kwargs',
+ ]
+
+ @pytest.fixture(scope='class')
+ def query_statment(self) -> str:
+ return 'SELECT * FROM default'
+
+ def test_options_deserializer(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ from couchbase_analytics.deserializer import DefaultJsonDeserializer
+
+ deserializer = DefaultJsonDeserializer()
+ q_opts = QueryOptions(deserializer=deserializer)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {}
+ assert req.options == exp_opts
+ assert req.deserializer == deserializer
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_deserializer_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ from couchbase_analytics.deserializer import DefaultJsonDeserializer
+
+ deserializer = DefaultJsonDeserializer()
+ kwargs: QueryOptionsKwargs = {'deserializer': deserializer}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {}
+ assert req.options == exp_opts
+ assert req.deserializer == deserializer
+ query_ctx.validate_query_context(req.body)
+
+ @pytest.mark.parametrize('max_retries', [5, 10, None])
+ def test_options_max_retries(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext, max_retries: Optional[int]
+ ) -> None:
+ if max_retries is not None:
+ q_opts = QueryOptions(max_retries=max_retries)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ else:
+ req = request_builder.build_base_query_request(query_statment)
+ exp_opts: QueryOptionsTransformedKwargs = {}
+ assert req.options == exp_opts
+ assert req.max_retries == (max_retries if max_retries is not None else 7)
+ query_ctx.validate_query_context(req.body)
+
+ @pytest.mark.parametrize('max_retries', [5, 10, None])
+ def test_options_max_retries_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext, max_retries: Optional[int]
+ ) -> None:
+ if max_retries is not None:
+ kwargs: QueryOptionsKwargs = {'max_retries': max_retries}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ else:
+ req = request_builder.build_base_query_request(query_statment)
+ exp_opts: QueryOptionsTransformedKwargs = {}
+ assert req.options == exp_opts
+ assert req.max_retries == (max_retries if max_retries is not None else 7)
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_named_parameters(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ params: Dict[str, JSONType] = {'foo': 'bar', 'baz': 1, 'quz': False}
+ q_opts = QueryOptions(named_parameters=params)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {'named_parameters': params}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_named_parameters_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ params: Dict[str, JSONType] = {'foo': 'bar', 'baz': 1, 'quz': False}
+ kwargs: QueryOptionsKwargs = {'named_parameters': params}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {'named_parameters': params}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_positional_parameters(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ params: List[JSONType] = ['foo', 'bar', 1, False]
+ q_opts = QueryOptions(positional_parameters=params)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {'positional_parameters': params}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_positional_parameters_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ params: List[JSONType] = ['foo', 'bar', 1, False]
+ kwargs: QueryOptionsKwargs = {'positional_parameters': params}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {'positional_parameters': params}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_raw(self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext) -> None:
+ pos_params: List[JSONType] = ['foo', 'bar', 1, False]
+ params: Dict[str, Any] = {'readonly': True, 'positional_params': pos_params}
+ q_opts = QueryOptions(raw=params)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {'raw': params}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_raw_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ pos_params: List[JSONType] = ['foo', 'bar', 1, False]
+ params: Dict[str, Any] = {'readonly': True, 'positional_params': pos_params}
+ kwargs: QueryOptionsKwargs = {'raw': params}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {'raw': params}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_readonly(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ q_opts = QueryOptions(readonly=True)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {'readonly': True}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_readonly_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ kwargs: QueryOptionsKwargs = {'readonly': True}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {'readonly': True}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_scan_consistency(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ from couchbase_analytics.query import QueryScanConsistency
+
+ q_opts = QueryOptions(scan_consistency=QueryScanConsistency.REQUEST_PLUS)
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_scan_consistency_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ from couchbase_analytics.query import QueryScanConsistency
+
+ kwargs: QueryOptionsKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {'scan_consistency': QueryScanConsistency.REQUEST_PLUS.value}
+ assert req.options == exp_opts
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_timeout(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ q_opts = QueryOptions(timeout=timedelta(seconds=20))
+ req = request_builder.build_base_query_request(query_statment, q_opts)
+ exp_opts: QueryOptionsTransformedKwargs = {'timeout': 20.0}
+ assert req.options == exp_opts
+ # NOTE: we add time to the server timeout to ensure a client side timeout
+ assert req.body['timeout'] == '25000.0ms'
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_timeout_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder, query_ctx: QueryContext
+ ) -> None:
+ kwargs: QueryOptionsKwargs = {'timeout': timedelta(seconds=20)}
+ req = request_builder.build_base_query_request(query_statment, **kwargs)
+ exp_opts: QueryOptionsTransformedKwargs = {'timeout': 20.0}
+ assert req.options == exp_opts
+ # NOTE: we add time to the server timeout to ensure a client side timeout
+ assert req.body['timeout'] == '25000.0ms'
+ query_ctx.validate_query_context(req.body)
+
+ def test_options_timeout_must_be_positive(self, query_statment: str, request_builder: _RequestBuilder) -> None:
+ q_opts = QueryOptions(timeout=timedelta(seconds=-1))
+ with pytest.raises(ValueError):
+ request_builder.build_base_query_request(query_statment, q_opts)
+
+ def test_options_timeout_must_be_positive_kwargs(
+ self, query_statment: str, request_builder: _RequestBuilder
+ ) -> None:
+ kwargs: QueryOptionsKwargs = {'timeout': timedelta(seconds=-1)}
+ with pytest.raises(ValueError):
+ request_builder.build_base_query_request(query_statment, **kwargs)
+
+
+class ClusterQueryOptionsTests(QueryOptionsTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ClusterQueryOptionsTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ClusterQueryOptionsTests) if valid_test_method(meth)]
+ test_list = set(QueryOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='query_ctx')
+ def query_context(self) -> QueryContext:
+ return QueryContext()
+
+ @pytest.fixture(scope='class')
+ def request_builder(self) -> _RequestBuilder:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ return _RequestBuilder(_ClientAdapter('https://localhost', cred))
+
+
+class ScopeQueryOptionsTests(QueryOptionsTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ScopeQueryOptionsTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ScopeQueryOptionsTests) if valid_test_method(meth)]
+ test_list = set(QueryOptionsTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='query_ctx')
+ def query_context(self) -> QueryContext:
+ return QueryContext('test-database', 'test-scope')
+
+ @pytest.fixture(scope='class')
+ def request_builder(self) -> _RequestBuilder:
+ cred = Credential.from_username_and_password('Administrator', 'password')
+ return _RequestBuilder(_ClientAdapter('https://localhost', cred), 'test-database', 'test-scope')
diff --git a/couchbase_analytics/tests/test_server_t.py b/couchbase_analytics/tests/test_server_t.py
new file mode 100644
index 0000000..74ddbd4
--- /dev/null
+++ b/couchbase_analytics/tests/test_server_t.py
@@ -0,0 +1,252 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from concurrent.futures import Future
+from datetime import timedelta
+from typing import TYPE_CHECKING, Union
+
+import pytest
+
+from couchbase_analytics.errors import AnalyticsError, InvalidCredentialError, QueryError, TimeoutError
+from couchbase_analytics.options import QueryOptions
+from couchbase_analytics.result import BlockingQueryResult
+from tests import SyncQueryType, YieldFixture
+from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType
+
+if TYPE_CHECKING:
+ from tests.environments.base_environment import BlockingTestEnvironment
+
+
+class TestServerTestSuite:
+ TEST_MANIFEST = [
+ 'test_auth_error_unauthorized',
+ 'test_auth_error_insufficient_permissions',
+ 'test_error_non_retriable_response',
+ 'test_error_retriable_response_timeout',
+ 'test_error_retriable_response_retries_exceeded',
+ 'test_error_retriable_http503',
+ 'test_error_timeout',
+ 'test_results_object_values',
+ 'test_results_raw_values',
+ ]
+
+ def test_auth_error_unauthorized(self, test_env: BlockingTestEnvironment) -> None:
+ test_env.set_url_path('/test_error')
+ test_env.update_request_json({'error_type': ErrorType.Unauthorized.value})
+ statement = 'SELECT "Hello, data!" AS greeting'
+ with pytest.raises(InvalidCredentialError) as ex:
+ test_env.cluster_or_scope.execute_query(statement)
+ test_env.assert_error_context_num_attempts(1, ex.value._context)
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+
+ def test_auth_error_insufficient_permissions(self, test_env: BlockingTestEnvironment) -> None:
+ test_env.set_url_path('/test_error')
+ test_env.update_request_json({'error_type': ErrorType.InsufficientPermissions.value})
+ statement = 'SELECT "Hello, data!" AS greeting'
+ with pytest.raises(QueryError) as ex:
+ test_env.cluster_or_scope.execute_query(statement)
+ assert ex.value.code == 20001
+ assert 'Insufficient permissions' in ex.value.server_message
+ test_env.assert_error_context_num_attempts(1, ex.value._context)
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+
+ @pytest.mark.parametrize(
+ 'retry_group_type',
+ [RetriableGroupType.Zero, RetriableGroupType.First, RetriableGroupType.Middle, RetriableGroupType.Last],
+ )
+ @pytest.mark.parametrize(
+ 'non_retriable_spec',
+ [
+ NonRetriableSpecificationType.AllEmpty,
+ NonRetriableSpecificationType.AllFalse,
+ NonRetriableSpecificationType.Random,
+ ],
+ )
+ def test_error_non_retriable_response(
+ self,
+ test_env: BlockingTestEnvironment,
+ retry_group_type: RetriableGroupType,
+ non_retriable_spec: NonRetriableSpecificationType,
+ ) -> None:
+ test_env.set_url_path('/test_error')
+ test_env.update_request_json(
+ {
+ 'error_type': ErrorType.Retriable.value,
+ 'retry_group_type': retry_group_type.value,
+ 'non_retriable_spec': non_retriable_spec.value,
+ }
+ )
+ statement = 'SELECT "Hello, data!" AS greeting'
+ with pytest.raises(QueryError) as ex:
+ test_env.cluster_or_scope.execute_query(statement)
+ test_env.assert_error_context_num_attempts(1, ex.value._context)
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+
+ def test_error_retriable_response_timeout(self, test_env: BlockingTestEnvironment) -> None:
+ test_env.set_url_path('/test_error')
+ test_env.update_request_json(
+ {'error_type': ErrorType.Retriable.value, 'retry_group_type': RetriableGroupType.All.value}
+ )
+ statement = 'SELECT "Hello, data!" AS greeting'
+ with pytest.raises(TimeoutError) as ex:
+ # just-in-case, increase the max_retries to ensure we hit the timeout
+ test_env.cluster_or_scope.execute_query(
+ statement, QueryOptions(max_retries=10, timeout=timedelta(seconds=1.5))
+ )
+
+ test_env.assert_error_context_num_attempts(4, ex.value._context, exact=False)
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+
+ def test_error_retriable_response_retries_exceeded(self, test_env: BlockingTestEnvironment) -> None:
+ test_env.set_url_path('/test_error')
+ test_env.update_request_json(
+ {'error_type': ErrorType.Retriable.value, 'retry_group_type': RetriableGroupType.All.value}
+ )
+ statement = 'SELECT "Hello, data!" AS greeting'
+ allowed_retries = 5
+ q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10))
+ with pytest.raises(QueryError) as ex:
+ test_env.cluster_or_scope.execute_query(statement, q_opts)
+
+ print(ex.value)
+ test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context)
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+
+ @pytest.mark.parametrize('analytics_error', [False, True])
+ def test_error_retriable_http503(self, test_env: BlockingTestEnvironment, analytics_error: bool) -> None:
+ test_env.set_url_path('/test_error')
+ test_env.update_request_json({'error_type': ErrorType.Http503.value, 'analytics_error': analytics_error})
+ statement = 'SELECT "Hello, data!" AS greeting'
+ allowed_retries = 5
+ q_opts = QueryOptions(max_retries=allowed_retries, timeout=timedelta(seconds=10))
+ ex: Union[pytest.ExceptionInfo[AnalyticsError], pytest.ExceptionInfo[QueryError]]
+ if analytics_error:
+ with pytest.raises(QueryError) as ex:
+ test_env.cluster_or_scope.execute_query(statement, q_opts)
+ else:
+ with pytest.raises(AnalyticsError) as ex:
+ test_env.cluster_or_scope.execute_query(statement, q_opts)
+
+ test_env.assert_error_context_num_attempts(allowed_retries + 1, ex.value._context)
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+
+ @pytest.mark.parametrize('server_side', [False, True])
+ def test_error_timeout(self, test_env: BlockingTestEnvironment, server_side: bool) -> None:
+ test_env.set_url_path('/test_error')
+ if server_side:
+ req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 1, 'server_side': True}
+ else:
+ req_json = {'error_type': ErrorType.Timeout.value, 'timeout': 3}
+
+ test_env.update_request_json(req_json)
+ statement = 'SELECT "Hello, data!" AS greeting'
+ with pytest.raises(TimeoutError) as ex:
+ test_env.cluster_or_scope.execute_query(statement, QueryOptions(timeout=timedelta(seconds=2)))
+ test_env.assert_error_context_num_attempts(1, ex.value._context)
+ if server_side:
+ test_env.assert_error_context_contains_last_dispatch(ex.value._context)
+ else:
+ test_env.assert_error_context_missing_last_dispatch(ex.value._context)
+
+ @pytest.mark.parametrize('stream', [False, True])
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_results_object_values(
+ self, test_env: BlockingTestEnvironment, query_type: SyncQueryType, stream: bool
+ ) -> None:
+ expected_rows = 50
+ test_env.set_url_path('/test_results')
+ test_env.update_request_json(
+ {'result_type': ResultType.Object.value, 'row_count': expected_rows, 'stream': stream}
+ )
+ statement = 'SELECT "Hello, data!" AS greeting'
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(statement)
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(lazy_execute=True))
+ else:
+ res = test_env.cluster_or_scope.execute_query(statement, enable_cancel=True)
+ assert isinstance(res, Future)
+ result = res.result()
+
+ assert isinstance(result, BlockingQueryResult)
+ test_env.assert_rows(result, expected_rows)
+
+ @pytest.mark.parametrize('stream', [False, True])
+ @pytest.mark.parametrize('query_type', [SyncQueryType.NORMAL, SyncQueryType.LAZY, SyncQueryType.CANCELLABLE])
+ def test_results_raw_values(
+ self, test_env: BlockingTestEnvironment, query_type: SyncQueryType, stream: bool
+ ) -> None:
+ expected_rows = 50
+ test_env.set_url_path('/test_results')
+ test_env.update_request_json(
+ {'result_type': ResultType.Raw.value, 'row_count': expected_rows, 'stream': stream}
+ )
+ statement = 'SELECT "Hello, data!" AS greeting'
+ if query_type == SyncQueryType.NORMAL:
+ result = test_env.cluster_or_scope.execute_query(statement)
+ elif query_type == SyncQueryType.LAZY:
+ result = test_env.cluster_or_scope.execute_query(statement, QueryOptions(lazy_execute=True))
+ else:
+ res = test_env.cluster_or_scope.execute_query(statement, enable_cancel=True)
+ assert isinstance(res, Future)
+ result = res.result()
+
+ assert isinstance(result, BlockingQueryResult)
+ test_env.assert_rows(result, expected_rows)
+
+
+class ClusterTestServerTests(TestServerTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ClusterTestServerTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ClusterTestServerTests) if valid_test_method(meth)]
+ test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='test_env')
+ def couchbase_test_environment(
+ self, sync_test_env_with_server: BlockingTestEnvironment
+ ) -> YieldFixture[BlockingTestEnvironment]:
+ test_env = sync_test_env_with_server.enable_test_server()
+ yield test_env
+ test_env.disable_test_server()
+
+
+class ScopeTestServerTests(TestServerTestSuite):
+ @pytest.fixture(scope='class', autouse=True)
+ def validate_test_manifest(self) -> None:
+ def valid_test_method(meth: str) -> bool:
+ attr = getattr(ScopeTestServerTests, meth)
+ return callable(attr) and not meth.startswith('__') and meth.startswith('test')
+
+ method_list = [meth for meth in dir(ScopeTestServerTests) if valid_test_method(meth)]
+ test_list = set(TestServerTestSuite.TEST_MANIFEST).symmetric_difference(method_list)
+ if test_list:
+ pytest.fail(f'Test manifest invalid. Missing/extra tests: {test_list}.')
+
+ @pytest.fixture(scope='class', name='test_env')
+ def couchbase_test_environment(
+ self, sync_test_env_with_server: BlockingTestEnvironment
+ ) -> YieldFixture[BlockingTestEnvironment]:
+ test_env = sync_test_env_with_server.enable_test_server()
+ test_env.enable_scope()
+ yield test_env
+ test_env.disable_scope().disable_test_server()
diff --git a/couchbase_analytics_version.py b/couchbase_analytics_version.py
new file mode 100644
index 0000000..5e1b306
--- /dev/null
+++ b/couchbase_analytics_version.py
@@ -0,0 +1,250 @@
+#!/usr/bin/env python
+
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import print_function
+
+import datetime
+import os.path
+import re
+import subprocess
+import warnings
+from typing import Optional
+
+
+class CantInvokeGit(Exception):
+ pass
+
+
+class VersionNotFound(Exception):
+ pass
+
+
+class MalformedGitTag(Exception):
+ pass
+
+
+RE_XYZ = re.compile(r'(\d+)\.(\d+)\.(\d+)(?:-(.*))?')
+
+VERSION_FILE = os.path.join(os.path.dirname(__file__), 'couchbase_analytics', '_version.py')
+
+
+class VersionInfo:
+ def __init__(self, rawtext: str):
+ self.rawtext = rawtext
+ t = self.rawtext.rsplit('-', 2)
+ if len(t) != 3:
+ raise MalformedGitTag(self.rawtext)
+
+ vinfo, ncommits, self.sha = t
+ self.ncommits = int(ncommits)
+
+ # Split up the X.Y.Z
+ match = RE_XYZ.match(vinfo)
+ if match is not None:
+ (self.ver_maj, self.ver_min, self.ver_patch, self.ver_extra) = match.groups()
+
+ # Per PEP-440, replace any 'DP' with an 'a', and any beta with 'b'
+ if self.ver_extra:
+ self.ver_extra = re.sub(r'^dp', 'dev', self.ver_extra, count=1)
+ self.ver_extra = re.sub(r'^alpha', 'a', self.ver_extra, count=1)
+ self.ver_extra = re.sub(r'^beta', 'b', self.ver_extra, count=1)
+ m = re.search(r'^([ab]|dev|rc|post)\.?(\d+)?', self.ver_extra)
+ if m is not None:
+ if m.group(1) in ['dev', 'post']:
+ self.ver_extra = '.' + self.ver_extra.replace('.', '')
+ if m.group(2) is None:
+ # No suffix, then add the number
+ first = self.ver_extra[0]
+ self.ver_extra = first + '0' + self.ver_extra[1:]
+
+ @property
+ def is_final(self) -> bool:
+ return self.ncommits == 0
+
+ @property
+ def is_prerelease(self) -> bool:
+ return self.ver_extra is not None and not self.ver_extra.isspace()
+
+ @property
+ def xyz_version(self) -> str:
+ return '.'.join((self.ver_maj, self.ver_min, self.ver_patch))
+
+ @property
+ def base_version(self) -> str:
+ """Returns the actual upstream version (without dev info)"""
+ components = [self.xyz_version]
+ if self.ver_extra:
+ components.append(self.ver_extra)
+ return ''.join(components)
+
+ @property
+ def package_version(self) -> str:
+ """Returns the well formed PEP-440 version"""
+ vbase = self.base_version
+ if self.ncommits:
+ if self.ver_extra:
+ vbase += f'+{self.sha}'
+ else:
+ vbase += f'.dev{self.ncommits}+{self.sha}'
+ return vbase
+
+
+def get_version() -> str:
+ """
+ Returns the version from the generated version file without actually
+ loading it (and thus trying to load the extension module).
+ """
+ if not os.path.exists(VERSION_FILE):
+ raise VersionNotFound(VERSION_FILE + ' does not exist')
+ fp = open(VERSION_FILE, 'r')
+ vline = None
+ for x in fp.readlines():
+ x = x.rstrip()
+ if not x:
+ continue
+ if not x.startswith('__version__'):
+ continue
+
+ vline = x.split('=')[1]
+ break
+ if not vline:
+ raise VersionNotFound('version file present but has no contents')
+
+ return vline.strip().rstrip().replace("'", '')
+
+
+def get_git_describe() -> str:
+ if not os.path.exists(os.path.join(os.path.dirname(__file__), '.git')):
+ raise CantInvokeGit('Not a git build')
+
+ try:
+ po = subprocess.Popen(
+ ('git', 'describe', '--tags', '--long', '--always'), stdout=subprocess.PIPE, stderr=subprocess.PIPE
+ )
+ except OSError as e:
+ raise CantInvokeGit(e) from None
+
+ stdout, stderr = po.communicate()
+ if po.returncode != 0:
+ raise CantInvokeGit("Couldn't invoke git describe", stderr)
+
+ return stdout.decode('utf-8').rstrip()
+
+
+def gen_version(
+ do_write: Optional[bool] = True, txt: Optional[str] = None, update_pyproject: Optional[bool] = False
+) -> None:
+ """
+ Generate a version based on git tag info. This will write the
+ couchbase_analytics/_version.py file. If not inside a git tree it will
+ raise a CantInvokeGit exception - which is normal
+ (and squashed by setup.py) if we are running from a tarball
+ """
+
+ if txt is None:
+ txt = get_git_describe()
+
+ t = txt.rsplit('-', 2)
+ if len(t) != 3:
+ only_sha = re.match('[a-z0-9]+', txt)
+ if only_sha is not None and only_sha.group():
+ txt = f'0.0.1-0-{txt}'
+
+ try:
+ info = VersionInfo(txt)
+ vstr = info.package_version
+ except MalformedGitTag:
+ warnings.warn("Malformed input '{0}'".format(txt), stacklevel=2)
+ vstr = '0.0.0' + txt
+
+ if not do_write:
+ print(vstr)
+ return
+
+ lines = (
+ '# This file automatically generated by',
+ '# {0}'.format(__file__),
+ '# at',
+ '# {0}'.format(datetime.datetime.now().isoformat(' ')),
+ "__version__ = '{0}'".format(vstr),
+ '',
+ )
+ with open(VERSION_FILE, 'w') as fp:
+ fp.write('\n'.join(lines))
+
+ if update_pyproject is True:
+ update_pyproject_version(os.path.join(os.path.dirname(__file__), 'pyproject.toml'), vstr)
+
+
+# uv does not support a dynamic project version (yet), this is a workaround in the interim
+def update_pyproject_version(pyproject_path: str, new_version: str) -> bool:
+ import tomli
+ import tomli_w # type: ignore[import-not-found]
+
+ if not os.path.exists(pyproject_path):
+ print(f"Error: pyproject.toml file not found at '{pyproject_path}'")
+ return False
+
+ try:
+ with open(pyproject_path, 'rb') as f:
+ data = tomli.load(f)
+
+ if 'project' in data and isinstance(data['project'], dict):
+ current_version = data['project'].get('version')
+ if current_version == new_version:
+ print(f"Version is already '{new_version}'. No update needed.")
+ return True
+
+ data['project']['version'] = new_version
+ print(f"Updated version from '{current_version}' to '{new_version}' in '{pyproject_path}'")
+
+ # Write the modified content back to the file
+ with open(pyproject_path, 'wb') as f:
+ tomli_w.dump(data, f)
+ return True
+ else:
+ print(f"Error: '[project]' section not found or is malformed in '{pyproject_path}'.")
+ return False
+
+ except tomli.TOMLDecodeError as e:
+ print(f"Error: Failed to parse pyproject.toml at '{pyproject_path}'. Invalid TOML format: {e}")
+ return False
+ except Exception as e:
+ print(f'An unexpected error occurred: {e}')
+ return False
+
+
+if __name__ == '__main__':
+ from argparse import ArgumentParser
+
+ ap = ArgumentParser(description='Parse git version to PEP-440 version')
+ ap.add_argument('-c', '--mode', choices=('show', 'make', 'parse'))
+ ap.add_argument('--update-pyproject', help='Update pyproject.toml with the version', action='store_true')
+ ap.add_argument('-i', '--input', help='Sample input string (instead of git)')
+ options = ap.parse_args()
+
+ cmd = options.mode
+ if cmd == 'show':
+ print(get_version())
+ elif cmd == 'make':
+ gen_version(do_write=True, txt=options.input, update_pyproject=options.update_pyproject)
+ print(get_version())
+ elif cmd == 'parse':
+ gen_version(do_write=False, txt=options.input)
+
+ else:
+ raise Exception("Command must be 'show' or 'make'")
diff --git a/examples/async/basic_cluster_query_anyio.py b/examples/async/basic_cluster_query_anyio.py
new file mode 100644
index 0000000..84cd32b
--- /dev/null
+++ b/examples/async/basic_cluster_query_anyio.py
@@ -0,0 +1,73 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from datetime import timedelta
+
+# NOTE: anyio is a dependency of acouchbase_analytics
+import anyio
+
+from acouchbase_analytics.cluster import AsyncCluster
+from acouchbase_analytics.credential import Credential
+from acouchbase_analytics.options import ClusterOptions, QueryOptions, TimeoutOptions
+
+
+async def main() -> None:
+ # Update this to your cluster
+ endpoint = 'https://--your-instance--'
+ username = 'username'
+ pw = 'Password!123'
+ # User Input ends here.
+
+ cred = Credential.from_username_and_password(username, pw)
+ # NOTE: Only an example on how to use options. Not a recommendation.
+ timeout_opts = TimeoutOptions(query_timeout=timedelta(seconds=30))
+ cluster = AsyncCluster.create_instance(endpoint, cred, ClusterOptions(timeout_options=timeout_opts))
+
+ # Execute a query and buffer all result rows in client memory.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline LIMIT 10;'
+ res = await cluster.execute_query(statement)
+ all_rows = await res.get_all_rows()
+ # NOTE: all_rows is a list, _do not_ use `async for`
+ for row in all_rows:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a query and process rows as they arrive from server.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country="United States" LIMIT 10;'
+ res = await cluster.execute_query(statement)
+ async for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming query with positional arguments.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$1 LIMIT $2;'
+ res = await cluster.execute_query(statement, QueryOptions(positional_parameters=['United States', 10]))
+ async for row in res:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming query with named arguments.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$country LIMIT $limit;'
+ res = await cluster.execute_query(
+ statement, QueryOptions(named_parameters={'country': 'United States', 'limit': 10})
+ )
+ async for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+
+if __name__ == '__main__':
+ anyio.run(main)
diff --git a/examples/async/basic_cluster_query_asyncio.py b/examples/async/basic_cluster_query_asyncio.py
new file mode 100644
index 0000000..39b9113
--- /dev/null
+++ b/examples/async/basic_cluster_query_asyncio.py
@@ -0,0 +1,70 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import asyncio
+from datetime import timedelta
+
+from acouchbase_analytics.cluster import AsyncCluster
+from acouchbase_analytics.credential import Credential
+from acouchbase_analytics.options import ClusterOptions, QueryOptions, TimeoutOptions
+
+
+async def main() -> None:
+ # Update this to your cluster
+ endpoint = 'https://--your-instance--'
+ username = 'username'
+ pw = 'Password!123'
+ # User Input ends here.
+
+ cred = Credential.from_username_and_password(username, pw)
+ # NOTE: Only an example on how to use options. Not a recommendation.
+ timeout_opts = TimeoutOptions(query_timeout=timedelta(seconds=30))
+ cluster = AsyncCluster.create_instance(endpoint, cred, ClusterOptions(timeout_options=timeout_opts))
+
+ # Execute a query and buffer all result rows in client memory.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline LIMIT 10;'
+ res = await cluster.execute_query(statement)
+ all_rows = await res.get_all_rows()
+ # NOTE: all_rows is a list, _do not_ use `async for`
+ for row in all_rows:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a query and process rows as they arrive from server.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country="United States" LIMIT 10;'
+ res = await cluster.execute_query(statement)
+ async for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming query with positional arguments.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$1 LIMIT $2;'
+ res = await cluster.execute_query(statement, QueryOptions(positional_parameters=['United States', 10]))
+ async for row in res:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming query with named arguments.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$country LIMIT $limit;'
+ res = await cluster.execute_query(
+ statement, QueryOptions(named_parameters={'country': 'United States', 'limit': 10})
+ )
+ async for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+
+if __name__ == '__main__':
+ asyncio.run(main())
diff --git a/examples/async/basic_scope_query_anyio.py b/examples/async/basic_scope_query_anyio.py
new file mode 100644
index 0000000..10e6961
--- /dev/null
+++ b/examples/async/basic_scope_query_anyio.py
@@ -0,0 +1,72 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from datetime import timedelta
+
+# NOTE: anyio is a dependency of acouchbase_analytics
+import anyio
+
+from acouchbase_analytics.cluster import AsyncCluster
+from acouchbase_analytics.credential import Credential
+from acouchbase_analytics.options import ClusterOptions, QueryOptions, TimeoutOptions
+
+
+async def main() -> None:
+ # Update this to your cluster
+ endpoint = 'https://--your-instance--'
+ username = 'username'
+ pw = 'Password!123'
+ # User Input ends here.
+
+ cred = Credential.from_username_and_password(username, pw)
+ # NOTE: Only an example on how to use options. Not a recommendation.
+ timeout_opts = TimeoutOptions(query_timeout=timedelta(seconds=30))
+ opts = ClusterOptions(timeout_options=timeout_opts)
+ scope = AsyncCluster.create_instance(endpoint, cred, opts).database('travel-sample').scope('inventory')
+
+ # Execute a scope-level query and buffer all result rows in client memory.
+ statement = 'SELECT * FROM airline LIMIT 10;'
+ res = await scope.execute_query(statement)
+ all_rows = await res.get_all_rows()
+ # NOTE: all_rows is a list, _do not_ use `async for`
+ for row in all_rows:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a scope-level query and process rows as they arrive from server.
+ statement = 'SELECT * FROM airline WHERE country="United States" LIMIT 10;'
+ res = await scope.execute_query(statement)
+ async for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming scope-level query with positional arguments.
+ statement = 'SELECT * FROM airline WHERE country=$1 LIMIT $2;'
+ res = await scope.execute_query(statement, QueryOptions(positional_parameters=['United States', 10]))
+ async for row in res:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming scope-level query with named arguments.
+ statement = 'SELECT * FROM airline WHERE country=$country LIMIT $limit;'
+ res = await scope.execute_query(statement, QueryOptions(named_parameters={'country': 'United States', 'limit': 10}))
+ async for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+
+if __name__ == '__main__':
+ anyio.run(main)
diff --git a/examples/async/basic_scope_query_asyncio.py b/examples/async/basic_scope_query_asyncio.py
new file mode 100644
index 0000000..368bc08
--- /dev/null
+++ b/examples/async/basic_scope_query_asyncio.py
@@ -0,0 +1,69 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import asyncio
+from datetime import timedelta
+
+from acouchbase_analytics.cluster import AsyncCluster
+from acouchbase_analytics.credential import Credential
+from acouchbase_analytics.options import ClusterOptions, QueryOptions, TimeoutOptions
+
+
+async def main() -> None:
+ # Update this to your cluster
+ endpoint = 'https://--your-instance--'
+ username = 'username'
+ pw = 'Password!123'
+ # User Input ends here.
+
+ cred = Credential.from_username_and_password(username, pw)
+ # NOTE: Only an example on how to use options. Not a recommendation.
+ timeout_opts = TimeoutOptions(query_timeout=timedelta(seconds=30))
+ opts = ClusterOptions(timeout_options=timeout_opts)
+ scope = AsyncCluster.create_instance(endpoint, cred, opts).database('travel-sample').scope('inventory')
+
+ # Execute a scope-level query and buffer all result rows in client memory.
+ statement = 'SELECT * FROM airline LIMIT 10;'
+ res = await scope.execute_query(statement)
+ all_rows = await res.get_all_rows()
+ # NOTE: all_rows is a list, _do not_ use `async for`
+ for row in all_rows:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a scope-level query and process rows as they arrive from server.
+ statement = 'SELECT * FROM airline WHERE country="United States" LIMIT 10;'
+ res = await scope.execute_query(statement)
+ async for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming scope-level query with positional arguments.
+ statement = 'SELECT * FROM airline WHERE country=$1 LIMIT $2;'
+ res = await scope.execute_query(statement, QueryOptions(positional_parameters=['United States', 10]))
+ async for row in res:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming scope-level query with named arguments.
+ statement = 'SELECT * FROM airline WHERE country=$country LIMIT $limit;'
+ res = await scope.execute_query(statement, QueryOptions(named_parameters={'country': 'United States', 'limit': 10}))
+ async for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+
+if __name__ == '__main__':
+ asyncio.run(main())
diff --git a/examples/sync/basic_cluster_query.py b/examples/sync/basic_cluster_query.py
new file mode 100644
index 0000000..9147e81
--- /dev/null
+++ b/examples/sync/basic_cluster_query.py
@@ -0,0 +1,66 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from datetime import timedelta
+
+from couchbase_analytics.cluster import Cluster
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.options import ClusterOptions, QueryOptions, TimeoutOptions
+
+
+def main() -> None:
+ # Update this to your cluster
+ endpoint = 'couchbases://--your-instance--'
+ username = 'username'
+ pw = 'Password!123'
+ # User Input ends here.
+
+ cred = Credential.from_username_and_password(username, pw)
+ # NOTE: Only an example on how to use options. Not a recommendation.
+ timeout_opts = TimeoutOptions(query_timeout=timedelta(seconds=30))
+ cluster = Cluster.create_instance(endpoint, cred, ClusterOptions(timeout_options=timeout_opts))
+
+ # Execute a query and buffer all result rows in client memory.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline LIMIT 10;'
+ res = cluster.execute_query(statement)
+ all_rows = res.get_all_rows()
+ for row in all_rows:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a query and process rows as they arrive from server.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country="United States" LIMIT 10;'
+ res = cluster.execute_query(statement)
+ for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming query with positional arguments.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$1 LIMIT $2;'
+ res = cluster.execute_query(statement, QueryOptions(positional_parameters=['United States', 10]))
+ for row in res:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming query with named arguments.
+ statement = 'SELECT * FROM `travel-sample`.inventory.airline WHERE country=$country LIMIT $limit;'
+ res = cluster.execute_query(statement, QueryOptions(named_parameters={'country': 'United States', 'limit': 10}))
+ for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/examples/sync/basic_scope_query.py b/examples/sync/basic_scope_query.py
new file mode 100644
index 0000000..8b74cac
--- /dev/null
+++ b/examples/sync/basic_scope_query.py
@@ -0,0 +1,67 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from datetime import timedelta
+
+from couchbase_analytics.cluster import Cluster
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.options import ClusterOptions, QueryOptions, TimeoutOptions
+
+
+def main() -> None:
+ # Update this to your cluster
+ endpoint = 'couchbases://--your-instance--'
+ username = 'username'
+ pw = 'Password!123'
+ # User Input ends here.
+
+ cred = Credential.from_username_and_password(username, pw)
+ # NOTE: Only an example on how to use options. Not a recommendation.
+ timeout_opts = TimeoutOptions(query_timeout=timedelta(seconds=30))
+ opts = ClusterOptions(timeout_options=timeout_opts)
+ scope = Cluster.create_instance(endpoint, cred, opts).database('travel-sample').scope('inventory')
+
+ # Execute a scope-level query and buffer all result rows in client memory.
+ statement = 'SELECT * FROM airline LIMIT 10;'
+ res = scope.execute_query(statement)
+ all_rows = res.get_all_rows()
+ for row in all_rows:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a scope-level query and process rows as they arrive from server.
+ statement = 'SELECT * FROM airline WHERE country="United States" LIMIT 10;'
+ res = scope.execute_query(statement)
+ for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming scope-level query with positional arguments.
+ statement = 'SELECT * FROM airline WHERE country=$1 LIMIT $2;'
+ res = scope.execute_query(statement, QueryOptions(positional_parameters=['United States', 10]))
+ for row in res:
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+ # Execute a streaming scope-level query with named arguments.
+ statement = 'SELECT * FROM airline WHERE country=$country LIMIT $limit;'
+ res = scope.execute_query(statement, QueryOptions(named_parameters={'country': 'United States', 'limit': 10}))
+ for row in res.rows():
+ print(f'Found row: {row}')
+ print(f'metadata={res.metadata()}')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 0000000..7a07981
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,19 @@
+[mypy]
+exclude = (?x)(
+ setup\.py$
+ | ci_scripts/
+ | docs/
+ | tests/utils/
+ )
+
+[mypy-ijson.*]
+ignore_missing_imports = True
+
+[mypy-pytest.*]
+ignore_missing_imports = True
+
+[mypy-setuptools.*]
+ignore_missing_imports = True
+
+[mypy-tests.utils.*]
+follow_imports = skip
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..471cd09
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,133 @@
+[build-system]
+requires = [
+ "setuptools>=65",
+ "wheel",
+]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "couchbase-analytics"
+version = "1.0.0.dev1"
+dependencies = [
+ "anyio~=4.9.0",
+ "httpx~=0.28.1",
+ "ijson~=3.4.0",
+ "sniffio~=1.3.1",
+ "typing-extensions~=4.11; python_version<'3.11'",
+]
+requires-python = ">=3.9"
+authors = [
+ { name = "Couchbase, Inc.", email = "PythonPackage@couchbase.com" },
+]
+maintainers = [
+ { name = "Couchbase, Inc.", email = "PythonPackage@couchbase.com" },
+]
+description = "Python Client for Couchbase Analytics"
+readme = "README.md"
+keywords = [
+ "couchbase",
+ "nosql",
+ "pycouchbase",
+ "couchbase++",
+ "analytics",
+]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: Apache Software License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: Implementation :: CPython",
+ "Topic :: Database",
+ "Topic :: Software Development :: Libraries",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+]
+
+[project.license]
+file = "LICENSE"
+
+[project.urls]
+Homepage = "https://couchbase.com"
+Documentation = "https://docs.couchbase.com/python-analytics-sdk/current/hello-world/overview.html"
+"API Reference" = "https://docs.couchbase.com/sdk-api/analytics-python-client/"
+Repository = "https://github.com/couchbase/analytics-python-client"
+"Bug Tracker" = "https://issues.couchbase.com/projects/PYCO/issues/"
+"Release Notes" = "https://docs.couchbase.com/python-analytics-sdk/current/project-docs/analytics-sdk-release-notes.html"
+
+[dependency-groups]
+dev = [
+ "aiohttp~=3.11.10",
+ "mypy~=1.16.1",
+ "pre-commit~=4.2.0",
+ "pytest~=8.3.5",
+ "ruff~=0.12.0",
+ "tomli~=2.2.1",
+ "tomli-w~=1.2.0",
+]
+sphinx = [
+ "Sphinx~=7.4.7",
+ "sphinx-rtd-theme~=2.0",
+ "sphinx-copybutton~=0.5",
+ "enum-tools~=0.12",
+ "sphinx-toolbox~=3.7",
+]
+
+[tool.setuptools]
+include-package-data = true
+
+[tool.setuptools.packages.find]
+include = [
+ "acouchbase_analytics",
+ "couchbase_analytics",
+ "acouchbase_analytics.*",
+ "couchbase_analytics.*",
+]
+exclude = [
+ "acouchbase_analytics.tests",
+ "couchbase_analytics.tests",
+]
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+log_cli = true
+testpaths = [
+ "tests",
+ "acouchbase_analytics/tests",
+ "couchbase_analytics/tests",
+]
+python_classes = [
+ "*Tests",
+]
+python_files = [
+ "*_t.py",
+]
+markers = [
+ "pycbac_couchbase: marks a test for the couchbase API (deselect with '-m \"not pycbac_couchbase\"')",
+ "pycbac_acouchbase: marks a test for the acouchbase API (deselect with '-m \"not pycbac_acouchbase\"')",
+ "pycbac_unit: marks a test as a unit test",
+ "pycbac_integration: marks a test as an integration test",
+]
+
+[tool.ruff]
+line-length = 120
+extend-exclude = [
+ "tests/test_config.ini",
+]
+
+[tool.ruff.lint]
+select = [
+ "E",
+ "F",
+ "B",
+ "C",
+ "I",
+]
+
+[tool.ruff.format]
+quote-style = "single"
+docstring-code-format = false
diff --git a/requirements-dev.in b/requirements-dev.in
new file mode 100644
index 0000000..3361cb1
--- /dev/null
+++ b/requirements-dev.in
@@ -0,0 +1,7 @@
+aiohttp~=3.11.10
+mypy~=1.16.1
+pre-commit~=4.2.0
+pytest~=8.3.5
+ruff~=0.12.0
+tomli~=2.2.1
+tomli-w~=1.2.0
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..d97c12f
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,78 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements-dev.in --python-version 3.9 --universal -o requirements-dev.txt
+aiohappyeyeballs==2.6.1
+ # via aiohttp
+aiohttp==3.11.18
+ # via -r requirements-dev.in
+aiosignal==1.3.2
+ # via aiohttp
+async-timeout==5.0.1 ; python_full_version < '3.11'
+ # via aiohttp
+attrs==25.3.0
+ # via aiohttp
+cfgv==3.4.0
+ # via pre-commit
+colorama==0.4.6 ; sys_platform == 'win32'
+ # via pytest
+distlib==0.3.9
+ # via virtualenv
+exceptiongroup==1.3.0 ; python_full_version < '3.11'
+ # via pytest
+filelock==3.18.0
+ # via virtualenv
+frozenlist==1.7.0
+ # via
+ # aiohttp
+ # aiosignal
+identify==2.6.12
+ # via pre-commit
+idna==3.10
+ # via yarl
+iniconfig==2.1.0
+ # via pytest
+multidict==6.6.3
+ # via
+ # aiohttp
+ # yarl
+mypy==1.16.1
+ # via -r requirements-dev.in
+mypy-extensions==1.1.0
+ # via mypy
+nodeenv==1.9.1
+ # via pre-commit
+packaging==25.0
+ # via pytest
+pathspec==0.12.1
+ # via mypy
+platformdirs==4.3.8
+ # via virtualenv
+pluggy==1.6.0
+ # via pytest
+pre-commit==4.2.0
+ # via -r requirements-dev.in
+propcache==0.3.2
+ # via
+ # aiohttp
+ # yarl
+pytest==8.3.5
+ # via -r requirements-dev.in
+pyyaml==6.0.2
+ # via pre-commit
+ruff==0.12.2
+ # via -r requirements-dev.in
+tomli==2.2.1
+ # via
+ # -r requirements-dev.in
+ # mypy
+ # pytest
+tomli-w==1.2.0
+ # via -r requirements-dev.in
+typing-extensions==4.14.0
+ # via
+ # exceptiongroup
+ # multidict
+ # mypy
+virtualenv==20.31.2
+ # via pre-commit
+yarl==1.20.1
+ # via aiohttp
diff --git a/requirements-sphinx.in b/requirements-sphinx.in
new file mode 100644
index 0000000..8733811
--- /dev/null
+++ b/requirements-sphinx.in
@@ -0,0 +1,5 @@
+Sphinx~=7.4.7
+sphinx-rtd-theme~=2.0
+sphinx-copybutton~=0.5
+enum-tools~=0.12
+sphinx-toolbox~=3.7
diff --git a/requirements-sphinx.txt b/requirements-sphinx.txt
new file mode 100644
index 0000000..0c15c0b
--- /dev/null
+++ b/requirements-sphinx.txt
@@ -0,0 +1,160 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements-sphinx.in --python-version 3.9 --universal -o requirements-sphinx.txt
+alabaster==0.7.16
+ # via sphinx
+apeye==1.4.1
+ # via sphinx-toolbox
+apeye-core==1.1.5
+ # via apeye
+autodocsumm==0.2.14
+ # via sphinx-toolbox
+babel==2.17.0
+ # via sphinx
+beautifulsoup4==4.13.4
+ # via sphinx-toolbox
+cachecontrol==0.14.3
+ # via sphinx-toolbox
+certifi==2025.6.15
+ # via
+ # requests
+ # sphinx-prompt
+charset-normalizer==3.4.2
+ # via requests
+colorama==0.4.6 ; sys_platform == 'win32'
+ # via sphinx
+cssutils==2.11.1
+ # via dict2css
+dict2css==0.3.0.post1
+ # via sphinx-toolbox
+docutils==0.20.1
+ # via
+ # sphinx
+ # sphinx-prompt
+ # sphinx-rtd-theme
+ # sphinx-tabs
+ # sphinx-toolbox
+domdf-python-tools==3.10.0
+ # via
+ # apeye
+ # apeye-core
+ # dict2css
+ # sphinx-toolbox
+enum-tools==0.13.0
+ # via -r requirements-sphinx.in
+filelock==3.18.0
+ # via
+ # cachecontrol
+ # sphinx-toolbox
+html5lib==1.1
+ # via sphinx-toolbox
+idna==3.10
+ # via
+ # apeye-core
+ # requests
+ # sphinx-prompt
+imagesize==1.4.1
+ # via sphinx
+importlib-metadata==8.7.0 ; python_full_version < '3.10'
+ # via sphinx
+jinja2==3.1.6
+ # via
+ # sphinx
+ # sphinx-jinja2-compat
+ # sphinx-prompt
+markupsafe==3.0.2
+ # via
+ # jinja2
+ # sphinx-jinja2-compat
+more-itertools==10.7.0
+ # via cssutils
+msgpack==1.1.1
+ # via cachecontrol
+natsort==8.4.0
+ # via domdf-python-tools
+packaging==25.0
+ # via sphinx
+platformdirs==4.3.8
+ # via apeye
+pygments==2.19.2
+ # via
+ # enum-tools
+ # sphinx
+ # sphinx-prompt
+ # sphinx-tabs
+requests==2.32.4
+ # via
+ # apeye
+ # cachecontrol
+ # sphinx
+ # sphinx-prompt
+ruamel-yaml==0.18.14
+ # via sphinx-toolbox
+ruamel-yaml-clib==0.2.12 ; python_full_version < '3.14' and platform_python_implementation == 'CPython'
+ # via ruamel-yaml
+six==1.17.0
+ # via html5lib
+snowballstemmer==3.0.1
+ # via sphinx
+soupsieve==2.7
+ # via beautifulsoup4
+sphinx==7.4.7
+ # via
+ # -r requirements-sphinx.in
+ # autodocsumm
+ # sphinx-autodoc-typehints
+ # sphinx-copybutton
+ # sphinx-prompt
+ # sphinx-rtd-theme
+ # sphinx-tabs
+ # sphinx-toolbox
+ # sphinxcontrib-jquery
+sphinx-autodoc-typehints==2.3.0
+ # via sphinx-toolbox
+sphinx-copybutton==0.5.2
+ # via -r requirements-sphinx.in
+sphinx-jinja2-compat==0.3.0
+ # via sphinx-toolbox
+sphinx-prompt==1.8.0 ; python_full_version < '3.11'
+ # via sphinx-toolbox
+sphinx-prompt==1.10.0 ; python_full_version >= '3.11'
+ # via sphinx-toolbox
+sphinx-rtd-theme==2.0.0
+ # via -r requirements-sphinx.in
+sphinx-tabs==3.4.5
+ # via sphinx-toolbox
+sphinx-toolbox==3.10.0
+ # via -r requirements-sphinx.in
+sphinxcontrib-applehelp==2.0.0
+ # via sphinx
+sphinxcontrib-devhelp==2.0.0
+ # via sphinx
+sphinxcontrib-htmlhelp==2.1.0
+ # via sphinx
+sphinxcontrib-jquery==4.1
+ # via sphinx-rtd-theme
+sphinxcontrib-jsmath==1.0.1
+ # via sphinx
+sphinxcontrib-qthelp==2.0.0
+ # via sphinx
+sphinxcontrib-serializinghtml==2.0.0
+ # via sphinx
+standard-imghdr==3.10.14 ; python_full_version >= '3.13'
+ # via sphinx-jinja2-compat
+tabulate==0.9.0
+ # via sphinx-toolbox
+tomli==2.2.1 ; python_full_version < '3.11'
+ # via sphinx
+typing-extensions==4.14.0
+ # via
+ # beautifulsoup4
+ # domdf-python-tools
+ # enum-tools
+ # sphinx-toolbox
+urllib3==2.5.0
+ # via
+ # requests
+ # sphinx-prompt
+webencodings==0.5.1
+ # via html5lib
+zipp==3.23.0 ; python_full_version < '3.10'
+ # via importlib-metadata
diff --git a/requirements.in b/requirements.in
new file mode 100644
index 0000000..43c2e1d
--- /dev/null
+++ b/requirements.in
@@ -0,0 +1,6 @@
+anyio~=4.9.0
+sniffio~=1.3.1
+httpx~=0.28.1
+ijson~=3.4.0
+# Typing support
+typing-extensions~=4.11; python_version<"3.11.0"
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..a97f0ed
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,33 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements.in --python-version 3.9 --universal -o requirements.txt
+anyio==4.9.0
+ # via
+ # -r requirements.in
+ # httpx
+certifi==2025.6.15
+ # via
+ # httpcore
+ # httpx
+exceptiongroup==1.3.0 ; python_full_version < '3.11'
+ # via anyio
+h11==0.16.0
+ # via httpcore
+httpcore==1.0.9
+ # via httpx
+httpx==0.28.1
+ # via -r requirements.in
+idna==3.10
+ # via
+ # anyio
+ # httpx
+ijson==3.4.0
+ # via -r requirements.in
+sniffio==1.3.1
+ # via
+ # -r requirements.in
+ # anyio
+typing-extensions==4.14.0 ; python_full_version < '3.13'
+ # via
+ # -r requirements.in
+ # anyio
+ # exceptiongroup
diff --git a/run-mypy b/run-mypy
new file mode 100755
index 0000000..48288a9
--- /dev/null
+++ b/run-mypy
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+set -o errexit
+
+cd "$(dirname "$0")"
+
+mypy . --strict
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..122ec77
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,39 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+import sys
+
+from setuptools import setup
+
+sys.path.append('.')
+import couchbase_analytics_version # nopep8 # isort:skip # noqa: E402
+
+try:
+ couchbase_analytics_version.gen_version()
+except couchbase_analytics_version.CantInvokeGit:
+ pass
+
+PYCBAC_README = os.path.join(os.path.dirname(__file__), 'README.md')
+PYCBAC_VERSION = couchbase_analytics_version.get_version()
+
+print(f'Python Analytics SDK version: {PYCBAC_VERSION}')
+
+setup(
+ name='couchbase-analytics',
+ version=PYCBAC_VERSION,
+ long_description=open(PYCBAC_README, 'r').read(),
+ long_description_content_type='text/markdown',
+)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..362877b
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,40 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from enum import Enum
+from typing import AsyncGenerator, Generator, Optional, TypeVar
+
+T = TypeVar('T')
+AsyncYieldFixture = AsyncGenerator[T, None]
+YieldFixture = Generator[T, None, None]
+
+
+class SyncQueryType(Enum):
+ NORMAL = 'normal'
+ LAZY = 'lazy'
+ CANCELLABLE = 'cancellable'
+
+
+class AnalyticsTestEnvironmentError(Exception):
+ """Raised when something with the test environment is incorrect."""
+
+ def __init__(self, message: Optional[str] = None) -> None:
+ super().__init__(message)
+
+ def __repr__(self) -> str:
+ return f'{type(self).__name__}({super().__repr__()})'
+
+ def __str__(self) -> str:
+ return self.__repr__()
diff --git a/tests/analytics_config.py b/tests/analytics_config.py
new file mode 100644
index 0000000..2dfea05
--- /dev/null
+++ b/tests/analytics_config.py
@@ -0,0 +1,151 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import os
+import pathlib
+from configparser import ConfigParser
+from typing import Optional, Tuple
+from uuid import uuid4
+
+import pytest
+
+from tests import AnalyticsTestEnvironmentError
+
+BASEDIR = pathlib.Path(__file__).parent.parent
+CONFIG_FILE = os.path.join(pathlib.Path(__file__).parent, 'test_config.ini')
+ENV_TRUE = ['true', '1', 'y', 'yes', 'on']
+
+
+class AnalyticsConfig:
+ def __init__(self) -> None:
+ self._scheme = 'http'
+ self._host = 'localhost'
+ self._port = 8095
+ self._username = 'Administrator'
+ self._password = 'password'
+ self._nonprod = False
+ self._database_name = ''
+ self._scope_name = ''
+ self._collection_name = ''
+ self._disable_server_certificate_verification = False
+ self._create_keyspace = False
+
+ @property
+ def database_name(self) -> str:
+ return self._database_name
+
+ @property
+ def collection_name(self) -> str:
+ return self._collection_name
+
+ @property
+ def create_keyspace(self) -> bool:
+ return self._create_keyspace
+
+ @property
+ def fqdn(self) -> str:
+ return f'`{self._database_name}`.`{self._scope_name}`.`{self._collection_name}`'
+
+ @property
+ def nonprod(self) -> bool:
+ return self._nonprod
+
+ @property
+ def disable_server_certificate_verification(self) -> bool:
+ return self._disable_server_certificate_verification
+
+ @property
+ def scope_name(self) -> str:
+ return self._scope_name
+
+ def get_connection_string(self, ignore_port: Optional[bool] = False) -> str:
+ if ignore_port is None or ignore_port is False and self._port is not None:
+ return f'{self._scheme}://{self._host}:{self._port}'
+ return f'{self._scheme}://{self._host}'
+
+ def get_username_and_pw(self) -> Tuple[str, str]:
+ return self._username, self._password
+
+ @classmethod
+ def load_config(cls) -> AnalyticsConfig:
+ analytics_config = cls()
+ try:
+ test_config = ConfigParser()
+ test_config.read(CONFIG_FILE)
+ test_config_analytics = test_config['analytics']
+ analytics_config._scheme = os.environ.get(
+ 'PYCBAC_SCHEME', test_config_analytics.get('scheme', fallback='https')
+ )
+ analytics_config._host = os.environ.get(
+ 'PYCBAC_HOST', test_config_analytics.get('host', fallback='localhost')
+ )
+ port = os.environ.get('PYCBAC_PORT', test_config_analytics.get('port', fallback='8095'))
+ analytics_config._port = int(port)
+ analytics_config._username = os.environ.get(
+ 'PYCBAC_USERNAME', test_config_analytics.get('username', fallback='Administrator')
+ )
+ analytics_config._password = os.environ.get(
+ 'PYCBAC_PASSWORD', test_config_analytics.get('password', fallback='password')
+ )
+ use_nonprod = os.environ.get('PYCBAC_NONPROD', test_config_analytics.get('nonprod', fallback='OFF'))
+ if use_nonprod.lower() in ENV_TRUE:
+ analytics_config._nonprod = True
+ else:
+ analytics_config._nonprod = False
+ analytics_config._database_name = os.environ.get(
+ 'PYCBAC_DATABASE', test_config_analytics.get('database_name', fallback='travel-sample')
+ )
+ analytics_config._scope_name = os.environ.get(
+ 'PYCBAC_SCOPE', test_config_analytics.get('scope_name', fallback='inventory')
+ )
+ analytics_config._collection_name = os.environ.get(
+ 'PYCBAC_COLLECTION', test_config_analytics.get('collection_name', fallback='airline')
+ )
+ disable_cert_verification = os.environ.get(
+ 'PYCBAC_DISABLE_SERVER_CERT_VERIFICATION',
+ test_config_analytics.get('disable_server_cert_verification', fallback='ON'),
+ )
+ if disable_cert_verification.lower() in ENV_TRUE:
+ analytics_config._disable_server_certificate_verification = True
+ fqdn = os.environ.get('PYCBAC_FQDN', test_config_analytics.get('fqdn', fallback=None))
+ if fqdn is not None:
+ fqdn_tokens = fqdn.split('.')
+ if len(fqdn_tokens) != 3:
+ raise AnalyticsTestEnvironmentError(
+ (f'Invalid FQDN provided. Expected database.scope.collection. FQDN provide={fqdn}')
+ )
+
+ analytics_config._database_name = f'{fqdn_tokens[0]}'
+ analytics_config._scope_name = f'{fqdn_tokens[1]}'
+ analytics_config._collection_name = f'{fqdn_tokens[2]}'
+ analytics_config._create_keyspace = False
+ else:
+ # lets make the database unique (enough)
+ analytics_config._database_name = f'travel-sample-{str(uuid4())[:8]}'
+ analytics_config._scope_name = 'inventory'
+ analytics_config._collection_name = 'airline'
+
+ except Exception as ex:
+ raise AnalyticsTestEnvironmentError(f'Problem trying read/load test configuration:\n{ex}') from None
+
+ return analytics_config
+
+
+@pytest.fixture(name='analytics_config', scope='session')
+def analytics_test_config() -> AnalyticsConfig:
+ config = AnalyticsConfig.load_config()
+ return config
diff --git a/tests/environments/__init__.py b/tests/environments/__init__.py
new file mode 100644
index 0000000..72df2de
--- /dev/null
+++ b/tests/environments/__init__.py
@@ -0,0 +1,14 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/tests/environments/base_environment.py b/tests/environments/base_environment.py
new file mode 100644
index 0000000..8afd4ab
--- /dev/null
+++ b/tests/environments/base_environment.py
@@ -0,0 +1,600 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import json
+import pathlib
+import sys
+from os import path
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict, Union
+
+if sys.version_info < (3, 11):
+ from typing_extensions import Unpack
+else:
+ from typing import Unpack
+
+import anyio
+import pytest
+
+from acouchbase_analytics.cluster import AsyncCluster
+from acouchbase_analytics.result import AsyncQueryResult
+from acouchbase_analytics.scope import AsyncScope
+from couchbase_analytics.cluster import Cluster
+from couchbase_analytics.credential import Credential
+from couchbase_analytics.options import ClusterOptions, SecurityOptions
+from couchbase_analytics.result import BlockingQueryResult
+from couchbase_analytics.scope import Scope
+from tests import AnalyticsTestEnvironmentError
+from tests.test_server import ResultType
+from tests.utils._run_web_server import WebServerHandler
+
+if TYPE_CHECKING:
+ from tests.analytics_config import AnalyticsConfig
+
+
+TEST_AIRLINE_DATA_PATH = path.join(pathlib.Path(__file__).parent.parent, 'test_data', 'airline.json')
+
+
+class TestEnvironmentOptionsKwargs(TypedDict, total=False):
+ async_cluster: Optional[AsyncCluster]
+ cluster: Optional[Cluster]
+ database_name: Optional[str]
+ scope_name: Optional[str]
+ collection_name: Optional[str]
+ server_handler: Optional[WebServerHandler]
+ backend: Optional[str]
+
+
+class TestEnvironment:
+ def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOptionsKwargs]) -> None:
+ self._config = config
+ self._async_cluster = kwargs.pop('async_cluster', None)
+ self._cluster = kwargs.pop('cluster', None)
+ self._database_name = kwargs.pop('database_name', None)
+ self._scope_name = kwargs.pop('scope_name', None)
+ self._collection_name = kwargs.pop('collection_name', None)
+ self._async_scope: Optional[AsyncScope] = None
+ self._scope: Optional[Scope] = None
+ self._use_scope = False
+ self._server_handler = kwargs.pop('server_handler', None)
+
+ @property
+ def config(self) -> AnalyticsConfig:
+ return self._config
+
+ @property
+ def fqdn(self) -> str:
+ return self.config.fqdn
+
+ @property
+ def collection_name(self) -> Optional[str]:
+ return self._collection_name
+
+ @property
+ def use_scope(self) -> bool:
+ return self._use_scope
+
+ def assert_error_context_num_attempts(
+ self, expected_attempts: int, context: Optional[str], exact: Optional[bool] = True
+ ) -> None:
+ assert isinstance(context, str)
+ ctx_keys = context.replace('{', '').replace('}', '').split(',')
+ assert len(ctx_keys) > 1
+ match = next((k for k in ctx_keys if 'num_attempts' in k), None)
+ assert match is not None
+ match_keys = match.split()
+ assert len(match_keys) == 2
+ if exact is True:
+ assert int(match_keys[1].replace("'", '').replace('"', '')) == expected_attempts
+ else:
+ assert int(match_keys[1].replace("'", '').replace('"', '')) >= expected_attempts
+
+ def assert_error_context_contains_last_dispatch(self, context: Optional[str]) -> None:
+ assert isinstance(context, str)
+ ctx_keys = context.replace('{', '').replace('}', '').split(',')
+ assert len(ctx_keys) > 1
+ match = next((k for k in ctx_keys if 'last_dispatched_to' in k), None)
+ assert match is not None
+ match = next((k for k in ctx_keys if 'last_dispatched_from' in k), None)
+ assert match is not None
+
+ def assert_error_context_missing_last_dispatch(self, context: Optional[str] = None) -> None:
+ if context is None:
+ return
+ assert isinstance(context, str)
+ ctx_keys = context.replace('{', '').replace('}', '').split(',')
+ assert len(ctx_keys) > 1
+ match = next((k for k in ctx_keys if 'last_dispatched_to' in k), None)
+ assert match is None
+ match = next((k for k in ctx_keys if 'last_dispatched_from' in k), None)
+ assert match is None
+
+ def load_collection_data_from_file(self, file_path: str, limit: Optional[int] = 100) -> List[Dict[str, Any]]:
+ with open(file_path, mode='+r') as json_file:
+ json_data: List[Dict[str, Any]] = json.load(json_file)
+
+ if limit is not None and len(json_data) > limit:
+ return json_data[:limit]
+ return json_data
+
+
+class BlockingTestEnvironment(TestEnvironment):
+ def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOptionsKwargs]) -> None:
+ super().__init__(config, **kwargs)
+
+ @property
+ def cluster(self) -> Cluster:
+ if self._cluster is None:
+ raise AnalyticsTestEnvironmentError('No cluster available.')
+ return self._cluster
+
+ @property
+ def scope(self) -> Scope:
+ if self._scope is None:
+ raise AnalyticsTestEnvironmentError('No scope available.')
+ return self._scope
+
+ @property
+ def cluster_or_scope(self) -> Union[Cluster, Scope]:
+ if self._scope is not None:
+ return self.scope
+ return self.cluster
+
+ def assert_rows(self, result: BlockingQueryResult, expected_count: int) -> None:
+ count = 0
+ assert isinstance(result, (BlockingQueryResult,))
+ for row in result.rows():
+ assert row is not None
+ count += 1
+ assert count >= expected_count
+
+ def assert_streaming_response_state(self, result: BlockingQueryResult) -> None:
+ assert hasattr(result._http_response, '_core_response') is False
+ assert result._http_response._request_context.is_shutdown is True
+
+ def disable_scope(self) -> BlockingTestEnvironment:
+ self._scope = None
+ self._use_scope = False
+ return self
+
+ def disable_test_server(self) -> BlockingTestEnvironment:
+ if self._server_handler is not None:
+ self._server_handler.stop_server()
+ # self._server_handler = None
+ return self
+
+ def enable_scope(
+ self, database_name: Optional[str] = None, scope_name: Optional[str] = None
+ ) -> BlockingTestEnvironment:
+ if self._cluster is None:
+ raise AnalyticsTestEnvironmentError('No cluster available.')
+ db_name = database_name if database_name is not None else self._database_name
+ if db_name is None:
+ raise AnalyticsTestEnvironmentError('Cannot create scope without a database name.')
+ scope_name = scope_name if scope_name is not None else self._scope_name
+ if scope_name is None:
+ raise AnalyticsTestEnvironmentError('Cannot create scope without a scope name.')
+ self._scope = self._cluster.database(db_name).scope(scope_name)
+ self._use_scope = True
+ return self
+
+ def enable_test_server(self) -> BlockingTestEnvironment:
+ if self._server_handler is None:
+ raise AnalyticsTestEnvironmentError('No server handler provided, cannot enable test server.')
+ if self._cluster is None or not hasattr(self._cluster, '_impl'):
+ raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.')
+ from tests.utils._client_adapter import _TestClientAdapter
+ from tests.utils._test_httpx import TestHTTPTransport
+
+ print(f'{self._cluster=}')
+ new_adapter = _TestClientAdapter(
+ adapter=self._cluster._impl._client_adapter,
+ http_transport_cls=TestHTTPTransport,
+ )
+ new_adapter.create_client()
+ self._cluster._impl._client_adapter = new_adapter
+ url = self._cluster._impl.client_adapter.connection_details.url.get_formatted_url()
+ print(f'Connecting to test server at {url}')
+ self._server_handler.start_server()
+ self.warmup_test_server()
+ return self
+
+ def setup(self) -> None:
+ if self.config.create_keyspace is False:
+ return
+
+ setup_statements = [
+ f'CREATE DATABASE `{self.config.database_name}` IF NOT EXISTS;',
+ f'CREATE SCOPE `{self.config.database_name}`.`{self.config.scope_name}` IF NOT EXISTS;',
+ (
+ 'CREATE COLLECTION '
+ f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`'
+ ' IF NOT EXISTS PRIMARY KEY (pk: UUID) AUTOGENERATED;'
+ ),
+ ]
+
+ for statement in setup_statements:
+ try:
+ self.cluster.execute_query(statement)
+ except Exception as ex:
+ raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') from None
+
+ json_data = self.load_collection_data_from_file(TEST_AIRLINE_DATA_PATH)
+ docs = []
+ for d in json_data:
+ if 'collection' in d:
+ d['collection'] = self.config.collection_name
+ if 'scope' in d:
+ d['scope'] = self.config.scope_name
+ docs.append(json.dumps(d))
+ statement = (
+ f'USE `{self.config.database_name}`.`{self.config.scope_name}`; '
+ f'UPSERT INTO `{self.config.collection_name}` ({",".join(docs)})'
+ )
+
+ try:
+ self.cluster.execute_query(statement)
+ except Exception as ex:
+ raise AnalyticsTestEnvironmentError(f'Unable to load collection data. Error: {ex}') from None
+
+ def set_url_path(self, url_path: str) -> None:
+ if self._server_handler is None:
+ raise AnalyticsTestEnvironmentError('No server handler provided, cannot set URL path.')
+ if self._cluster is None or not hasattr(self._cluster, '_impl'):
+ raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.')
+ self._cluster._impl._client_adapter.set_request_path(url_path)
+
+ def teardown(self) -> None:
+ if self.config.create_keyspace is False:
+ return
+
+ teardown_statements = [
+ f'DROP DATABASE `{self.config.database_name}` IF EXISTS;',
+ f'DROP SCOPE `{self.config.database_name}`.`{self.config.scope_name}` IF EXISTS;',
+ (
+ 'DROP COLLECTION '
+ f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`'
+ ' IF EXISTS;'
+ ),
+ ]
+
+ for statement in teardown_statements:
+ try:
+ self.cluster.execute_query(statement)
+ except Exception as ex:
+ raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') from None
+
+ def update_request_extensions(self, extensions: Dict[str, object]) -> None:
+ if self._server_handler is None:
+ raise AnalyticsTestEnvironmentError('No server handler provided, cannot update request extensions.')
+ if self._cluster is None or not hasattr(self._cluster, '_impl'):
+ raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.')
+ self._cluster._impl._client_adapter.update_request_extensions(extensions)
+
+ def update_request_json(self, json: Dict[str, object]) -> None:
+ if self._server_handler is None:
+ raise AnalyticsTestEnvironmentError('No server handler provided, cannot update request JSON.')
+ if self._cluster is None or not hasattr(self._cluster, '_impl'):
+ raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.')
+ self._cluster._impl._client_adapter.update_request_json(json)
+
+ def warmup_test_server(self) -> None:
+ row_count = 5
+ self.set_url_path('/test_results')
+ self.update_request_json({'result_type': ResultType.Object.value, 'row_count': 5, 'stream': True})
+ statement = 'SELECT "Hello, data!" AS greeting'
+ exc = None
+ for _ in range(3):
+ exc = None
+ try:
+ res = self.cluster.execute_query(statement)
+ rows = list(res.rows())
+ if len(rows) == row_count:
+ break
+ except Exception as ex:
+ exc = AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}')
+ if exc is not None:
+ raise exc
+
+ @classmethod
+ def get_environment(
+ cls, config: AnalyticsConfig, server_handler: Optional[WebServerHandler] = None
+ ) -> BlockingTestEnvironment:
+ if config is None:
+ raise AnalyticsTestEnvironmentError('No test config provided.')
+
+ env_opts: TestEnvironmentOptionsKwargs = {}
+ if server_handler is not None:
+ connstr = server_handler.connstr
+ env_opts['server_handler'] = server_handler
+ else:
+ connstr = config.get_connection_string()
+ username, pw = config.get_username_and_pw()
+ cred = Credential.from_username_and_password(username, pw)
+ sec_opts: Optional[SecurityOptions] = None
+ if config.nonprod is True:
+ from couchbase_analytics.common._core.certificates import _Certificates
+
+ sec_opts = SecurityOptions.trust_only_certificates(_Certificates.get_nonprod_certificates())
+
+ if config.disable_server_certificate_verification is True:
+ if sec_opts is not None:
+ sec_opts['disable_server_certificate_verification'] = True
+ else:
+ sec_opts = SecurityOptions(disable_server_certificate_verification=True)
+
+ if sec_opts is not None:
+ opts = ClusterOptions(security_options=sec_opts)
+ env_opts['cluster'] = Cluster.create_instance(connstr, cred, opts)
+ else:
+ env_opts['cluster'] = Cluster.create_instance(connstr, cred)
+
+ env_opts['database_name'] = config.database_name
+ env_opts['scope_name'] = config.scope_name
+ env_opts['collection_name'] = config.collection_name
+ return cls(config, **env_opts)
+
+
+class AsyncTestEnvironment(TestEnvironment):
+ def __init__(self, config: AnalyticsConfig, **kwargs: Unpack[TestEnvironmentOptionsKwargs]) -> None:
+ self._backend = kwargs.pop('backend', None)
+ super().__init__(config, **kwargs)
+
+ @property
+ def cluster(self) -> AsyncCluster:
+ if self._async_cluster is None:
+ raise AnalyticsTestEnvironmentError('No async cluster available.')
+ return self._async_cluster
+
+ @property
+ def cluster_or_scope(self) -> Union[AsyncCluster, AsyncScope]:
+ if self._async_scope is not None:
+ return self.scope
+ return self.cluster
+
+ @property
+ def scope(self) -> AsyncScope:
+ if self._async_scope is None:
+ raise AnalyticsTestEnvironmentError('No scope available.')
+ return self._async_scope
+
+ async def assert_rows(self, result: AsyncQueryResult, expected_count: int) -> None:
+ count = 0
+ assert isinstance(result, (AsyncQueryResult,))
+ async for row in result.rows():
+ assert row is not None
+ count += 1
+ assert count >= expected_count
+
+ def assert_streaming_response_state(self, result: AsyncQueryResult) -> None:
+ assert hasattr(result._http_response, '_core_response') is False
+ assert result._http_response._request_context.is_shutdown is True
+
+ def disable_scope(self) -> AsyncTestEnvironment:
+ self._async_scope = None
+ self._use_scope = False
+ return self
+
+ def disable_test_server(self) -> AsyncTestEnvironment:
+ if self._server_handler is not None:
+ self._server_handler.stop_server()
+ return self
+
+ def enable_scope(
+ self, database_name: Optional[str] = None, scope_name: Optional[str] = None
+ ) -> AsyncTestEnvironment:
+ if self._async_cluster is None:
+ raise AnalyticsTestEnvironmentError('No cluster available.')
+ db_name = database_name if database_name is not None else self._database_name
+ if db_name is None:
+ raise AnalyticsTestEnvironmentError('Cannot create scope without a database name.')
+ scope_name = scope_name if scope_name is not None else self._scope_name
+ if scope_name is None:
+ raise AnalyticsTestEnvironmentError('Cannot create scope without a scope name.')
+ self._async_scope = self._async_cluster.database(db_name).scope(scope_name)
+ self._use_scope = True
+ return self
+
+ async def enable_test_server(self) -> AsyncTestEnvironment:
+ if self._server_handler is None:
+ raise AnalyticsTestEnvironmentError('No server handler provided, cannot enable test server.')
+ if self._async_cluster is None or not hasattr(self._async_cluster, '_impl'):
+ raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.')
+ from tests.utils._async_client_adapter import _TestAsyncClientAdapter
+ from tests.utils._test_async_httpx import TestAsyncHTTPTransport
+
+ # close the adapter here b/c we need to await
+ await self._async_cluster._impl._client_adapter.close_client()
+ new_adapter = _TestAsyncClientAdapter(
+ adapter=self._async_cluster._impl._client_adapter,
+ http_transport_cls=TestAsyncHTTPTransport,
+ )
+ await new_adapter.create_client()
+ self._async_cluster._impl._client_adapter = new_adapter
+ url = self._async_cluster._impl.client_adapter.connection_details.url.get_formatted_url()
+ print(f'Connecting to test server at {url}')
+ self._server_handler.start_server()
+ await self.warmup_test_server()
+ return self
+
+ async def setup(self) -> None:
+ if self.config.create_keyspace is False:
+ return
+
+ setup_statements = [
+ f'CREATE DATABASE `{self.config.database_name}` IF NOT EXISTS;',
+ f'CREATE SCOPE `{self.config.database_name}`.`{self.config.scope_name}` IF NOT EXISTS;',
+ (
+ 'CREATE COLLECTION '
+ f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`'
+ ' IF NOT EXISTS PRIMARY KEY (pk: UUID) AUTOGENERATED;'
+ ),
+ ]
+
+ for statement in setup_statements:
+ try:
+ await self.cluster.execute_query(statement)
+ except Exception as ex:
+ raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') from None
+
+ json_data = self.load_collection_data_from_file(TEST_AIRLINE_DATA_PATH)
+ docs = []
+ for d in json_data:
+ if 'collection' in d:
+ d['collection'] = self.config.collection_name
+ if 'scope' in d:
+ d['scope'] = self.config.scope_name
+ docs.append(json.dumps(d))
+ statement = (
+ f'USE `{self.config.database_name}`.`{self.config.scope_name}`; '
+ f'UPSERT INTO `{self.config.collection_name}` ({",".join(docs)})'
+ )
+
+ try:
+ await self.cluster.execute_query(statement)
+ except Exception as ex:
+ raise AnalyticsTestEnvironmentError(f'Unable to load collection data. Error: {ex}') from None
+
+ def set_url_path(self, url_path: str) -> None:
+ if self._server_handler is None:
+ raise AnalyticsTestEnvironmentError('No server handler provided, cannot set URL path.')
+ if self._async_cluster is None or not hasattr(self._async_cluster, '_impl'):
+ raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.')
+ self._async_cluster._impl._client_adapter.set_request_path(url_path)
+
+ async def sleep(self, delay: float) -> None:
+ await anyio.sleep(delay)
+
+ async def teardown(self) -> None:
+ if self.config.create_keyspace is False:
+ return
+
+ teardown_statements = [
+ f'DROP DATABASE `{self.config.database_name}` IF EXISTS;',
+ f'DROP SCOPE `{self.config.database_name}`.`{self.config.scope_name}` IF EXISTS;',
+ (
+ 'DROP COLLECTION '
+ f'`{self.config.database_name}`.`{self.config.scope_name}`.`{self.config.collection_name}`'
+ ' IF EXISTS;'
+ ),
+ ]
+
+ for statement in teardown_statements:
+ try:
+ await self.cluster.execute_query(statement)
+ except Exception as ex:
+ raise AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}') from None
+
+ def update_request_extensions(self, extensions: Dict[str, object]) -> None:
+ if self._server_handler is None:
+ raise AnalyticsTestEnvironmentError('No server handler provided, cannot update request extensions.')
+ if self._async_cluster is None or not hasattr(self._async_cluster, '_impl'):
+ raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.')
+ self._async_cluster._impl._client_adapter.update_request_extensions(extensions)
+
+ def update_request_json(self, json: Dict[str, object]) -> None:
+ if self._server_handler is None:
+ raise AnalyticsTestEnvironmentError('No server handler provided, cannot update request JSON.')
+ if self._async_cluster is None or not hasattr(self._async_cluster, '_impl'):
+ raise AnalyticsTestEnvironmentError('No cluster available, cannot enable test server.')
+ self._async_cluster._impl._client_adapter.update_request_json(json)
+
+ async def warmup_test_server(self) -> None:
+ row_count = 5
+ self.set_url_path('/test_results')
+ self.update_request_json({'result_type': ResultType.Object.value, 'row_count': 5, 'stream': True})
+ statement = 'SELECT "Hello, data!" AS greeting'
+ exc = None
+ for _ in range(3):
+ exc = None
+ try:
+ res = await self.cluster.execute_query(statement)
+ rows = [r async for r in res.rows()]
+ if len(rows) == row_count:
+ break
+ except Exception as ex:
+ exc = AnalyticsTestEnvironmentError(f'Unable to execute statement={statement}. Error: {ex}')
+ if exc is not None:
+ raise exc
+
+ @classmethod
+ def get_environment(
+ cls, config: AnalyticsConfig, server_handler: Optional[WebServerHandler] = None, backend: Optional[str] = None
+ ) -> AsyncTestEnvironment:
+ if config is None:
+ raise AnalyticsTestEnvironmentError('No test config provided.')
+
+ env_opts: TestEnvironmentOptionsKwargs = {}
+ if server_handler is not None:
+ connstr = server_handler.connstr
+ env_opts['server_handler'] = server_handler
+ else:
+ connstr = config.get_connection_string()
+ if backend is not None:
+ env_opts['backend'] = backend
+ username, pw = config.get_username_and_pw()
+ cred = Credential.from_username_and_password(username, pw)
+ sec_opts: Optional[SecurityOptions] = None
+ if config.nonprod is True:
+ from couchbase_analytics.common._core.certificates import _Certificates
+
+ sec_opts = SecurityOptions.trust_only_certificates(_Certificates.get_nonprod_certificates())
+
+ if config.disable_server_certificate_verification is True:
+ if sec_opts is not None:
+ sec_opts['disable_server_certificate_verification'] = True
+ else:
+ sec_opts = SecurityOptions(disable_server_certificate_verification=True)
+
+ print(f'{env_opts=}')
+ if sec_opts is not None:
+ opts = ClusterOptions(security_options=sec_opts)
+ env_opts['async_cluster'] = AsyncCluster.create_instance(connstr, cred, opts)
+ else:
+ env_opts['async_cluster'] = AsyncCluster.create_instance(connstr, cred)
+
+ env_opts['database_name'] = config.database_name
+ env_opts['scope_name'] = config.scope_name
+ env_opts['collection_name'] = config.collection_name
+ return cls(config, **env_opts)
+
+
+@pytest.fixture(scope='class', name='sync_test_env')
+def base_test_environment(analytics_config: AnalyticsConfig) -> BlockingTestEnvironment:
+ print('Creating sync test environment')
+ return BlockingTestEnvironment.get_environment(analytics_config)
+
+
+@pytest.fixture(scope='class', name='sync_test_env_with_server')
+def base_test_environment_with_server(analytics_config: AnalyticsConfig) -> BlockingTestEnvironment:
+ print('Creating sync test environment w/ test server')
+ server_handler = WebServerHandler()
+ return BlockingTestEnvironment.get_environment(analytics_config, server_handler=server_handler)
+
+
+@pytest.fixture(scope='class', name='async_test_env')
+def base_async_test_environment(analytics_config: AnalyticsConfig, anyio_backend: str) -> AsyncTestEnvironment:
+ print('Creating async test environment')
+ return AsyncTestEnvironment.get_environment(analytics_config, backend=anyio_backend)
+
+
+@pytest.fixture(scope='class', name='async_test_env_with_server')
+def base_async_test_environment_with_server(
+ analytics_config: AnalyticsConfig, anyio_backend: str
+) -> AsyncTestEnvironment:
+ print('Creating async test environment w/ test server')
+ server_handler = WebServerHandler()
+ return AsyncTestEnvironment.get_environment(analytics_config, server_handler=server_handler, backend=anyio_backend)
diff --git a/tests/environments/simple_environment.py b/tests/environments/simple_environment.py
new file mode 100644
index 0000000..6c33d40
--- /dev/null
+++ b/tests/environments/simple_environment.py
@@ -0,0 +1,242 @@
+# ruff: noqa: E501
+import json
+from enum import Enum
+from typing import Any, Tuple
+
+import pytest
+
+
+class JsonDataType(Enum):
+ SIMPLE_REQUEST = 'simple_request'
+ MULTIPLE_RESULTS = 'multiple_results'
+ MULTIPLE_RESULTS_RAW = 'multiple_results_raw'
+ FAILED_REQUEST = 'failed_request'
+ FAILED_REQUEST_MULTI_ERRORS = 'failed_request_multi_errors'
+ FAILED_REQUEST_MID_STREAM = 'failed_request_mid_stream'
+
+
+JSON_DATA = {
+ 'simple_request': """
+{
+ "requestID": "98f69cf0-6d00-4a61-b8b6-e3b29fb6061b",
+ "signature": {
+ "*": "*"
+ },
+ "results": [
+ {
+ "greeting": "Hello, data!"
+ }
+ ],
+ "plans": {},
+ "status": "success",
+ "metrics": {
+ "elapsedTime": "60.13982ms",
+ "executionTime": "56.765081ms",
+ "compileTime": "3.244949ms",
+ "queueWaitTime": "0ns",
+ "resultCount": 1,
+ "resultSize": 27,
+ "processedObjects": 0
+ }
+}""".strip(),
+ 'multiple_results': """
+{
+ "requestID": "94c7f89f-92b6-4aba-a90d-be715ca47309",
+ "signature": {
+ "*": "*"
+ },
+ "results": [
+ {"id": 1, "name": "John Doe", "age": 30, "city": "New York"},
+ {"id": 2, "name": "Jane Smith", "age": 25, "city": "Los Angeles"},
+ {"id": 3, "name": "Sam Brown", "age": 22, "city": "Chicago"},
+ {"id": 4, "name": "Lisa White", "age": 28, "city": "Houston"},
+ {"id": 5, "name": "Tom Green", "age": 35, "city": "Phoenix"},
+ {"id": 6, "name": "Anna Blue", "age": 27, "city": "Philadelphia"},
+ {"id": 7, "name": "Mike Black", "age": 32, "city": "San Antonio"},
+ {"id": 8, "name": "Sara Yellow", "age": 29, "city": "San Diego"},
+ {"id": 9, "name": "Chris Red", "age": 31, "city": "Dallas"},
+ {"id": 10, "name": "Kate Purple", "age": 26, "city": "San Jose"},
+ {"id": 11, "name": "Paul Orange", "age": 33, "city": "Austin"},
+ {"id": 12, "name": "Nina Pink", "age": 24, "city": "Jacksonville"},
+ {"id": 13, "name": "Leo Grey", "age": 36, "city": "Fort Worth"},
+ {"id": 14, "name": "Eva Cyan", "age": 23, "city": "Columbus"},
+ {"id": 15, "name": "Zoe Brown", "age": 34, "city": "Charlotte"},
+ {"id": 16, "name": "Liam Gold", "age": 21, "city": "San Francisco"},
+ {"id": 17, "name": "Mia Silver", "age": 30, "city": "Indianapolis"},
+ {"id": 18, "name": "Noah Bronze", "age": 25, "city": "Seattle"},
+ {"id": 19, "name": "Olivia Copper", "age": 22, "city": "Denver"},
+ {"id": 20, "name": "Ethan Steel", "age": 28, "city": "Washington"},
+ {"id": 21, "name": "Sophia Iron", "age": 35, "city": "Boston"},
+ {"id": 22, "name": "James Wood", "age": 27, "city": "El Paso"},
+ {"id": 23, "name": "Ava Stone", "age": 32, "city": "Detroit"},
+ {"id": 24, "name": "Lucas Clay", "age": 29, "city": "Nashville"},
+ {"id": 25, "name": "Charlotte Brick", "age": 31, "city": "Baltimore"},
+ {"id": 26, "name": "Benjamin Marble", "age": 26, "city": "Milwaukee"},
+ {"id": 27, "name": "Amelia Slate", "age": 33, "city": "Albuquerque"},
+ {"id": 28, "name": "Oliver Quartz", "age": 24, "city": "Tucson"},
+ {"id": 29, "name": "Isabella Granite", "age": 36, "city": "Fresno"},
+ {"id": 30, "name": "Elijah Onyx", "age": 23, "city": "Sacramento"},
+ {"id": 31, "name": "Mason Jade", "age": 34, "city": "Long Beach"},
+ {"id": 32, "name": "Charlotte Ruby", "age": 21, "city": "Kansas City"},
+ {"id": 33, "name": "Aiden Sapphire", "age": 30, "city": "Mesa"},
+ {"id": 34, "name": "Harper Emerald", "age": 25, "city": "Virginia Beach"},
+ {"id": 35, "name": "Ella Amethyst", "age": 22, "city": "Atlanta"},
+ {"id": 36, "name": "Liam Diamond", "age": 28, "city": "Colorado Springs"}
+ ],
+ "plans": {},
+ "status": "success",
+ "metrics": {
+ "elapsedTime": "14.927542ms",
+ "executionTime": "12.875792ms",
+ "compileTime": "4.178042ms",
+ "queueWaitTime": "0ns",
+ "resultCount": 2,
+ "resultSize": 300,
+ "processedObjects": 2,
+ "bufferCacheHitRatio": "100.00%"
+ }
+}""".strip(),
+ 'multiple_results_raw': """
+{
+ "requestID": "94c7f89f-92b6-4aba-a90d-be715ca47309",
+ "signature": {
+ "*": "*"
+ },
+ "results": [
+ "airline_19433",
+ "airline_137",
+ "airline_18239",
+ "airline_10123",
+ "airline_19290",
+ "airline_19774",
+ "airline_4738",
+ "airline_4816",
+ "airline_18178",
+ "airline_10226"
+ ],
+ "plans": {},
+ "status": "success",
+ "metrics": {
+ "elapsedTime": "14.927542ms",
+ "executionTime": "12.875792ms",
+ "compileTime": "4.178042ms",
+ "queueWaitTime": "0ns",
+ "resultCount": 2,
+ "resultSize": 300,
+ "processedObjects": 2,
+ "bufferCacheHitRatio": "100.00%"
+ }
+}""".strip(),
+ 'failed_request': """
+{
+ "requestID": "c5f50c58-c044-481f-a26a-357a29f7446e",
+ "errors": [
+ {
+ "code": 24000,
+ "msg": "Syntax error: TokenMgrError: Lexical error at line 1, column 14. Encountered: after : \\"'m not N1QL;\\""
+ }
+ ],
+ "status": "fatal",
+ "metrics": {
+ "elapsedTime": "3.146092ms",
+ "executionTime": "1.907313ms",
+ "compileTime": "0ns",
+ "queueWaitTime": "0ns",
+ "resultCount": 0,
+ "resultSize": 0,
+ "processedObjects": 0,
+ "bufferCacheHitRatio": "0.00%",
+ "bufferCachePageReadCount": 0,
+ "errorCount": 1
+ }
+}""".strip(),
+ 'failed_request_multi_errors': """
+{
+ "requestID": "c5f50c58-c044-481f-a26a-357a29f7446e",
+ "errors": [
+ {
+ "code": 24000,
+ "msg": "Syntax error: TokenMgrError: Lexical error at line 1, column 14. Encountered: after : \\"'m not N1QL;\\""
+ },
+ {
+ "code": 20001,
+ "msg": "Insufficient permissions or the requested object does not exist"
+ }
+ ],
+ "status": "fatal",
+ "metrics": {
+ "elapsedTime": "3.146092ms",
+ "executionTime": "1.907313ms",
+ "compileTime": "0ns",
+ "queueWaitTime": "0ns",
+ "resultCount": 0,
+ "resultSize": 0,
+ "processedObjects": 0,
+ "bufferCacheHitRatio": "0.00%",
+ "bufferCachePageReadCount": 0,
+ "errorCount": 2
+ }
+}""".strip(),
+ 'failed_request_mid_stream': """
+{
+ "requestID": "c5f50c58-c044-481f-a26a-357a29f7446e",
+ "results": [
+ {"id": 1, "name": "John Doe", "age": 30, "city": "New York"},
+ {"id": 2, "name": "Jane Smith", "age": 25, "city": "Los Angeles"}
+ ],
+ "errors": [
+ {
+ "code": 24000,
+ "msg": "Syntax error: TokenMgrError: Lexical error at line 1, column 14. Encountered: after : \\"'m not N1QL;\\""
+ }
+ ],
+ "status": "fatal",
+ "metrics": {
+ "elapsedTime": "3.146092ms",
+ "executionTime": "1.907313ms",
+ "compileTime": "0ns",
+ "queueWaitTime": "0ns",
+ "resultCount": 2,
+ "resultSize": 2,
+ "processedObjects": 2,
+ "bufferCacheHitRatio": "0.00%",
+ "bufferCachePageReadCount": 0,
+ "errorCount": 2
+ }
+}""".strip(),
+}
+
+
+class BaseSimpleEnvironment:
+ def __init__(self, backend: str) -> None:
+ self._backend = backend
+
+ def get_json_data(self, json_type: JsonDataType) -> Tuple[Any, bytes]:
+ """
+ Retrieve JSON data by key.
+ """
+ key = json_type.value if isinstance(json_type, JsonDataType) else json_type
+ if key not in JSON_DATA:
+ raise KeyError(f"Key '{key}' not found in JSON data.")
+ data = JSON_DATA[key]
+ return json.loads(data), bytes(data, 'utf-8')
+
+
+class AsyncSimpleEnvironment(BaseSimpleEnvironment):
+ def __init__(self, backend: str) -> None:
+ super().__init__(backend)
+
+
+class SimpleEnvironment(BaseSimpleEnvironment):
+ def __init__(self, backend: str) -> None:
+ super().__init__(backend)
+
+
+@pytest.fixture(scope='class', name='simple_async_test_env')
+def simple_async_test_environment(anyio_backend: str) -> AsyncSimpleEnvironment:
+ return AsyncSimpleEnvironment(anyio_backend)
+
+
+@pytest.fixture(scope='class', name='simple_test_env')
+def simple_test_environment(anyio_backend: str) -> SimpleEnvironment:
+ return SimpleEnvironment(anyio_backend)
diff --git a/tests/test_config.ini b/tests/test_config.ini
new file mode 100644
index 0000000..82d35de
--- /dev/null
+++ b/tests/test_config.ini
@@ -0,0 +1,9 @@
+[analytics]
+scheme = http
+host = 3585106b-20250708.cb-sdk.bemdas.com
+port = 8095
+username = Administrator
+password = password
+nonprod = False
+disable_server_cert_verification = False
+fqdn = travel-sample.inventory.airline
diff --git a/tests/test_data/airline.json b/tests/test_data/airline.json
new file mode 100644
index 0000000..0470e02
--- /dev/null
+++ b/tests/test_data/airline.json
@@ -0,0 +1,189 @@
+[
+ {"callsign":"OA","collection":"airline","country":"United States","iata":null,"icao":"OAR","id":17629,"name":"Orbit Regional Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"COMAIR","collection":"airline","country":"United States","iata":"OH","icao":"COM","id":1828,"name":"Comair","scope":"inventory","type":"airline"},
+ {"callsign":"GIANT","collection":"airline","country":"United States","iata":"5Y","icao":"GTI","id":928,"name":"Atlas Air","scope":"inventory","type":"airline"},
+ {"callsign":"FRONTIER-AIR","collection":"airline","country":"United States","iata":"2F","icao":"FTA","id":2470,"name":"Frontier Flying Service","scope":"inventory","type":"airline"},
+ {"callsign":"STARWAY","collection":"airline","country":"France","iata":"SE","icao":"SEU","id":5479,"name":"XL Airways France","scope":"inventory","type":"airline"},
+ {"callsign":"AMTRAN","collection":"airline","country":"United States","iata":null,"icao":"AMT","id":315,"name":"ATA Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"RYAN AIR","collection":"airline","country":"United States","iata":null,"icao":"RYA","id":4294,"name":"Ryan Air Services","scope":"inventory","type":"airline"},
+ {"callsign":"LOCAIR","collection":"airline","country":"United States","iata":"ZQ","icao":"LOC","id":10748,"name":"Locair","scope":"inventory","type":"airline"},
+ {"callsign":"AIRCALIN","collection":"airline","country":"France","iata":"SB","icao":"ACI","id":139,"name":"Air Caledonie International","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":"WP","icao":"MKU","id":8809,"name":"Island Air (WP)","scope":"inventory","type":"airline"},
+ {"callsign":"CHAUTAUQUA","collection":"airline","country":"United States","iata":"RP","icao":"CHQ","id":1739,"name":"Chautauqua Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"MIDWAY","collection":"airline","country":"United States","iata":"JI","icao":"MDW","id":3494,"name":"Midway Airlines","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":"T6","icao":"TP6","id":16264,"name":"Trans Pas Air","scope":"inventory","type":"airline"},
+ {"callsign":"CORSICA","collection":"airline","country":"France","iata":"XK","icao":"CCM","id":1909,"name":"Corse-Mediterranee","scope":"inventory","type":"airline"},
+ {"callsign":"Envoy","collection":"airline","country":"United States","iata":null,"icao":"ENY","id":19619,"name":"Envoy Air","scope":"inventory","type":"airline"},
+ {"callsign":"COUNTY","collection":"airline","country":"United Kingdom","iata":null,"icao":"NCF","id":3684,"name":"Norfolk County Flight College","scope":"inventory","type":"airline"},
+ {"callsign":"WESTERN","collection":"airline","country":"United States","iata":"WA","icao":"WAL","id":5424,"name":"Western Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"HAGELAND","collection":"airline","country":"United States","iata":"H6","icao":"HAG","id":2657,"name":"Hageland Aviation Services","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":null,"icao":"OAN","id":17630,"name":"Orbit Atlantic Airways","scope":"inventory","type":"airline"},
+ {"callsign":"RAW","collection":"airline","country":"United States","iata":"KG","icao":"RAW","id":18241,"name":"Royal Airways","scope":"inventory","type":"airline"},
+ {"callsign":"Rainbow Air","collection":"airline","country":"United States","iata":"RX","icao":"RPO","id":19676,"name":"Rainbow Air Polynesia","scope":"inventory","type":"airline"},
+ {"callsign":"AIR SHUTTLE","collection":"airline","country":"United States","iata":"YV","icao":"ASH","id":3466,"name":"Mesa Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"GULF FLIGHT","collection":"airline","country":"United States","iata":null,"icao":"GFT","id":2645,"name":"Gulfstream International Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"CITRUS","collection":"airline","country":"United States","iata":"FL","icao":"TRS","id":1316,"name":"AirTran Airways","scope":"inventory","type":"airline"},
+ {"callsign":"AIGLE AZUR","collection":"airline","country":"France","iata":"ZI","icao":"AAF","id":21,"name":"Aigle Azur","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United Kingdom","iata":"1F","icao":"CIF","id":16261,"name":"CB Airways UK ( Interliging Flights )","scope":"inventory","type":"airline"},
+ {"callsign":"FLIGHTLINE","collection":"airline","country":"United Kingdom","iata":"B5","icao":"FLT","id":2395,"name":"Flightline","scope":"inventory","type":"airline"},
+ {"callsign":"GATEWAY","collection":"airline","country":"United States","iata":"G7","icao":"GJS","id":2577,"name":"GoJet Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"KINLOSS","collection":"airline","country":"United Kingdom","iata":null,"icao":"KIN","id":4113,"name":"Kinloss Flying Training Unit","scope":"inventory","type":"airline"},
+ {"callsign":"FRENCH WEST","collection":"airline","country":"France","iata":"TX","icao":"FWI","id":567,"name":"Air Caraïbes","scope":"inventory","type":"airline"},
+ {"callsign":"PIEDMONT","collection":"airline","country":"United States","iata":"PI","icao":"PDT","id":3969,"name":"Piedmont Airlines (1948-1989)","scope":"inventory","type":"airline"},
+ {"callsign":"TSUNAMI","collection":"airline","country":"United States","iata":"LW","icao":"NMI","id":3865,"name":"Pacific Wings","scope":"inventory","type":"airline"},
+ {"callsign":"VIRGIN","collection":"airline","country":"United Kingdom","iata":"VS","icao":"VIR","id":5347,"name":"Virgin Atlantic Airways","scope":"inventory","type":"airline"},
+ {"callsign":"TWINJET","collection":"airline","country":"France","iata":"T7","icao":"TJT","id":4965,"name":"Twin Jet","scope":"inventory","type":"airline"},
+ {"callsign":"CHANNEX","collection":"airline","country":"United Kingdom","iata":"LS","icao":"EXS","id":3026,"name":"Jet2.com","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":null,"icao":"CEO","id":19351,"name":"Comfort Express Virtual Charters","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":null,"icao":"XSR","id":18257,"name":"Executive AirShare","scope":"inventory","type":"airline"},
+ {"callsign":"USKY","collection":"airline","country":"United States","iata":"E1","icao":"ES2","id":16702,"name":"Usa Sky Cargo","scope":"inventory","type":"airline"},
+ {"callsign":"WORLD","collection":"airline","country":"United States","iata":"WO","icao":"WOA","id":5465,"name":"World Airways","scope":"inventory","type":"airline"},
+ {"callsign":"SPEEDBIRD","collection":"airline","country":"United Kingdom","iata":"BA","icao":"BAW","id":1355,"name":"British Airways","scope":"inventory","type":"airline"},
+ {"callsign":"AIRFRANS","collection":"airline","country":"France","iata":"AF","icao":"AFR","id":137,"name":"Air France","scope":"inventory","type":"airline"},
+ {"callsign":"Comfort Express","collection":"airline","country":"United States","iata":null,"icao":"EVC","id":19350,"name":"Comfort Express Virtual Charters Albany","scope":"inventory","type":"airline"},
+ {"callsign":"MIDLAND","collection":"airline","country":"United Kingdom","iata":"BD","icao":"BMA","id":1437,"name":"bmi","scope":"inventory","type":"airline"},
+ {"callsign":"PACIFIC ISLE","collection":"airline","country":"United States","iata":null,"icao":"PSA","id":3860,"name":"Pacific Island Aviation","scope":"inventory","type":"airline"},
+ {"callsign":"ALLEGIANT","collection":"airline","country":"United States","iata":"G4","icao":"AAY","id":35,"name":"Allegiant Air","scope":"inventory","type":"airline"},
+ {"callsign":"AIR SUNSHINE","collection":"airline","country":"United States","iata":null,"icao":"RSI","id":295,"name":"Air Sunshine","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"France","iata":"PJ","icao":"SPM","id":477,"name":"Air Saint Pierre","scope":"inventory","type":"airline"},
+ {"callsign":"Vickjet","collection":"airline","country":"France","iata":"KT","icao":"VKJ","id":16837,"name":"VickJet","scope":"inventory","type":"airline"},
+ {"callsign":"AMERICAN","collection":"airline","country":"United States","iata":"AA","icao":"AAL","id":24,"name":"American Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"BERING AIR","collection":"airline","country":"United States","iata":"8E","icao":"BRG","id":1472,"name":"Bering Air","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":"-+","icao":"--+","id":13391,"name":"U.S. Air","scope":"inventory","type":"airline"},
+ {"callsign":"AIR CHIEF","collection":"airline","country":"United States","iata":null,"icao":"AIO","id":5279,"name":"United States Air Force","scope":"inventory","type":"airline"},
+ {"callsign":"CACTUS","collection":"airline","country":"United States","iata":"HP","icao":"AWE","id":281,"name":"America West Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"RUBY","collection":"airline","country":"United States","iata":"V2","icao":"RBY","id":18178,"name":"Vision Airlines (V2)","scope":"inventory","type":"airline"},
+ {"callsign":"SUN COUNTRY","collection":"airline","country":"United States","iata":"SY","icao":"SCX","id":4356,"name":"Sun Country Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"KESTREL","collection":"airline","country":"United Kingdom","iata":"MT","icao":"TCX","id":4897,"name":"Thomas Cook Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"CYCLONE","collection":"airline","country":"United States","iata":"ZA","icao":"CYD","id":792,"name":"Access Air","scope":"inventory","type":"airline"},
+ {"callsign":"HORIZON AIR","collection":"airline","country":"United States","iata":"QX","icao":"QXE","id":2778,"name":"Horizon Air","scope":"inventory","type":"airline"},
+ {"callsign":"Epic","collection":"airline","country":"United States","iata":"FA","icao":"4AA","id":9833,"name":"Epic Holiday","scope":"inventory","type":"airline"},
+ {"callsign":"NEW ENGLAND","collection":"airline","country":"United States","iata":"EJ","icao":"NEA","id":3644,"name":"New England Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"TOMSON","collection":"airline","country":"United Kingdom","iata":"BY","icao":"TOM","id":5013,"name":"Thomsonfly","scope":"inventory","type":"airline"},
+ {"callsign":"SASQUATCH","collection":"airline","country":"United States","iata":"K5","icao":"SQH","id":10765,"name":"SeaPort Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"ERAH","collection":"airline","country":"United States","iata":"7H","icao":"ERR","id":16726,"name":"Era Alaska","scope":"inventory","type":"airline"},
+ {"callsign":"INDIGO BLUE","collection":"airline","country":"United States","iata":"I9","icao":"IBU","id":2855,"name":"Indigo","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United Kingdom","iata":"N9","icao":"N99","id":19808,"name":"All Europe","scope":"inventory","type":"airline"},
+ {"callsign":"ACEY","collection":"airline","country":"United States","iata":"EV","icao":"ASQ","id":452,"name":"Atlantic Southeast Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"FREEDOM AIR","collection":"airline","country":"United States","iata":null,"icao":"FRL","id":2456,"name":"Freedom Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"COLGAN","collection":"airline","country":"United States","iata":"9L","icao":"CJC","id":1821,"name":"Colgan Air","scope":"inventory","type":"airline"},
+ {"callsign":"NIGHT CARGO","collection":"airline","country":"United States","iata":"2Q","icao":"SNC","id":149,"name":"Air Cargo Carriers","scope":"inventory","type":"airline"},
+ {"callsign":"AIR MIKE","collection":"airline","country":"United States","iata":"CS","icao":"CMI","id":1884,"name":"Continental Micronesia","scope":"inventory","type":"airline"},
+ {"callsign":"BEE MED","collection":"airline","country":"United Kingdom","iata":"KJ","icao":"LAJ","id":1543,"name":"British Mediterranean Airways","scope":"inventory","type":"airline"},
+ {"callsign":"Rainbow","collection":"airline","country":"United States","iata":"RN","icao":"RAB","id":19674,"name":"Rainbow Air (RAI)","scope":"inventory","type":"airline"},
+ {"callsign":"FREEDOM","collection":"airline","country":"United States","iata":"FP","icao":"FRE","id":2454,"name":"Freedom Air","scope":"inventory","type":"airline"},
+ {"callsign":"NORTHWEST","collection":"airline","country":"United States","iata":"NW","icao":"NWA","id":3731,"name":"Northwest Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"Orbit","collection":"airline","country":"United States","iata":null,"icao":"OBT","id":16932,"name":"Orbit Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"AIRLIFT","collection":"airline","country":"United States","iata":null,"icao":"AIR","id":210,"name":"Airlift International","scope":"inventory","type":"airline"},
+ {"callsign":"GLOBESPAN","collection":"airline","country":"United Kingdom","iata":"B4","icao":"GSM","id":2425,"name":"Flyglobespan","scope":"inventory","type":"airline"},
+ {"callsign":"JETSET","collection":"airline","country":"United Kingdom","iata":"DP","icao":"FCA","id":2357,"name":"First Choice Airways","scope":"inventory","type":"airline"},
+ {"callsign":"SOUTH PACIFIC","collection":"airline","country":"United States","iata":null,"icao":"SPI","id":4816,"name":"South Pacific Island Airways","scope":"inventory","type":"airline"},
+ {"callsign":"CROWN AIRWAYS","collection":"airline","country":"United States","iata":null,"icao":"CRO","id":1931,"name":"Crown Airways","scope":"inventory","type":"airline"},
+ {"callsign":"T\u0026","collection":"airline","country":"France","iata":"\u0026T","icao":"T\u0026O","id":13947,"name":"Tom\\'s \u0026 co airliners","scope":"inventory","type":"airline"},
+ {"callsign":"ACE AIR","collection":"airline","country":"United States","iata":"KO","icao":"AER","id":109,"name":"Alaska Central Express","scope":"inventory","type":"airline"},
+ {"callsign":"SCENIC","collection":"airline","country":"United States","iata":null,"icao":"SCE","id":4342,"name":"Scenic Airlines","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":"76","icao":"SJS","id":17859,"name":"Southjet","scope":"inventory","type":"airline"},
+ {"callsign":"SOUTHWEST","collection":"airline","country":"United States","iata":"WN","icao":"SWA","id":4547,"name":"Southwest Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"FLAGSHIP","collection":"airline","country":"United States","iata":"9E","icao":"FLG","id":3976,"name":"Pinnacle Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"AYLINE","collection":"airline","country":"United Kingdom","iata":"GR","icao":"AUR","id":508,"name":"Aurigny Air Services","scope":"inventory","type":"airline"},
+ {"callsign":"OA","collection":"airline","country":"United States","iata":null,"icao":"OAI","id":17628,"name":"Orbit International Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"MERCURY","collection":"airline","country":"United States","iata":"S5","icao":"TCF","id":4822,"name":"Shuttle America","scope":"inventory","type":"airline"},
+ {"callsign":"FLO WEST","collection":"airline","country":"United States","iata":"RF","icao":"FWL","id":2404,"name":"Florida West International Airways","scope":"inventory","type":"airline"},
+ {"callsign":"REUNION","collection":"airline","country":"France","iata":"UU","icao":"REU","id":1191,"name":"Air Austral","scope":"inventory","type":"airline"},
+ {"callsign":"CONTINENTAL","collection":"airline","country":"United States","iata":"CO","icao":"COA","id":1881,"name":"Continental Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"DELTA","collection":"airline","country":"United States","iata":"DL","icao":"DAL","id":2009,"name":"Delta Air Lines","scope":"inventory","type":"airline"},
+ {"callsign":"Stallion","collection":"airline","country":"United States","iata":"BZ","icao":"BSA","id":15975,"name":"Black Stallion Airways","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":"78","icao":"XAN","id":17862,"name":"Southjet cargo","scope":"inventory","type":"airline"},
+ {"callsign":"LONGHORN","collection":"airline","country":"United States","iata":"EO","icao":"LHN","id":2293,"name":"Express One International","scope":"inventory","type":"airline"},
+ {"callsign":"BRITAIR","collection":"airline","country":"France","iata":"DB","icao":"BZH","id":1523,"name":"Brit Air","scope":"inventory","type":"airline"},
+ {"callsign":"RYAN INTERNATIONAL","collection":"airline","country":"United States","iata":"RD","icao":"RYN","id":4295,"name":"Ryan International Airlines","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":"H1","icao":"HA1","id":16735,"name":"Hankook Air US","scope":"inventory","type":"airline"},
+ {"callsign":"EXECJET","collection":"airline","country":"United States","iata":"1I","icao":"EJA","id":3641,"name":"NetJets","scope":"inventory","type":"airline"},
+ {"callsign":"SPIRIT WINGS","collection":"airline","country":"United States","iata":"NK","icao":"NKS","id":4687,"name":"Spirit Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"BBN","collection":"airline","country":"United Kingdom","iata":null,"icao":"EGH","id":19525,"name":"BBN-Airways","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":"77","icao":"ZCS","id":17860,"name":"Southjet connect","scope":"inventory","type":"airline"},
+ {"callsign":"MONARCH","collection":"airline","country":"United Kingdom","iata":"ZB","icao":"MON","id":3532,"name":"Monarch Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"JETBLUE","collection":"airline","country":"United States","iata":"B6","icao":"JBU","id":3029,"name":"JetBlue Airways","scope":"inventory","type":"airline"},
+ {"callsign":"EAGLE FLIGHT","collection":"airline","country":"United States","iata":"MQ","icao":"EGF","id":659,"name":"American Eagle Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"FLYSTAR","collection":"airline","country":"United Kingdom","iata":"5W","icao":"AEU","id":112,"name":"Astraeus","scope":"inventory","type":"airline"},
+ {"callsign":"PENINSULA","collection":"airline","country":"United States","iata":"KS","icao":"PEN","id":3935,"name":"Peninsula Airways","scope":"inventory","type":"airline"},
+ {"callsign":"FLIGHTVUE","collection":"airline","country":"United Kingdom","iata":null,"icao":"VUE","id":665,"name":"AD Aviation","scope":"inventory","type":"airline"},
+ {"callsign":"EASY","collection":"airline","country":"United Kingdom","iata":"U2","icao":"EZY","id":2297,"name":"easyJet","scope":"inventory","type":"airline"},
+ {"callsign":"COMMUTAIR","collection":"airline","country":"United States","iata":"C5","icao":"UCA","id":1843,"name":"CommutAir","scope":"inventory","type":"airline"},
+ {"callsign":"TXW","collection":"airline","country":"United States","iata":"TQ","icao":"TXW","id":10123,"name":"Texas Wings","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United Kingdom","iata":null,"icao":"BMR","id":1445,"name":"British Midland Regional","scope":"inventory","type":"airline"},
+ {"callsign":"BRINTEL","collection":"airline","country":"United Kingdom","iata":"BS","icao":"BIH","id":1411,"name":"British International Helicopters","scope":"inventory","type":"airline"},
+ {"callsign":"HELI EXCEL","collection":"airline","country":"United Kingdom","iata":null,"icao":"XEL","id":2265,"name":"Excel Charter","scope":"inventory","type":"airline"},
+ {"callsign":"CORSAIR","collection":"airline","country":"France","iata":"SS","icao":"CRL","id":1908,"name":"Corsairfly","scope":"inventory","type":"airline"},
+ {"callsign":"EAVA","collection":"airline","country":"United States","iata":"13","icao":"EAV","id":19290,"name":"Eastern Atlantic Virtual Airlines","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United Kingdom","iata":null,"icao":"JRB","id":10642,"name":"Jc royal.britannica","scope":"inventory","type":"airline"},
+ {"callsign":"REDWOOD","collection":"airline","country":"United States","iata":"VX","icao":"VRD","id":5331,"name":"Virgin America","scope":"inventory","type":"airline"},
+ {"callsign":"MILE-AIR","collection":"airline","country":"United States","iata":"Q5","icao":"MLA","id":10,"name":"40-Mile Air","scope":"inventory","type":"airline"},
+ {"callsign":"AIRMAX","collection":"airline","country":"United States","iata":null,"icao":"XBM","id":15887,"name":"CBM America","scope":"inventory","type":"airline"},
+ {"callsign":"GETAWAY","collection":"airline","country":"United States","iata":"U5","icao":"GWY","id":5207,"name":"USA3000 Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"APACHE","collection":"airline","country":"United States","iata":"ZM","icao":"IWA","id":19016,"name":"Apache Air","scope":"inventory","type":"airline"},
+ {"callsign":"SKYWEST","collection":"airline","country":"United States","iata":"OO","icao":"SKW","id":4738,"name":"SkyWest","scope":"inventory","type":"airline"},
+ {"callsign":"CAIR","collection":"airline","country":"United States","iata":"9K","icao":"KAP","id":1629,"name":"Cape Air","scope":"inventory","type":"airline"},
+ {"callsign":"AIR MOOREA","collection":"airline","country":"France","iata":null,"icao":"TAH","id":551,"name":"Air Moorea","scope":"inventory","type":"airline"},
+ {"callsign":"MEDITERRANEE","collection":"airline","country":"France","iata":"DR","icao":"BIE","id":547,"name":"Air Mediterranee","scope":"inventory","type":"airline"},
+ {"callsign":"SKAGWAY AIR","collection":"airline","country":"United States","iata":"N5","icao":"SGY","id":4411,"name":"Skagway Air Service","scope":"inventory","type":"airline"},
+ {"callsign":"SWALLOW","collection":"airline","country":"United Kingdom","iata":null,"icao":"WOW","id":492,"name":"Air Southwest","scope":"inventory","type":"airline"},
+ {"callsign":"AIR FLORIDA","collection":"airline","country":"United States","iata":"QH","icao":"FLZ","id":882,"name":"Air Florida","scope":"inventory","type":"airline"},
+ {"callsign":"SUCKLING","collection":"airline","country":"United Kingdom","iata":null,"icao":"SAY","id":4323,"name":"ScotAirways","scope":"inventory","type":"airline"},
+ {"callsign":"HIWAY","collection":"airline","country":"United Kingdom","iata":null,"icao":"HWY","id":2761,"name":"Highland Airways","scope":"inventory","type":"airline"},
+ {"callsign":"SOUTHEAST AIR","collection":"airline","country":"United States","iata":null,"icao":"SEA","id":4370,"name":"Southeast Air","scope":"inventory","type":"airline"},
+ {"callsign":"UNITED","collection":"airline","country":"United States","iata":"UA","icao":"UAL","id":5209,"name":"United Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"Maryland Flight","collection":"airline","country":"United States","iata":"M1","icao":"M1F","id":18930,"name":"Maryland Air","scope":"inventory","type":"airline"},
+ {"callsign":"RED DRAGON","collection":"airline","country":"United Kingdom","iata":"6G","icao":"AWW","id":565,"name":"Air Wales","scope":"inventory","type":"airline"},
+ {"callsign":"SEABORNE","collection":"airline","country":"United States","iata":"BB","icao":"SBS","id":4335,"name":"Seaborne Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"AIRLINAIR","collection":"airline","country":"France","iata":"A5","icao":"RLA","id":1203,"name":"Airlinair","scope":"inventory","type":"airline"},
+ {"callsign":"SOUTHERN EXPRESS","collection":"airline","country":"United States","iata":null,"icao":"SOU","id":4804,"name":"Southern Airways","scope":"inventory","type":"airline"},
+ {"callsign":"U S AIR","collection":"airline","country":"United States","iata":"US","icao":"USA","id":5265,"name":"US Airways","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":"YX","icao":"MEP","id":3497,"name":"Midwest Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"MESABA","collection":"airline","country":"United States","iata":"XJ","icao":"MES","id":3467,"name":"Mesaba Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"FRONTIER FLIGHT","collection":"airline","country":"United States","iata":"F9","icao":"FFT","id":2468,"name":"Frontier Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"BEMIDJI","collection":"airline","country":"United States","iata":"CH","icao":"BMJ","id":1442,"name":"Bemidji Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"JET LINK","collection":"airline","country":"United States","iata":"XE","icao":"BTA","id":2295,"name":"ExpressJet","scope":"inventory","type":"airline"},
+ {"callsign":"OMNI-EXPRESS","collection":"airline","country":"United States","iata":"OY","icao":"OAE","id":3781,"name":"Omni Air International","scope":"inventory","type":"airline"},
+ {"callsign":"AIR WISCONSIN","collection":"airline","country":"United States","iata":"ZW","icao":"AWI","id":282,"name":"Air Wisconsin","scope":"inventory","type":"airline"},
+ {"callsign":"WATERSKI","collection":"airline","country":"United States","iata":"AX","icao":"LOF","id":5160,"name":"Trans States Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"DISTRICT","collection":"airline","country":"United States","iata":"BK","icao":"PDC","id":4026,"name":"Potomac Air","scope":"inventory","type":"airline"},
+ {"callsign":"HELIFRANCE","collection":"airline","country":"France","iata":"8H","icao":"HFR","id":2704,"name":"Heli France","scope":"inventory","type":"airline"},
+ {"callsign":"KENMORE","collection":"airline","country":"United States","iata":"M5","icao":"KEN","id":3123,"name":"Kenmore Air","scope":"inventory","type":"airline"},
+ {"callsign":"HEX AIRLINE","collection":"airline","country":"France","iata":"UD","icao":"HER","id":2757,"name":"Hex'Air","scope":"inventory","type":"airline"},
+ {"callsign":"CREST","collection":"airline","country":"United Kingdom","iata":null,"icao":"CAN","id":1923,"name":"Crest Aviation","scope":"inventory","type":"airline"},
+ {"callsign":"ALLEGHENY","collection":"airline","country":"United States","iata":null,"icao":"ALO","id":287,"name":"Allegheny Commuter Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"Spike Air","collection":"airline","country":"United States","iata":"S0","icao":"SAL","id":19774,"name":"Spike Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"JERSEY","collection":"airline","country":"United Kingdom","iata":"BE","icao":"BEE","id":2421,"name":"Flybe","scope":"inventory","type":"airline"},
+ {"callsign":"FOYLE","collection":"airline","country":"United Kingdom","iata":"GS","icao":"UPA","id":690,"name":"Air Foyle","scope":"inventory","type":"airline"},
+ {"callsign":"T.J. Air","collection":"airline","country":"United States","iata":"TJ","icao":"TJA","id":18529,"name":"T.J. Air","scope":"inventory","type":"airline"},
+ {"callsign":"FLYER","collection":"airline","country":"United Kingdom","iata":"CJ","icao":"CFE","id":1795,"name":"BA CityFlyer","scope":"inventory","type":"airline"},
+ {"callsign":"EASTFLIGHT","collection":"airline","country":"United Kingdom","iata":"T3","icao":"EZE","id":2117,"name":"Eastern Airways","scope":"inventory","type":"airline"},
+ {"callsign":"TAHITI AIRLINES","collection":"airline","country":"France","iata":"TN","icao":"THT","id":225,"name":"Air Tahiti Nui","scope":"inventory","type":"airline"},
+ {"callsign":"BRICKYARD","collection":"airline","country":"United States","iata":"RW","icao":"RPA","id":4187,"name":"Republic Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"ALOHA","collection":"airline","country":"United States","iata":"AQ","icao":"AAH","id":22,"name":"Aloha Airlines","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":"YE","icao":"YEL","id":18239,"name":"Yellowtail","scope":"inventory","type":"airline"},
+ {"callsign":"HAWAIIAN","collection":"airline","country":"United States","iata":"HA","icao":"HAL","id":2688,"name":"Hawaiian Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"aws","collection":"airline","country":"United States","iata":"B0","icao":"666","id":17841,"name":"Aws express","scope":"inventory","type":"airline"},
+ {"callsign":"LAKES AIR","collection":"airline","country":"United States","iata":"ZK","icao":"GLA","id":2607,"name":"Great Lakes Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"XAIR","collection":"airline","country":"United States","iata":"XA","icao":"XAU","id":19433,"name":"XAIR USA","scope":"inventory","type":"airline"},
+ {"callsign":"EXPO","collection":"airline","country":"United Kingdom","iata":"JN","icao":"XLA","id":2264,"name":"Excel Airways","scope":"inventory","type":"airline"},
+ {"callsign":"GEEBEE AIRWAYS","collection":"airline","country":"United Kingdom","iata":"GT","icao":"GBL","id":2486,"name":"GB Airways","scope":"inventory","type":"airline"},
+ {"callsign":"Rainbow Air","collection":"airline","country":"United Kingdom","iata":"RU","icao":"RUE","id":19677,"name":"Rainbow Air Euro","scope":"inventory","type":"airline"},
+ {"callsign":"SKYWAY-EX","collection":"airline","country":"United States","iata":"AL","icao":"SYX","id":4589,"name":"Skywalk Airlines","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":"A2","icao":"AL2","id":19804,"name":"All America","scope":"inventory","type":"airline"},
+ {"callsign":"atifly","collection":"airline","country":"United States","iata":"A1","icao":"A1F","id":10226,"name":"Atifly","scope":"inventory","type":"airline"},
+ {"callsign":"KESTREL","collection":"airline","country":"United Kingdom","iata":"VZ","icao":"MYT","id":3568,"name":"MyTravel Airways","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":"N8","icao":"NCR","id":19287,"name":"National Air Cargo","scope":"inventory","type":"airline"},
+ {"callsign":"Cudlua","collection":"airline","country":"United Kingdom","iata":null,"icao":"CUD","id":16881,"name":"Air Cudlua","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":null,"icao":"XOJ","id":17563,"name":"XOJET","scope":"inventory","type":"airline"},
+ {"callsign":"BIG A","collection":"airline","country":"United States","iata":"JW","icao":"APW","id":397,"name":"Arrow Air","scope":"inventory","type":"airline"},
+ {"callsign":"Compass Rose","collection":"airline","country":"United States","iata":"CP","icao":"CPZ","id":1860,"name":"Compass Airlines","scope":"inventory","type":"airline"},
+ {"callsign":null,"collection":"airline","country":"United States","iata":"WQ","icao":"PQW","id":13633,"name":"PanAm World Airways","scope":"inventory","type":"airline"},
+ {"callsign":"Rainbow Air","collection":"airline","country":"United States","iata":"RM","icao":"RNY","id":19678,"name":"Rainbow Air US","scope":"inventory","type":"airline"},
+ {"callsign":"EVERGREEN","collection":"airline","country":"United States","iata":"EZ","icao":"EIA","id":2261,"name":"Evergreen International Airlines","scope":"inventory","type":"airline"},
+ {"callsign":"BABY","collection":"airline","country":"United Kingdom","iata":"WW","icao":"BMI","id":1441,"name":"bmibaby","scope":"inventory","type":"airline"},
+ {"callsign":"FRENCH SUN","collection":"airline","country":"France","iata":"TO","icao":"TVF","id":8745,"name":"Transavia France","scope":"inventory","type":"airline"},
+ {"callsign":"US-HELI","collection":"airline","country":"United States","iata":null,"icao":"USH","id":5268,"name":"US Helicopter","scope":"inventory","type":"airline"},
+ {"callsign":"REGIONAL EUROPE","collection":"airline","country":"France","iata":"YS","icao":"RAE","id":4299,"name":"Régional","scope":"inventory","type":"airline"}
+ ]
diff --git a/tests/test_server/__init__.py b/tests/test_server/__init__.py
new file mode 100644
index 0000000..b19f568
--- /dev/null
+++ b/tests/test_server/__init__.py
@@ -0,0 +1,78 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from enum import Enum
+
+
+class ErrorType(Enum):
+ Timeout = 'timeout'
+ Unauthorized = 'unauthorized'
+ InsufficientPermissions = 'insufficient_permissions'
+ Retriable = 'retriable'
+ Http503 = 'http_503'
+
+ @staticmethod
+ def from_str(error_type: str) -> ErrorType:
+ match = next((t for t in ErrorType if t.value == error_type), None)
+ if not match:
+ raise ValueError(f'Invalid error type: {error_type}. Valid options are: {[e.value for e in ErrorType]}')
+ return match
+
+
+class ResultType(Enum):
+ Object = 'object'
+ Raw = 'raw'
+
+ @staticmethod
+ def from_str(result_type: str) -> ResultType:
+ match = next((t for t in ResultType if t.value == result_type), None)
+ if not match:
+ raise ValueError(f'Invalid result type: {result_type}. Valid options are: {[e.value for e in ResultType]}')
+ return match
+
+
+class RetriableGroupType(Enum):
+ All = 'all'
+ Zero = 'zero'
+ First = 'first'
+ Middle = 'middle'
+ Last = 'last'
+
+ @staticmethod
+ def from_str(rgt: str) -> RetriableGroupType:
+ match = next((t for t in RetriableGroupType if t.value == rgt), None)
+ if not match:
+ raise ValueError(
+ f'Invalid retriable group type: {rgt}. Valid options are: {[e.value for e in RetriableGroupType]}'
+ )
+ return match
+
+
+class NonRetriableSpecificationType(Enum):
+ AllEmpty = 'all_empty'
+ AllFalse = 'all_false'
+ Random = 'random'
+
+ @staticmethod
+ def from_str(nrst: str) -> NonRetriableSpecificationType:
+ match = next((t for t in NonRetriableSpecificationType if t.value == nrst), None)
+ if not match:
+ raise ValueError(
+ f'Invalid non-retriable specification type: {nrst}. '
+ f'Valid options are: {[e.value for e in NonRetriableSpecificationType]}'
+ )
+ return match
diff --git a/tests/test_server/request.py b/tests/test_server/request.py
new file mode 100644
index 0000000..7ded62a
--- /dev/null
+++ b/tests/test_server/request.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Dict, Optional
+
+from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType
+
+
+@dataclass
+class ServerErrorRequest:
+ error_type: ErrorType
+ retry_group_type: Optional[RetriableGroupType] = None
+ non_retriable_spec: Optional[NonRetriableSpecificationType] = None
+ error_count: Optional[int] = None
+
+ @classmethod
+ def from_json(cls, json_data: Dict[str, Any]) -> ServerErrorRequest:
+ error_type = json_data.get('error_type', None)
+ if error_type is None:
+ raise ValueError('Missing "error_type" in JSON data.')
+ err_type = ErrorType.from_str(error_type)
+ retry_grp = json_data.get('retry_group_type', None)
+ rgt = None
+ if retry_grp is not None:
+ rgt = RetriableGroupType.from_str(retry_grp)
+ non_retry_spec = json_data.get('non_retriable_spec', None)
+ nrst = None
+ if non_retry_spec is not None:
+ nrst = NonRetriableSpecificationType.from_str(non_retry_spec)
+ return cls(
+ error_type=err_type,
+ retry_group_type=rgt,
+ non_retriable_spec=nrst,
+ error_count=json_data.get('error_count', None),
+ )
+
+
+@dataclass
+class ServerResultsRequest:
+ result_type: ResultType
+ row_count: Optional[int] = None
+ chunk_size: Optional[int] = None
+ stream: Optional[bool] = False
+ until: Optional[float] = None
+
+ @classmethod
+ def from_json(cls, json_data: Dict[str, Any]) -> ServerResultsRequest:
+ until_raw = json_data.get('until', None)
+ if until_raw is not None and not isinstance(until_raw, (float, int)):
+ raise ValueError(f'Invalid "until" value: {until_raw}. Must be a number.')
+ until = float(until_raw) if until_raw is not None else None
+
+ row_count = json_data.get('row_count', None)
+ if row_count is None and until is None:
+ raise ValueError('Missing "row_count" in JSON data.')
+ if until is None and not isinstance(row_count, int):
+ raise ValueError(f'Invalid "row_count" value: {row_count}. Must be an integer.')
+
+ rtype = json_data.get('result_type', None)
+ result_type = ResultType.from_str(rtype) if rtype is not None else ResultType.Object
+
+ chunk_raw = json_data.get('chunk_size', None)
+ if chunk_raw is not None and not isinstance(chunk_raw, int):
+ raise ValueError(f'Invalid "chunk_size" value: {chunk_raw}. Must be an integer.')
+ chunk_size = int(chunk_raw) if chunk_raw is not None else None
+
+ return cls(
+ result_type=result_type,
+ row_count=row_count,
+ chunk_size=chunk_size,
+ stream=json_data.get('stream', False),
+ until=until,
+ )
+
+
+@dataclass
+class ServerSlowResultsRequest:
+ row_count: int
+ result_type: Optional[ResultType] = ResultType.Object
+ chunk_size: Optional[int] = None
+ stream: Optional[bool] = False
+ until: Optional[float] = None
+
+
+@dataclass
+class ServerTimeoutRequest:
+ error_type: ErrorType
+ timeout: float
+ server_side: Optional[bool] = False
+
+ @classmethod
+ def from_json(cls, json_data: Dict[str, Any]) -> ServerTimeoutRequest:
+ timeout = json_data.get('timeout', None)
+ if timeout is None:
+ raise ValueError('Missing "timeout" in JSON data.')
+ if not isinstance(timeout, (int, float)):
+ raise ValueError(f'Invalid "timeout" value: {timeout}. Must be a number.')
+ return cls(
+ error_type=ErrorType.Timeout, timeout=float(timeout), server_side=json_data.get('server_side', False)
+ )
+
+
+@dataclass
+class ServerHttp503Request:
+ error_type: ErrorType
+ analytics_error: Optional[bool] = False
+
+ @classmethod
+ def from_json(cls, json_data: Dict[str, Any]) -> ServerHttp503Request:
+ return cls(error_type=ErrorType.Http503, analytics_error=json_data.get('analytics_error', False))
diff --git a/tests/test_server/response.py b/tests/test_server/response.py
new file mode 100644
index 0000000..31cf753
--- /dev/null
+++ b/tests/test_server/response.py
@@ -0,0 +1,350 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass, field
+from random import choice
+from typing import Any, Callable, Dict, Generator, List, Optional, Union
+from uuid import uuid4
+
+from tests.test_server import ErrorType, NonRetriableSpecificationType, ResultType, RetriableGroupType
+
+US_CITIES = [
+ 'New York City',
+ 'Los Angeles',
+ 'Chicago',
+ 'Houston',
+ 'Phoenix',
+ 'Philadelphia',
+ 'San Antonio',
+ 'San Diego',
+ 'Dallas',
+ 'San Jose',
+ 'Austin',
+ 'Jacksonville',
+ 'Fort Worth',
+ 'Columbus',
+ 'Charlotte',
+ 'Indianapolis',
+ 'San Francisco',
+ 'Seattle',
+ 'Denver',
+ 'Washington, D.C.',
+ 'Boston',
+ 'El Paso',
+ 'Nashville',
+ 'Detroit',
+ 'Oklahoma City',
+ 'Portland',
+ 'Las Vegas',
+ 'Memphis',
+ 'Louisville',
+ 'Baltimore',
+ 'Milwaukee',
+ 'Albuquerque',
+ 'Tucson',
+ 'Fresno',
+ 'Sacramento',
+ 'Mesa',
+ 'Atlanta',
+ 'Kansas City',
+ 'Colorado Springs',
+ 'Raleigh',
+ 'Omaha',
+ 'Miami',
+ 'Long Beach',
+ 'Virginia Beach',
+ 'Oakland',
+ 'Minneapolis',
+ 'Tampa',
+ 'New Orleans',
+ 'Cleveland',
+ 'Orlando',
+]
+
+NAMES = [
+ 'Alice Smith',
+ 'Bob Johnson',
+ 'Catherine Davis',
+ 'David Miller',
+ 'Emily Wilson',
+ 'Frank Moore',
+ 'Grace Taylor',
+ 'Henry Anderson',
+ 'Ivy Thomas',
+ 'Jack Jackson',
+ 'Karen White',
+ 'Leo Harris',
+ 'Mia Martin',
+ 'Noah Garcia',
+ 'Olivia Rodriguez',
+ 'Peter Martinez',
+ 'Quinn Clark',
+ 'Rachel Lewis',
+ 'Sam Lee',
+ 'Tina Hall',
+ 'Uma Young',
+ 'Victor King',
+ 'Wendy Wright',
+ 'Xavier Lopez',
+ 'Yara Hill',
+ 'Zack Scott',
+ 'Amelia Green',
+ 'Ben Baker',
+ 'Chloe Adams',
+ 'Daniel Nelson',
+ 'Ella Carter',
+ 'Finn Mitchell',
+ 'Georgia Perez',
+ 'Harry Roberts',
+ 'Isla Turner',
+ 'James Phillips',
+ 'Kim Campbell',
+ 'Liam Parker',
+ 'Maya Evans',
+ 'Nathan Edwards',
+ 'Owen Collins',
+ 'Penelope Stewart',
+ 'Ryan Sanchez',
+ 'Sophia Morris',
+ 'Thomas Rogers',
+ 'Victoria Reed',
+ 'William Cook',
+ 'Zara Bell',
+ 'Ethan Murphy',
+ 'Lily Russell',
+]
+
+
+@dataclass
+class ServerResponseMetrics:
+ elapsed_time: float
+ execution_time: float
+ compile_time: float
+ queue_wait_time: float
+ result_count: int
+ result_size: int
+ processed_objects: int
+ buffer_cache_hit_ratio: float
+ buffer_cache_page_read_count: int
+ error_count: int
+
+ def to_json_repr(self) -> Dict[str, Union[str, int]]:
+ return {
+ 'elapsedTime': f'{self.elapsed_time:.2f}s',
+ 'executionTime': f'{self.execution_time:.2f}s',
+ 'compileTime': f'{self.compile_time}ns',
+ 'queueWaitTime': f'{self.queue_wait_time}ns',
+ 'resultCount': self.result_count,
+ 'resultSize': self.result_size,
+ 'processedObjects': self.processed_objects,
+ 'bufferCacheHitRatio': f'{self.buffer_cache_hit_ratio}%',
+ 'bufferCachePageReadCount': self.buffer_cache_page_read_count,
+ 'errorCount': self.error_count,
+ }
+
+ @staticmethod
+ def create() -> ServerResponseMetrics:
+ return ServerResponseMetrics(0.0, 0.0, 0.0, 0.0, 0, 0, 0, 0.0, 0, 0)
+
+
+@dataclass
+class ServerResponseError:
+ code: int
+ msg: str
+ retriable: Optional[bool] = None
+
+ def to_json_repr(self) -> Dict[str, Union[str, int, bool]]:
+ output: Dict[str, Union[str, int, bool]] = {'code': self.code, 'msg': self.msg}
+ if self.retriable is not None:
+ output['retriable'] = self.retriable
+ return output
+
+ @staticmethod
+ def _build_retry_group(
+ retry_specification: NonRetriableSpecificationType, err_count: int, retriable_idx: Optional[int] = -1
+ ) -> List[ServerResponseError]:
+ errors: List[ServerResponseError] = []
+ for err_idx in range(err_count):
+ if err_idx == retriable_idx:
+ errors.append(ServerResponseError(24045, 'Some unknown error occurred', True))
+ elif retry_specification == NonRetriableSpecificationType.AllEmpty:
+ errors.append(ServerResponseError(24040, 'Some unknown error occurred'))
+ elif retry_specification == NonRetriableSpecificationType.AllFalse:
+ errors.append(ServerResponseError(24040, 'Some unknown error occurred', False))
+ elif retry_specification == NonRetriableSpecificationType.Random:
+ if choice([0, 1]):
+ errors.append(ServerResponseError(24040, 'Some unknown error occurred', False))
+ else:
+ errors.append(ServerResponseError(24040, 'Some unknown error occurred'))
+ else:
+ raise RuntimeError('Unrecognized retry specification type.')
+
+ return errors
+
+ @staticmethod
+ def build_retry_group(
+ group_type: RetriableGroupType,
+ retry_specification: Optional[NonRetriableSpecificationType] = None,
+ err_count: Optional[int] = None,
+ ) -> List[ServerResponseError]:
+ if err_count is None:
+ err_count = choice([2, 3, 4, 5])
+ if group_type == RetriableGroupType.All:
+ return [ServerResponseError(24045, 'Some unknown retriable error occurred', True) for _ in range(err_count)]
+
+ if retry_specification is None:
+ raise RuntimeError('No non-retriable specification type provided.')
+ if group_type == RetriableGroupType.Zero:
+ return ServerResponseError._build_retry_group(retry_specification, err_count)
+ elif group_type == RetriableGroupType.First:
+ return ServerResponseError._build_retry_group(retry_specification, err_count, retriable_idx=0)
+ elif group_type == RetriableGroupType.Middle:
+ return ServerResponseError._build_retry_group(
+ retry_specification, err_count, retriable_idx=(err_count // 2)
+ )
+ elif group_type == RetriableGroupType.Last:
+ return ServerResponseError._build_retry_group(retry_specification, err_count, retriable_idx=err_count - 1)
+ else:
+ raise RuntimeError('Unrecognized retriable group type.')
+
+ @staticmethod
+ def build_errors(
+ resp: ServerResponse,
+ error_type: ErrorType,
+ group_type: Optional[RetriableGroupType] = None,
+ retry_specification: Optional[NonRetriableSpecificationType] = None,
+ err_count: Optional[int] = None,
+ ) -> ServerResponse:
+ if error_type == ErrorType.Timeout:
+ resp.http_status = 200
+ resp.status = 'timeout'
+ resp.metrics.error_count = 1
+ resp.errors = [ServerResponseError(21002, 'Request timed out and will be cancelled.', True)]
+ elif error_type == ErrorType.Http503:
+ resp.http_status = 503
+ resp.status = 'fatal'
+ resp.metrics.error_count = 1
+ resp.errors = [ServerResponseError(23000, 'Service is currently unavailable.')]
+ elif error_type == ErrorType.InsufficientPermissions:
+ resp.http_status = 403
+ resp.status = 'fatal'
+ resp.metrics.error_count = 1
+ resp.errors = [
+ ServerResponseError(20001, 'Insufficient permissions or the requested object does not exist')
+ ]
+ elif error_type == ErrorType.Unauthorized:
+ resp.http_status = 401
+ resp.status = 'fatal'
+ resp.metrics.error_count = 1
+ resp.errors = [ServerResponseError(20000, 'Unauthorized user.')]
+ elif error_type == ErrorType.Retriable:
+ resp.http_status = 200
+ resp.status = 'fatal'
+ if group_type is None:
+ raise RuntimeError('No retry group type provided.')
+ if group_type != RetriableGroupType.All and retry_specification is None:
+ raise RuntimeError('No non-retriable specification type provided.')
+ resp.errors = ServerResponseError.build_retry_group(group_type, retry_specification, err_count=err_count)
+ resp.metrics.error_count = len(resp.errors)
+ else:
+ raise RuntimeError('Unrecognized error type.')
+
+ return resp
+
+
+@dataclass
+class ServerResponseResults:
+ results: Union[List[str], List[Dict[str, Any]]]
+
+ def to_json_repr(self) -> Union[List[str], List[Dict[str, Any]]]:
+ return self.results
+
+ @staticmethod
+ def build_results(resp: ServerResponse, row_count: int, result_type: ResultType) -> None:
+ if result_type == ResultType.Object:
+ obj_results: List[Dict[str, Any]] = []
+ for idx in range(row_count):
+ name = choice(NAMES)
+ city = choice(US_CITIES)
+ obj_results.append({'id': idx + 1, 'name': name, 'city': city})
+ resp.results = ServerResponseResults(obj_results)
+ resp.metrics.result_count = row_count
+ resp.metrics.result_size = row_count * 10
+ elif result_type == ResultType.Raw:
+ resp.results = ServerResponseResults([choice(US_CITIES) for _ in range(row_count)])
+ resp.metrics.result_count = row_count
+ resp.metrics.result_size = row_count * 10
+ else:
+ raise RuntimeError(f'Unrecognized result type. Got type: {result_type}')
+
+ @staticmethod
+ def get_result_generator(result_type: ResultType) -> Callable[[], Union[Generator[bytes, None, None]]]:
+ if result_type == ResultType.Object:
+
+ def obj_generator() -> Generator[bytes, None, None]:
+ idx = 0
+ while True:
+ name = choice(NAMES)
+ city = choice(US_CITIES)
+ yield json.dumps({'id': idx + 1, 'name': name, 'city': city}).encode('utf-8')
+ idx += 1
+
+ return obj_generator
+ elif result_type == ResultType.Raw:
+
+ def raw_generator() -> Generator[bytes, None, None]:
+ while True:
+ yield bytes(choice(NAMES), 'utf-8')
+
+ return raw_generator
+ else:
+ raise RuntimeError(f'Unrecognized result type. Got type: {result_type}')
+
+
+@dataclass
+class ServerResponse:
+ http_status: int
+ status: str
+ metrics: ServerResponseMetrics
+ request_id: str = field(default_factory=lambda: str(uuid4()))
+ signature: Optional[Dict[str, str]] = None
+ plans: Optional[Dict[str, Any]] = None
+ results: Optional[ServerResponseResults] = None
+ errors: Optional[List[ServerResponseError]] = None
+
+ def to_json_repr(self, exclude_metrics: Optional[bool] = False) -> Dict[str, Any]:
+ output: Dict[str, Any] = {'requestID': self.request_id, 'status': self.status}
+ if self.signature is not None:
+ output['signature'] = self.signature
+ if self.plans is not None:
+ output['plans'] = self.plans
+ if self.results is not None:
+ output['results'] = self.results.to_json_repr()
+ if self.errors is not None:
+ output['errors'] = [e.to_json_repr() for e in self.errors]
+ if exclude_metrics is False:
+ output['metrics'] = self.metrics.to_json_repr()
+ return output
+
+ def update_elapsed_time(self, t: float) -> None:
+ self.metrics.elapsed_time = t
+ self.metrics.execution_time = t
+
+ @staticmethod
+ def create() -> ServerResponse:
+ return ServerResponse(200, 'success', ServerResponseMetrics.create())
diff --git a/tests/test_server/web_server.py b/tests/test_server/web_server.py
new file mode 100644
index 0000000..999fe7e
--- /dev/null
+++ b/tests/test_server/web_server.py
@@ -0,0 +1,325 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# ruff: noqa: E402
+
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+import pathlib
+import sys
+from time import perf_counter
+from typing import Optional, Union
+from uuid import uuid4
+
+from aiohttp import web
+
+CLIENT_ROOT = pathlib.Path(__file__).parent.parent.parent
+sys.path.append(str(CLIENT_ROOT))
+
+from tests.test_server import ErrorType
+from tests.test_server.request import (
+ ServerErrorRequest,
+ ServerHttp503Request,
+ ServerResultsRequest,
+ ServerTimeoutRequest,
+)
+from tests.test_server.response import ServerResponse, ServerResponseError, ServerResponseResults
+from tests.utils import AsyncBytesIterator, AsyncInfiniteBytesIterator
+
+logging.basicConfig(
+ level=logging.INFO, stream=sys.stderr, format='%(asctime)s - %(levelname)s - (PID:%(process)d) - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+
+class AsyncWebServer:
+ def __init__(self, host: Optional[str] = '0.0.0.0', port: Optional[int] = 8080) -> None:
+ self._app = web.Application()
+ self._host = host
+ self._port = port
+ self._app.add_routes(
+ [
+ web.post('/test_error', self.handle_error_request),
+ web.post('/test_results', self.handle_results_request),
+ web.post('/test_slow_results', self.handle_slow_results_request),
+ ]
+ )
+
+ async def _handle_timeout_error_request(self, request: ServerTimeoutRequest) -> web.Response:
+ timeout = request.timeout
+ start = perf_counter()
+ await asyncio.sleep(timeout)
+ end = perf_counter()
+ elapsed = end - start
+ request_id = str(uuid4())
+ resp = ServerResponse.create()
+ if request.server_side:
+ ServerResponseError.build_errors(resp, ErrorType.Timeout)
+ resp.update_elapsed_time(elapsed)
+ return web.json_response(resp.to_json_repr())
+
+ return web.json_response(
+ {
+ 'requestID': request_id,
+ 'status': 'timeout',
+ 'elapsedTime': f'{elapsed}s',
+ 'message': f'Request timed out after {timeout} seconds.',
+ }
+ )
+
+ def _handle_auth_error_request(self, error_type: ErrorType) -> web.Response:
+ start = perf_counter()
+ resp = ServerResponse.create()
+ ServerResponseError.build_errors(resp, error_type)
+ end = perf_counter()
+ elapsed = end - start
+ resp.update_elapsed_time(elapsed)
+ return web.json_response(resp.to_json_repr())
+
+ def _handle_http503_error_request(self, request: ServerHttp503Request) -> web.Response:
+ if request.analytics_error is False:
+ return web.Response(status=503, text='Service Unavailable')
+ start = perf_counter()
+ resp = ServerResponse.create()
+ ServerResponseError.build_errors(resp, request.error_type)
+ end = perf_counter()
+ elapsed = end - start
+ resp.update_elapsed_time(elapsed)
+ return web.json_response(resp.to_json_repr(), status=resp.http_status)
+
+ async def _handle_retry_error_request(self, request: ServerErrorRequest) -> web.Response:
+ start = perf_counter()
+ resp = ServerResponse.create()
+ ServerResponseError.build_errors(
+ resp,
+ request.error_type,
+ group_type=request.retry_group_type,
+ retry_specification=request.non_retriable_spec,
+ err_count=request.error_count,
+ )
+ end = perf_counter()
+ elapsed = end - start
+ resp.update_elapsed_time(elapsed)
+ return web.json_response(resp.to_json_repr())
+
+ async def _handle_timed_streaming_request(
+ self, request: ServerResultsRequest, web_request: web.Request
+ ) -> web.StreamResponse:
+ if request.until is None:
+ raise ValueError('Missing "until" in JSON data.')
+ resp = ServerResponse.create()
+ start = perf_counter()
+ response = web.StreamResponse(
+ status=200,
+ headers={
+ 'Content-Type': 'application/json',
+ 'Transfer-Encoding': 'chunked',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ },
+ )
+ await response.prepare(web_request)
+ now = asyncio.get_running_loop().time()
+ deadline = now + request.until
+ chunk_size = request.chunk_size or 100
+ bytes_generator = ServerResponseResults.get_result_generator(request.result_type)
+ initial_data = json.dumps({'requestID': resp.request_id, 'status': resp.status}).encode('utf-8')
+ async_inf_iterator = AsyncInfiniteBytesIterator(
+ bytes_generator(), initial_data=initial_data, chunk_size=chunk_size
+ )
+ num_bytes_sent = 0
+ while deadline > now:
+ chunk = await async_inf_iterator.__anext__()
+ num_bytes_sent += len(chunk)
+ logger.info(f'Writing chunk of size {len(chunk)}; {chunk=}; {num_bytes_sent=}')
+ await response.write(chunk)
+ now = asyncio.get_running_loop().time()
+ end = perf_counter()
+ elapsed = end - start
+ resp.update_elapsed_time(elapsed)
+ metrics = resp.metrics
+ metrics.result_count = async_inf_iterator.get_data_count()
+ meta = json.dumps({'metrics': metrics.to_json_repr()}).encode('utf-8')
+ async_inf_iterator.stop_iterating(end_data=meta)
+ async for chunk in async_inf_iterator:
+ num_bytes_sent += len(chunk)
+ logger.info(f'Writing chunk of size {len(chunk)}; {chunk=}; {num_bytes_sent=}')
+ await response.write(chunk)
+ logger.info(f'Writing EOF; {num_bytes_sent=}')
+ await response.write_eof()
+ logger.info('returning response')
+ return response
+
+ async def _handle_count_streaming_request(
+ self, request: ServerResultsRequest, web_request: web.Request
+ ) -> web.StreamResponse:
+ if request.row_count is None:
+ raise ValueError('Missing "row_count" in JSON data.')
+
+ response = web.StreamResponse(
+ status=200,
+ headers={
+ 'Content-Type': 'application/json',
+ 'Transfer-Encoding': 'chunked',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ },
+ )
+ await response.prepare(web_request)
+ resp = ServerResponse.create()
+ start = perf_counter()
+ ServerResponseResults.build_results(resp, request.row_count, request.result_type)
+ end = perf_counter()
+ elapsed = end - start
+ resp.update_elapsed_time(elapsed)
+
+ chunk_size = request.chunk_size or 100
+ async_iterator = AsyncBytesIterator(bytes(json.dumps(resp.to_json_repr()), 'utf-8'), chunk_size=chunk_size)
+ async for chunk in async_iterator:
+ logger.info(f'Writing chunk of size {len(chunk)}; {chunk=}')
+ await response.write(chunk)
+ logger.info('Writing EOF')
+ await response.write_eof()
+ logger.info('returning response')
+ return response
+
+ def _handle_basic_results_request(
+ self, request: ServerResultsRequest, web_request: web.Request
+ ) -> Union[web.Response, web.StreamResponse]:
+ if request.row_count is None:
+ raise ValueError('Missing "row_count" in JSON data.')
+ resp = ServerResponse.create()
+ start = perf_counter()
+ ServerResponseResults.build_results(resp, request.row_count, request.result_type)
+ end = perf_counter()
+ elapsed = end - start
+ resp.update_elapsed_time(elapsed)
+ res = resp.to_json_repr()
+ return web.json_response(res)
+
+ async def handle_error_request(self, request: web.Request) -> web.Response:
+ try:
+ received_json = await request.json()
+ if 'error_type' not in received_json:
+ raise ValueError('Missing "error_type" in JSON data.')
+
+ error_req = ServerErrorRequest.from_json(received_json)
+ if error_req.error_type == ErrorType.Timeout:
+ timeout_req = ServerTimeoutRequest.from_json(received_json)
+ return await self._handle_timeout_error_request(timeout_req)
+ elif error_req.error_type in [ErrorType.InsufficientPermissions, ErrorType.Unauthorized]:
+ return self._handle_auth_error_request(error_req.error_type)
+ elif error_req.error_type == ErrorType.Retriable:
+ return await self._handle_retry_error_request(error_req)
+ elif error_req.error_type == ErrorType.Http503:
+ http503_req = ServerHttp503Request.from_json(received_json)
+ return self._handle_http503_error_request(http503_req)
+ logger.info(f'Received JSON: {received_json}')
+ return web.json_response({'status': 'success', 'data': received_json})
+ except json.JSONDecodeError:
+ received_text = await request.text()
+ msg = 'POST request received, but data is not valid JSON. Showing as plain text.'
+ logger.error(msg)
+ logger.error(f'Received text: {received_text}')
+ return web.Response(status=400, text='Bad Request')
+ except Exception as e:
+ logger.error(f'An error occurred: {e}', exc_info=True)
+ return web.Response(status=400, text='Bad Request')
+
+ async def handle_results_request(self, request: web.Request) -> Union[web.Response, web.StreamResponse]:
+ try:
+ received_json = await request.json()
+ result_req = ServerResultsRequest.from_json(received_json)
+ if result_req.stream:
+ if result_req.until is not None:
+ return await self._handle_timed_streaming_request(result_req, request)
+ return await self._handle_count_streaming_request(result_req, request)
+ return self._handle_basic_results_request(result_req, request)
+ except json.JSONDecodeError:
+ received_text = await request.text()
+ msg = 'POST request received, but data is not valid JSON. Showing as plain text.'
+ logger.error(msg)
+ logger.error(f'Received text: {received_text}')
+ return web.Response(status=400, text='Bad Request')
+ except Exception as e:
+ logger.error(f'An error occurred: {e}', exc_info=True)
+ return web.Response(status=400, text='Bad Request')
+
+ async def handle_slow_results_request(self, request: web.Request) -> web.StreamResponse:
+ try:
+ received_json = await request.json()
+ if 'request_type' not in received_json:
+ raise ValueError('Missing "request_type" in JSON data.')
+
+ logger.info(f'Received JSON: {received_json}')
+ return web.json_response({'status': 'success', 'data': received_json})
+ except json.JSONDecodeError:
+ received_text = await request.text()
+ msg = 'POST request received, but data is not valid JSON. Showing as plain text.'
+ logger.error(msg)
+ logger.error(f'Received text: {received_text}')
+ return web.Response(status=400, text='Bad Request')
+ except Exception as e:
+ logger.error(f'An error occurred: {e}', exc_info=True)
+ return web.Response(status=400, text='Bad Request')
+
+ async def start(self) -> None:
+ runner = web.AppRunner(self._app)
+ await runner.setup()
+ site = web.TCPSite(runner, self._host, self._port)
+ await site.start()
+ logger.info(f'Server running on http://{self._host}:{self._port}')
+
+ async def stop(self) -> None:
+ await self._app.shutdown()
+ await self._app.cleanup()
+
+
+async def run_server(host: str, port: int) -> None:
+ server = AsyncWebServer(host=host, port=port)
+ logger.info(f'Attempting to start server on {host}:{port}...')
+ await server.start()
+ logger.info('Server started. Listening for requests...')
+ try:
+ while True:
+ await asyncio.sleep(300)
+ except asyncio.CancelledError:
+ logger.info('asyncio task cancelled (e.g., from SIGTERM). Shutting down.')
+ except Exception as e:
+ logger.error(f'Unexpected error: {e}', exc_info=True)
+ finally:
+ logger.info('Stopping server...')
+ await server.stop()
+ logger.info('Server stopped.')
+
+
+if __name__ == '__main__':
+ from argparse import ArgumentParser
+
+ ap = ArgumentParser(description='Run Async Web Server')
+ ap.add_argument(
+ '--host', type=str, default='127.0.0.1', help='Host address to bind to (e.g., 127.0.0.1 for localhost only)'
+ )
+ ap.add_argument('--port', type=int, default=8000, help='Port number to listen on')
+ options = ap.parse_args()
+ try:
+ asyncio.run(run_server(host=options.host, port=options.port))
+ except KeyboardInterrupt:
+ pass
+ except Exception as e:
+ logger.critical(f'Critical error: {e}', exc_info=True)
diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py
new file mode 100644
index 0000000..07ae16b
--- /dev/null
+++ b/tests/utils/__init__.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python
+
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+import os
+import pathlib
+import random
+from collections.abc import AsyncIterator as PyAsyncIterator
+from collections.abc import Iterator
+from typing import Any, Dict, Generator, List, Optional, Tuple, Union
+from urllib.parse import quote
+
+import anyio
+
+
+class AsyncInfiniteBytesIterator(PyAsyncIterator[bytes]):
+ def __init__(
+ self,
+ data_generator: Generator[bytes, None, None],
+ initial_data: Optional[Union[bytes, str]] = None,
+ chunk_size: Optional[int] = 100,
+ simulate_delay: Optional[bool] = False,
+ simulate_delay_range: Optional[Tuple[float, float]] = (0.01, 0.1),
+ ) -> None:
+ self._data_generator = data_generator
+ self._initial_data = bytearray()
+ if initial_data is not None:
+ if isinstance(initial_data, bytes):
+ self._initial_data = bytearray(initial_data)[:-1]
+ else:
+ self._initial_data = bytearray(initial_data, 'utf-8')[:-1]
+ self._initial_data += b', "results": ['
+ self._end_data = bytearray()
+
+ self._data = bytearray() if self._initial_data is None else bytearray(self._initial_data)
+ self._chunk_size = chunk_size or 100
+ self._simulate_delay = simulate_delay or False
+ self._simulate_delay_range = simulate_delay_range or (0.01, 0.1)
+ self._start = 0
+ self._stop_iterating = False
+ self._data_count = 0
+
+ def get_data_count(self) -> int:
+ return self._data_count
+
+ def stop_iterating(self, end_data: Optional[Union[bytes, str]] = None) -> None:
+ self._stop_iterating = True
+ if end_data is not None:
+ if isinstance(end_data, bytes):
+ self._end_data = bytearray(end_data)[1:-1]
+ else:
+ self._end_data = bytearray(end_data, 'utf-8')[1:-1]
+
+ def __aiter__(self) -> AsyncInfiniteBytesIterator:
+ return self
+
+ async def __anext__(self) -> bytes:
+ if self._simulate_delay:
+ delay = random.uniform(*self._simulate_delay_range)
+ await anyio.sleep(delay)
+
+ while True:
+ await anyio.sleep(0.5)
+ if len(self._data) < self._chunk_size:
+ if self._stop_iterating:
+ if len(self._data) == 0:
+ raise StopAsyncIteration
+ if len(self._end_data) > 0:
+ print(f'end_data={self._end_data}')
+ # ending a results array
+ self._data += b'], '
+ self._data += bytearray(self._end_data)
+ # ending the overall JSON object
+ self._data += b'}'
+ # reset end_data
+ self._end_data = bytearray()
+ else:
+ while len(self._data) < (2 * self._chunk_size):
+ if self._data_count > 0:
+ self._data += b', '
+ # the data generator should yields whole JSON objects
+ self._data += next(self._data_generator)
+ self._data_count += 1
+
+ if len(self._data) > self._chunk_size:
+ chunk = bytes(self._data[: self._chunk_size])
+ del self._data[: self._chunk_size]
+ else:
+ chunk = bytes(self._data[:])
+ del self._data[:]
+
+ return chunk
+
+
+class AsyncBytesIterator(PyAsyncIterator[bytes]):
+ def __init__(
+ self,
+ data: Union[bytes, str],
+ chunk_size: Optional[int] = 100,
+ simulate_delay: Optional[bool] = False,
+ simulate_delay_range: Optional[Tuple[float, float]] = (0.01, 0.1),
+ ) -> None:
+ self._data = data if isinstance(data, bytes) else bytes(data, 'utf-8')
+ self._chunk_size = chunk_size or 100
+ self._simulate_delay = simulate_delay or False
+ self._simulate_delay_range = simulate_delay_range or (0.01, 0.1)
+ self._start = 0
+ self._stop = self._chunk_size
+
+ def __aiter__(self) -> AsyncBytesIterator:
+ return self
+
+ async def __anext__(self) -> bytes:
+ if self._simulate_delay:
+ delay = random.uniform(*self._simulate_delay_range)
+ await anyio.sleep(delay)
+ if not self._data:
+ raise StopAsyncIteration
+ while True:
+ if len(self._data) == 0:
+ raise StopAsyncIteration
+
+ if self._start >= len(self._data):
+ raise StopAsyncIteration
+
+ if self._stop >= len(self._data):
+ self._stop = len(self._data)
+
+ chunk = self._data[self._start : self._stop]
+ self._start = self._stop
+ self._stop += self._chunk_size
+ return chunk
+
+
+class BytesIterator(Iterator[bytes]):
+ def __init__(self, data: Union[bytes, str], chunk_size: Optional[int] = 100) -> None:
+ self._data = data if isinstance(data, bytes) else bytes(data, 'utf-8')
+ self._chunk_size = chunk_size or 100
+ self._start = 0
+ self._stop = self._chunk_size
+
+ def __iter__(self) -> BytesIterator:
+ return self
+
+ def __next__(self) -> bytes:
+ if not self._data:
+ raise StopIteration
+ while True:
+ if len(self._data) == 0:
+ raise StopIteration
+
+ if self._start >= len(self._data):
+ raise StopIteration
+
+ if self._stop >= len(self._data):
+ self._stop = len(self._data)
+
+ chunk = self._data[self._start : self._stop]
+ self._start = self._stop
+ self._stop += self._chunk_size
+ return chunk
+
+
+def get_test_cert_path() -> str:
+ return os.path.join(pathlib.Path(__file__).parent, 'certs', 'dinocluster.pem')
+
+
+def get_test_cert_list() -> List[str]:
+ cert_file = pathlib.Path(get_test_cert_path())
+ cert_file1 = pathlib.Path(os.path.join(pathlib.Path(__file__).parent, 'certs', 'dinoca.pem'))
+ return [cert_file.read_text(), cert_file1.read_text()]
+
+
+def get_test_cert_str() -> str:
+ cert_file = pathlib.Path(get_test_cert_path())
+ return cert_file.read_text()
+
+
+def to_query_str(params: Dict[str, Any]) -> str:
+ encoded_params = []
+ for k, v in params.items():
+ if v in [True, False]:
+ encoded_params.append(f'{quote(k)}={quote(str(v).lower())}')
+ else:
+ encoded_params.append(f'{quote(k)}={quote(str(v))}')
+
+ return '&'.join(encoded_params)
diff --git a/tests/utils/_async_client_adapter.py b/tests/utils/_async_client_adapter.py
new file mode 100644
index 0000000..a935101
--- /dev/null
+++ b/tests/utils/_async_client_adapter.py
@@ -0,0 +1,69 @@
+from typing import Dict
+
+from httpx import URL, Response
+
+from acouchbase_analytics.protocol._core.client_adapter import _AsyncClientAdapter
+from couchbase_analytics.protocol._core.request import QueryRequest
+
+
+def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
+ if not hasattr(self, 'PYCBAC_TESTING'):
+ raise RuntimeError('This is a testing only adapter')
+ self._http_transport_cls = kwargs.pop('http_transport_cls', None)
+ if self._http_transport_cls is not None and not hasattr(self._http_transport_cls, 'PYCBAC_TESTING'):
+ raise RuntimeError('http_transport_cls must be a test transport')
+ adapter: _AsyncClientAdapter = kwargs.pop('adapter', None)
+ self._client_id = adapter._client_id
+ self._prefix = adapter._prefix
+ self._cluster_id = adapter._cluster_id
+ self._opts_builder = adapter._opts_builder
+ self._conn_details = adapter._conn_details
+ if self._http_transport_cls is None:
+ self._http_transport_cls = adapter._http_transport_cls
+
+
+async def send_request_override(self: _AsyncClientAdapter, request: QueryRequest) -> Response:
+ if not hasattr(self, '_client'):
+ raise RuntimeError('Client not created yet')
+
+ request_json = request.body
+ if hasattr(self, '_request_json') and self._request_json is not None:
+ request_json.update(self._request_json)
+
+ request_extensions = request.extensions
+ if hasattr(self, '_request_extensions') and self._request_extensions is not None:
+ if request_extensions is None:
+ request_extensions = self._request_extensions
+ else:
+ if 'timeout' in self._request_extensions:
+ request_extensions['timeout'].update(self._request_extensions['timeout'])
+
+ url = URL(scheme=request.url.scheme, host=request.url.host, port=request.url.port, path=request.url.path)
+ req = self._client.build_request(request.method, url, json=request_json, extensions=request_extensions)
+ return await self._client.send(req, stream=True)
+
+
+def set_request_path(self: _AsyncClientAdapter, path: str) -> None:
+ self.ANALYTICS_PATH = path
+
+
+def update_request_json(self: _AsyncClientAdapter, json: Dict[str, object]) -> None:
+ self._request_json = json # type: ignore[attr-defined]
+
+
+def update_request_extensions(self: _AsyncClientAdapter, extensions: Dict[str, str]) -> None:
+ self._request_extensions = extensions # type: ignore[attr-defined]
+
+
+class _TestAsyncClientAdapter(_AsyncClientAdapter):
+ pass
+
+
+_TestAsyncClientAdapter.__init__ = client_adapter_init_override # type: ignore[method-assign]
+_TestAsyncClientAdapter.send_request = send_request_override # type: ignore[method-assign]
+_TestAsyncClientAdapter.set_request_path = set_request_path
+_TestAsyncClientAdapter.update_request_json = update_request_json
+_TestAsyncClientAdapter.update_request_extensions = update_request_extensions
+_TestAsyncClientAdapter.PYCBAC_TESTING = True
+
+__all__ = ['_TestAsyncClientAdapter']
diff --git a/tests/utils/_async_utils.py b/tests/utils/_async_utils.py
new file mode 100644
index 0000000..b48ae0e
--- /dev/null
+++ b/tests/utils/_async_utils.py
@@ -0,0 +1,51 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from __future__ import annotations
+
+from types import TracebackType
+from typing import Any, Callable, List, Optional, Type
+
+import anyio
+
+
+class TaskGroupResultCollector:
+ def __init__(self) -> None:
+ self._results: List[Any] = []
+
+ @property
+ def results(self) -> List[Any]:
+ return self._results
+
+ async def _execute(self, fn: Callable[..., Any], *args: object) -> None:
+ result = await fn(*args)
+ self._results.append(result)
+
+ def start_soon(self, fn: Callable[..., Any], *args: object) -> None:
+ self._taskgroup.start_soon(self._execute, fn, *args)
+
+ async def __aenter__(self) -> TaskGroupResultCollector:
+ self._taskgroup = anyio.create_task_group()
+ await self._taskgroup.__aenter__()
+ return self
+
+ async def __aexit__(
+ self, exc_type: Optional[Type[BaseException]], exc_val: Optional[BaseException], exc_tb: Optional[TracebackType]
+ ) -> Any:
+ try:
+ res = await self._taskgroup.__aexit__(exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb)
+ return res
+ finally:
+ del self._taskgroup
diff --git a/tests/utils/_client_adapter.py b/tests/utils/_client_adapter.py
new file mode 100644
index 0000000..36b3048
--- /dev/null
+++ b/tests/utils/_client_adapter.py
@@ -0,0 +1,70 @@
+from typing import Dict
+
+from httpx import URL, Response
+
+from couchbase_analytics.protocol._core.client_adapter import _ClientAdapter
+from couchbase_analytics.protocol._core.request import QueryRequest
+
+
+def client_adapter_init_override(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
+ if not hasattr(self, 'PYCBAC_TESTING'):
+ raise RuntimeError('This is a testing only adapter')
+ self._http_transport_cls = kwargs.pop('http_transport_cls', None)
+ if self._http_transport_cls is not None and not hasattr(self._http_transport_cls, 'PYCBAC_TESTING'):
+ raise RuntimeError('http_transport_cls must be a test transport')
+ adapter: _ClientAdapter = kwargs.pop('adapter', None)
+ adapter.close_client()
+ self._client_id = adapter._client_id
+ self._prefix = adapter._prefix
+ self._cluster_id = adapter._cluster_id
+ self._opts_builder = adapter._opts_builder
+ self._conn_details = adapter._conn_details
+ if self._http_transport_cls is None:
+ self._http_transport_cls = adapter._http_transport_cls
+
+
+def send_request_override(self: _ClientAdapter, request: QueryRequest) -> Response:
+ if not hasattr(self, '_client'):
+ raise RuntimeError('Client not created yet')
+
+ request_json = request.body
+ if hasattr(self, '_request_json') and self._request_json is not None:
+ request_json.update(self._request_json)
+
+ request_extensions = request.extensions
+ if hasattr(self, '_request_extensions') and self._request_extensions is not None:
+ if request_extensions is None:
+ request_extensions = self._request_extensions
+ else:
+ if 'timeout' in self._request_extensions:
+ request_extensions['timeout'].update(self._request_extensions['timeout'])
+
+ url = URL(scheme=request.url.scheme, host=request.url.host, port=request.url.port, path=request.url.path)
+ req = self._client.build_request(request.method, url, json=request_json, extensions=request_extensions)
+ return self._client.send(req, stream=True)
+
+
+def set_request_path(self: _ClientAdapter, path: str) -> None:
+ self.ANALYTICS_PATH = path
+
+
+def update_request_json(self: _ClientAdapter, json: Dict[str, object]) -> None:
+ self._request_json = json # type: ignore[attr-defined]
+
+
+def update_request_extensions(self: _ClientAdapter, extensions: Dict[str, str]) -> None:
+ self._request_extensions = extensions # type: ignore[attr-defined]
+
+
+class _TestClientAdapter(_ClientAdapter):
+ pass
+
+
+_TestClientAdapter.__init__ = client_adapter_init_override # type: ignore[method-assign]
+_TestClientAdapter.send_request = send_request_override # type: ignore[method-assign]
+_TestClientAdapter.set_request_path = set_request_path
+_TestClientAdapter.update_request_json = update_request_json
+_TestClientAdapter.update_request_extensions = update_request_extensions
+_TestClientAdapter.PYCBAC_TESTING = True
+
+__all__ = ['_TestClientAdapter']
diff --git a/tests/utils/_run_web_server.py b/tests/utils/_run_web_server.py
new file mode 100644
index 0000000..0d550d7
--- /dev/null
+++ b/tests/utils/_run_web_server.py
@@ -0,0 +1,100 @@
+# Copyright 2016-2024. Couchbase, Inc.
+# All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import atexit
+import logging
+import pathlib
+import subprocess
+import sys
+import time
+from os import path
+from typing import Optional
+
+WEB_SERVER_PATH = path.join(pathlib.Path(__file__).parent.parent, 'test_server', 'web_server.py')
+
+print(f'Web server script path: {WEB_SERVER_PATH}')
+
+logging.basicConfig(
+ level=logging.INFO, stream=sys.stderr, format='%(asctime)s - %(levelname)s - (PID:%(process)d) - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+
+class WebServerHandler:
+ def __init__(self, host: Optional[str] = '0.0.0.0', port: Optional[int] = 8080) -> None:
+ self._host = host or '0.0.0.0'
+ self._port = port or 8080
+ self._server_process: Optional[subprocess.Popen[bytes]] = None
+ atexit.register(self.stop_server)
+
+ @property
+ def connstr(self) -> str:
+ host = self._host if self._host != '0.0.0.0' else 'localhost'
+ return f'http://{host}:{self._port}'
+
+ def start_server(self) -> None:
+ if self._server_process and self._server_process.poll() is None:
+ logger.info(f'Web server is already running (PID: {self._server_process.pid}).')
+ return
+
+ if not path.exists(WEB_SERVER_PATH):
+ msg = f'Web server script not found at {WEB_SERVER_PATH}.'
+ logger.error(msg)
+ raise FileNotFoundError(msg)
+
+ try:
+ cmd = [sys.executable, WEB_SERVER_PATH, '--host', self._host, '--port', str(self._port)]
+ self._server_process = subprocess.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr)
+ time.sleep(1)
+
+ # Check if the server process unexpectedly exited during startup
+ if self._server_process.poll() is not None:
+ logger.error(
+ (
+ f'Server process (PID: {self._server_process.pid}) exited immediately after launch. '
+ f'Exit code: {self._server_process.returncode}.'
+ )
+ )
+ self._server_process = None
+ else:
+ logger.info('Server should be running at http://%s:%d/', self._host, self._port)
+ except Exception as e:
+ logger.error(f'Failed to start web server: {e}', exc_info=True)
+ self._server_process = None
+ raise
+
+ def stop_server(self) -> None:
+ if self._server_process is None:
+ self._server_process = None
+ return
+
+ if self._server_process.poll() is not None:
+ self._server_process = None
+ return
+
+ try:
+ self._server_process.terminate()
+ try:
+ self._server_process.wait(timeout=5)
+ logger.info(f'Web server stopped (PID: {self._server_process.pid}).')
+ except subprocess.TimeoutExpired:
+ logger.warning(f'Web server (PID: {self._server_process.pid}) did not terminate in time, killing it.')
+ self._server_process.kill()
+ self._server_process.wait()
+ except Exception as e:
+ logger.error(f'Error stopping web server: {e}', exc_info=True)
+ raise
+ finally:
+ self._server_process = None
diff --git a/tests/utils/_test_async_httpx.py b/tests/utils/_test_async_httpx.py
new file mode 100644
index 0000000..da7ad7d
--- /dev/null
+++ b/tests/utils/_test_async_httpx.py
@@ -0,0 +1,234 @@
+import typing
+
+from httpcore import AsyncConnectionPool, Origin, Request, Response
+from httpcore._async.connection import RETRIES_BACKOFF_FACTOR, AsyncHTTPConnection, exponential_backoff, logger
+from httpcore._async.connection_pool import AsyncPoolRequest, PoolByteStream
+from httpcore._async.interfaces import AsyncConnectionInterface
+from httpcore._backends.base import AsyncNetworkStream
+from httpcore._exceptions import ConnectError, ConnectionNotAvailable, ConnectTimeout, UnsupportedProtocol
+from httpcore._ssl import default_ssl_context
+from httpcore._trace import Trace
+from httpx import AsyncHTTPTransport, Limits, create_ssl_context
+
+
+class TestAsyncHTTPConnection(AsyncHTTPConnection):
+ def __init__(self, *args, **kwargs) -> None: # type: ignore
+ super().__init__(*args, **kwargs)
+
+ async def _connect(self, request: Request) -> AsyncNetworkStream:
+ timeouts = request.extensions.get('timeout', {})
+ sni_hostname = request.extensions.get('sni_hostname', None)
+ timeout = timeouts.get('connect', None)
+ # TESTING_OVERRIDE
+ test_connect_timeout = timeouts.get('test_connect_timeout', None)
+ print(f'PYCBAC OVERRIDE: connect timeout: {timeout}, test_connect_timeout: {test_connect_timeout}')
+
+ retries_left = self._retries
+ delays = exponential_backoff(factor=RETRIES_BACKOFF_FACTOR)
+
+ while True:
+ try:
+ if self._uds is None:
+ kwargs = {
+ 'host': self._origin.host.decode('ascii'),
+ 'port': self._origin.port,
+ 'local_address': self._local_address,
+ 'timeout': timeout,
+ 'socket_options': self._socket_options,
+ }
+ async with Trace('connect_tcp', logger, request, kwargs) as trace:
+ stream = await self._network_backend.connect_tcp(**kwargs)
+ trace.return_value = stream
+ else:
+ kwargs = {
+ 'path': self._uds,
+ 'timeout': timeout,
+ 'socket_options': self._socket_options,
+ }
+ async with Trace('connect_unix_socket', logger, request, kwargs) as trace:
+ stream = await self._network_backend.connect_unix_socket(**kwargs)
+ trace.return_value = stream
+
+ if self._origin.scheme in (b'https', b'wss'):
+ ssl_context = default_ssl_context() if self._ssl_context is None else self._ssl_context
+ alpn_protocols = ['http/1.1', 'h2'] if self._http2 else ['http/1.1']
+ ssl_context.set_alpn_protocols(alpn_protocols)
+
+ kwargs = {
+ 'ssl_context': ssl_context,
+ 'server_hostname': sni_hostname or self._origin.host.decode('ascii'),
+ 'timeout': timeout,
+ }
+ async with Trace('start_tls', logger, request, kwargs) as trace:
+ stream = await stream.start_tls(**kwargs)
+ trace.return_value = stream
+ return stream
+ except (ConnectError, ConnectTimeout):
+ if retries_left <= 0:
+ raise
+ retries_left -= 1
+ delay = next(delays)
+ async with Trace('retry', logger, request, kwargs) as trace:
+ await self._network_backend.sleep(delay)
+
+
+class TestAsyncConnectionPool(AsyncConnectionPool):
+ def __init__(self, *args, **kwargs) -> None: # type: ignore
+ super().__init__(*args, **kwargs)
+
+ def create_connection(self, origin: Origin) -> AsyncConnectionInterface:
+ if self._proxy is not None:
+ if self._proxy.url.scheme in (b'socks5', b'socks5h'):
+ from httpcore._async.socks_proxy import AsyncSocks5Connection
+
+ return AsyncSocks5Connection(
+ proxy_origin=self._proxy.url.origin,
+ proxy_auth=self._proxy.auth,
+ remote_origin=origin,
+ ssl_context=self._ssl_context,
+ keepalive_expiry=self._keepalive_expiry,
+ http1=self._http1,
+ http2=self._http2,
+ network_backend=self._network_backend,
+ )
+ elif origin.scheme == b'http':
+ from httpcore._async.http_proxy import AsyncForwardHTTPConnection
+
+ return AsyncForwardHTTPConnection(
+ proxy_origin=self._proxy.url.origin,
+ proxy_headers=self._proxy.headers,
+ proxy_ssl_context=self._proxy.ssl_context,
+ remote_origin=origin,
+ keepalive_expiry=self._keepalive_expiry,
+ network_backend=self._network_backend,
+ )
+ from httpcore._async.http_proxy import AsyncTunnelHTTPConnection
+
+ return AsyncTunnelHTTPConnection(
+ proxy_origin=self._proxy.url.origin,
+ proxy_headers=self._proxy.headers,
+ proxy_ssl_context=self._proxy.ssl_context,
+ remote_origin=origin,
+ ssl_context=self._ssl_context,
+ keepalive_expiry=self._keepalive_expiry,
+ http1=self._http1,
+ http2=self._http2,
+ network_backend=self._network_backend,
+ )
+
+ # TESTING_OVERRIDE
+ return TestAsyncHTTPConnection(
+ origin=origin,
+ ssl_context=self._ssl_context,
+ keepalive_expiry=self._keepalive_expiry,
+ http1=self._http1,
+ http2=self._http2,
+ retries=self._retries,
+ local_address=self._local_address,
+ uds=self._uds,
+ network_backend=self._network_backend,
+ socket_options=self._socket_options,
+ )
+
+ async def handle_async_request(self, request: Request) -> Response:
+ """
+ Send an HTTP request, and return an HTTP response.
+
+ This is the core implementation that is called into by `.request()` or `.stream()`.
+ """
+ scheme = request.url.scheme.decode()
+ if scheme == '':
+ raise UnsupportedProtocol("Request URL is missing an 'http://' or 'https://' protocol.")
+ if scheme not in ('http', 'https', 'ws', 'wss'):
+ raise UnsupportedProtocol(f"Request URL has an unsupported protocol '{scheme}://'.")
+
+ timeouts = request.extensions.get('timeout', {})
+ timeout = timeouts.get('pool', None)
+ # TESTING_OVERRIDE
+ test_pool_timeout = timeouts.get('test_pool_timeout', None)
+ print(f'PYCBAC OVERRIDE: pool timeout: {timeout}, test_pool_timeout: {test_pool_timeout}')
+
+ with self._optional_thread_lock:
+ # Add the incoming request to our request queue.
+ pool_request = AsyncPoolRequest(request)
+ self._requests.append(pool_request)
+
+ try:
+ while True:
+ with self._optional_thread_lock:
+ # Assign incoming requests to available connections,
+ # closing or creating new connections as required.
+ closing = self._assign_requests_to_connections()
+ await self._close_connections(closing)
+
+ # Wait until this request has an assigned connection.
+ connection = await pool_request.wait_for_connection(timeout=timeout)
+
+ try:
+ # Send the request on the assigned connection.
+ response = await connection.handle_async_request(pool_request.request)
+ except ConnectionNotAvailable:
+ # In some cases a connection may initially be available to
+ # handle a request, but then become unavailable.
+ #
+ # In this case we clear the connection and try again.
+ pool_request.clear_connection()
+ else:
+ break # pragma: nocover
+
+ except BaseException as exc:
+ with self._optional_thread_lock:
+ # For any exception or cancellation we remove the request from
+ # the queue, and then re-assign requests to connections.
+ self._requests.remove(pool_request)
+ closing = self._assign_requests_to_connections()
+
+ await self._close_connections(closing)
+ raise exc from None
+
+ # Return the response. Note that in this case we still have to manage
+ # the point at which the response is closed.
+ assert isinstance(response.stream, typing.AsyncIterable)
+ return Response(
+ status=response.status,
+ headers=response.headers,
+ content=PoolByteStream(stream=response.stream, pool_request=pool_request, pool=self),
+ extensions=response.extensions,
+ )
+
+
+def async_http_transport_init_override(self, *args, **kwargs) -> None: # type: ignore
+ verify = kwargs.get('verify')
+ cert = kwargs.get('cert')
+ trust_env = kwargs.get('trust_env')
+ ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) # type: ignore
+
+ # See https://github.com/encode/httpx/blob/master/httpx/_config.py for defaults
+ # default keepalive_expiry is 5 seconds
+ limits = kwargs.get('limits', Limits(max_connections=100, max_keepalive_connections=20))
+ http1 = kwargs.get('http1')
+ http2 = kwargs.get('http2')
+ uds = kwargs.get('uds')
+ local_address = kwargs.get('local_address')
+ retries = kwargs.get('retries', 0)
+ socket_options = kwargs.get('socket_options')
+ self._pool = TestAsyncConnectionPool(
+ ssl_context=ssl_context,
+ max_connections=limits.max_connections,
+ max_keepalive_connections=limits.max_keepalive_connections,
+ keepalive_expiry=limits.keepalive_expiry,
+ http1=http1,
+ http2=http2,
+ uds=uds,
+ local_address=local_address,
+ retries=retries,
+ socket_options=socket_options,
+ )
+
+
+AsyncHTTPTransport.__init__ = async_http_transport_init_override # type: ignore
+AsyncHTTPTransport.PYCBAC_TESTING = True
+
+TestAsyncHTTPTransport = AsyncHTTPTransport
+
+__all__ = ['TestAsyncHTTPTransport']
diff --git a/tests/utils/_test_httpx.py b/tests/utils/_test_httpx.py
new file mode 100644
index 0000000..e0a85f2
--- /dev/null
+++ b/tests/utils/_test_httpx.py
@@ -0,0 +1,261 @@
+import time
+import typing
+
+from httpcore import ConnectionPool, Origin, Request, Response
+from httpcore._backends.base import NetworkStream
+from httpcore._exceptions import ConnectError, ConnectionNotAvailable, ConnectTimeout, PoolTimeout, UnsupportedProtocol
+from httpcore._ssl import default_ssl_context
+from httpcore._sync.connection import RETRIES_BACKOFF_FACTOR, HTTPConnection, exponential_backoff, logger
+from httpcore._sync.connection_pool import PoolByteStream, PoolRequest
+from httpcore._sync.interfaces import ConnectionInterface
+from httpcore._trace import Trace
+from httpx import HTTPTransport, Limits, create_ssl_context
+
+
+class TestHTTPConnection(HTTPConnection):
+ def __init__(self, *args, **kwargs) -> None: # type: ignore
+ super().__init__(*args, **kwargs)
+
+ def _connect(self, request: Request) -> NetworkStream:
+ timeouts = request.extensions.get('timeout', {})
+ sni_hostname = request.extensions.get('sni_hostname', None)
+ timeout = timeouts.get('connect', None)
+ # -- START PYCBAC TESTING --
+ test_connect_timeout = timeouts.get('test_connect_timeout', None)
+ print(f'PYCBAC OVERRIDE: connect timeout: {timeout}, test_connect_timeout: {test_connect_timeout}')
+ # -- END PYCBAC TESTING --
+
+ retries_left = self._retries
+ delays = exponential_backoff(factor=RETRIES_BACKOFF_FACTOR)
+
+ # -- START PYCBAC TESTING --
+ deadline = time.monotonic() + timeout
+ # -- END PYCBAC TESTING --
+ while True:
+ try:
+ if self._uds is None:
+ kwargs = {
+ 'host': self._origin.host.decode('ascii'),
+ 'port': self._origin.port,
+ 'local_address': self._local_address,
+ 'timeout': timeout,
+ 'socket_options': self._socket_options,
+ }
+ with Trace('connect_tcp', logger, request, kwargs) as trace:
+ # -- START PYCBAC TESTING --
+ if test_connect_timeout is not None:
+ time.sleep(test_connect_timeout)
+ current_time = time.monotonic()
+ if current_time > deadline:
+ raise ConnectTimeout(f'Connection timed out after {timeout} seconds')
+ # -- END PYCBAC TESTING --
+ stream = self._network_backend.connect_tcp(**kwargs)
+ trace.return_value = stream
+ else:
+ kwargs = {
+ 'path': self._uds,
+ 'timeout': timeout,
+ 'socket_options': self._socket_options,
+ }
+ with Trace('connect_unix_socket', logger, request, kwargs) as trace:
+ stream = self._network_backend.connect_unix_socket(**kwargs)
+ trace.return_value = stream
+
+ if self._origin.scheme in (b'https', b'wss'):
+ ssl_context = default_ssl_context() if self._ssl_context is None else self._ssl_context
+ alpn_protocols = ['http/1.1', 'h2'] if self._http2 else ['http/1.1']
+ ssl_context.set_alpn_protocols(alpn_protocols)
+
+ kwargs = {
+ 'ssl_context': ssl_context,
+ 'server_hostname': sni_hostname or self._origin.host.decode('ascii'),
+ 'timeout': timeout,
+ }
+ with Trace('start_tls', logger, request, kwargs) as trace:
+ stream = stream.start_tls(**kwargs)
+ trace.return_value = stream
+ return stream
+ except (ConnectError, ConnectTimeout):
+ if retries_left <= 0:
+ raise
+ retries_left -= 1
+ delay = next(delays)
+ with Trace('retry', logger, request, kwargs) as trace:
+ self._network_backend.sleep(delay)
+
+
+class TestConnectionPool(ConnectionPool):
+ def __init__(self, *args, **kwargs) -> None: # type: ignore
+ super().__init__(*args, **kwargs)
+
+ def create_connection(self, origin: Origin) -> ConnectionInterface:
+ if self._proxy is not None:
+ if self._proxy.url.scheme in (b'socks5', b'socks5h'):
+ from httpcore._sync.socks_proxy import Socks5Connection
+
+ return Socks5Connection(
+ proxy_origin=self._proxy.url.origin,
+ proxy_auth=self._proxy.auth,
+ remote_origin=origin,
+ ssl_context=self._ssl_context,
+ keepalive_expiry=self._keepalive_expiry,
+ http1=self._http1,
+ http2=self._http2,
+ network_backend=self._network_backend,
+ )
+ elif origin.scheme == b'http':
+ from httpcore._sync.http_proxy import ForwardHTTPConnection
+
+ return ForwardHTTPConnection(
+ proxy_origin=self._proxy.url.origin,
+ proxy_headers=self._proxy.headers,
+ proxy_ssl_context=self._proxy.ssl_context,
+ remote_origin=origin,
+ keepalive_expiry=self._keepalive_expiry,
+ network_backend=self._network_backend,
+ )
+ from httpcore._sync.http_proxy import TunnelHTTPConnection
+
+ return TunnelHTTPConnection(
+ proxy_origin=self._proxy.url.origin,
+ proxy_headers=self._proxy.headers,
+ proxy_ssl_context=self._proxy.ssl_context,
+ remote_origin=origin,
+ ssl_context=self._ssl_context,
+ keepalive_expiry=self._keepalive_expiry,
+ http1=self._http1,
+ http2=self._http2,
+ network_backend=self._network_backend,
+ )
+
+ # TESTING_OVERRIDE
+ return TestHTTPConnection(
+ origin=origin,
+ ssl_context=self._ssl_context,
+ keepalive_expiry=self._keepalive_expiry,
+ http1=self._http1,
+ http2=self._http2,
+ retries=self._retries,
+ local_address=self._local_address,
+ uds=self._uds,
+ network_backend=self._network_backend,
+ socket_options=self._socket_options,
+ )
+
+ def handle_request(self, request: Request) -> Response:
+ """
+ Send an HTTP request, and return an HTTP response.
+
+ This is the core implementation that is called into by `.request()` or `.stream()`.
+ """
+ scheme = request.url.scheme.decode()
+ if scheme == '':
+ raise UnsupportedProtocol("Request URL is missing an 'http://' or 'https://' protocol.")
+ if scheme not in ('http', 'https', 'ws', 'wss'):
+ raise UnsupportedProtocol(f"Request URL has an unsupported protocol '{scheme}://'.")
+
+ timeouts = request.extensions.get('timeout', {})
+ timeout = timeouts.get('pool', None)
+ # -- START PYCBAC TESTING --
+ test_pool_timeout = timeouts.get('test_pool_timeout', None)
+ print(f'PYCBAC OVERRIDE: pool timeout: {timeout}, test_pool_timeout: {test_pool_timeout}')
+ # -- END PYCBAC TESTING --
+
+ with self._optional_thread_lock:
+ # Add the incoming request to our request queue.
+ pool_request = PoolRequest(request)
+ self._requests.append(pool_request)
+
+ # PYCBAC Addition: track the deadline
+ deadline = time.monotonic() + timeout
+ try:
+ while True:
+ with self._optional_thread_lock:
+ # Assign incoming requests to available connections,
+ # closing or creating new connections as required.
+ closing = self._assign_requests_to_connections()
+ self._close_connections(closing)
+
+ # -- START PYCBAC TESTING --
+ if test_pool_timeout is not None:
+ time.sleep(test_pool_timeout)
+ current_time = time.monotonic()
+ if current_time > deadline:
+ raise PoolTimeout(f'Connection timed out after {timeout} seconds')
+ # -- END PYCBAC TESTING --
+ # Wait until this request has an assigned connection.
+ connection = pool_request.wait_for_connection(timeout=timeout)
+ # PYCBAC Addition: We _always_ set the request timeouts, so no need to validate keys
+ connect_timeout = round(deadline - time.monotonic(), 6) # round to microseconds
+ pool_request.request.extensions['timeout']['connect'] = connect_timeout
+
+ try:
+ # Send the request on the assigned connection.
+ response = connection.handle_request(pool_request.request)
+ except ConnectionNotAvailable:
+ # In some cases a connection may initially be available to
+ # handle a request, but then become unavailable.
+ #
+ # In this case we clear the connection and try again.
+ pool_request.clear_connection()
+ # PYCBAC Addition: We update the timeout for the next attempt
+ timeout = round(deadline - time.monotonic(), 6) # round to microseconds
+ else:
+ break # pragma: nocover
+
+ except BaseException as exc:
+ with self._optional_thread_lock:
+ # For any exception or cancellation we remove the request from
+ # the queue, and then re-assign requests to connections.
+ self._requests.remove(pool_request)
+ closing = self._assign_requests_to_connections()
+
+ self._close_connections(closing)
+ raise exc from None
+
+ # Return the response. Note that in this case we still have to manage
+ # the point at which the response is closed.
+ assert isinstance(response.stream, typing.Iterable)
+ return Response(
+ status=response.status,
+ headers=response.headers,
+ content=PoolByteStream(stream=response.stream, pool_request=pool_request, pool=self),
+ extensions=response.extensions,
+ )
+
+
+def http_transport_init_override(self, *args, **kwargs) -> None: # type: ignore
+ verify = kwargs.get('verify')
+ cert = kwargs.get('cert')
+ trust_env = kwargs.get('trust_env')
+ ssl_context = create_ssl_context(verify=verify, cert=cert, trust_env=trust_env) # type: ignore
+
+ # See https://github.com/encode/httpx/blob/master/httpx/_config.py for defaults
+ # default keepalive_expiry is 5 seconds
+ limits = kwargs.get('limits', Limits(max_connections=100, max_keepalive_connections=20))
+ http1 = kwargs.get('http1')
+ http2 = kwargs.get('http2')
+ uds = kwargs.get('uds')
+ local_address = kwargs.get('local_address')
+ retries = kwargs.get('retries', 0)
+ socket_options = kwargs.get('socket_options')
+ self._pool = TestConnectionPool(
+ ssl_context=ssl_context,
+ max_connections=limits.max_connections,
+ max_keepalive_connections=limits.max_keepalive_connections,
+ keepalive_expiry=limits.keepalive_expiry,
+ http1=http1,
+ http2=http2,
+ uds=uds,
+ local_address=local_address,
+ retries=retries,
+ socket_options=socket_options,
+ )
+
+
+HTTPTransport.__init__ = http_transport_init_override # type: ignore
+HTTPTransport.PYCBAC_TESTING = True
+
+TestHTTPTransport = HTTPTransport
+
+__all__ = ['TestHTTPTransport']
diff --git a/tests/utils/certs/dinoca.pem b/tests/utils/certs/dinoca.pem
new file mode 100644
index 0000000..8f5285d
--- /dev/null
+++ b/tests/utils/certs/dinoca.pem
@@ -0,0 +1,31 @@
+-----BEGIN CERTIFICATE-----
+MIIFTDCCAzSgAwIBAgIBATANBgkqhkiG9w0BAQsFADA4MTYwNAYDVQQDEy1kaW5v
+Y2VydC0xQTlBODI1Ri0wNDU1LTU2ODgtOTE2My1GN0Y0NTkyREUwRkUwHhcNMjUw
+MTAxMDAwMDAwWhcNMzUwMTAxMDAwMDAwWjA4MTYwNAYDVQQDEy1kaW5vY2VydC0x
+QTlBODI1Ri0wNDU1LTU2ODgtOTE2My1GN0Y0NTkyREUwRkUwggIiMA0GCSqGSIb3
+DQEBAQUAA4ICDwAwggIKAoICAQDOeBwdVDdX4u255kqXv7bw6unoCNYVjiuv4eos
+XSGvLnHTffMzb185BNEjfv6Otu4rm3eo6y37UeeT8WVOr/8YUekZE9MJBRcofgd2
+G3ACk/LUsugl9egmR7Ivj9WHG9ILNQPSXq9cubZXo52k+s53/dvP12vleHYmW274
+/as0FXn+mcXbbjF/Nru9HV1OokmLsAcxxJceQjb9wJntOr36ej+ROPaKDmaD11uv
+gXRgXnA2ngPP82DsLImplD5OBEhOjtIaD+0G/TWXpHEV3ZKADYSMYRrex6JHrcBh
+2Es6E4Xckv06VHSEfhVEJPS2in59fcHUpxuvaYVBHilWbLEsCkN5DzDxb3G/m9vX
+UqZYgF+gQ6+30A4Zbbn9tQQIX30cz1ml0kOKirNEPM58/RxOFIDLTQ+5n1tSP/c1
+oPit5c/e+YzKVWeoJSdXC+zlYSrVDDJ1R3XTkSg9Ja5fsGn9OCBvWrrGII2H0RF2
+8lCU9YUTO0kTbVLNA03e4/RXUKnZAhUE2LEwk5A8FK61SPSiRq4mkilJ8yi8ZFZW
+I+tCU4q6xjuwysu2D8DG4kY7M8S0zoafNYvT4cosdN2OGPqe8rKhgpfFRZBgiiks
+BW59CBkdui9/q5oYPm6KGD1dX9PCzjRFyT/wFMHs2jCqcC7ejmUUufdN3aZgPyCg
+C0VRvwIDAQABo2EwXzAOBgNVHQ8BAf8EBAMCAoQwHQYDVR0lBBYwFAYIKwYBBQUH
+AwIGCCsGAQUFBwMBMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFMeOieNqIXlo
+aZ6Q3wbunaMjI75DMA0GCSqGSIb3DQEBCwUAA4ICAQAJAiQck9JoXItAu+eWRKKf
+6T/MaaF3ukGF+Yusqqj3fOm5VJ23gUQhpEtZ09ALVmOvGf9LUGJ7Yvotsl+GLCLL
+XzRDVEAjU2i2j31INQgK26H/HsekYQ0GppMpDj44BMlWL1XfbcQgHC4WgUep3ju2
+Kt+LNUjc0CEc8nGqd3jeg801sgFNrstStRPFwIeFgcapcSPuggxbLVAlNYci6CpK
+ufxUEFwOMrQGwrFblW104j1GWd/f8R4Mn6FH/Ru7ZCdZcp2hRjtjnnqi8zkKefGB
+dQdOSr00FM01cqYfGL4J0JHn9Q50OfrhfVzHe7h33iulrqsIDvPK0nGr5LMIrlZL
+mWK0KKOGbq6IWXx5pKa5/Lve/B6RO3wbJheGC2vNOF/JuMuhds2bGVfaEPCHBvM1
+3fPN90FzGTxKTsug/Tg/C7+zoTUqJ4I0zdqW0fzJ65Rpb/INU0WjrX5h5w+fSgY0
+wQdKIkezcQ7OoPmpIsuEQvnCoPdVFsNA4eHdRp5u877olE/iDmljsu3sa0Y6xxnv
+wmK1E44EciNQ7aj5lzeLrSP0/uFZRTDP4h7B4jlkFWqgPpE6uSYTNVZgwVwQCcA7
+ZlQIVK3aOQvact80pqXn8Zu2MHlvVR6L3po+FIBsa8ha2rGTsOasVFkXQLnKBO7r
+Og2yKTFaMcFFhK2+PVFxQQ==
+-----END CERTIFICATE-----
diff --git a/tests/utils/certs/dinocluster.pem b/tests/utils/certs/dinocluster.pem
new file mode 100644
index 0000000..ae4790a
--- /dev/null
+++ b/tests/utils/certs/dinocluster.pem
@@ -0,0 +1,31 @@
+-----BEGIN CERTIFICATE-----
+MIIFWzCCA0OgAwIBAgIBATANBgkqhkiG9w0BAQsFADA4MTYwNAYDVQQDEy1kaW5v
+Y2VydC0xQTlBODI1Ri0wNDU1LTU2ODgtOTE2My1GN0Y0NTkyREUwRkUwHhcNMjUw
+MTAxMDAwMDAwWhcNMzUwMTAxMDAwMDAwWjAkMSIwIAYDVQQDExlkaW5vY2VydC1j
+bHVzdGVyLTIwZDM1YTlhMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
+loAWLxlfNyGOu3PzG3Dm0qV5lRFjiMK5tWu4vTdzEcJhwWq/bA4MFKodTy1KIfn5
+RrTMCURsYxn7TJkoN5RO8o+6ZEUa5bPcTc+jmaTvLT5gPjKVDVmPdhJVq0ywcyqG
+9y5tVXnd9smNMPqT4w+5V01CCkb3ThDduOPGPWp6kQSWcqZ+bZYR+V86cTv5WyTo
+vMW0Yj5GHY3k0Ag79BSrtzhsVOowgVq5FP38KtXeo5f0WVmez5d+p08ZxHbKTZps
+ZvCjYL9WPQ2c1M4nnAholFhWFDMMhvO5ACHdU+62jD8yGCgmyknZ8sQ9cFHMYEng
+t6Tz/ZQHuvOBI8haCop60tVKp1CN2Sr7pNnhoYooVE7sLhL9i4JVSt1NLqg3tf5o
+PXmg7xVi5HBnDriDVRCRvCuRPpz3HizYkuXzOOifnUlqkFiuV/iD6W4UlvA9Tz1Y
+yAAEzwOWukNjJYtQFOtwy9QEfSp0hHqeRNjqQzCCqiDNgNEVQ8qShNlY9lgg06qb
+LwT1+GLWIvg51+tJrmX0sG5o31brZeZmhEFC0jexJSjkfSdm7WdzvO9BGrlElVHa
+2IIj3INBSz7IiYPpjmIFrAq2ffbQgI5TBaze4KIKEMsFH0uw7wKx9kwn8NhsKf6/
+d6mCjWFgtFfxg8st0tGoCathWR+lECBG6+K2GcE+WX0CAwEAAaOBgzCBgDAOBgNV
+HQ8BAf8EBAMCAoQwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMA8GA1Ud
+EwEB/wQFMAMBAf8wHQYDVR0OBBYEFH1QPn4vjPED0IC+7kTDagKvnA00MB8GA1Ud
+IwQYMBaAFMeOieNqIXloaZ6Q3wbunaMjI75DMA0GCSqGSIb3DQEBCwUAA4ICAQCR
+vGQxPK3nt1WWOC0yoRL6WAMiwE1fzRKsnqbKkEH2BRLAPNxmBMl76YySn6xSIQZf
+uVbYuo8wPHB+//3J/238bJ29WIHdls+6pB5ioTYUx3NzXZAk8Lz/Pex0XqEvOGHV
+WoZMGc/dDb8H1Qp62lU6lzlki61nA+NUm8vQaJ2vb7XHnPWIhRXVfvUbcc08aClm
+5PEQoXytXPc7S1yDeqOZ1fyKzz+mqTpCAYjT5m1uJ5FBqK1iChi5Fye4aF+wWSQ8
+RDRsB7MZsQueRCSxvesmmmtxU91MXYVftiZNwHKsXoqFOWEORboVsYk6I/CWXiaC
+ijRVxCep2L3h8T4bOBsWAbW+UhvL0ZzTfm/YNOOWtKy9F0HYpLbhHP4hwX577KGQ
+4pHUWRq3j0iSDkg6ORdLRBI51nDEYseHSTFYxUqD3FU12kYesWNcvRBqAh8nsiU/
+c9Yc230EF3ZXiar9voqBZmQ7P4S6Pkud4tllo4yotNS1TLn7UvzpudOeEwMroi+n
+ATY0+MXcgOd21yKVgk833TuYzl3Alj9RK1jeJY+GVualZzTqyLeaEvbGN0fmZbj6
+OwNoRoZIknh6pLvsup/XZjluuLj7+8atZLfsa/Dd+pVTNjWkDdMljiHQQ6nQWo/P
+KMfDlOlfgAsxPTY9XPrb6HWFfLEeHN7FTkLK2ZccvA==
+-----END CERTIFICATE-----
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..1ca1a7f
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,1979 @@
+version = 1
+revision = 2
+requires-python = ">=3.9"
+resolution-markers = [
+ "python_full_version >= '3.13'",
+ "python_full_version >= '3.11' and python_full_version < '3.13'",
+ "python_full_version == '3.10.*'",
+ "python_full_version < '3.10'",
+]
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.11.18"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "async-timeout", marker = "python_full_version < '3.11'" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/63/e7/fa1a8c00e2c54b05dc8cb5d1439f627f7c267874e3f7bb047146116020f9/aiohttp-3.11.18.tar.gz", hash = "sha256:ae856e1138612b7e412db63b7708735cff4d38d0399f6a5435d3dac2669f558a", size = 7678653, upload-time = "2025-04-21T09:43:09.191Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/c3/e5f64af7e97a02f547020e6ff861595766bb5ecb37c7492fac9fe3c14f6c/aiohttp-3.11.18-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:96264854fedbea933a9ca4b7e0c745728f01380691687b7365d18d9e977179c4", size = 711703, upload-time = "2025-04-21T09:40:25.487Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/2f/53c26e96efa5fd01ebcfe1fefdfb7811f482bb21f4fa103d85eca4dcf888/aiohttp-3.11.18-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9602044ff047043430452bc3a2089743fa85da829e6fc9ee0025351d66c332b6", size = 471348, upload-time = "2025-04-21T09:40:27.569Z" },
+ { url = "https://files.pythonhosted.org/packages/80/47/dcc248464c9b101532ee7d254a46f6ed2c1fd3f4f0f794cf1f2358c0d45b/aiohttp-3.11.18-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5691dc38750fcb96a33ceef89642f139aa315c8a193bbd42a0c33476fd4a1609", size = 457611, upload-time = "2025-04-21T09:40:28.978Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/ca/67d816ef075e8ac834b5f1f6b18e8db7d170f7aebaf76f1be462ea10cab0/aiohttp-3.11.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:554c918ec43f8480b47a5ca758e10e793bd7410b83701676a4782672d670da55", size = 1591976, upload-time = "2025-04-21T09:40:30.804Z" },
+ { url = "https://files.pythonhosted.org/packages/46/00/0c120287aa51c744438d99e9aae9f8c55ca5b9911c42706966c91c9d68d6/aiohttp-3.11.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a4076a2b3ba5b004b8cffca6afe18a3b2c5c9ef679b4d1e9859cf76295f8d4f", size = 1632819, upload-time = "2025-04-21T09:40:32.731Z" },
+ { url = "https://files.pythonhosted.org/packages/54/a3/3923c9040cd4927dfee1aa017513701e35adcfc35d10729909688ecaa465/aiohttp-3.11.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:767a97e6900edd11c762be96d82d13a1d7c4fc4b329f054e88b57cdc21fded94", size = 1666567, upload-time = "2025-04-21T09:40:34.901Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/ab/40dacb15c0c58f7f17686ea67bc186e9f207341691bdb777d1d5ff4671d5/aiohttp-3.11.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0ddc9337a0fb0e727785ad4f41163cc314376e82b31846d3835673786420ef1", size = 1594959, upload-time = "2025-04-21T09:40:36.714Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/98/d40c2b7c4a5483f9a16ef0adffce279ced3cc44522e84b6ba9e906be5168/aiohttp-3.11.18-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f414f37b244f2a97e79b98d48c5ff0789a0b4b4609b17d64fa81771ad780e415", size = 1538516, upload-time = "2025-04-21T09:40:38.263Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/10/e0bf3a03524faac45a710daa034e6f1878b24a1fef9c968ac8eb786ae657/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fdb239f47328581e2ec7744ab5911f97afb10752332a6dd3d98e14e429e1a9e7", size = 1529037, upload-time = "2025-04-21T09:40:40.349Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/d6/5ff5282e00e4eb59c857844984cbc5628f933e2320792e19f93aff518f52/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2c50bad73ed629cc326cc0f75aed8ecfb013f88c5af116f33df556ed47143eb", size = 1546813, upload-time = "2025-04-21T09:40:42.106Z" },
+ { url = "https://files.pythonhosted.org/packages/de/96/f1014f84101f9b9ad2d8acf3cc501426475f7f0cc62308ae5253e2fac9a7/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a8d8f20c39d3fa84d1c28cdb97f3111387e48209e224408e75f29c6f8e0861d", size = 1523852, upload-time = "2025-04-21T09:40:44.164Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/86/ec772c6838dd6bae3229065af671891496ac1834b252f305cee8152584b2/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:106032eaf9e62fd6bc6578c8b9e6dc4f5ed9a5c1c7fb2231010a1b4304393421", size = 1603766, upload-time = "2025-04-21T09:40:46.203Z" },
+ { url = "https://files.pythonhosted.org/packages/84/38/31f85459c9402d409c1499284fc37a96f69afadce3cfac6a1b5ab048cbf1/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:b491e42183e8fcc9901d8dcd8ae644ff785590f1727f76ca86e731c61bfe6643", size = 1620647, upload-time = "2025-04-21T09:40:48.168Z" },
+ { url = "https://files.pythonhosted.org/packages/31/2f/54aba0040764dd3d362fb37bd6aae9b3034fcae0b27f51b8a34864e48209/aiohttp-3.11.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ad8c745ff9460a16b710e58e06a9dec11ebc0d8f4dd82091cefb579844d69868", size = 1559260, upload-time = "2025-04-21T09:40:50.219Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/d2/a05c7dd9e1b6948c1c5d04f1a8bcfd7e131923fa809bb87477d5c76f1517/aiohttp-3.11.18-cp310-cp310-win32.whl", hash = "sha256:8e57da93e24303a883146510a434f0faf2f1e7e659f3041abc4e3fb3f6702a9f", size = 418051, upload-time = "2025-04-21T09:40:52.272Z" },
+ { url = "https://files.pythonhosted.org/packages/39/e2/796a6179e8abe267dfc84614a50291560a989d28acacbc5dab3bcd4cbec4/aiohttp-3.11.18-cp310-cp310-win_amd64.whl", hash = "sha256:cc93a4121d87d9f12739fc8fab0a95f78444e571ed63e40bfc78cd5abe700ac9", size = 442908, upload-time = "2025-04-21T09:40:54.345Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/10/fd9ee4f9e042818c3c2390054c08ccd34556a3cb209d83285616434cf93e/aiohttp-3.11.18-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:427fdc56ccb6901ff8088544bde47084845ea81591deb16f957897f0f0ba1be9", size = 712088, upload-time = "2025-04-21T09:40:55.776Z" },
+ { url = "https://files.pythonhosted.org/packages/22/eb/6a77f055ca56f7aae2cd2a5607a3c9e7b9554f1497a069dcfcb52bfc9540/aiohttp-3.11.18-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c828b6d23b984255b85b9b04a5b963a74278b7356a7de84fda5e3b76866597b", size = 471450, upload-time = "2025-04-21T09:40:57.301Z" },
+ { url = "https://files.pythonhosted.org/packages/78/dc/5f3c0d27c91abf0bb5d103e9c9b0ff059f60cf6031a5f06f456c90731f42/aiohttp-3.11.18-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5c2eaa145bb36b33af1ff2860820ba0589e165be4ab63a49aebfd0981c173b66", size = 457836, upload-time = "2025-04-21T09:40:59.322Z" },
+ { url = "https://files.pythonhosted.org/packages/49/7b/55b65af9ef48b9b811c91ff8b5b9de9650c71147f10523e278d297750bc8/aiohttp-3.11.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d518ce32179f7e2096bf4e3e8438cf445f05fedd597f252de9f54c728574756", size = 1690978, upload-time = "2025-04-21T09:41:00.795Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/5a/3f8938c4f68ae400152b42742653477fc625d6bfe02e764f3521321c8442/aiohttp-3.11.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700055a6e05c2f4711011a44364020d7a10fbbcd02fbf3e30e8f7e7fddc8717", size = 1745307, upload-time = "2025-04-21T09:41:02.89Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/42/89b694a293333ef6f771c62da022163bcf44fb03d4824372d88e3dc12530/aiohttp-3.11.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bd1cde83e4684324e6ee19adfc25fd649d04078179890be7b29f76b501de8e4", size = 1780692, upload-time = "2025-04-21T09:41:04.461Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/ce/1a75384e01dd1bf546898b6062b1b5f7a59b6692ef802e4dd6db64fed264/aiohttp-3.11.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73b8870fe1c9a201b8c0d12c94fe781b918664766728783241a79e0468427e4f", size = 1676934, upload-time = "2025-04-21T09:41:06.728Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/31/442483276e6c368ab5169797d9873b5875213cbcf7e74b95ad1c5003098a/aiohttp-3.11.18-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25557982dd36b9e32c0a3357f30804e80790ec2c4d20ac6bcc598533e04c6361", size = 1621190, upload-time = "2025-04-21T09:41:08.293Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/83/90274bf12c079457966008a58831a99675265b6a34b505243e004b408934/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7e889c9df381a2433802991288a61e5a19ceb4f61bd14f5c9fa165655dcb1fd1", size = 1658947, upload-time = "2025-04-21T09:41:11.054Z" },
+ { url = "https://files.pythonhosted.org/packages/91/c1/da9cee47a0350b78fdc93670ebe7ad74103011d7778ab4c382ca4883098d/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9ea345fda05bae217b6cce2acf3682ce3b13d0d16dd47d0de7080e5e21362421", size = 1654443, upload-time = "2025-04-21T09:41:13.213Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/f2/73cbe18dc25d624f79a09448adfc4972f82ed6088759ddcf783cd201956c/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9f26545b9940c4b46f0a9388fd04ee3ad7064c4017b5a334dd450f616396590e", size = 1644169, upload-time = "2025-04-21T09:41:14.827Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/32/970b0a196c4dccb1b0cfa5b4dc3b20f63d76f1c608f41001a84b2fd23c3d/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3a621d85e85dccabd700294494d7179ed1590b6d07a35709bb9bd608c7f5dd1d", size = 1728532, upload-time = "2025-04-21T09:41:17.168Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/50/b1dc810a41918d2ea9574e74125eb053063bc5e14aba2d98966f7d734da0/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9c23fd8d08eb9c2af3faeedc8c56e134acdaf36e2117ee059d7defa655130e5f", size = 1750310, upload-time = "2025-04-21T09:41:19.353Z" },
+ { url = "https://files.pythonhosted.org/packages/95/24/39271f5990b35ff32179cc95537e92499d3791ae82af7dcf562be785cd15/aiohttp-3.11.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9e6b0e519067caa4fd7fb72e3e8002d16a68e84e62e7291092a5433763dc0dd", size = 1691580, upload-time = "2025-04-21T09:41:21.868Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/78/75d0353feb77f041460564f12fe58e456436bbc00cbbf5d676dbf0038cc2/aiohttp-3.11.18-cp311-cp311-win32.whl", hash = "sha256:122f3e739f6607e5e4c6a2f8562a6f476192a682a52bda8b4c6d4254e1138f4d", size = 417565, upload-time = "2025-04-21T09:41:24.78Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/97/b912dcb654634a813f8518de359364dfc45976f822116e725dc80a688eee/aiohttp-3.11.18-cp311-cp311-win_amd64.whl", hash = "sha256:e6f3c0a3a1e73e88af384b2e8a0b9f4fb73245afd47589df2afcab6b638fa0e6", size = 443652, upload-time = "2025-04-21T09:41:26.48Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/d2/5bc436f42bf4745c55f33e1e6a2d69e77075d3e768e3d1a34f96ee5298aa/aiohttp-3.11.18-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:63d71eceb9cad35d47d71f78edac41fcd01ff10cacaa64e473d1aec13fa02df2", size = 706671, upload-time = "2025-04-21T09:41:28.021Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/d0/2dbabecc4e078c0474abb40536bbde717fb2e39962f41c5fc7a216b18ea7/aiohttp-3.11.18-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1929da615840969929e8878d7951b31afe0bac883d84418f92e5755d7b49508", size = 466169, upload-time = "2025-04-21T09:41:29.783Z" },
+ { url = "https://files.pythonhosted.org/packages/70/84/19edcf0b22933932faa6e0be0d933a27bd173da02dc125b7354dff4d8da4/aiohttp-3.11.18-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d0aebeb2392f19b184e3fdd9e651b0e39cd0f195cdb93328bd124a1d455cd0e", size = 457554, upload-time = "2025-04-21T09:41:31.327Z" },
+ { url = "https://files.pythonhosted.org/packages/32/d0/e8d1f034ae5624a0f21e4fb3feff79342ce631f3a4d26bd3e58b31ef033b/aiohttp-3.11.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3849ead845e8444f7331c284132ab314b4dac43bfae1e3cf350906d4fff4620f", size = 1690154, upload-time = "2025-04-21T09:41:33.541Z" },
+ { url = "https://files.pythonhosted.org/packages/16/de/2f9dbe2ac6f38f8495562077131888e0d2897e3798a0ff3adda766b04a34/aiohttp-3.11.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e8452ad6b2863709f8b3d615955aa0807bc093c34b8e25b3b52097fe421cb7f", size = 1733402, upload-time = "2025-04-21T09:41:35.634Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/04/bd2870e1e9aef990d14b6df2a695f17807baf5c85a4c187a492bda569571/aiohttp-3.11.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b8d2b42073611c860a37f718b3d61ae8b4c2b124b2e776e2c10619d920350ec", size = 1783958, upload-time = "2025-04-21T09:41:37.456Z" },
+ { url = "https://files.pythonhosted.org/packages/23/06/4203ffa2beb5bedb07f0da0f79b7d9039d1c33f522e0d1a2d5b6218e6f2e/aiohttp-3.11.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40fbf91f6a0ac317c0a07eb328a1384941872f6761f2e6f7208b63c4cc0a7ff6", size = 1695288, upload-time = "2025-04-21T09:41:39.756Z" },
+ { url = "https://files.pythonhosted.org/packages/30/b2/e2285dda065d9f29ab4b23d8bcc81eb881db512afb38a3f5247b191be36c/aiohttp-3.11.18-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ff5625413fec55216da5eaa011cf6b0a2ed67a565914a212a51aa3755b0009", size = 1618871, upload-time = "2025-04-21T09:41:41.972Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e0/88f2987885d4b646de2036f7296ebea9268fdbf27476da551c1a7c158bc0/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7f33a92a2fde08e8c6b0c61815521324fc1612f397abf96eed86b8e31618fdb4", size = 1646262, upload-time = "2025-04-21T09:41:44.192Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/19/4d2da508b4c587e7472a032290b2981f7caeca82b4354e19ab3df2f51d56/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:11d5391946605f445ddafda5eab11caf310f90cdda1fd99865564e3164f5cff9", size = 1677431, upload-time = "2025-04-21T09:41:46.049Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/ae/047473ea50150a41440f3265f53db1738870b5a1e5406ece561ca61a3bf4/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3cc314245deb311364884e44242e00c18b5896e4fe6d5f942e7ad7e4cb640adb", size = 1637430, upload-time = "2025-04-21T09:41:47.973Z" },
+ { url = "https://files.pythonhosted.org/packages/11/32/c6d1e3748077ce7ee13745fae33e5cb1dac3e3b8f8787bf738a93c94a7d2/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f421843b0f70740772228b9e8093289924359d306530bcd3926f39acbe1adda", size = 1703342, upload-time = "2025-04-21T09:41:50.323Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/1d/a3b57bfdbe285f0d45572d6d8f534fd58761da3e9cbc3098372565005606/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e220e7562467dc8d589e31c1acd13438d82c03d7f385c9cd41a3f6d1d15807c1", size = 1740600, upload-time = "2025-04-21T09:41:52.111Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/71/f9cd2fed33fa2b7ce4d412fb7876547abb821d5b5520787d159d0748321d/aiohttp-3.11.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ab2ef72f8605046115bc9aa8e9d14fd49086d405855f40b79ed9e5c1f9f4faea", size = 1695131, upload-time = "2025-04-21T09:41:53.94Z" },
+ { url = "https://files.pythonhosted.org/packages/97/97/d1248cd6d02b9de6aa514793d0dcb20099f0ec47ae71a933290116c070c5/aiohttp-3.11.18-cp312-cp312-win32.whl", hash = "sha256:12a62691eb5aac58d65200c7ae94d73e8a65c331c3a86a2e9670927e94339ee8", size = 412442, upload-time = "2025-04-21T09:41:55.689Z" },
+ { url = "https://files.pythonhosted.org/packages/33/9a/e34e65506e06427b111e19218a99abf627638a9703f4b8bcc3e3021277ed/aiohttp-3.11.18-cp312-cp312-win_amd64.whl", hash = "sha256:364329f319c499128fd5cd2d1c31c44f234c58f9b96cc57f743d16ec4f3238c8", size = 439444, upload-time = "2025-04-21T09:41:57.977Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/18/be8b5dd6b9cf1b2172301dbed28e8e5e878ee687c21947a6c81d6ceaa15d/aiohttp-3.11.18-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:474215ec618974054cf5dc465497ae9708543cbfc312c65212325d4212525811", size = 699833, upload-time = "2025-04-21T09:42:00.298Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/84/ecdc68e293110e6f6f6d7b57786a77555a85f70edd2b180fb1fafaff361a/aiohttp-3.11.18-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ced70adf03920d4e67c373fd692123e34d3ac81dfa1c27e45904a628567d804", size = 462774, upload-time = "2025-04-21T09:42:02.015Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/85/f07718cca55884dad83cc2433746384d267ee970e91f0dcc75c6d5544079/aiohttp-3.11.18-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d9f6c0152f8d71361905aaf9ed979259537981f47ad099c8b3d81e0319814bd", size = 454429, upload-time = "2025-04-21T09:42:03.728Z" },
+ { url = "https://files.pythonhosted.org/packages/82/02/7f669c3d4d39810db8842c4e572ce4fe3b3a9b82945fdd64affea4c6947e/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a35197013ed929c0aed5c9096de1fc5a9d336914d73ab3f9df14741668c0616c", size = 1670283, upload-time = "2025-04-21T09:42:06.053Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/79/b82a12f67009b377b6c07a26bdd1b81dab7409fc2902d669dbfa79e5ac02/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:540b8a1f3a424f1af63e0af2d2853a759242a1769f9f1ab053996a392bd70118", size = 1717231, upload-time = "2025-04-21T09:42:07.953Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/38/d5a1f28c3904a840642b9a12c286ff41fc66dfa28b87e204b1f242dbd5e6/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9e6710ebebfce2ba21cee6d91e7452d1125100f41b906fb5af3da8c78b764c1", size = 1769621, upload-time = "2025-04-21T09:42:09.855Z" },
+ { url = "https://files.pythonhosted.org/packages/53/2d/deb3749ba293e716b5714dda06e257f123c5b8679072346b1eb28b766a0b/aiohttp-3.11.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8af2ef3b4b652ff109f98087242e2ab974b2b2b496304063585e3d78de0b000", size = 1678667, upload-time = "2025-04-21T09:42:11.741Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/a8/04b6e11683a54e104b984bd19a9790eb1ae5f50968b601bb202d0406f0ff/aiohttp-3.11.18-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28c3f975e5ae3dbcbe95b7e3dcd30e51da561a0a0f2cfbcdea30fc1308d72137", size = 1601592, upload-time = "2025-04-21T09:42:14.137Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/9d/c33305ae8370b789423623f0e073d09ac775cd9c831ac0f11338b81c16e0/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c28875e316c7b4c3e745172d882d8a5c835b11018e33432d281211af35794a93", size = 1621679, upload-time = "2025-04-21T09:42:16.056Z" },
+ { url = "https://files.pythonhosted.org/packages/56/45/8e9a27fff0538173d47ba60362823358f7a5f1653c6c30c613469f94150e/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:13cd38515568ae230e1ef6919e2e33da5d0f46862943fcda74e7e915096815f3", size = 1656878, upload-time = "2025-04-21T09:42:18.368Z" },
+ { url = "https://files.pythonhosted.org/packages/84/5b/8c5378f10d7a5a46b10cb9161a3aac3eeae6dba54ec0f627fc4ddc4f2e72/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0e2a92101efb9f4c2942252c69c63ddb26d20f46f540c239ccfa5af865197bb8", size = 1620509, upload-time = "2025-04-21T09:42:20.141Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/2f/99dee7bd91c62c5ff0aa3c55f4ae7e1bc99c6affef780d7777c60c5b3735/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e6d3e32b8753c8d45ac550b11a1090dd66d110d4ef805ffe60fa61495360b3b2", size = 1680263, upload-time = "2025-04-21T09:42:21.993Z" },
+ { url = "https://files.pythonhosted.org/packages/03/0a/378745e4ff88acb83e2d5c884a4fe993a6e9f04600a4560ce0e9b19936e3/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ea4cf2488156e0f281f93cc2fd365025efcba3e2d217cbe3df2840f8c73db261", size = 1715014, upload-time = "2025-04-21T09:42:23.87Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/0b/b5524b3bb4b01e91bc4323aad0c2fcaebdf2f1b4d2eb22743948ba364958/aiohttp-3.11.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d4df95ad522c53f2b9ebc07f12ccd2cb15550941e11a5bbc5ddca2ca56316d7", size = 1666614, upload-time = "2025-04-21T09:42:25.764Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/b7/3d7b036d5a4ed5a4c704e0754afe2eef24a824dfab08e6efbffb0f6dd36a/aiohttp-3.11.18-cp313-cp313-win32.whl", hash = "sha256:cdd1bbaf1e61f0d94aced116d6e95fe25942f7a5f42382195fd9501089db5d78", size = 411358, upload-time = "2025-04-21T09:42:27.558Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/3c/143831b32cd23b5263a995b2a1794e10aa42f8a895aae5074c20fda36c07/aiohttp-3.11.18-cp313-cp313-win_amd64.whl", hash = "sha256:bdd619c27e44382cf642223f11cfd4d795161362a5a1fc1fa3940397bc89db01", size = 437658, upload-time = "2025-04-21T09:42:29.209Z" },
+ { url = "https://files.pythonhosted.org/packages/da/fa/14e97d31f602866abeeb7af07c47fccd2ad92542250531b7b2975633f817/aiohttp-3.11.18-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:469ac32375d9a716da49817cd26f1916ec787fc82b151c1c832f58420e6d3533", size = 712454, upload-time = "2025-04-21T09:42:31.296Z" },
+ { url = "https://files.pythonhosted.org/packages/54/18/c651486e8f8dd44bcb79b9c2bbfd2efde42e10ddb8bbac9caa7d6e1363f6/aiohttp-3.11.18-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3cec21dd68924179258ae14af9f5418c1ebdbba60b98c667815891293902e5e0", size = 471772, upload-time = "2025-04-21T09:42:33.049Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/79/3b3f5b29e1c7313569cf86bc6a08484de700a8af5b7c98daa2e25cfe3f31/aiohttp-3.11.18-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b426495fb9140e75719b3ae70a5e8dd3a79def0ae3c6c27e012fc59f16544a4a", size = 457978, upload-time = "2025-04-21T09:42:34.823Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/40/f894bb78bf5d02663dac6b853965e66f18478db9fa8dbab0111a1ef06d80/aiohttp-3.11.18-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad2f41203e2808616292db5d7170cccf0c9f9c982d02544443c7eb0296e8b0c7", size = 1598194, upload-time = "2025-04-21T09:42:36.741Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/f4/206e072bd546786d225c8cd173e35a5a8a0e1c904cbea31ab7d415a40e48/aiohttp-3.11.18-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc0ae0a5e9939e423e065a3e5b00b24b8379f1db46046d7ab71753dfc7dd0e1", size = 1636984, upload-time = "2025-04-21T09:42:39.305Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/b6/762fb278cc06fb6a6d1ab698ac9ccc852913684e69ed6c9ce58e201deb5e/aiohttp-3.11.18-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe7cdd3f7d1df43200e1c80f1aed86bb36033bf65e3c7cf46a2b97a253ef8798", size = 1670821, upload-time = "2025-04-21T09:42:41.299Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/04/83179727a2ff485da1121d22817830173934b4f5c62cc16fccdd962a30ec/aiohttp-3.11.18-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5199be2a2f01ffdfa8c3a6f5981205242986b9e63eb8ae03fd18f736e4840721", size = 1594289, upload-time = "2025-04-21T09:42:45.603Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/3d/ce16c66106086b25b9c8f2e0ec5b4ba6b9a57463ec80ecfe09905bc5d626/aiohttp-3.11.18-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ccec9e72660b10f8e283e91aa0295975c7bd85c204011d9f5eb69310555cf30", size = 1541054, upload-time = "2025-04-21T09:42:47.922Z" },
+ { url = "https://files.pythonhosted.org/packages/22/23/6357f8cc4240ff10fa9720a53dbcb42998dc845a76496ac5a726e51af9a8/aiohttp-3.11.18-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1596ebf17e42e293cbacc7a24c3e0dc0f8f755b40aff0402cb74c1ff6baec1d3", size = 1531172, upload-time = "2025-04-21T09:42:49.839Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/6a/64e39ae4c5d7fd308be394661c136a664df5b801d850376638add277e2a1/aiohttp-3.11.18-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:eab7b040a8a873020113ba814b7db7fa935235e4cbaf8f3da17671baa1024863", size = 1547347, upload-time = "2025-04-21T09:42:52.288Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6a/91d0c16776e46cc05c59ffc998f9c8b9559534be45c70f579cd93fd6b231/aiohttp-3.11.18-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:5d61df4a05476ff891cff0030329fee4088d40e4dc9b013fac01bc3c745542c2", size = 1526207, upload-time = "2025-04-21T09:42:54.301Z" },
+ { url = "https://files.pythonhosted.org/packages/44/49/05eb21c47530b06a562f812ebf96028ada312b80f3a348a33447fac47e3d/aiohttp-3.11.18-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:46533e6792e1410f9801d09fd40cbbff3f3518d1b501d6c3c5b218f427f6ff08", size = 1605179, upload-time = "2025-04-21T09:42:56.67Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/01/16ef0248d7ae21340bcef794197774076f9b1326d5c97372eb07a9df4955/aiohttp-3.11.18-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c1b90407ced992331dd6d4f1355819ea1c274cc1ee4d5b7046c6761f9ec11829", size = 1625656, upload-time = "2025-04-21T09:42:58.999Z" },
+ { url = "https://files.pythonhosted.org/packages/45/71/250147cc232ea93cba34092c80a0dffa889e9ca0020b65c5913721473a12/aiohttp-3.11.18-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a2fd04ae4971b914e54fe459dd7edbbd3f2ba875d69e057d5e3c8e8cac094935", size = 1565783, upload-time = "2025-04-21T09:43:01.184Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/22/1a949e69cb9654e67b45831f675d2bfa5627eb61c4c4707a209ba5863ef4/aiohttp-3.11.18-cp39-cp39-win32.whl", hash = "sha256:b2f317d1678002eee6fe85670039fb34a757972284614638f82b903a03feacdc", size = 418350, upload-time = "2025-04-21T09:43:04.357Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/ca/3f44aabf63be958ee8ee0cb4c7ad24ea58cc73b0a73919bac9a0b4b92410/aiohttp-3.11.18-cp39-cp39-win_amd64.whl", hash = "sha256:5e7007b8d1d09bce37b54111f593d173691c530b80f27c6493b928dabed9e6ef", size = 443178, upload-time = "2025-04-21T09:43:06.296Z" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "alabaster"
+version = "0.7.16"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776, upload-time = "2024-01-10T00:56:10.189Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511, upload-time = "2024-01-10T00:56:08.388Z" },
+]
+
+[[package]]
+name = "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, upload-time = "2025-03-17T00:02:54.77Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
+]
+
+[[package]]
+name = "apeye"
+version = "1.4.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "apeye-core" },
+ { name = "domdf-python-tools" },
+ { name = "platformdirs" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4f/6b/cc65e31843d7bfda8313a9dc0c77a21e8580b782adca53c7cb3e511fe023/apeye-1.4.1.tar.gz", hash = "sha256:14ea542fad689e3bfdbda2189a354a4908e90aee4bf84c15ab75d68453d76a36", size = 99219, upload-time = "2023-08-14T15:32:41.381Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/89/7b/2d63664777b3e831ac1b1d8df5bbf0b7c8bee48e57115896080890527b1b/apeye-1.4.1-py3-none-any.whl", hash = "sha256:44e58a9104ec189bf42e76b3a7fe91e2b2879d96d48e9a77e5e32ff699c9204e", size = 107989, upload-time = "2023-08-14T15:32:40.064Z" },
+]
+
+[[package]]
+name = "apeye-core"
+version = "1.1.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "domdf-python-tools" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e5/4c/4f108cfd06923bd897bf992a6ecb6fb122646ee7af94d7f9a64abd071d4c/apeye_core-1.1.5.tar.gz", hash = "sha256:5de72ed3d00cc9b20fea55e54b7ab8f5ef8500eb33a5368bc162a5585e238a55", size = 96511, upload-time = "2024-01-30T17:45:48.727Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/9f/fa9971d2a0c6fef64c87ba362a493a4f230eff4ea8dfb9f4c7cbdf71892e/apeye_core-1.1.5-py3-none-any.whl", hash = "sha256:dc27a93f8c9e246b3b238c5ea51edf6115ab2618ef029b9f2d9a190ec8228fbf", size = 99286, upload-time = "2024-01-30T17:45:46.764Z" },
+]
+
+[[package]]
+name = "async-timeout"
+version = "5.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "25.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
+]
+
+[[package]]
+name = "autodocsumm"
+version = "0.2.14"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/03/96/92afe8a7912b327c01f0a8b6408c9556ee13b1aba5b98d587ac7327ff32d/autodocsumm-0.2.14.tar.gz", hash = "sha256:2839a9d4facc3c4eccd306c08695540911042b46eeafcdc3203e6d0bab40bc77", size = 46357, upload-time = "2024-10-23T18:51:47.369Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/bc/3f66af9beb683728e06ca08797e4e9d3e44f432f339718cae3ba856a9cad/autodocsumm-0.2.14-py3-none-any.whl", hash = "sha256:3bad8717fc5190802c60392a7ab04b9f3c97aa9efa8b3780b3d81d615bfe5dc0", size = 14640, upload-time = "2024-10-23T18:51:45.115Z" },
+]
+
+[[package]]
+name = "babel"
+version = "2.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" },
+]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "soupsieve" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" },
+]
+
+[[package]]
+name = "cachecontrol"
+version = "0.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "msgpack" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/3a/0cbeb04ea57d2493f3ec5a069a117ab467f85e4a10017c6d854ddcbff104/cachecontrol-0.14.3.tar.gz", hash = "sha256:73e7efec4b06b20d9267b441c1f733664f989fb8688391b670ca812d70795d11", size = 28985, upload-time = "2025-04-30T16:45:06.135Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/4c/800b0607b00b3fd20f1087f80ab53d6b4d005515b0f773e4831e37cfa83f/cachecontrol-0.14.3-py3-none-any.whl", hash = "sha256:b35e44a3113f17d2a31c1e6b27b9de6d4405f84ae51baa8c1d3cc5b633010cae", size = 21802, upload-time = "2025-04-30T16:45:03.863Z" },
+]
+
+[package.optional-dependencies]
+filecache = [
+ { name = "filelock" },
+]
+
+[[package]]
+name = "certifi"
+version = "2025.7.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981, upload-time = "2025-07-14T03:29:28.449Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722, upload-time = "2025-07-14T03:29:26.863Z" },
+]
+
+[[package]]
+name = "cfgv"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/9b/892a8c8af9110935e5adcbb06d9c6fe741b6bb02608c6513983048ba1a18/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd", size = 144649, upload-time = "2025-05-02T08:31:48.889Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/a5/4179abd063ff6414223575e008593861d62abfc22455b5d1a44995b7c101/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6", size = 155045, upload-time = "2025-05-02T08:31:50.757Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/95/bc08c7dfeddd26b4be8c8287b9bb055716f31077c8b0ea1cd09553794665/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d", size = 147356, upload-time = "2025-05-02T08:31:52.634Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/2d/7a5b635aa65284bf3eab7653e8b4151ab420ecbae918d3e359d1947b4d61/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86", size = 149471, upload-time = "2025-05-02T08:31:56.207Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/38/51fc6ac74251fd331a8cfdb7ec57beba8c23fd5493f1050f71c87ef77ed0/charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c", size = 151317, upload-time = "2025-05-02T08:31:57.613Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/17/edee1e32215ee6e9e46c3e482645b46575a44a2d72c7dfd49e49f60ce6bf/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0", size = 146368, upload-time = "2025-05-02T08:31:59.468Z" },
+ { url = "https://files.pythonhosted.org/packages/26/2c/ea3e66f2b5f21fd00b2825c94cafb8c326ea6240cd80a91eb09e4a285830/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef", size = 154491, upload-time = "2025-05-02T08:32:01.219Z" },
+ { url = "https://files.pythonhosted.org/packages/52/47/7be7fa972422ad062e909fd62460d45c3ef4c141805b7078dbab15904ff7/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6", size = 157695, upload-time = "2025-05-02T08:32:03.045Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/42/9f02c194da282b2b340f28e5fb60762de1151387a36842a92b533685c61e/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366", size = 154849, upload-time = "2025-05-02T08:32:04.651Z" },
+ { url = "https://files.pythonhosted.org/packages/67/44/89cacd6628f31fb0b63201a618049be4be2a7435a31b55b5eb1c3674547a/charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db", size = 150091, upload-time = "2025-05-02T08:32:06.719Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/79/4b8da9f712bc079c0f16b6d67b099b0b8d808c2292c937f267d816ec5ecc/charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a", size = 98445, upload-time = "2025-05-02T08:32:08.66Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/d7/96970afb4fb66497a40761cdf7bd4f6fca0fc7bafde3a84f836c1f57a926/charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509", size = 105782, upload-time = "2025-05-02T08:32:10.46Z" },
+ { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794, upload-time = "2025-05-02T08:32:11.945Z" },
+ { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846, upload-time = "2025-05-02T08:32:13.946Z" },
+ { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350, upload-time = "2025-05-02T08:32:15.873Z" },
+ { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657, upload-time = "2025-05-02T08:32:17.283Z" },
+ { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260, upload-time = "2025-05-02T08:32:18.807Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164, upload-time = "2025-05-02T08:32:20.333Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571, upload-time = "2025-05-02T08:32:21.86Z" },
+ { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952, upload-time = "2025-05-02T08:32:23.434Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959, upload-time = "2025-05-02T08:32:24.993Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030, upload-time = "2025-05-02T08:32:26.435Z" },
+ { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015, upload-time = "2025-05-02T08:32:28.376Z" },
+ { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106, upload-time = "2025-05-02T08:32:30.281Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402, upload-time = "2025-05-02T08:32:32.191Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936, upload-time = "2025-05-02T08:32:33.712Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790, upload-time = "2025-05-02T08:32:35.768Z" },
+ { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924, upload-time = "2025-05-02T08:32:37.284Z" },
+ { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626, upload-time = "2025-05-02T08:32:38.803Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567, upload-time = "2025-05-02T08:32:40.251Z" },
+ { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957, upload-time = "2025-05-02T08:32:41.705Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408, upload-time = "2025-05-02T08:32:43.709Z" },
+ { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399, upload-time = "2025-05-02T08:32:46.197Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815, upload-time = "2025-05-02T08:32:48.105Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537, upload-time = "2025-05-02T08:32:49.719Z" },
+ { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565, upload-time = "2025-05-02T08:32:51.404Z" },
+ { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357, upload-time = "2025-05-02T08:32:53.079Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776, upload-time = "2025-05-02T08:32:54.573Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622, upload-time = "2025-05-02T08:32:56.363Z" },
+ { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435, upload-time = "2025-05-02T08:32:58.551Z" },
+ { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653, upload-time = "2025-05-02T08:33:00.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231, upload-time = "2025-05-02T08:33:02.081Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243, upload-time = "2025-05-02T08:33:04.063Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442, upload-time = "2025-05-02T08:33:06.418Z" },
+ { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147, upload-time = "2025-05-02T08:33:08.183Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057, upload-time = "2025-05-02T08:33:09.986Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454, upload-time = "2025-05-02T08:33:11.814Z" },
+ { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174, upload-time = "2025-05-02T08:33:13.707Z" },
+ { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166, upload-time = "2025-05-02T08:33:15.458Z" },
+ { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064, upload-time = "2025-05-02T08:33:17.06Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641, upload-time = "2025-05-02T08:33:18.753Z" },
+ { url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671, upload-time = "2025-05-02T08:34:12.696Z" },
+ { url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744, upload-time = "2025-05-02T08:34:14.665Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993, upload-time = "2025-05-02T08:34:17.134Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382, upload-time = "2025-05-02T08:34:19.081Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536, upload-time = "2025-05-02T08:34:21.073Z" },
+ { url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349, upload-time = "2025-05-02T08:34:23.193Z" },
+ { url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365, upload-time = "2025-05-02T08:34:25.187Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499, upload-time = "2025-05-02T08:34:27.359Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735, upload-time = "2025-05-02T08:34:29.798Z" },
+ { url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786, upload-time = "2025-05-02T08:34:31.858Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203, upload-time = "2025-05-02T08:34:33.88Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436, upload-time = "2025-05-02T08:34:35.907Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772, upload-time = "2025-05-02T08:34:37.935Z" },
+ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "couchbase-analytics"
+version = "1.0.0.dev1"
+source = { editable = "." }
+dependencies = [
+ { name = "anyio" },
+ { name = "httpx" },
+ { name = "ijson" },
+ { name = "sniffio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "aiohttp" },
+ { name = "mypy" },
+ { name = "pre-commit" },
+ { name = "pytest" },
+ { name = "ruff" },
+ { name = "tomli" },
+ { name = "tomli-w" },
+]
+sphinx = [
+ { name = "enum-tools" },
+ { name = "sphinx" },
+ { name = "sphinx-copybutton" },
+ { name = "sphinx-rtd-theme" },
+ { name = "sphinx-toolbox" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "anyio", specifier = "~=4.9.0" },
+ { name = "httpx", specifier = "~=0.28.1" },
+ { name = "ijson", specifier = "~=3.4.0" },
+ { name = "sniffio", specifier = "~=1.3.1" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = "~=4.11" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "aiohttp", specifier = "~=3.11.10" },
+ { name = "mypy", specifier = "~=1.16.1" },
+ { name = "pre-commit", specifier = "~=4.2.0" },
+ { name = "pytest", specifier = "~=8.3.5" },
+ { name = "ruff", specifier = "~=0.12.0" },
+ { name = "tomli", specifier = "~=2.2.1" },
+ { name = "tomli-w", specifier = "~=1.2.0" },
+]
+sphinx = [
+ { name = "enum-tools", specifier = "~=0.12" },
+ { name = "sphinx", specifier = "~=7.4.7" },
+ { name = "sphinx-copybutton", specifier = "~=0.5" },
+ { name = "sphinx-rtd-theme", specifier = "~=2.0" },
+ { name = "sphinx-toolbox", specifier = "~=3.7" },
+]
+
+[[package]]
+name = "cssutils"
+version = "2.11.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "more-itertools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/33/9f/329d26121fe165be44b1dfff21aa0dc348f04633931f1d20ed6cf448a236/cssutils-2.11.1.tar.gz", hash = "sha256:0563a76513b6af6eebbe788c3bf3d01c920e46b3f90c8416738c5cfc773ff8e2", size = 711657, upload-time = "2024-06-04T15:51:39.373Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/ec/bb273b7208c606890dc36540fe667d06ce840a6f62f9fae7e658fcdc90fb/cssutils-2.11.1-py3-none-any.whl", hash = "sha256:a67bfdfdff4f3867fab43698ec4897c1a828eca5973f4073321b3bccaf1199b1", size = 385747, upload-time = "2024-06-04T15:51:37.499Z" },
+]
+
+[[package]]
+name = "dict2css"
+version = "0.3.0.post1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cssutils" },
+ { name = "domdf-python-tools" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/24/eb/776eef1f1aa0188c0fc165c3a60b71027539f71f2eedc43ad21b060e9c39/dict2css-0.3.0.post1.tar.gz", hash = "sha256:89c544c21c4ca7472c3fffb9d37d3d926f606329afdb751dc1de67a411b70719", size = 7845, upload-time = "2023-11-22T11:09:20.958Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/47/290daabcf91628f4fc0e17c75a1690b354ba067066cd14407712600e609f/dict2css-0.3.0.post1-py3-none-any.whl", hash = "sha256:f006a6b774c3e31869015122ae82c491fd25e7de4a75607a62aa3e798f837e0d", size = 25647, upload-time = "2023-11-22T11:09:19.221Z" },
+]
+
+[[package]]
+name = "distlib"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
+]
+
+[[package]]
+name = "docutils"
+version = "0.20.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/53/a5da4f2c5739cf66290fac1431ee52aff6851c7c8ffd8264f13affd7bcdd/docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b", size = 2058365, upload-time = "2023-05-16T23:39:19.748Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666, upload-time = "2023-05-16T23:39:15.976Z" },
+]
+
+[[package]]
+name = "domdf-python-tools"
+version = "3.10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "natsort" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/36/8b/ab2d8a292bba8fe3135cacc8bfd3576710a14b8f2d0a8cde19130d5c9d21/domdf_python_tools-3.10.0.tar.gz", hash = "sha256:2ae308d2f4f1e9145f5f4ba57f840fbfd1c2983ee26e4824347789649d3ae298", size = 100458, upload-time = "2025-02-12T17:34:05.747Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/11/208f72084084d3f6a2ed5ebfdfc846692c3f7ad6dce65e400194924f7eed/domdf_python_tools-3.10.0-py3-none-any.whl", hash = "sha256:5e71c1be71bbcc1f881d690c8984b60e64298ec256903b3147f068bc33090c36", size = 126860, upload-time = "2025-02-12T17:34:04.093Z" },
+]
+
+[[package]]
+name = "enum-tools"
+version = "0.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pygments" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6d/87/50091e20c2765aa495b24521844a7d8f7041d48e4f9b47dd928cd38c8606/enum_tools-0.13.0.tar.gz", hash = "sha256:0d13335e361d300dc0f8fd82c8cf9951417246f9676144f5ee1761eb690228eb", size = 18904, upload-time = "2025-04-17T15:26:59.412Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/45/cf8a8df3ebe78db691ab54525d552085b67658877f0334f4b0c08c43b518/enum_tools-0.13.0-py3-none-any.whl", hash = "sha256:e0112b16767dd08cb94105844b52770eae67ece6f026916a06db4a3d330d2a95", size = 22366, upload-time = "2025-04-17T15:26:58.34Z" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
+]
+
+[[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, upload-time = "2025-03-14T07:11:40.47Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" },
+ { url = "https://files.pythonhosted.org/packages/77/f0/77c11d13d39513b298e267b22eb6cb559c103d56f155aa9a49097221f0b6/frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61", size = 47735, upload-time = "2025-06-09T22:59:48.133Z" },
+ { url = "https://files.pythonhosted.org/packages/37/12/9d07fa18971a44150593de56b2f2947c46604819976784bcf6ea0d5db43b/frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d", size = 46775, upload-time = "2025-06-09T22:59:49.564Z" },
+ { url = "https://files.pythonhosted.org/packages/70/34/f73539227e06288fcd1f8a76853e755b2b48bca6747e99e283111c18bcd4/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e", size = 224644, upload-time = "2025-06-09T22:59:51.35Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/68/c1d9c2f4a6e438e14613bad0f2973567586610cc22dcb1e1241da71de9d3/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9", size = 222125, upload-time = "2025-06-09T22:59:52.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/d0/98e8f9a515228d708344d7c6986752be3e3192d1795f748c24bcf154ad99/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c", size = 233455, upload-time = "2025-06-09T22:59:54.74Z" },
+ { url = "https://files.pythonhosted.org/packages/79/df/8a11bcec5600557f40338407d3e5bea80376ed1c01a6c0910fcfdc4b8993/frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981", size = 227339, upload-time = "2025-06-09T22:59:56.187Z" },
+ { url = "https://files.pythonhosted.org/packages/50/82/41cb97d9c9a5ff94438c63cc343eb7980dac4187eb625a51bdfdb7707314/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615", size = 212969, upload-time = "2025-06-09T22:59:57.604Z" },
+ { url = "https://files.pythonhosted.org/packages/13/47/f9179ee5ee4f55629e4f28c660b3fdf2775c8bfde8f9c53f2de2d93f52a9/frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50", size = 222862, upload-time = "2025-06-09T22:59:59.498Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/52/df81e41ec6b953902c8b7e3a83bee48b195cb0e5ec2eabae5d8330c78038/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa", size = 222492, upload-time = "2025-06-09T23:00:01.026Z" },
+ { url = "https://files.pythonhosted.org/packages/84/17/30d6ea87fa95a9408245a948604b82c1a4b8b3e153cea596421a2aef2754/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577", size = 238250, upload-time = "2025-06-09T23:00:03.401Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/00/ecbeb51669e3c3df76cf2ddd66ae3e48345ec213a55e3887d216eb4fbab3/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59", size = 218720, upload-time = "2025-06-09T23:00:05.282Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/c0/c224ce0e0eb31cc57f67742071bb470ba8246623c1823a7530be0e76164c/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e", size = 232585, upload-time = "2025-06-09T23:00:07.962Z" },
+ { url = "https://files.pythonhosted.org/packages/55/3c/34cb694abf532f31f365106deebdeac9e45c19304d83cf7d51ebbb4ca4d1/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd", size = 234248, upload-time = "2025-06-09T23:00:09.428Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c0/2052d8b6cecda2e70bd81299e3512fa332abb6dcd2969b9c80dfcdddbf75/frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718", size = 221621, upload-time = "2025-06-09T23:00:11.32Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/bf/7dcebae315436903b1d98ffb791a09d674c88480c158aa171958a3ac07f0/frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e", size = 39578, upload-time = "2025-06-09T23:00:13.526Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/5f/f69818f017fa9a3d24d1ae39763e29b7f60a59e46d5f91b9c6b21622f4cd/frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464", size = 43830, upload-time = "2025-06-09T23:00:14.98Z" },
+ { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" },
+ { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" },
+ { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" },
+ { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" },
+ { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" },
+ { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" },
+ { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" },
+ { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" },
+ { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" },
+ { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" },
+ { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" },
+ { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" },
+ { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" },
+ { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" },
+ { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" },
+ { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" },
+ { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" },
+ { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" },
+ { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" },
+ { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" },
+ { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" },
+ { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" },
+ { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" },
+ { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" },
+ { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" },
+ { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" },
+ { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" },
+ { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" },
+ { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" },
+ { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" },
+ { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/b1/ee59496f51cd244039330015d60f13ce5a54a0f2bd8d79e4a4a375ab7469/frozenlist-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cea3dbd15aea1341ea2de490574a4a37ca080b2ae24e4b4f4b51b9057b4c3630", size = 82434, upload-time = "2025-06-09T23:02:05.195Z" },
+ { url = "https://files.pythonhosted.org/packages/75/e1/d518391ce36a6279b3fa5bc14327dde80bcb646bb50d059c6ca0756b8d05/frozenlist-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d536ee086b23fecc36c2073c371572374ff50ef4db515e4e503925361c24f71", size = 48232, upload-time = "2025-06-09T23:02:07.728Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/8d/a0d04f28b6e821a9685c22e67b5fb798a5a7b68752f104bfbc2dccf080c4/frozenlist-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfcebf56f703cb2e346315431699f00db126d158455e513bd14089d992101e44", size = 47186, upload-time = "2025-06-09T23:02:09.243Z" },
+ { url = "https://files.pythonhosted.org/packages/93/3a/a5334c0535c8b7c78eeabda1579179e44fe3d644e07118e59a2276dedaf1/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974c5336e61d6e7eb1ea5b929cb645e882aadab0095c5a6974a111e6479f8878", size = 226617, upload-time = "2025-06-09T23:02:10.949Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/67/8258d971f519dc3f278c55069a775096cda6610a267b53f6248152b72b2f/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c70db4a0ab5ab20878432c40563573229a7ed9241506181bba12f6b7d0dc41cb", size = 224179, upload-time = "2025-06-09T23:02:12.603Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/89/8225905bf889b97c6d935dd3aeb45668461e59d415cb019619383a8a7c3b/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1137b78384eebaf70560a36b7b229f752fb64d463d38d1304939984d5cb887b6", size = 235783, upload-time = "2025-06-09T23:02:14.678Z" },
+ { url = "https://files.pythonhosted.org/packages/54/6e/ef52375aa93d4bc510d061df06205fa6dcfd94cd631dd22956b09128f0d4/frozenlist-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e793a9f01b3e8b5c0bc646fb59140ce0efcc580d22a3468d70766091beb81b35", size = 229210, upload-time = "2025-06-09T23:02:16.313Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/55/62c87d1a6547bfbcd645df10432c129100c5bd0fd92a384de6e3378b07c1/frozenlist-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74739ba8e4e38221d2c5c03d90a7e542cb8ad681915f4ca8f68d04f810ee0a87", size = 215994, upload-time = "2025-06-09T23:02:17.9Z" },
+ { url = "https://files.pythonhosted.org/packages/45/d2/263fea1f658b8ad648c7d94d18a87bca7e8c67bd6a1bbf5445b1bd5b158c/frozenlist-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e63344c4e929b1a01e29bc184bbb5fd82954869033765bfe8d65d09e336a677", size = 225122, upload-time = "2025-06-09T23:02:19.479Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/22/7145e35d12fb368d92124f679bea87309495e2e9ddf14c6533990cb69218/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ea2a7369eb76de2217a842f22087913cdf75f63cf1307b9024ab82dfb525938", size = 224019, upload-time = "2025-06-09T23:02:20.969Z" },
+ { url = "https://files.pythonhosted.org/packages/44/1e/7dae8c54301beb87bcafc6144b9a103bfd2c8f38078c7902984c9a0c4e5b/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:836b42f472a0e006e02499cef9352ce8097f33df43baaba3e0a28a964c26c7d2", size = 239925, upload-time = "2025-06-09T23:02:22.466Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/1e/99c93e54aa382e949a98976a73b9b20c3aae6d9d893f31bbe4991f64e3a8/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e22b9a99741294b2571667c07d9f8cceec07cb92aae5ccda39ea1b6052ed4319", size = 220881, upload-time = "2025-06-09T23:02:24.521Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/9c/ca5105fa7fb5abdfa8837581be790447ae051da75d32f25c8f81082ffc45/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a19e85cc503d958abe5218953df722748d87172f71b73cf3c9257a91b999890", size = 234046, upload-time = "2025-06-09T23:02:26.206Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/4d/e99014756093b4ddbb67fb8f0df11fe7a415760d69ace98e2ac6d5d43402/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f22dac33bb3ee8fe3e013aa7b91dc12f60d61d05b7fe32191ffa84c3aafe77bd", size = 235756, upload-time = "2025-06-09T23:02:27.79Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/72/a19a40bcdaa28a51add2aaa3a1a294ec357f36f27bd836a012e070c5e8a5/frozenlist-1.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ccec739a99e4ccf664ea0775149f2749b8a6418eb5b8384b4dc0a7d15d304cb", size = 222894, upload-time = "2025-06-09T23:02:29.848Z" },
+ { url = "https://files.pythonhosted.org/packages/08/49/0042469993e023a758af81db68c76907cd29e847d772334d4d201cbe9a42/frozenlist-1.7.0-cp39-cp39-win32.whl", hash = "sha256:b3950f11058310008a87757f3eee16a8e1ca97979833239439586857bc25482e", size = 39848, upload-time = "2025-06-09T23:02:31.413Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/45/827d86ee475c877f5f766fbc23fb6acb6fada9e52f1c9720e2ba3eae32da/frozenlist-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:43a82fce6769c70f2f5a06248b614a7d268080a9d20f7457ef10ecee5af82b63", size = 44102, upload-time = "2025-06-09T23:02:32.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "html5lib"
+version = "1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+ { name = "webencodings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "identify"
+version = "2.6.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/88/d193a27416618628a5eea64e3223acd800b40749a96ffb322a9b55a49ed1/identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6", size = 99254, upload-time = "2025-05-23T20:37:53.3Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145, upload-time = "2025-05-23T20:37:51.495Z" },
+]
+
+[[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, upload-time = "2024-09-15T18:07:39.745Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
+]
+
+[[package]]
+name = "ijson"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/4f/1cfeada63f5fce87536651268ddf5cca79b8b4bbb457aee4e45777964a0a/ijson-3.4.0.tar.gz", hash = "sha256:5f74dcbad9d592c428d3ca3957f7115a42689ee7ee941458860900236ae9bb13", size = 65782, upload-time = "2025-05-08T02:37:20.135Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/6b/a247ba44004154aaa71f9e6bd9f05ba412f490cc4043618efb29314f035e/ijson-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e27e50f6dcdee648f704abc5d31b976cd2f90b4642ed447cf03296d138433d09", size = 87609, upload-time = "2025-05-08T02:35:20.535Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/1d/8d2009d74373b7dec2a49b1167e396debb896501396c70a674bb9ccc41ff/ijson-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2a753be681ac930740a4af9c93cfb4edc49a167faed48061ea650dc5b0f406f1", size = 59243, upload-time = "2025-05-08T02:35:21.958Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/a85a21ebaba81f64a326c303a94625fb94b84890c52d9efdd8acb38b6312/ijson-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a07c47aed534e0ec198e6a2d4360b259d32ac654af59c015afc517ad7973b7fb", size = 59309, upload-time = "2025-05-08T02:35:23.317Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/35/273dfa1f27c38eeaba105496ecb54532199f76c0120177b28315daf5aec3/ijson-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c55f48181e11c597cd7146fb31edc8058391201ead69f8f40d2ecbb0b3e4fc6", size = 131213, upload-time = "2025-05-08T02:35:24.735Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/37/9d3bb0e200a103ca9f8e9315c4d96ecaca43a3c1957c1ac069ea9dc9c6ba/ijson-3.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd5669f96f79d8a2dd5ae81cbd06770a4d42c435fd4a75c74ef28d9913b697d", size = 125456, upload-time = "2025-05-08T02:35:25.896Z" },
+ { url = "https://files.pythonhosted.org/packages/00/54/8f015c4df30200fd14435dec9c67bf675dff0fee44a16c084a8ec0f82922/ijson-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e3ddd46d16b8542c63b1b8af7006c758d4e21cc1b86122c15f8530fae773461", size = 130192, upload-time = "2025-05-08T02:35:27.367Z" },
+ { url = "https://files.pythonhosted.org/packages/88/01/46a0540ad3461332edcc689a8874fa13f0a4c00f60f02d155b70e36f5e0b/ijson-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1504cec7fe04be2bb0cc33b50c9dd3f83f98c0540ad4991d4017373b7853cfe6", size = 132217, upload-time = "2025-05-08T02:35:28.545Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/da/8f8df42f3fd7ef279e20eae294738eed62d41ed5b6a4baca5121abc7cf0f/ijson-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2f2ff456adeb216603e25d7915f10584c1b958b6eafa60038d76d08fc8a5fb06", size = 127118, upload-time = "2025-05-08T02:35:29.726Z" },
+ { url = "https://files.pythonhosted.org/packages/82/0a/a410d9d3b082cc2ec9738d54935a589974cbe54c0f358e4d17465594d660/ijson-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0ab00d75d61613a125fbbb524551658b1ad6919a52271ca16563ca5bc2737bb1", size = 129808, upload-time = "2025-05-08T02:35:31.247Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/c6/a3e2a446b8bd2cf91cb4ca7439f128d2b379b5a79794d0ea25e379b0f4f3/ijson-3.4.0-cp310-cp310-win32.whl", hash = "sha256:ada421fd59fe2bfa4cfa64ba39aeba3f0753696cdcd4d50396a85f38b1d12b01", size = 51160, upload-time = "2025-05-08T02:35:32.964Z" },
+ { url = "https://files.pythonhosted.org/packages/18/7c/e6620603df42d2ef8a92076eaa5cd2b905366e86e113adf49e7b79970bd3/ijson-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:8c75e82cec05d00ed3a4af5f4edf08f59d536ed1a86ac7e84044870872d82a33", size = 53710, upload-time = "2025-05-08T02:35:34.033Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/0d/3e2998f4d7b7d2db2d511e4f0cf9127b6e2140c325c3cb77be46ae46ff1d/ijson-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e369bf5a173ca51846c243002ad8025d32032532523b06510881ecc8723ee54", size = 87643, upload-time = "2025-05-08T02:35:35.693Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/7b/afef2b08af2fee5ead65fcd972fadc3e31f9ae2b517fe2c378d50a9bf79b/ijson-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:26e7da0a3cd2a56a1fde1b34231867693f21c528b683856f6691e95f9f39caec", size = 59260, upload-time = "2025-05-08T02:35:37.166Z" },
+ { url = "https://files.pythonhosted.org/packages/da/4a/39f583a2a13096f5063028bb767622f09cafc9ec254c193deee6c80af59f/ijson-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1c28c7f604729be22aa453e604e9617b665fa0c24cd25f9f47a970e8130c571a", size = 59311, upload-time = "2025-05-08T02:35:38.538Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/58/5b80efd54b093e479c98d14b31d7794267281f6a8729f2c94fbfab661029/ijson-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bed8bcb84d3468940f97869da323ba09ae3e6b950df11dea9b62e2b231ca1e3", size = 136125, upload-time = "2025-05-08T02:35:39.976Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f5/f37659b1647ecc3992216277cd8a45e2194e84e8818178f77c99e1d18463/ijson-3.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:296bc824f4088f2af814aaf973b0435bc887ce3d9f517b1577cc4e7d1afb1cb7", size = 130699, upload-time = "2025-05-08T02:35:41.483Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/2f/4c580ac4bb5eda059b672ad0a05e4bafdae5182a6ec6ab43546763dafa91/ijson-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8145f8f40617b6a8aa24e28559d0adc8b889e56a203725226a8a60fa3501073f", size = 134963, upload-time = "2025-05-08T02:35:43.017Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/9e/64ec39718609faab6ed6e1ceb44f9c35d71210ad9c87fff477c03503e8f8/ijson-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b674a97bd503ea21bc85103e06b6493b1b2a12da3372950f53e1c664566a33a4", size = 137405, upload-time = "2025-05-08T02:35:44.618Z" },
+ { url = "https://files.pythonhosted.org/packages/71/b2/f0bf0e4a0962845597996de6de59c0078bc03a1f899e03908220039f4cf6/ijson-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8bc731cf1c3282b021d3407a601a5a327613da9ad3c4cecb1123232623ae1826", size = 131861, upload-time = "2025-05-08T02:35:46.22Z" },
+ { url = "https://files.pythonhosted.org/packages/17/83/4a2e3611e2b4842b413ec84d2e54adea55ab52e4408ea0f1b1b927e19536/ijson-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:42ace5e940e0cf58c9de72f688d6829ddd815096d07927ee7e77df2648006365", size = 134297, upload-time = "2025-05-08T02:35:47.401Z" },
+ { url = "https://files.pythonhosted.org/packages/38/75/2d332911ac765b44cd7da0cb2b06143521ad5e31dfcc8d8587e6e6168bc8/ijson-3.4.0-cp311-cp311-win32.whl", hash = "sha256:5be39a0df4cd3f02b304382ea8885391900ac62e95888af47525a287c50005e9", size = 51161, upload-time = "2025-05-08T02:35:49.164Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/ba/4ad571f9f7fcf5906b26e757b130c1713c5f0198a1e59568f05d53a0816c/ijson-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b1be1781792291e70d2e177acf564ec672a7907ba74f313583bdf39fe81f9b7", size = 53710, upload-time = "2025-05-08T02:35:50.323Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ec/317ee5b2d13e50448833ead3aa906659a32b376191f6abc2a7c6112d2b27/ijson-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:956b148f88259a80a9027ffbe2d91705fae0c004fbfba3e5a24028fbe72311a9", size = 87212, upload-time = "2025-05-08T02:35:51.835Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/43/b06c96ced30cacecc5d518f89b0fd1c98c294a30ff88848b70ed7b7f72a1/ijson-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b89960f5c721106394c7fba5760b3f67c515b8eb7d80f612388f5eca2f4621", size = 59175, upload-time = "2025-05-08T02:35:52.988Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/df/b4aeafb7ecde463130840ee9be36130823ec94a00525049bf700883378b8/ijson-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9a0bb591cf250dd7e9dfab69d634745a7f3272d31cfe879f9156e0a081fd97ee", size = 59011, upload-time = "2025-05-08T02:35:54.394Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7c/a80b8e361641609507f62022089626d4b8067f0826f51e1c09e4ba86eba8/ijson-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72e92de999977f4c6b660ffcf2b8d59604ccd531edcbfde05b642baf283e0de8", size = 146094, upload-time = "2025-05-08T02:35:55.601Z" },
+ { url = "https://files.pythonhosted.org/packages/01/44/fa416347b9a802e3646c6ff377fc3278bd7d6106e17beb339514b6a3184e/ijson-3.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e9602157a5b869d44b6896e64f502c712a312fcde044c2e586fccb85d3e316e", size = 137903, upload-time = "2025-05-08T02:35:56.814Z" },
+ { url = "https://files.pythonhosted.org/packages/24/c6/41a9ad4d42df50ff6e70fdce79b034f09b914802737ebbdc141153d8d791/ijson-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e83660edb931a425b7ff662eb49db1f10d30ca6d4d350e5630edbed098bc01", size = 148339, upload-time = "2025-05-08T02:35:58.595Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/6f/7d01efda415b8502dce67e067ed9e8a124f53e763002c02207e542e1a2f1/ijson-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:49bf8eac1c7b7913073865a859c215488461f7591b4fa6a33c14b51cb73659d0", size = 149383, upload-time = "2025-05-08T02:36:00.197Z" },
+ { url = "https://files.pythonhosted.org/packages/95/6c/0d67024b9ecb57916c5e5ab0350251c9fe2f86dc9c8ca2b605c194bdad6a/ijson-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:160b09273cb42019f1811469508b0a057d19f26434d44752bde6f281da6d3f32", size = 141580, upload-time = "2025-05-08T02:36:01.998Z" },
+ { url = "https://files.pythonhosted.org/packages/06/43/e10edcc1c6a3b619294de835e7678bfb3a1b8a75955f3689fd66a1e9e7b4/ijson-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2019ff4e6f354aa00c76c8591bd450899111c61f2354ad55cc127e2ce2492c44", size = 150280, upload-time = "2025-05-08T02:36:03.926Z" },
+ { url = "https://files.pythonhosted.org/packages/07/84/1cbeee8e8190a1ebe6926569a92cf1fa80ddb380c129beb6f86559e1bb24/ijson-3.4.0-cp312-cp312-win32.whl", hash = "sha256:931c007bf6bb8330705429989b2deed6838c22b63358a330bf362b6e458ba0bf", size = 51512, upload-time = "2025-05-08T02:36:05.595Z" },
+ { url = "https://files.pythonhosted.org/packages/66/13/530802bc391c95be6fe9f96e9aa427d94067e7c0b7da7a9092344dc44c4b/ijson-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:71523f2b64cb856a820223e94d23e88369f193017ecc789bb4de198cc9d349eb", size = 54081, upload-time = "2025-05-08T02:36:07.099Z" },
+ { url = "https://files.pythonhosted.org/packages/77/b3/b1d2eb2745e5204ec7a25365a6deb7868576214feb5e109bce368fb692c9/ijson-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e8d96f88d75196a61c9d9443de2b72c2d4a7ba9456ff117b57ae3bba23a54256", size = 87216, upload-time = "2025-05-08T02:36:08.414Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/cd/cd6d340087617f8cc9bedbb21d974542fe2f160ed0126b8288d3499a469b/ijson-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c45906ce2c1d3b62f15645476fc3a6ca279549127f01662a39ca5ed334a00cf9", size = 59170, upload-time = "2025-05-08T02:36:09.604Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/4d/32d3a9903b488d3306e3c8288f6ee4217d2eea82728261db03a1045eb5d1/ijson-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4ab4bc2119b35c4363ea49f29563612237cae9413d2fbe54b223be098b97bc9e", size = 59013, upload-time = "2025-05-08T02:36:10.696Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/c8/db15465ab4b0b477cee5964c8bfc94bf8c45af8e27a23e1ad78d1926e587/ijson-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97b0a9b5a15e61dfb1f14921ea4e0dba39f3a650df6d8f444ddbc2b19b479ff1", size = 146564, upload-time = "2025-05-08T02:36:11.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/d8/0755545bc122473a9a434ab90e0f378780e603d75495b1ca3872de757873/ijson-3.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3047bb994dabedf11de11076ed1147a307924b6e5e2df6784fb2599c4ad8c60", size = 137917, upload-time = "2025-05-08T02:36:13.532Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/c6/aeb89c8939ebe3f534af26c8c88000c5e870dbb6ae33644c21a4531f87d2/ijson-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68c83161b052e9f5dc8191acbc862bb1e63f8a35344cb5cd0db1afd3afd487a6", size = 148897, upload-time = "2025-05-08T02:36:14.813Z" },
+ { url = "https://files.pythonhosted.org/packages/be/0e/7ef6e9b372106f2682a4a32b3c65bf86bb471a1670e4dac242faee4a7d3f/ijson-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1eebd9b6c20eb1dffde0ae1f0fbb4aeacec2eb7b89adb5c7c0449fc9fd742760", size = 149711, upload-time = "2025-05-08T02:36:16.476Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/5d/9841c3ed75bcdabf19b3202de5f862a9c9c86ce5c7c9d95fa32347fdbf5f/ijson-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13fb6d5c35192c541421f3ee81239d91fc15a8d8f26c869250f941f4b346a86c", size = 141691, upload-time = "2025-05-08T02:36:18.044Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/d2/ce74e17218dba292e9be10a44ed0c75439f7958cdd263adb0b5b92d012d5/ijson-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:28b7196ff7b37c4897c547a28fa4876919696739fc91c1f347651c9736877c69", size = 150738, upload-time = "2025-05-08T02:36:19.483Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/43/dcc480f94453b1075c9911d4755b823f3ace275761bb37b40139f22109ca/ijson-3.4.0-cp313-cp313-win32.whl", hash = "sha256:3c2691d2da42629522140f77b99587d6f5010440d58d36616f33bc7bdc830cc3", size = 51512, upload-time = "2025-05-08T02:36:20.99Z" },
+ { url = "https://files.pythonhosted.org/packages/35/dd/d8c5f15efd85ba51e6e11451ebe23d779361a9ec0d192064c2a8c3cdfcb8/ijson-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:c4554718c275a044c47eb3874f78f2c939f300215d9031e785a6711cc51b83fc", size = 54074, upload-time = "2025-05-08T02:36:22.075Z" },
+ { url = "https://files.pythonhosted.org/packages/79/73/24ad8cd106203419c4d22bed627e02e281d66b83e91bc206a371893d0486/ijson-3.4.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:915a65e3f3c0eee2ea937bc62aaedb6c14cc1e8f0bb9f3f4fb5a9e2bbfa4b480", size = 91694, upload-time = "2025-05-08T02:36:23.289Z" },
+ { url = "https://files.pythonhosted.org/packages/17/2d/f7f680984bcb7324a46a4c2df3bd73cf70faef0acfeb85a3f811abdfd590/ijson-3.4.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:afbe9748707684b6c5adc295c4fdcf27765b300aec4d484e14a13dca4e5c0afa", size = 61390, upload-time = "2025-05-08T02:36:24.42Z" },
+ { url = "https://files.pythonhosted.org/packages/09/a1/f3ca7bab86f95bdb82494739e71d271410dfefce4590785d511669127145/ijson-3.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d823f8f321b4d8d5fa020d0a84f089fec5d52b7c0762430476d9f8bf95bbc1a9", size = 61140, upload-time = "2025-05-08T02:36:26.708Z" },
+ { url = "https://files.pythonhosted.org/packages/51/79/dd340df3d4fc7771c95df29997956b92ed0570fe7b616d1792fea9ad93f2/ijson-3.4.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8a0a2c54f3becf76881188beefd98b484b1d3bd005769a740d5b433b089fa23", size = 214739, upload-time = "2025-05-08T02:36:27.973Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f0/85380b7f51d1f5fb7065d76a7b623e02feca920cc678d329b2eccc0011e0/ijson-3.4.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ced19a83ab09afa16257a0b15bc1aa888dbc555cb754be09d375c7f8d41051f2", size = 198338, upload-time = "2025-05-08T02:36:29.496Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/cd/313264cf2ec42e0f01d198c49deb7b6fadeb793b3685e20e738eb6b3fa13/ijson-3.4.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8100f9885eff1f38d35cef80ef759a1bbf5fc946349afa681bd7d0e681b7f1a0", size = 207515, upload-time = "2025-05-08T02:36:30.981Z" },
+ { url = "https://files.pythonhosted.org/packages/12/94/bf14457aa87ea32641f2db577c9188ef4e4ae373478afef422b31fc7f309/ijson-3.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d7bcc3f7f21b0f703031ecd15209b1284ea51b2a329d66074b5261de3916c1eb", size = 210081, upload-time = "2025-05-08T02:36:32.403Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/b4/eaee39e290e40e52d665db9bd1492cfdce86bd1e47948e0440db209c6023/ijson-3.4.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2dcb190227b09dd171bdcbfe4720fddd574933c66314818dfb3960c8a6246a77", size = 199253, upload-time = "2025-05-08T02:36:33.861Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/9c/e09c7b9ac720a703ab115b221b819f149ed54c974edfff623c1e925e57da/ijson-3.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:eda4cfb1d49c6073a901735aaa62e39cb7ab47f3ad7bb184862562f776f1fa8a", size = 203816, upload-time = "2025-05-08T02:36:35.348Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/14/acd304f412e32d16a2c12182b9d78206bb0ae35354d35664f45db05c1b3b/ijson-3.4.0-cp313-cp313t-win32.whl", hash = "sha256:0772638efa1f3b72b51736833404f1cbd2f5beeb9c1a3d392e7d385b9160cba7", size = 53760, upload-time = "2025-05-08T02:36:36.608Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/24/93dd0a467191590a5ed1fc2b35842bca9d09900d001e00b0b497c0208ef6/ijson-3.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3d8a0d67f36e4fb97c61a724456ef0791504b16ce6f74917a31c2e92309bbeb9", size = 56948, upload-time = "2025-05-08T02:36:37.849Z" },
+ { url = "https://files.pythonhosted.org/packages/77/bc/a6777b5c3505b12fa9c5c0b9b3601418ae664653b032697ff465a4ecf508/ijson-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8a990401dc7350c1739f42187823e68d2ef6964b55040c6e9f3a29461f9929e2", size = 87662, upload-time = "2025-05-08T02:36:39.378Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/89/adc0ac5c24fc6524d52893d951a66120416ced4ceee9fa53de649624fa5d/ijson-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:80f50e0f5da4cd6b65e2d8ff38cb61b26559608a05dd3a3f9cfa6f19848e6f22", size = 59262, upload-time = "2025-05-08T02:36:40.8Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/c4/22e4eb1c12dde0a1c59ff321793ca8b796d85fa2ff638ec06a8e66f98b02/ijson-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2d9ca52f5650d820a2e7aa672dea1c560f609e165337e5b3ed7cf56d696bf309", size = 59323, upload-time = "2025-05-08T02:36:41.95Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/64/83457822e41fb9ecaf36e50d149978c4bf693cc9e14a72a34afe6ca5d133/ijson-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:940c8c5fd20fb89b56dde9194a4f1c7b779149f1ab26af6d8dc1da51a95d26dd", size = 130202, upload-time = "2025-05-08T02:36:43.202Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/a0/ce14ccfcddb039c115fc879380695bad5e8d8f3ba092454df5cb6ed4771c/ijson-3.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:41dbb525666017ad856ac9b4f0f4b87d3e56b7dfde680d5f6d123556b22e2172", size = 124547, upload-time = "2025-05-08T02:36:44.761Z" },
+ { url = "https://files.pythonhosted.org/packages/59/7c/f78870bf57daa578542b2ea46da336d03de7c2971d2b2fcfed3773757a17/ijson-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9f84f5e2eea5c2d271c97221c382db005534294d1175ddd046a12369617c41c", size = 129407, upload-time = "2025-05-08T02:36:46.319Z" },
+ { url = "https://files.pythonhosted.org/packages/02/08/693a327b50f9036026e062016d6417cd2ce31699cc56c27fe82fb9185140/ijson-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c0cd126c11835839bba8ac0baaba568f67d701fc4f717791cf37b10b74a2ebd7", size = 130991, upload-time = "2025-05-08T02:36:47.595Z" },
+ { url = "https://files.pythonhosted.org/packages/83/22/96ff12c3ca91613bb020bcf9b3aaee510324af999b08b7e7d2e7acb14123/ijson-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f9a9d3bbc6d91c24a2524a189d2aca703cb5f7e8eb34ad0aff3c91702404a983", size = 126175, upload-time = "2025-05-08T02:36:48.992Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/59/3b37550686448fc053c456b9af47aa407e6ac4183015f435c0ea11db5849/ijson-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:56679ee133470d0f1f598a8ad109d760fcfebeef4819531e29335aefb7e4cb1a", size = 128775, upload-time = "2025-05-08T02:36:50.54Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/27/6922201d19427c1c6d1f970de3ede105d52ab87654c4d2c76920815bc57a/ijson-3.4.0-cp39-cp39-win32.whl", hash = "sha256:583c15ded42ba80104fa1d0fa0dfdd89bb47922f3bb893a931bb843aeb55a3f3", size = 51250, upload-time = "2025-05-08T02:36:51.811Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/70/9939dbbe3541d7cca69c95f64201cd2fd6dba7a6488e3b55e6227d6f6e42/ijson-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:4563e603e56f4451572d96b47311dffef5b933d825f3417881d4d3630c6edac2", size = 53737, upload-time = "2025-05-08T02:36:53.369Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/22/da919f16ca9254f8a9ea0ba482d2c1d012ce6e4c712dcafd8adb16b16c63/ijson-3.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:54e989c35dba9cf163d532c14bcf0c260897d5f465643f0cd1fba9c908bed7ef", size = 56480, upload-time = "2025-05-08T02:36:54.942Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/54/c2afd289e034d11c4909f4ea90c9dae55053bed358064f310c3dd5033657/ijson-3.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:494eeb8e87afef22fbb969a4cb81ac2c535f30406f334fb6136e9117b0bb5380", size = 55956, upload-time = "2025-05-08T02:36:56.178Z" },
+ { url = "https://files.pythonhosted.org/packages/43/d6/18799b0fca9ecb8a47e22527eedcea3267e95d4567b564ef21d0299e2d12/ijson-3.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81603de95de1688958af65cd2294881a4790edae7de540b70c65c8253c5dc44a", size = 69394, upload-time = "2025-05-08T02:36:57.699Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/d6/c58032c69e9e977bf6d954f22cad0cd52092db89c454ea98926744523665/ijson-3.4.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8524be12c1773e1be466034cc49c1ecbe3d5b47bb86217bd2a57f73f970a6c19", size = 70378, upload-time = "2025-05-08T02:36:58.98Z" },
+ { url = "https://files.pythonhosted.org/packages/da/03/07c6840454d5d228bb5b4509c9a7ac5b9c0b8258e2b317a53f97372be1eb/ijson-3.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17994696ec895d05e0cfa21b11c68c920c82634b4a3d8b8a1455d6fe9fdee8f7", size = 67770, upload-time = "2025-05-08T02:37:00.162Z" },
+ { url = "https://files.pythonhosted.org/packages/32/c7/da58a9840380308df574dfdb0276c9d802b12f6125f999e92bcef36db552/ijson-3.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0b67727aaee55d43b2e82b6a866c3cbcb2b66a5e9894212190cbd8773d0d9857", size = 53858, upload-time = "2025-05-08T02:37:01.691Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/9b/0bc0594d357600c03c3b5a3a34043d764fc3ad3f0757d2f3aae5b28f6c1c/ijson-3.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cdc8c5ca0eec789ed99db29c68012dda05027af0860bb360afd28d825238d69d", size = 56483, upload-time = "2025-05-08T02:37:03.274Z" },
+ { url = "https://files.pythonhosted.org/packages/00/1f/506cf2574673da1adcc8a794ebb85bf857cabe6294523978637e646814de/ijson-3.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8e6b44b6ec45d5b1a0ee9d97e0e65ab7f62258727004cbbe202bf5f198bc21f7", size = 55957, upload-time = "2025-05-08T02:37:04.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/3d/a7cd8d8a6de0f3084fe4d457a8f76176e11b013867d1cad16c67d25e8bec/ijson-3.4.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b51e239e4cb537929796e840d349fc731fdc0d58b1a0683ce5465ad725321e0f", size = 69394, upload-time = "2025-05-08T02:37:06.142Z" },
+ { url = "https://files.pythonhosted.org/packages/32/51/aa30abc02aabfc41c95887acf5f1f88da569642d7197fbe5aa105545226d/ijson-3.4.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed05d43ec02be8ddb1ab59579761f6656b25d241a77fd74f4f0f7ec09074318a", size = 70377, upload-time = "2025-05-08T02:37:07.353Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/37/7773659b8d8d98b34234e1237352f6b446a3c12941619686c7d4a8a5c69c/ijson-3.4.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfeca1aaa59d93fd0a3718cbe5f7ef0effff85cf837e0bceb71831a47f39cc14", size = 67767, upload-time = "2025-05-08T02:37:08.587Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/1f/dd52a84ed140e31a5d226cd47d98d21aa559aead35ef7bae479eab4c494c/ijson-3.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7ca72ca12e9a1dd4252c97d952be34282907f263f7e28fcdff3a01b83981e837", size = 53864, upload-time = "2025-05-08T02:37:10.044Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/08/0bbdce5e765fee9b5a29f8a9670c00adb54809122cdadd06cd2d33244d68/ijson-3.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f79b2cd52bd220fff83b3ee4ef89b54fd897f57cc8564a6d8ab7ac669de3930", size = 56416, upload-time = "2025-05-08T02:37:11.23Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/33/3f62475b40ddb2bf9de1fb9e5f47d89748b4b91fe3c2cd645111d62438fb/ijson-3.4.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:d16eed737610ad5ad8989b5864fbe09c64133129734e840c29085bb0d497fb03", size = 55903, upload-time = "2025-05-08T02:37:12.476Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/25/c8955e4fef31f7d16635361ec9a2195845c45a2db1483d7790a57a640cc2/ijson-3.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b3aac1d7a27e1e3bdec5bd0689afe55c34aa499baa06a80852eda31f1ffa6dc", size = 69358, upload-time = "2025-05-08T02:37:14.854Z" },
+ { url = "https://files.pythonhosted.org/packages/45/b1/900f5d9a868304ff571bab7d10491df17e92105a9846a619d6e4d806e60e/ijson-3.4.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:784ae654aa9851851e87f323e9429b20b58a5399f83e6a7e348e080f2892081f", size = 70343, upload-time = "2025-05-08T02:37:16.115Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/ed/2a6e467b4c403b0f182724929dd0c85da98e1d1b84e4766028d2c3220eea/ijson-3.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d05bd8fa6a8adefb32bbf7b993d2a2f4507db08453dd1a444c281413a6d9685", size = 67710, upload-time = "2025-05-08T02:37:17.675Z" },
+ { url = "https://files.pythonhosted.org/packages/11/c8/de4e995b17effb92f610efc3193393d05f8f233062a716d254d7b4e736c1/ijson-3.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b5a05fd935cc28786b88c16976313086cd96414c6a3eb0a3822c47ab48b1793e", size = 53782, upload-time = "2025-05-08T02:37:18.894Z" },
+]
+
+[[package]]
+name = "imagesize"
+version = "1.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/84/62473fb57d61e31fef6e36d64a179c8781605429fd927b5dd608c997be31/imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a", size = 1280026, upload-time = "2022-07-01T12:21:05.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769, upload-time = "2022-07-01T12:21:02.467Z" },
+]
+
+[[package]]
+name = "importlib-metadata"
+version = "8.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "zipp" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" },
+ { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393, upload-time = "2024-10-18T15:20:52.426Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732, upload-time = "2024-10-18T15:20:53.578Z" },
+ { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866, upload-time = "2024-10-18T15:20:55.06Z" },
+ { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964, upload-time = "2024-10-18T15:20:55.906Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977, upload-time = "2024-10-18T15:20:57.189Z" },
+ { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366, upload-time = "2024-10-18T15:20:58.235Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091, upload-time = "2024-10-18T15:20:59.235Z" },
+ { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065, upload-time = "2024-10-18T15:21:00.307Z" },
+ { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514, upload-time = "2024-10-18T15:21:01.122Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" },
+ { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" },
+ { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" },
+ { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" },
+ { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" },
+ { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" },
+ { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
+ { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
+ { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
+ { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
+ { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
+ { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344, upload-time = "2024-10-18T15:21:43.721Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389, upload-time = "2024-10-18T15:21:44.666Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607, upload-time = "2024-10-18T15:21:45.452Z" },
+ { url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728, upload-time = "2024-10-18T15:21:46.295Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826, upload-time = "2024-10-18T15:21:47.134Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843, upload-time = "2024-10-18T15:21:48.334Z" },
+ { url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219, upload-time = "2024-10-18T15:21:49.587Z" },
+ { url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946, upload-time = "2024-10-18T15:21:50.441Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063, upload-time = "2024-10-18T15:21:51.385Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506, upload-time = "2024-10-18T15:21:52.974Z" },
+]
+
+[[package]]
+name = "more-itertools"
+version = "10.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/a0/834b0cebabbfc7e311f30b46c8188790a37f89fc8d756660346fe5abfd09/more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3", size = 127671, upload-time = "2025-04-22T14:17:41.838Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2b/9f/7ba6f94fc1e9ac3d2b853fdff3035fb2fa5afbed898c4a72b8a020610594/more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e", size = 65278, upload-time = "2025-04-22T14:17:40.49Z" },
+]
+
+[[package]]
+name = "msgpack"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/52/f30da112c1dc92cf64f57d08a273ac771e7b29dea10b4b30369b2d7e8546/msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed", size = 81799, upload-time = "2025-06-13T06:51:37.228Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/35/7bfc0def2f04ab4145f7f108e3563f9b4abae4ab0ed78a61f350518cc4d2/msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8", size = 78278, upload-time = "2025-06-13T06:51:38.534Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/c5/df5d6c1c39856bc55f800bf82778fd4c11370667f9b9e9d51b2f5da88f20/msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2", size = 402805, upload-time = "2025-06-13T06:51:39.538Z" },
+ { url = "https://files.pythonhosted.org/packages/20/8e/0bb8c977efecfe6ea7116e2ed73a78a8d32a947f94d272586cf02a9757db/msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4", size = 408642, upload-time = "2025-06-13T06:51:41.092Z" },
+ { url = "https://files.pythonhosted.org/packages/59/a1/731d52c1aeec52006be6d1f8027c49fdc2cfc3ab7cbe7c28335b2910d7b6/msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0", size = 395143, upload-time = "2025-06-13T06:51:42.575Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/92/b42911c52cda2ba67a6418ffa7d08969edf2e760b09015593c8a8a27a97d/msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26", size = 395986, upload-time = "2025-06-13T06:51:43.807Z" },
+ { url = "https://files.pythonhosted.org/packages/61/dc/8ae165337e70118d4dab651b8b562dd5066dd1e6dd57b038f32ebc3e2f07/msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75", size = 402682, upload-time = "2025-06-13T06:51:45.534Z" },
+ { url = "https://files.pythonhosted.org/packages/58/27/555851cb98dcbd6ce041df1eacb25ac30646575e9cd125681aa2f4b1b6f1/msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338", size = 406368, upload-time = "2025-06-13T06:51:46.97Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/64/39a26add4ce16f24e99eabb9005e44c663db00e3fce17d4ae1ae9d61df99/msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd", size = 65004, upload-time = "2025-06-13T06:51:48.582Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/18/73dfa3e9d5d7450d39debde5b0d848139f7de23bd637a4506e36c9800fd6/msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8", size = 71548, upload-time = "2025-06-13T06:51:49.558Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/83/97f24bf9848af23fe2ba04380388216defc49a8af6da0c28cc636d722502/msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558", size = 82728, upload-time = "2025-06-13T06:51:50.68Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/7f/2eaa388267a78401f6e182662b08a588ef4f3de6f0eab1ec09736a7aaa2b/msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d", size = 79279, upload-time = "2025-06-13T06:51:51.72Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/46/31eb60f4452c96161e4dfd26dbca562b4ec68c72e4ad07d9566d7ea35e8a/msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0", size = 423859, upload-time = "2025-06-13T06:51:52.749Z" },
+ { url = "https://files.pythonhosted.org/packages/45/16/a20fa8c32825cc7ae8457fab45670c7a8996d7746ce80ce41cc51e3b2bd7/msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f", size = 429975, upload-time = "2025-06-13T06:51:53.97Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ea/6c958e07692367feeb1a1594d35e22b62f7f476f3c568b002a5ea09d443d/msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704", size = 413528, upload-time = "2025-06-13T06:51:55.507Z" },
+ { url = "https://files.pythonhosted.org/packages/75/05/ac84063c5dae79722bda9f68b878dc31fc3059adb8633c79f1e82c2cd946/msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2", size = 413338, upload-time = "2025-06-13T06:51:57.023Z" },
+ { url = "https://files.pythonhosted.org/packages/69/e8/fe86b082c781d3e1c09ca0f4dacd457ede60a13119b6ce939efe2ea77b76/msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2", size = 422658, upload-time = "2025-06-13T06:51:58.419Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/2b/bafc9924df52d8f3bb7c00d24e57be477f4d0f967c0a31ef5e2225e035c7/msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752", size = 427124, upload-time = "2025-06-13T06:51:59.969Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/3b/1f717e17e53e0ed0b68fa59e9188f3f610c79d7151f0e52ff3cd8eb6b2dc/msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295", size = 65016, upload-time = "2025-06-13T06:52:01.294Z" },
+ { url = "https://files.pythonhosted.org/packages/48/45/9d1780768d3b249accecc5a38c725eb1e203d44a191f7b7ff1941f7df60c/msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458", size = 72267, upload-time = "2025-06-13T06:52:02.568Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/26/389b9c593eda2b8551b2e7126ad3a06af6f9b44274eb3a4f054d48ff7e47/msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238", size = 82359, upload-time = "2025-06-13T06:52:03.909Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/65/7d1de38c8a22cf8b1551469159d4b6cf49be2126adc2482de50976084d78/msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157", size = 79172, upload-time = "2025-06-13T06:52:05.246Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/bd/cacf208b64d9577a62c74b677e1ada005caa9b69a05a599889d6fc2ab20a/msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce", size = 425013, upload-time = "2025-06-13T06:52:06.341Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/ec/fd869e2567cc9c01278a736cfd1697941ba0d4b81a43e0aa2e8d71dab208/msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a", size = 426905, upload-time = "2025-06-13T06:52:07.501Z" },
+ { url = "https://files.pythonhosted.org/packages/55/2a/35860f33229075bce803a5593d046d8b489d7ba2fc85701e714fc1aaf898/msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c", size = 407336, upload-time = "2025-06-13T06:52:09.047Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/16/69ed8f3ada150bf92745fb4921bd621fd2cdf5a42e25eb50bcc57a5328f0/msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b", size = 409485, upload-time = "2025-06-13T06:52:10.382Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/b6/0c398039e4c6d0b2e37c61d7e0e9d13439f91f780686deb8ee64ecf1ae71/msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef", size = 412182, upload-time = "2025-06-13T06:52:11.644Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d0/0cf4a6ecb9bc960d624c93effaeaae75cbf00b3bc4a54f35c8507273cda1/msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a", size = 419883, upload-time = "2025-06-13T06:52:12.806Z" },
+ { url = "https://files.pythonhosted.org/packages/62/83/9697c211720fa71a2dfb632cad6196a8af3abea56eece220fde4674dc44b/msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c", size = 65406, upload-time = "2025-06-13T06:52:14.271Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/23/0abb886e80eab08f5e8c485d6f13924028602829f63b8f5fa25a06636628/msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4", size = 72558, upload-time = "2025-06-13T06:52:15.252Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload-time = "2025-06-13T06:52:16.64Z" },
+ { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload-time = "2025-06-13T06:52:17.843Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload-time = "2025-06-13T06:52:18.982Z" },
+ { url = "https://files.pythonhosted.org/packages/20/22/2ebae7ae43cd8f2debc35c631172ddf14e2a87ffcc04cf43ff9df9fff0d3/msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a", size = 423749, upload-time = "2025-06-13T06:52:20.211Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1b/54c08dd5452427e1179a40b4b607e37e2664bca1c790c60c442c8e972e47/msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac", size = 404458, upload-time = "2025-06-13T06:52:21.429Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload-time = "2025-06-13T06:52:22.995Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload-time = "2025-06-13T06:52:24.152Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/bd/0792be119d7fe7dc2148689ef65c90507d82d20a204aab3b98c74a1f8684/msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b", size = 81882, upload-time = "2025-06-13T06:52:39.316Z" },
+ { url = "https://files.pythonhosted.org/packages/75/77/ce06c8e26a816ae8730a8e030d263c5289adcaff9f0476f9b270bdd7c5c2/msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232", size = 78414, upload-time = "2025-06-13T06:52:40.341Z" },
+ { url = "https://files.pythonhosted.org/packages/73/27/190576c497677fb4a0d05d896b24aea6cdccd910f206aaa7b511901befed/msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf", size = 400927, upload-time = "2025-06-13T06:52:41.399Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/af/6a0aa5a06762e70726ec3c10fb966600d84a7220b52635cb0ab2dc64d32f/msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf", size = 405903, upload-time = "2025-06-13T06:52:42.699Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/80/3f3da358cecbbe8eb12360814bd1277d59d2608485934742a074d99894a9/msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90", size = 393192, upload-time = "2025-06-13T06:52:43.986Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c6/3a0ec7fdebbb4f3f8f254696cd91d491c29c501dbebd86286c17e8f68cd7/msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1", size = 393851, upload-time = "2025-06-13T06:52:45.177Z" },
+ { url = "https://files.pythonhosted.org/packages/39/37/df50d5f8e68514b60fbe70f6e8337ea2b32ae2be030871bcd9d1cf7d4b62/msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88", size = 400292, upload-time = "2025-06-13T06:52:46.381Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/ec/1e067292e02d2ceb4c8cb5ba222c4f7bb28730eef5676740609dc2627e0f/msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478", size = 401873, upload-time = "2025-06-13T06:52:47.957Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/31/e8c9c6b5b58d64c9efa99c8d181fcc25f38ead357b0360379fbc8a4234ad/msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57", size = 65028, upload-time = "2025-06-13T06:52:49.166Z" },
+ { url = "https://files.pythonhosted.org/packages/20/d6/cd62cded572e5e25892747a5d27850170bcd03c855e9c69c538e024de6f9/msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084", size = 71700, upload-time = "2025-06-13T06:52:50.244Z" },
+]
+
+[[package]]
+name = "multidict"
+version = "6.6.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/5dad12e82fbdf7470f29bff2171484bf07cb3b16ada60a6589af8f376440/multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc", size = 101006, upload-time = "2025-06-30T15:53:46.929Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/67/414933982bce2efce7cbcb3169eaaf901e0f25baec69432b4874dfb1f297/multidict-6.6.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a2be5b7b35271f7fff1397204ba6708365e3d773579fe2a30625e16c4b4ce817", size = 77017, upload-time = "2025-06-30T15:50:58.931Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/fe/d8a3ee1fad37dc2ef4f75488b0d9d4f25bf204aad8306cbab63d97bff64a/multidict-6.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12f4581d2930840295c461764b9a65732ec01250b46c6b2c510d7ee68872b140", size = 44897, upload-time = "2025-06-30T15:51:00.999Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/e0/265d89af8c98240265d82b8cbcf35897f83b76cd59ee3ab3879050fd8c45/multidict-6.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dd7793bab517e706c9ed9d7310b06c8672fd0aeee5781bfad612f56b8e0f7d14", size = 44574, upload-time = "2025-06-30T15:51:02.449Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/05/6b759379f7e8e04ccc97cfb2a5dcc5cdbd44a97f072b2272dc51281e6a40/multidict-6.6.3-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:72d8815f2cd3cf3df0f83cac3f3ef801d908b2d90409ae28102e0553af85545a", size = 225729, upload-time = "2025-06-30T15:51:03.794Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/f5/8d5a15488edd9a91fa4aad97228d785df208ed6298580883aa3d9def1959/multidict-6.6.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:531e331a2ee53543ab32b16334e2deb26f4e6b9b28e41f8e0c87e99a6c8e2d69", size = 242515, upload-time = "2025-06-30T15:51:05.002Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/b5/a8f317d47d0ac5bb746d6d8325885c8967c2a8ce0bb57be5399e3642cccb/multidict-6.6.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42ca5aa9329a63be8dc49040f63817d1ac980e02eeddba763a9ae5b4027b9c9c", size = 222224, upload-time = "2025-06-30T15:51:06.148Z" },
+ { url = "https://files.pythonhosted.org/packages/76/88/18b2a0d5e80515fa22716556061189c2853ecf2aa2133081ebbe85ebea38/multidict-6.6.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:208b9b9757060b9faa6f11ab4bc52846e4f3c2fb8b14d5680c8aac80af3dc751", size = 253124, upload-time = "2025-06-30T15:51:07.375Z" },
+ { url = "https://files.pythonhosted.org/packages/62/bf/ebfcfd6b55a1b05ef16d0775ae34c0fe15e8dab570d69ca9941073b969e7/multidict-6.6.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:acf6b97bd0884891af6a8b43d0f586ab2fcf8e717cbd47ab4bdddc09e20652d8", size = 251529, upload-time = "2025-06-30T15:51:08.691Z" },
+ { url = "https://files.pythonhosted.org/packages/44/11/780615a98fd3775fc309d0234d563941af69ade2df0bb82c91dda6ddaea1/multidict-6.6.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68e9e12ed00e2089725669bdc88602b0b6f8d23c0c95e52b95f0bc69f7fe9b55", size = 241627, upload-time = "2025-06-30T15:51:10.605Z" },
+ { url = "https://files.pythonhosted.org/packages/28/3d/35f33045e21034b388686213752cabc3a1b9d03e20969e6fa8f1b1d82db1/multidict-6.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05db2f66c9addb10cfa226e1acb363450fab2ff8a6df73c622fefe2f5af6d4e7", size = 239351, upload-time = "2025-06-30T15:51:12.18Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/cc/ff84c03b95b430015d2166d9aae775a3985d757b94f6635010d0038d9241/multidict-6.6.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0db58da8eafb514db832a1b44f8fa7906fdd102f7d982025f816a93ba45e3dcb", size = 233429, upload-time = "2025-06-30T15:51:13.533Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/f0/8cd49a0b37bdea673a4b793c2093f2f4ba8e7c9d6d7c9bd672fd6d38cd11/multidict-6.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14117a41c8fdb3ee19c743b1c027da0736fdb79584d61a766da53d399b71176c", size = 243094, upload-time = "2025-06-30T15:51:14.815Z" },
+ { url = "https://files.pythonhosted.org/packages/96/19/5d9a0cfdafe65d82b616a45ae950975820289069f885328e8185e64283c2/multidict-6.6.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:877443eaaabcd0b74ff32ebeed6f6176c71850feb7d6a1d2db65945256ea535c", size = 248957, upload-time = "2025-06-30T15:51:16.076Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/dc/c90066151da87d1e489f147b9b4327927241e65f1876702fafec6729c014/multidict-6.6.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:70b72e749a4f6e7ed8fb334fa8d8496384840319512746a5f42fa0aec79f4d61", size = 243590, upload-time = "2025-06-30T15:51:17.413Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/39/458afb0cccbb0ee9164365273be3e039efddcfcb94ef35924b7dbdb05db0/multidict-6.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43571f785b86afd02b3855c5ac8e86ec921b760298d6f82ff2a61daf5a35330b", size = 237487, upload-time = "2025-06-30T15:51:19.039Z" },
+ { url = "https://files.pythonhosted.org/packages/35/38/0016adac3990426610a081787011177e661875546b434f50a26319dc8372/multidict-6.6.3-cp310-cp310-win32.whl", hash = "sha256:20c5a0c3c13a15fd5ea86c42311859f970070e4e24de5a550e99d7c271d76318", size = 41390, upload-time = "2025-06-30T15:51:20.362Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d2/17897a8f3f2c5363d969b4c635aa40375fe1f09168dc09a7826780bfb2a4/multidict-6.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab0a34a007704c625e25a9116c6770b4d3617a071c8a7c30cd338dfbadfe6485", size = 45954, upload-time = "2025-06-30T15:51:21.383Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/5f/d4a717c1e457fe44072e33fa400d2b93eb0f2819c4d669381f925b7cba1f/multidict-6.6.3-cp310-cp310-win_arm64.whl", hash = "sha256:769841d70ca8bdd140a715746199fc6473414bd02efd678d75681d2d6a8986c5", size = 42981, upload-time = "2025-06-30T15:51:22.809Z" },
+ { url = "https://files.pythonhosted.org/packages/08/f0/1a39863ced51f639c81a5463fbfa9eb4df59c20d1a8769ab9ef4ca57ae04/multidict-6.6.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:18f4eba0cbac3546b8ae31e0bbc55b02c801ae3cbaf80c247fcdd89b456ff58c", size = 76445, upload-time = "2025-06-30T15:51:24.01Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/0e/a7cfa451c7b0365cd844e90b41e21fab32edaa1e42fc0c9f68461ce44ed7/multidict-6.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef43b5dd842382329e4797c46f10748d8c2b6e0614f46b4afe4aee9ac33159df", size = 44610, upload-time = "2025-06-30T15:51:25.158Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/bb/a14a4efc5ee748cc1904b0748be278c31b9295ce5f4d2ef66526f410b94d/multidict-6.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bd1fd5eec01494e0f2e8e446a74a85d5e49afb63d75a9934e4a5423dba21d", size = 44267, upload-time = "2025-06-30T15:51:26.326Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/f8/410677d563c2d55e063ef74fe578f9d53fe6b0a51649597a5861f83ffa15/multidict-6.6.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5bd8d6f793a787153956cd35e24f60485bf0651c238e207b9a54f7458b16d539", size = 230004, upload-time = "2025-06-30T15:51:27.491Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/df/2b787f80059314a98e1ec6a4cc7576244986df3e56b3c755e6fc7c99e038/multidict-6.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bf99b4daf908c73856bd87ee0a2499c3c9a3d19bb04b9c6025e66af3fd07462", size = 247196, upload-time = "2025-06-30T15:51:28.762Z" },
+ { url = "https://files.pythonhosted.org/packages/05/f2/f9117089151b9a8ab39f9019620d10d9718eec2ac89e7ca9d30f3ec78e96/multidict-6.6.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b9e59946b49dafaf990fd9c17ceafa62976e8471a14952163d10a7a630413a9", size = 225337, upload-time = "2025-06-30T15:51:30.025Z" },
+ { url = "https://files.pythonhosted.org/packages/93/2d/7115300ec5b699faa152c56799b089a53ed69e399c3c2d528251f0aeda1a/multidict-6.6.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2db616467070d0533832d204c54eea6836a5e628f2cb1e6dfd8cd6ba7277cb7", size = 257079, upload-time = "2025-06-30T15:51:31.716Z" },
+ { url = "https://files.pythonhosted.org/packages/15/ea/ff4bab367623e39c20d3b07637225c7688d79e4f3cc1f3b9f89867677f9a/multidict-6.6.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7394888236621f61dcdd25189b2768ae5cc280f041029a5bcf1122ac63df79f9", size = 255461, upload-time = "2025-06-30T15:51:33.029Z" },
+ { url = "https://files.pythonhosted.org/packages/74/07/2c9246cda322dfe08be85f1b8739646f2c4c5113a1422d7a407763422ec4/multidict-6.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f114d8478733ca7388e7c7e0ab34b72547476b97009d643644ac33d4d3fe1821", size = 246611, upload-time = "2025-06-30T15:51:34.47Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/62/279c13d584207d5697a752a66ffc9bb19355a95f7659140cb1b3cf82180e/multidict-6.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdf22e4db76d323bcdc733514bf732e9fb349707c98d341d40ebcc6e9318ef3d", size = 243102, upload-time = "2025-06-30T15:51:36.525Z" },
+ { url = "https://files.pythonhosted.org/packages/69/cc/e06636f48c6d51e724a8bc8d9e1db5f136fe1df066d7cafe37ef4000f86a/multidict-6.6.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e995a34c3d44ab511bfc11aa26869b9d66c2d8c799fa0e74b28a473a692532d6", size = 238693, upload-time = "2025-06-30T15:51:38.278Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a4/66c9d8fb9acf3b226cdd468ed009537ac65b520aebdc1703dd6908b19d33/multidict-6.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766a4a5996f54361d8d5a9050140aa5362fe48ce51c755a50c0bc3706460c430", size = 246582, upload-time = "2025-06-30T15:51:39.709Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/01/c69e0317be556e46257826d5449feb4e6aa0d18573e567a48a2c14156f1f/multidict-6.6.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3893a0d7d28a7fe6ca7a1f760593bc13038d1d35daf52199d431b61d2660602b", size = 253355, upload-time = "2025-06-30T15:51:41.013Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/da/9cc1da0299762d20e626fe0042e71b5694f9f72d7d3f9678397cbaa71b2b/multidict-6.6.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:934796c81ea996e61914ba58064920d6cad5d99140ac3167901eb932150e2e56", size = 247774, upload-time = "2025-06-30T15:51:42.291Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/91/b22756afec99cc31105ddd4a52f95ab32b1a4a58f4d417979c570c4a922e/multidict-6.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ed948328aec2072bc00f05d961ceadfd3e9bfc2966c1319aeaf7b7c21219183", size = 242275, upload-time = "2025-06-30T15:51:43.642Z" },
+ { url = "https://files.pythonhosted.org/packages/be/f1/adcc185b878036a20399d5be5228f3cbe7f823d78985d101d425af35c800/multidict-6.6.3-cp311-cp311-win32.whl", hash = "sha256:9f5b28c074c76afc3e4c610c488e3493976fe0e596dd3db6c8ddfbb0134dcac5", size = 41290, upload-time = "2025-06-30T15:51:45.264Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/d4/27652c1c6526ea6b4f5ddd397e93f4232ff5de42bea71d339bc6a6cc497f/multidict-6.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc7f6fbc61b1c16050a389c630da0b32fc6d4a3d191394ab78972bf5edc568c2", size = 45942, upload-time = "2025-06-30T15:51:46.377Z" },
+ { url = "https://files.pythonhosted.org/packages/16/18/23f4932019804e56d3c2413e237f866444b774b0263bcb81df2fdecaf593/multidict-6.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:d4e47d8faffaae822fb5cba20937c048d4f734f43572e7079298a6c39fb172cb", size = 42880, upload-time = "2025-06-30T15:51:47.561Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/a0/6b57988ea102da0623ea814160ed78d45a2645e4bbb499c2896d12833a70/multidict-6.6.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:056bebbeda16b2e38642d75e9e5310c484b7c24e3841dc0fb943206a72ec89d6", size = 76514, upload-time = "2025-06-30T15:51:48.728Z" },
+ { url = "https://files.pythonhosted.org/packages/07/7a/d1e92665b0850c6c0508f101f9cf0410c1afa24973e1115fe9c6a185ebf7/multidict-6.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5f481cccb3c5c5e5de5d00b5141dc589c1047e60d07e85bbd7dea3d4580d63f", size = 45394, upload-time = "2025-06-30T15:51:49.986Z" },
+ { url = "https://files.pythonhosted.org/packages/52/6f/dd104490e01be6ef8bf9573705d8572f8c2d2c561f06e3826b081d9e6591/multidict-6.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10bea2ee839a759ee368b5a6e47787f399b41e70cf0c20d90dfaf4158dfb4e55", size = 43590, upload-time = "2025-06-30T15:51:51.331Z" },
+ { url = "https://files.pythonhosted.org/packages/44/fe/06e0e01b1b0611e6581b7fd5a85b43dacc08b6cea3034f902f383b0873e5/multidict-6.6.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2334cfb0fa9549d6ce2c21af2bfbcd3ac4ec3646b1b1581c88e3e2b1779ec92b", size = 237292, upload-time = "2025-06-30T15:51:52.584Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/71/4f0e558fb77696b89c233c1ee2d92f3e1d5459070a0e89153c9e9e804186/multidict-6.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8fee016722550a2276ca2cb5bb624480e0ed2bd49125b2b73b7010b9090e888", size = 258385, upload-time = "2025-06-30T15:51:53.913Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/25/cca0e68228addad24903801ed1ab42e21307a1b4b6dd2cf63da5d3ae082a/multidict-6.6.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5511cb35f5c50a2db21047c875eb42f308c5583edf96bd8ebf7d770a9d68f6d", size = 242328, upload-time = "2025-06-30T15:51:55.672Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/a3/46f2d420d86bbcb8fe660b26a10a219871a0fbf4d43cb846a4031533f3e0/multidict-6.6.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:712b348f7f449948e0a6c4564a21c7db965af900973a67db432d724619b3c680", size = 268057, upload-time = "2025-06-30T15:51:57.037Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/73/1c743542fe00794a2ec7466abd3f312ccb8fad8dff9f36d42e18fb1ec33e/multidict-6.6.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e15d2138ee2694e038e33b7c3da70e6b0ad8868b9f8094a72e1414aeda9c1a", size = 269341, upload-time = "2025-06-30T15:51:59.111Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/11/6ec9dcbe2264b92778eeb85407d1df18812248bf3506a5a1754bc035db0c/multidict-6.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8df25594989aebff8a130f7899fa03cbfcc5d2b5f4a461cf2518236fe6f15961", size = 256081, upload-time = "2025-06-30T15:52:00.533Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/2b/631b1e2afeb5f1696846d747d36cda075bfdc0bc7245d6ba5c319278d6c4/multidict-6.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:159ca68bfd284a8860f8d8112cf0521113bffd9c17568579e4d13d1f1dc76b65", size = 253581, upload-time = "2025-06-30T15:52:02.43Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/0e/7e3b93f79efeb6111d3bf9a1a69e555ba1d07ad1c11bceb56b7310d0d7ee/multidict-6.6.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e098c17856a8c9ade81b4810888c5ad1914099657226283cab3062c0540b0643", size = 250750, upload-time = "2025-06-30T15:52:04.26Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/9e/086846c1d6601948e7de556ee464a2d4c85e33883e749f46b9547d7b0704/multidict-6.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:67c92ed673049dec52d7ed39f8cf9ebbadf5032c774058b4406d18c8f8fe7063", size = 251548, upload-time = "2025-06-30T15:52:06.002Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/7b/86ec260118e522f1a31550e87b23542294880c97cfbf6fb18cc67b044c66/multidict-6.6.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:bd0578596e3a835ef451784053cfd327d607fc39ea1a14812139339a18a0dbc3", size = 262718, upload-time = "2025-06-30T15:52:07.707Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/bd/22ce8f47abb0be04692c9fc4638508b8340987b18691aa7775d927b73f72/multidict-6.6.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:346055630a2df2115cd23ae271910b4cae40f4e336773550dca4889b12916e75", size = 259603, upload-time = "2025-06-30T15:52:09.58Z" },
+ { url = "https://files.pythonhosted.org/packages/07/9c/91b7ac1691be95cd1f4a26e36a74b97cda6aa9820632d31aab4410f46ebd/multidict-6.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:555ff55a359302b79de97e0468e9ee80637b0de1fce77721639f7cd9440b3a10", size = 251351, upload-time = "2025-06-30T15:52:10.947Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/5c/4d7adc739884f7a9fbe00d1eac8c034023ef8bad71f2ebe12823ca2e3649/multidict-6.6.3-cp312-cp312-win32.whl", hash = "sha256:73ab034fb8d58ff85c2bcbadc470efc3fafeea8affcf8722855fb94557f14cc5", size = 41860, upload-time = "2025-06-30T15:52:12.334Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/a3/0fbc7afdf7cb1aa12a086b02959307848eb6bcc8f66fcb66c0cb57e2a2c1/multidict-6.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:04cbcce84f63b9af41bad04a54d4cc4e60e90c35b9e6ccb130be2d75b71f8c17", size = 45982, upload-time = "2025-06-30T15:52:13.6Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/95/8c825bd70ff9b02462dc18d1295dd08d3e9e4eb66856d292ffa62cfe1920/multidict-6.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:0f1130b896ecb52d2a1e615260f3ea2af55fa7dc3d7c3003ba0c3121a759b18b", size = 43210, upload-time = "2025-06-30T15:52:14.893Z" },
+ { url = "https://files.pythonhosted.org/packages/52/1d/0bebcbbb4f000751fbd09957257903d6e002943fc668d841a4cf2fb7f872/multidict-6.6.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:540d3c06d48507357a7d57721e5094b4f7093399a0106c211f33540fdc374d55", size = 75843, upload-time = "2025-06-30T15:52:16.155Z" },
+ { url = "https://files.pythonhosted.org/packages/07/8f/cbe241b0434cfe257f65c2b1bcf9e8d5fb52bc708c5061fb29b0fed22bdf/multidict-6.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c19cea2a690f04247d43f366d03e4eb110a0dc4cd1bbeee4d445435428ed35b", size = 45053, upload-time = "2025-06-30T15:52:17.429Z" },
+ { url = "https://files.pythonhosted.org/packages/32/d2/0b3b23f9dbad5b270b22a3ac3ea73ed0a50ef2d9a390447061178ed6bdb8/multidict-6.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7af039820cfd00effec86bda5d8debef711a3e86a1d3772e85bea0f243a4bd65", size = 43273, upload-time = "2025-06-30T15:52:19.346Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/fe/6eb68927e823999e3683bc49678eb20374ba9615097d085298fd5b386564/multidict-6.6.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:500b84f51654fdc3944e936f2922114349bf8fdcac77c3092b03449f0e5bc2b3", size = 237124, upload-time = "2025-06-30T15:52:20.773Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/ab/320d8507e7726c460cb77117848b3834ea0d59e769f36fdae495f7669929/multidict-6.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3fc723ab8a5c5ed6c50418e9bfcd8e6dceba6c271cee6728a10a4ed8561520c", size = 256892, upload-time = "2025-06-30T15:52:22.242Z" },
+ { url = "https://files.pythonhosted.org/packages/76/60/38ee422db515ac69834e60142a1a69111ac96026e76e8e9aa347fd2e4591/multidict-6.6.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:94c47ea3ade005b5976789baaed66d4de4480d0a0bf31cef6edaa41c1e7b56a6", size = 240547, upload-time = "2025-06-30T15:52:23.736Z" },
+ { url = "https://files.pythonhosted.org/packages/27/fb/905224fde2dff042b030c27ad95a7ae744325cf54b890b443d30a789b80e/multidict-6.6.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbc7cf464cc6d67e83e136c9f55726da3a30176f020a36ead246eceed87f1cd8", size = 266223, upload-time = "2025-06-30T15:52:25.185Z" },
+ { url = "https://files.pythonhosted.org/packages/76/35/dc38ab361051beae08d1a53965e3e1a418752fc5be4d3fb983c5582d8784/multidict-6.6.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:900eb9f9da25ada070f8ee4a23f884e0ee66fe4e1a38c3af644256a508ad81ca", size = 267262, upload-time = "2025-06-30T15:52:26.969Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/a3/0a485b7f36e422421b17e2bbb5a81c1af10eac1d4476f2ff92927c730479/multidict-6.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c6df517cf177da5d47ab15407143a89cd1a23f8b335f3a28d57e8b0a3dbb884", size = 254345, upload-time = "2025-06-30T15:52:28.467Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/59/bcdd52c1dab7c0e0d75ff19cac751fbd5f850d1fc39172ce809a74aa9ea4/multidict-6.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ef421045f13879e21c994b36e728d8e7d126c91a64b9185810ab51d474f27e7", size = 252248, upload-time = "2025-06-30T15:52:29.938Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/a4/2d96aaa6eae8067ce108d4acee6f45ced5728beda55c0f02ae1072c730d1/multidict-6.6.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c1e61bb4f80895c081790b6b09fa49e13566df8fbff817da3f85b3a8192e36b", size = 250115, upload-time = "2025-06-30T15:52:31.416Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d2/ed9f847fa5c7d0677d4f02ea2c163d5e48573de3f57bacf5670e43a5ffaa/multidict-6.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5e8523bb12d7623cd8300dbd91b9e439a46a028cd078ca695eb66ba31adee3c", size = 249649, upload-time = "2025-06-30T15:52:32.996Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/af/9155850372563fc550803d3f25373308aa70f59b52cff25854086ecb4a79/multidict-6.6.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ef58340cc896219e4e653dade08fea5c55c6df41bcc68122e3be3e9d873d9a7b", size = 261203, upload-time = "2025-06-30T15:52:34.521Z" },
+ { url = "https://files.pythonhosted.org/packages/36/2f/c6a728f699896252cf309769089568a33c6439626648843f78743660709d/multidict-6.6.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc9dc435ec8699e7b602b94fe0cd4703e69273a01cbc34409af29e7820f777f1", size = 258051, upload-time = "2025-06-30T15:52:35.999Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/60/689880776d6b18fa2b70f6cc74ff87dd6c6b9b47bd9cf74c16fecfaa6ad9/multidict-6.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e864486ef4ab07db5e9cb997bad2b681514158d6954dd1958dfb163b83d53e6", size = 249601, upload-time = "2025-06-30T15:52:37.473Z" },
+ { url = "https://files.pythonhosted.org/packages/75/5e/325b11f2222a549019cf2ef879c1f81f94a0d40ace3ef55cf529915ba6cc/multidict-6.6.3-cp313-cp313-win32.whl", hash = "sha256:5633a82fba8e841bc5c5c06b16e21529573cd654f67fd833650a215520a6210e", size = 41683, upload-time = "2025-06-30T15:52:38.927Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/ad/cf46e73f5d6e3c775cabd2a05976547f3f18b39bee06260369a42501f053/multidict-6.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:e93089c1570a4ad54c3714a12c2cef549dc9d58e97bcded193d928649cab78e9", size = 45811, upload-time = "2025-06-30T15:52:40.207Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/c9/2e3fe950db28fb7c62e1a5f46e1e38759b072e2089209bc033c2798bb5ec/multidict-6.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:c60b401f192e79caec61f166da9c924e9f8bc65548d4246842df91651e83d600", size = 43056, upload-time = "2025-06-30T15:52:41.575Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/58/aaf8114cf34966e084a8cc9517771288adb53465188843d5a19862cb6dc3/multidict-6.6.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:02fd8f32d403a6ff13864b0851f1f523d4c988051eea0471d4f1fd8010f11134", size = 82811, upload-time = "2025-06-30T15:52:43.281Z" },
+ { url = "https://files.pythonhosted.org/packages/71/af/5402e7b58a1f5b987a07ad98f2501fdba2a4f4b4c30cf114e3ce8db64c87/multidict-6.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f3aa090106b1543f3f87b2041eef3c156c8da2aed90c63a2fbed62d875c49c37", size = 48304, upload-time = "2025-06-30T15:52:45.026Z" },
+ { url = "https://files.pythonhosted.org/packages/39/65/ab3c8cafe21adb45b24a50266fd747147dec7847425bc2a0f6934b3ae9ce/multidict-6.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e924fb978615a5e33ff644cc42e6aa241effcf4f3322c09d4f8cebde95aff5f8", size = 46775, upload-time = "2025-06-30T15:52:46.459Z" },
+ { url = "https://files.pythonhosted.org/packages/49/ba/9fcc1b332f67cc0c0c8079e263bfab6660f87fe4e28a35921771ff3eea0d/multidict-6.6.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b9fe5a0e57c6dbd0e2ce81ca66272282c32cd11d31658ee9553849d91289e1c1", size = 229773, upload-time = "2025-06-30T15:52:47.88Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/14/0145a251f555f7c754ce2dcbcd012939bbd1f34f066fa5d28a50e722a054/multidict-6.6.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b24576f208793ebae00280c59927c3b7c2a3b1655e443a25f753c4611bc1c373", size = 250083, upload-time = "2025-06-30T15:52:49.366Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d4/d5c0bd2bbb173b586c249a151a26d2fb3ec7d53c96e42091c9fef4e1f10c/multidict-6.6.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135631cb6c58eac37d7ac0df380294fecdc026b28837fa07c02e459c7fb9c54e", size = 228980, upload-time = "2025-06-30T15:52:50.903Z" },
+ { url = "https://files.pythonhosted.org/packages/21/32/c9a2d8444a50ec48c4733ccc67254100c10e1c8ae8e40c7a2d2183b59b97/multidict-6.6.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:274d416b0df887aef98f19f21578653982cfb8a05b4e187d4a17103322eeaf8f", size = 257776, upload-time = "2025-06-30T15:52:52.764Z" },
+ { url = "https://files.pythonhosted.org/packages/68/d0/14fa1699f4ef629eae08ad6201c6b476098f5efb051b296f4c26be7a9fdf/multidict-6.6.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e252017a817fad7ce05cafbe5711ed40faeb580e63b16755a3a24e66fa1d87c0", size = 256882, upload-time = "2025-06-30T15:52:54.596Z" },
+ { url = "https://files.pythonhosted.org/packages/da/88/84a27570fbe303c65607d517a5f147cd2fc046c2d1da02b84b17b9bdc2aa/multidict-6.6.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4cc8d848cd4fe1cdee28c13ea79ab0ed37fc2e89dd77bac86a2e7959a8c3bc", size = 247816, upload-time = "2025-06-30T15:52:56.175Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/60/dca352a0c999ce96a5d8b8ee0b2b9f729dcad2e0b0c195f8286269a2074c/multidict-6.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9e236a7094b9c4c1b7585f6b9cca34b9d833cf079f7e4c49e6a4a6ec9bfdc68f", size = 245341, upload-time = "2025-06-30T15:52:57.752Z" },
+ { url = "https://files.pythonhosted.org/packages/50/ef/433fa3ed06028f03946f3993223dada70fb700f763f70c00079533c34578/multidict-6.6.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e0cb0ab69915c55627c933f0b555a943d98ba71b4d1c57bc0d0a66e2567c7471", size = 235854, upload-time = "2025-06-30T15:52:59.74Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/1f/487612ab56fbe35715320905215a57fede20de7db40a261759690dc80471/multidict-6.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:81ef2f64593aba09c5212a3d0f8c906a0d38d710a011f2f42759704d4557d3f2", size = 243432, upload-time = "2025-06-30T15:53:01.602Z" },
+ { url = "https://files.pythonhosted.org/packages/da/6f/ce8b79de16cd885c6f9052c96a3671373d00c59b3ee635ea93e6e81b8ccf/multidict-6.6.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:b9cbc60010de3562545fa198bfc6d3825df430ea96d2cc509c39bd71e2e7d648", size = 252731, upload-time = "2025-06-30T15:53:03.517Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/fe/a2514a6aba78e5abefa1624ca85ae18f542d95ac5cde2e3815a9fbf369aa/multidict-6.6.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70d974eaaa37211390cd02ef93b7e938de564bbffa866f0b08d07e5e65da783d", size = 247086, upload-time = "2025-06-30T15:53:05.48Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/22/b788718d63bb3cce752d107a57c85fcd1a212c6c778628567c9713f9345a/multidict-6.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3713303e4a6663c6d01d648a68f2848701001f3390a030edaaf3fc949c90bf7c", size = 243338, upload-time = "2025-06-30T15:53:07.522Z" },
+ { url = "https://files.pythonhosted.org/packages/22/d6/fdb3d0670819f2228f3f7d9af613d5e652c15d170c83e5f1c94fbc55a25b/multidict-6.6.3-cp313-cp313t-win32.whl", hash = "sha256:639ecc9fe7cd73f2495f62c213e964843826f44505a3e5d82805aa85cac6f89e", size = 47812, upload-time = "2025-06-30T15:53:09.263Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/d6/a9d2c808f2c489ad199723197419207ecbfbc1776f6e155e1ecea9c883aa/multidict-6.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:9f97e181f344a0ef3881b573d31de8542cc0dbc559ec68c8f8b5ce2c2e91646d", size = 53011, upload-time = "2025-06-30T15:53:11.038Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/40/b68001cba8188dd267590a111f9661b6256debc327137667e832bf5d66e8/multidict-6.6.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ce8b7693da41a3c4fde5871c738a81490cea5496c671d74374c8ab889e1834fb", size = 45254, upload-time = "2025-06-30T15:53:12.421Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/64/ba29bd6dfc895e592b2f20f92378e692ac306cf25dd0be2f8e0a0f898edb/multidict-6.6.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c8161b5a7778d3137ea2ee7ae8a08cce0010de3b00ac671c5ebddeaa17cefd22", size = 76959, upload-time = "2025-06-30T15:53:13.827Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/cd/872ae4c134257dacebff59834983c1615d6ec863b6e3d360f3203aad8400/multidict-6.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1328201ee930f069961ae707d59c6627ac92e351ed5b92397cf534d1336ce557", size = 44864, upload-time = "2025-06-30T15:53:15.658Z" },
+ { url = "https://files.pythonhosted.org/packages/15/35/d417d8f62f2886784b76df60522d608aba39dfc83dd53b230ca71f2d4c53/multidict-6.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b1db4d2093d6b235de76932febf9d50766cf49a5692277b2c28a501c9637f616", size = 44540, upload-time = "2025-06-30T15:53:17.208Z" },
+ { url = "https://files.pythonhosted.org/packages/85/59/25cddf781f12cddb2386baa29744a3fdd160eb705539b48065f0cffd86d5/multidict-6.6.3-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53becb01dd8ebd19d1724bebe369cfa87e4e7f29abbbe5c14c98ce4c383e16cd", size = 224075, upload-time = "2025-06-30T15:53:18.705Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/21/4055b6a527954c572498a8068c26bd3b75f2b959080e17e12104b592273c/multidict-6.6.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41bb9d1d4c303886e2d85bade86e59885112a7f4277af5ad47ab919a2251f306", size = 240535, upload-time = "2025-06-30T15:53:20.359Z" },
+ { url = "https://files.pythonhosted.org/packages/58/98/17f1f80bdba0b2fef49cf4ba59cebf8a81797f745f547abb5c9a4039df62/multidict-6.6.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:775b464d31dac90f23192af9c291dc9f423101857e33e9ebf0020a10bfcf4144", size = 219361, upload-time = "2025-06-30T15:53:22.371Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/0e/a5e595fdd0820069f0c29911d5dc9dc3a75ec755ae733ce59a4e6962ae42/multidict-6.6.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d04d01f0a913202205a598246cf77826fe3baa5a63e9f6ccf1ab0601cf56eca0", size = 251207, upload-time = "2025-06-30T15:53:24.307Z" },
+ { url = "https://files.pythonhosted.org/packages/66/9e/0f51e4cffea2daf24c137feabc9ec848ce50f8379c9badcbac00b41ab55e/multidict-6.6.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d25594d3b38a2e6cabfdcafef339f754ca6e81fbbdb6650ad773ea9775af35ab", size = 249749, upload-time = "2025-06-30T15:53:26.056Z" },
+ { url = "https://files.pythonhosted.org/packages/49/a0/a7cfc13c9a71ceb8c1c55457820733af9ce01e121139271f7b13e30c29d2/multidict-6.6.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35712f1748d409e0707b165bf49f9f17f9e28ae85470c41615778f8d4f7d9609", size = 239202, upload-time = "2025-06-30T15:53:28.096Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/50/7ae0d1149ac71cab6e20bb7faf2a1868435974994595dadfdb7377f7140f/multidict-6.6.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1c8082e5814b662de8589d6a06c17e77940d5539080cbab9fe6794b5241b76d9", size = 237269, upload-time = "2025-06-30T15:53:30.124Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/ac/2d0bf836c9c63a57360d57b773359043b371115e1c78ff648993bf19abd0/multidict-6.6.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:61af8a4b771f1d4d000b3168c12c3120ccf7284502a94aa58c68a81f5afac090", size = 232961, upload-time = "2025-06-30T15:53:31.766Z" },
+ { url = "https://files.pythonhosted.org/packages/85/e1/68a65f069df298615591e70e48bfd379c27d4ecb252117c18bf52eebc237/multidict-6.6.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:448e4a9afccbf297577f2eaa586f07067441e7b63c8362a3540ba5a38dc0f14a", size = 240863, upload-time = "2025-06-30T15:53:33.488Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/ab/702f1baca649f88ea1dc6259fc2aa4509f4ad160ba48c8e61fbdb4a5a365/multidict-6.6.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:233ad16999afc2bbd3e534ad8dbe685ef8ee49a37dbc2cdc9514e57b6d589ced", size = 246800, upload-time = "2025-06-30T15:53:35.21Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/0b/726e690bfbf887985a8710ef2f25f1d6dd184a35bd3b36429814f810a2fc/multidict-6.6.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:bb933c891cd4da6bdcc9733d048e994e22e1883287ff7540c2a0f3b117605092", size = 242034, upload-time = "2025-06-30T15:53:36.913Z" },
+ { url = "https://files.pythonhosted.org/packages/73/bb/839486b27bcbcc2e0d875fb9d4012b4b6aa99639137343106aa7210e047a/multidict-6.6.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37b09ca60998e87734699e88c2363abfd457ed18cfbf88e4009a4e83788e63ed", size = 235377, upload-time = "2025-06-30T15:53:38.618Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/46/574d75ab7b9ae8690fe27e89f5fcd0121633112b438edfb9ed2be8be096b/multidict-6.6.3-cp39-cp39-win32.whl", hash = "sha256:f54cb79d26d0cd420637d184af38f0668558f3c4bbe22ab7ad830e67249f2e0b", size = 41420, upload-time = "2025-06-30T15:53:40.309Z" },
+ { url = "https://files.pythonhosted.org/packages/78/c3/8b3bc755508b777868349f4bfa844d3d31832f075ee800a3d6f1807338c5/multidict-6.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:295adc9c0551e5d5214b45cf29ca23dbc28c2d197a9c30d51aed9e037cb7c578", size = 46124, upload-time = "2025-06-30T15:53:41.984Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/30/5a66e7e4550e80975faee5b5dd9e9bd09194d2fd8f62363119b9e46e204b/multidict-6.6.3-cp39-cp39-win_arm64.whl", hash = "sha256:15332783596f227db50fb261c2c251a58ac3873c457f3a550a95d5c0aa3c770d", size = 42973, upload-time = "2025-06-30T15:53:43.505Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/30/9aec301e9772b098c1f5c0ca0279237c9766d94b97802e9888010c64b0ed/multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a", size = 12313, upload-time = "2025-06-30T15:53:45.437Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "1.16.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/12/2bf23a80fcef5edb75de9a1e295d778e0f46ea89eb8b115818b663eff42b/mypy-1.16.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b4f0fed1022a63c6fec38f28b7fc77fca47fd490445c69d0a66266c59dd0b88a", size = 10958644, upload-time = "2025-06-16T16:51:11.649Z" },
+ { url = "https://files.pythonhosted.org/packages/08/50/bfe47b3b278eacf348291742fd5e6613bbc4b3434b72ce9361896417cfe5/mypy-1.16.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86042bbf9f5a05ea000d3203cf87aa9d0ccf9a01f73f71c58979eb9249f46d72", size = 10087033, upload-time = "2025-06-16T16:35:30.089Z" },
+ { url = "https://files.pythonhosted.org/packages/21/de/40307c12fe25675a0776aaa2cdd2879cf30d99eec91b898de00228dc3ab5/mypy-1.16.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7469ee5902c95542bea7ee545f7006508c65c8c54b06dc2c92676ce526f3ea", size = 11875645, upload-time = "2025-06-16T16:35:48.49Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/d8/85bdb59e4a98b7a31495bd8f1a4445d8ffc86cde4ab1f8c11d247c11aedc/mypy-1.16.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352025753ef6a83cb9e7f2427319bb7875d1fdda8439d1e23de12ab164179574", size = 12616986, upload-time = "2025-06-16T16:48:39.526Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/d0/bb25731158fa8f8ee9e068d3e94fcceb4971fedf1424248496292512afe9/mypy-1.16.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff9fa5b16e4c1364eb89a4d16bcda9987f05d39604e1e6c35378a2987c1aac2d", size = 12878632, upload-time = "2025-06-16T16:36:08.195Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/11/822a9beb7a2b825c0cb06132ca0a5183f8327a5e23ef89717c9474ba0bc6/mypy-1.16.1-cp310-cp310-win_amd64.whl", hash = "sha256:1256688e284632382f8f3b9e2123df7d279f603c561f099758e66dd6ed4e8bd6", size = 9484391, upload-time = "2025-06-16T16:37:56.151Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" },
+ { url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" },
+ { url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" },
+ { url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" },
+ { url = "https://files.pythonhosted.org/packages/28/e3/96964af4a75a949e67df4b95318fe2b7427ac8189bbc3ef28f92a1c5bc56/mypy-1.16.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ddc91eb318c8751c69ddb200a5937f1232ee8efb4e64e9f4bc475a33719de438", size = 11063480, upload-time = "2025-06-16T16:47:56.205Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/4d/cd1a42b8e5be278fab7010fb289d9307a63e07153f0ae1510a3d7b703193/mypy-1.16.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:87ff2c13d58bdc4bbe7dc0dedfe622c0f04e2cb2a492269f3b418df2de05c536", size = 10090538, upload-time = "2025-06-16T16:46:43.92Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/4f/c3c6b4b66374b5f68bab07c8cabd63a049ff69796b844bc759a0ca99bb2a/mypy-1.16.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a7cfb0fe29fe5a9841b7c8ee6dffb52382c45acdf68f032145b75620acfbd6f", size = 11836839, upload-time = "2025-06-16T16:36:28.039Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/7e/81ca3b074021ad9775e5cb97ebe0089c0f13684b066a750b7dc208438403/mypy-1.16.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:051e1677689c9d9578b9c7f4d206d763f9bbd95723cd1416fad50db49d52f359", size = 12715634, upload-time = "2025-06-16T16:50:34.441Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/95/bdd40c8be346fa4c70edb4081d727a54d0a05382d84966869738cfa8a497/mypy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d5d2309511cc56c021b4b4e462907c2b12f669b2dbeb68300110ec27723971be", size = 12895584, upload-time = "2025-06-16T16:34:54.857Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/fd/d486a0827a1c597b3b48b1bdef47228a6e9ee8102ab8c28f944cb83b65dc/mypy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:4f58ac32771341e38a853c5d0ec0dfe27e18e27da9cdb8bbc882d2249c71a3ee", size = 9573886, upload-time = "2025-06-16T16:36:43.589Z" },
+ { url = "https://files.pythonhosted.org/packages/49/5e/ed1e6a7344005df11dfd58b0fdd59ce939a0ba9f7ed37754bf20670b74db/mypy-1.16.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7fc688329af6a287567f45cc1cefb9db662defeb14625213a5b7da6e692e2069", size = 10959511, upload-time = "2025-06-16T16:47:21.945Z" },
+ { url = "https://files.pythonhosted.org/packages/30/88/a7cbc2541e91fe04f43d9e4577264b260fecedb9bccb64ffb1a34b7e6c22/mypy-1.16.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e198ab3f55924c03ead626ff424cad1732d0d391478dfbf7bb97b34602395da", size = 10075555, upload-time = "2025-06-16T16:50:14.084Z" },
+ { url = "https://files.pythonhosted.org/packages/93/f7/c62b1e31a32fbd1546cca5e0a2e5f181be5761265ad1f2e94f2a306fa906/mypy-1.16.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09aa4f91ada245f0a45dbc47e548fd94e0dd5a8433e0114917dc3b526912a30c", size = 11874169, upload-time = "2025-06-16T16:49:42.276Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/15/db580a28034657fb6cb87af2f8996435a5b19d429ea4dcd6e1c73d418e60/mypy-1.16.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13c7cd5b1cb2909aa318a90fd1b7e31f17c50b242953e7dd58345b2a814f6383", size = 12610060, upload-time = "2025-06-16T16:34:15.215Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/78/c17f48f6843048fa92d1489d3095e99324f2a8c420f831a04ccc454e2e51/mypy-1.16.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:58e07fb958bc5d752a280da0e890c538f1515b79a65757bbdc54252ba82e0b40", size = 12875199, upload-time = "2025-06-16T16:35:14.448Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/d6/ed42167d0a42680381653fd251d877382351e1bd2c6dd8a818764be3beb1/mypy-1.16.1-cp39-cp39-win_amd64.whl", hash = "sha256:f895078594d918f93337a505f8add9bd654d1a24962b4c6ed9390e12531eb31b", size = 9487033, upload-time = "2025-06-16T16:49:57.907Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "natsort"
+version = "8.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" },
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.9.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
+]
+
+[[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, upload-time = "2023-12-10T22:30:45Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.3.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362, upload-time = "2025-05-07T22:47:42.121Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pre-commit"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cfgv" },
+ { name = "identify" },
+ { name = "nodeenv" },
+ { name = "pyyaml" },
+ { name = "virtualenv" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/4e/ad52a7925ff01c1325653a730c7ec3175a23f948f08626a534133427dcff/propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3", size = 43133, upload-time = "2025-06-09T22:53:41.965Z" },
+ { url = "https://files.pythonhosted.org/packages/63/7c/e9399ba5da7780871db4eac178e9c2e204c23dd3e7d32df202092a1ed400/propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3", size = 43039, upload-time = "2025-06-09T22:53:43.268Z" },
+ { url = "https://files.pythonhosted.org/packages/22/e1/58da211eb8fdc6fc854002387d38f415a6ca5f5c67c1315b204a5d3e9d7a/propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e", size = 201903, upload-time = "2025-06-09T22:53:44.872Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/0a/550ea0f52aac455cb90111c8bab995208443e46d925e51e2f6ebdf869525/propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220", size = 213362, upload-time = "2025-06-09T22:53:46.707Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/af/9893b7d878deda9bb69fcf54600b247fba7317761b7db11fede6e0f28bd0/propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb", size = 210525, upload-time = "2025-06-09T22:53:48.547Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/bb/38fd08b278ca85cde36d848091ad2b45954bc5f15cce494bb300b9285831/propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614", size = 198283, upload-time = "2025-06-09T22:53:50.067Z" },
+ { url = "https://files.pythonhosted.org/packages/78/8c/9fe55bd01d362bafb413dfe508c48753111a1e269737fa143ba85693592c/propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50", size = 191872, upload-time = "2025-06-09T22:53:51.438Z" },
+ { url = "https://files.pythonhosted.org/packages/54/14/4701c33852937a22584e08abb531d654c8bcf7948a8f87ad0a4822394147/propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339", size = 199452, upload-time = "2025-06-09T22:53:53.229Z" },
+ { url = "https://files.pythonhosted.org/packages/16/44/447f2253d859602095356007657ee535e0093215ea0b3d1d6a41d16e5201/propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0", size = 191567, upload-time = "2025-06-09T22:53:54.541Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/b3/e4756258749bb2d3b46defcff606a2f47410bab82be5824a67e84015b267/propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2", size = 193015, upload-time = "2025-06-09T22:53:56.44Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/df/e6d3c7574233164b6330b9fd697beeac402afd367280e6dc377bb99b43d9/propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7", size = 204660, upload-time = "2025-06-09T22:53:57.839Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/53/e4d31dd5170b4a0e2e6b730f2385a96410633b4833dc25fe5dffd1f73294/propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b", size = 206105, upload-time = "2025-06-09T22:53:59.638Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/fe/74d54cf9fbe2a20ff786e5f7afcfde446588f0cf15fb2daacfbc267b866c/propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c", size = 196980, upload-time = "2025-06-09T22:54:01.071Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ec/c469c9d59dada8a7679625e0440b544fe72e99311a4679c279562051f6fc/propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70", size = 37679, upload-time = "2025-06-09T22:54:03.003Z" },
+ { url = "https://files.pythonhosted.org/packages/38/35/07a471371ac89d418f8d0b699c75ea6dca2041fbda360823de21f6a9ce0a/propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9", size = 41459, upload-time = "2025-06-09T22:54:04.134Z" },
+ { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" },
+ { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" },
+ { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" },
+ { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" },
+ { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" },
+ { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" },
+ { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" },
+ { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" },
+ { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" },
+ { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" },
+ { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" },
+ { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" },
+ { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" },
+ { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" },
+ { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" },
+ { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" },
+ { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" },
+ { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" },
+ { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" },
+ { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/39/8ea9bcfaaff16fd0b0fc901ee522e24c9ec44b4ca0229cfffb8066a06959/propcache-0.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7fad897f14d92086d6b03fdd2eb844777b0c4d7ec5e3bac0fbae2ab0602bbe5", size = 74678, upload-time = "2025-06-09T22:55:41.227Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/85/cab84c86966e1d354cf90cdc4ba52f32f99a5bca92a1529d666d957d7686/propcache-0.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1f43837d4ca000243fd7fd6301947d7cb93360d03cd08369969450cc6b2ce3b4", size = 43829, upload-time = "2025-06-09T22:55:42.417Z" },
+ { url = "https://files.pythonhosted.org/packages/23/f7/9cb719749152d8b26d63801b3220ce2d3931312b2744d2b3a088b0ee9947/propcache-0.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:261df2e9474a5949c46e962065d88eb9b96ce0f2bd30e9d3136bcde84befd8f2", size = 43729, upload-time = "2025-06-09T22:55:43.651Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/a2/0b2b5a210ff311260002a315f6f9531b65a36064dfb804655432b2f7d3e3/propcache-0.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e514326b79e51f0a177daab1052bc164d9d9e54133797a3a58d24c9c87a3fe6d", size = 204483, upload-time = "2025-06-09T22:55:45.327Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/e0/7aff5de0c535f783b0c8be5bdb750c305c1961d69fbb136939926e155d98/propcache-0.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a996adb6904f85894570301939afeee65f072b4fd265ed7e569e8d9058e4ec", size = 217425, upload-time = "2025-06-09T22:55:46.729Z" },
+ { url = "https://files.pythonhosted.org/packages/92/1d/65fa889eb3b2a7d6e4ed3c2b568a9cb8817547a1450b572de7bf24872800/propcache-0.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76cace5d6b2a54e55b137669b30f31aa15977eeed390c7cbfb1dafa8dfe9a701", size = 214723, upload-time = "2025-06-09T22:55:48.342Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e2/eecf6989870988dfd731de408a6fa366e853d361a06c2133b5878ce821ad/propcache-0.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31248e44b81d59d6addbb182c4720f90b44e1efdc19f58112a3c3a1615fb47ef", size = 200166, upload-time = "2025-06-09T22:55:49.775Z" },
+ { url = "https://files.pythonhosted.org/packages/12/06/c32be4950967f18f77489268488c7cdc78cbfc65a8ba8101b15e526b83dc/propcache-0.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abb7fa19dbf88d3857363e0493b999b8011eea856b846305d8c0512dfdf8fbb1", size = 194004, upload-time = "2025-06-09T22:55:51.335Z" },
+ { url = "https://files.pythonhosted.org/packages/46/6c/17b521a6b3b7cbe277a4064ff0aa9129dd8c89f425a5a9b6b4dd51cc3ff4/propcache-0.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d81ac3ae39d38588ad0549e321e6f773a4e7cc68e7751524a22885d5bbadf886", size = 203075, upload-time = "2025-06-09T22:55:52.681Z" },
+ { url = "https://files.pythonhosted.org/packages/62/cb/3bdba2b736b3e45bc0e40f4370f745b3e711d439ffbffe3ae416393eece9/propcache-0.3.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:cc2782eb0f7a16462285b6f8394bbbd0e1ee5f928034e941ffc444012224171b", size = 195407, upload-time = "2025-06-09T22:55:54.048Z" },
+ { url = "https://files.pythonhosted.org/packages/29/bd/760c5c6a60a4a2c55a421bc34a25ba3919d49dee411ddb9d1493bb51d46e/propcache-0.3.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:db429c19a6c7e8a1c320e6a13c99799450f411b02251fb1b75e6217cf4a14fcb", size = 196045, upload-time = "2025-06-09T22:55:55.485Z" },
+ { url = "https://files.pythonhosted.org/packages/76/58/ced2757a46f55b8c84358d6ab8de4faf57cba831c51e823654da7144b13a/propcache-0.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21d8759141a9e00a681d35a1f160892a36fb6caa715ba0b832f7747da48fb6ea", size = 208432, upload-time = "2025-06-09T22:55:56.884Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/ec/d98ea8d5a4d8fe0e372033f5254eddf3254344c0c5dc6c49ab84349e4733/propcache-0.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2ca6d378f09adb13837614ad2754fa8afaee330254f404299611bce41a8438cb", size = 210100, upload-time = "2025-06-09T22:55:58.498Z" },
+ { url = "https://files.pythonhosted.org/packages/56/84/b6d8a7ecf3f62d7dd09d9d10bbf89fad6837970ef868b35b5ffa0d24d9de/propcache-0.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34a624af06c048946709f4278b4176470073deda88d91342665d95f7c6270fbe", size = 200712, upload-time = "2025-06-09T22:55:59.906Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/32/889f4903ddfe4a9dc61da71ee58b763758cf2d608fe1decede06e6467f8d/propcache-0.3.2-cp39-cp39-win32.whl", hash = "sha256:4ba3fef1c30f306b1c274ce0b8baaa2c3cdd91f645c48f06394068f37d3837a1", size = 38187, upload-time = "2025-06-09T22:56:01.212Z" },
+ { url = "https://files.pythonhosted.org/packages/67/74/d666795fb9ba1dc139d30de64f3b6fd1ff9c9d3d96ccfdb992cd715ce5d2/propcache-0.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:7a2368eed65fc69a7a7a40b27f22e85e7627b74216f0846b04ba5c116e191ec9", size = 42025, upload-time = "2025-06-09T22:56:02.875Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
+[[package]]
+name = "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, upload-time = "2025-03-02T12:54:54.503Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634, upload-time = "2025-03-02T12:54:52.069Z" },
+]
+
+[[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, upload-time = "2024-08-06T20:33:50.674Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" },
+ { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" },
+ { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" },
+ { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" },
+ { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" },
+ { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" },
+ { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" },
+ { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
+ { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
+ { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
+ { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
+ { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
+ { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
+ { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777, upload-time = "2024-08-06T20:33:25.896Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318, upload-time = "2024-08-06T20:33:27.212Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891, upload-time = "2024-08-06T20:33:28.974Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614, upload-time = "2024-08-06T20:33:34.157Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360, upload-time = "2024-08-06T20:33:35.84Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006, upload-time = "2024-08-06T20:33:37.501Z" },
+ { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577, upload-time = "2024-08-06T20:33:39.389Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593, upload-time = "2024-08-06T20:33:46.63Z" },
+ { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312, upload-time = "2024-08-06T20:33:49.073Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.32.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
+]
+
+[[package]]
+name = "ruamel-yaml"
+version = "0.18.14"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/39/87/6da0df742a4684263261c253f00edd5829e6aca970fff69e75028cccc547/ruamel.yaml-0.18.14.tar.gz", hash = "sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7", size = 145511, upload-time = "2025-06-09T08:51:09.828Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/6d/6fe4805235e193aad4aaf979160dd1f3c487c57d48b810c816e6e842171b/ruamel.yaml-0.18.14-py3-none-any.whl", hash = "sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2", size = 118570, upload-time = "2025-06-09T08:51:06.348Z" },
+]
+
+[[package]]
+name = "ruamel-yaml-clib"
+version = "0.2.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315, upload-time = "2024-10-20T10:10:56.22Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/70/57/40a958e863e299f0c74ef32a3bde9f2d1ea8d69669368c0c502a0997f57f/ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5", size = 131301, upload-time = "2024-10-20T10:12:35.876Z" },
+ { url = "https://files.pythonhosted.org/packages/98/a8/29a3eb437b12b95f50a6bcc3d7d7214301c6c529d8fdc227247fa84162b5/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969", size = 633728, upload-time = "2024-10-20T10:12:37.858Z" },
+ { url = "https://files.pythonhosted.org/packages/35/6d/ae05a87a3ad540259c3ad88d71275cbd1c0f2d30ae04c65dcbfb6dcd4b9f/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df", size = 722230, upload-time = "2024-10-20T10:12:39.457Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/b7/20c6f3c0b656fe609675d69bc135c03aac9e3865912444be6339207b6648/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76", size = 686712, upload-time = "2024-10-20T10:12:41.119Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/11/d12dbf683471f888d354dac59593873c2b45feb193c5e3e0f2ebf85e68b9/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6", size = 663936, upload-time = "2024-10-21T11:26:37.419Z" },
+ { url = "https://files.pythonhosted.org/packages/72/14/4c268f5077db5c83f743ee1daeb236269fa8577133a5cfa49f8b382baf13/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd", size = 696580, upload-time = "2024-10-21T11:26:39.503Z" },
+ { url = "https://files.pythonhosted.org/packages/30/fc/8cd12f189c6405a4c1cf37bd633aa740a9538c8e40497c231072d0fef5cf/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a", size = 663393, upload-time = "2024-12-11T19:58:13.873Z" },
+ { url = "https://files.pythonhosted.org/packages/80/29/c0a017b704aaf3cbf704989785cd9c5d5b8ccec2dae6ac0c53833c84e677/ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da", size = 100326, upload-time = "2024-10-20T10:12:42.967Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/65/fa39d74db4e2d0cd252355732d966a460a41cd01c6353b820a0952432839/ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28", size = 118079, upload-time = "2024-10-20T10:12:44.117Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/8f/683c6ad562f558cbc4f7c029abcd9599148c51c54b5ef0f24f2638da9fbb/ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6", size = 132224, upload-time = "2024-10-20T10:12:45.162Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/d2/b79b7d695e2f21da020bd44c782490578f300dd44f0a4c57a92575758a76/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e", size = 641480, upload-time = "2024-10-20T10:12:46.758Z" },
+ { url = "https://files.pythonhosted.org/packages/68/6e/264c50ce2a31473a9fdbf4fa66ca9b2b17c7455b31ef585462343818bd6c/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e", size = 739068, upload-time = "2024-10-20T10:12:48.605Z" },
+ { url = "https://files.pythonhosted.org/packages/86/29/88c2567bc893c84d88b4c48027367c3562ae69121d568e8a3f3a8d363f4d/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52", size = 703012, upload-time = "2024-10-20T10:12:51.124Z" },
+ { url = "https://files.pythonhosted.org/packages/11/46/879763c619b5470820f0cd6ca97d134771e502776bc2b844d2adb6e37753/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642", size = 704352, upload-time = "2024-10-21T11:26:41.438Z" },
+ { url = "https://files.pythonhosted.org/packages/02/80/ece7e6034256a4186bbe50dee28cd032d816974941a6abf6a9d65e4228a7/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2", size = 737344, upload-time = "2024-10-21T11:26:43.62Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/ca/e4106ac7e80efbabdf4bf91d3d32fc424e41418458251712f5672eada9ce/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3", size = 714498, upload-time = "2024-12-11T19:58:15.592Z" },
+ { url = "https://files.pythonhosted.org/packages/67/58/b1f60a1d591b771298ffa0428237afb092c7f29ae23bad93420b1eb10703/ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4", size = 100205, upload-time = "2024-10-20T10:12:52.865Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/4f/b52f634c9548a9291a70dfce26ca7ebce388235c93588a1068028ea23fcc/ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb", size = 118185, upload-time = "2024-10-20T10:12:54.652Z" },
+ { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433, upload-time = "2024-10-20T10:12:55.657Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362, upload-time = "2024-10-20T10:12:57.155Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118, upload-time = "2024-10-20T10:12:58.501Z" },
+ { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497, upload-time = "2024-10-20T10:13:00.211Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042, upload-time = "2024-10-21T11:26:46.038Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831, upload-time = "2024-10-21T11:26:47.487Z" },
+ { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692, upload-time = "2024-12-11T19:58:17.252Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777, upload-time = "2024-10-20T10:13:01.395Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523, upload-time = "2024-10-20T10:13:02.768Z" },
+ { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011, upload-time = "2024-10-20T10:13:04.377Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488, upload-time = "2024-10-20T10:13:05.906Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066, upload-time = "2024-10-20T10:13:07.26Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785, upload-time = "2024-10-20T10:13:08.504Z" },
+ { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017, upload-time = "2024-10-21T11:26:48.866Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270, upload-time = "2024-10-21T11:26:50.213Z" },
+ { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059, upload-time = "2024-12-11T19:58:18.846Z" },
+ { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583, upload-time = "2024-10-20T10:13:09.658Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190, upload-time = "2024-10-20T10:13:10.66Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/46/ccdef7a84ad745c37cb3d9a81790f28fbc9adf9c237dba682017b123294e/ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987", size = 131834, upload-time = "2024-10-20T10:13:11.72Z" },
+ { url = "https://files.pythonhosted.org/packages/29/09/932360f30ad1b7b79f08757e0a6fb8c5392a52cdcc182779158fe66d25ac/ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45", size = 636120, upload-time = "2024-10-20T10:13:12.84Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/2a/5b27602e7a4344c1334e26bf4739746206b7a60a8acdba33a61473468b73/ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519", size = 724914, upload-time = "2024-10-20T10:13:14.605Z" },
+ { url = "https://files.pythonhosted.org/packages/da/1c/23497017c554fc06ff5701b29355522cff850f626337fff35d9ab352cb18/ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7", size = 689072, upload-time = "2024-10-20T10:13:15.939Z" },
+ { url = "https://files.pythonhosted.org/packages/68/e6/f3d4ff3223f9ea49c3b7169ec0268e42bd49f87c70c0e3e853895e4a7ae2/ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285", size = 667091, upload-time = "2024-10-21T11:26:52.274Z" },
+ { url = "https://files.pythonhosted.org/packages/84/62/ead07043527642491e5011b143f44b81ef80f1025a96069b7210e0f2f0f3/ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed", size = 699111, upload-time = "2024-10-21T11:26:54.294Z" },
+ { url = "https://files.pythonhosted.org/packages/52/b3/fe4d84446f7e4887e3bea7ceff0a7df23790b5ed625f830e79ace88ebefb/ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7", size = 666365, upload-time = "2024-12-11T19:58:20.444Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/b3/7feb99a00bfaa5c6868617bb7651308afde85e5a0b23cd187fe5de65feeb/ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12", size = 100863, upload-time = "2024-10-20T10:13:17.244Z" },
+ { url = "https://files.pythonhosted.org/packages/93/07/de635108684b7a5bb06e432b0930c5a04b6c59efe73bd966d8db3cc208f2/ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b", size = 118653, upload-time = "2024-10-20T10:13:18.289Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.12.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/cd/01015eb5034605fd98d829c5839ec2c6b4582b479707f7c1c2af861e8258/ruff-0.12.5.tar.gz", hash = "sha256:b209db6102b66f13625940b7f8c7d0f18e20039bb7f6101fbdac935c9612057e", size = 5170722, upload-time = "2025-07-24T13:26:37.456Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/de/ad2f68f0798ff15dd8c0bcc2889558970d9a685b3249565a937cd820ad34/ruff-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:1de2c887e9dec6cb31fcb9948299de5b2db38144e66403b9660c9548a67abd92", size = 11819133, upload-time = "2025-07-24T13:25:56.369Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/fc/c6b65cd0e7fbe60f17e7ad619dca796aa49fbca34bb9bea5f8faf1ec2643/ruff-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d1ab65e7d8152f519e7dea4de892317c9da7a108da1c56b6a3c1d5e7cf4c5e9a", size = 12501114, upload-time = "2025-07-24T13:25:59.471Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/de/c6bec1dce5ead9f9e6a946ea15e8d698c35f19edc508289d70a577921b30/ruff-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:962775ed5b27c7aa3fdc0d8f4d4433deae7659ef99ea20f783d666e77338b8cf", size = 11716873, upload-time = "2025-07-24T13:26:01.496Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/16/cf372d2ebe91e4eb5b82a2275c3acfa879e0566a7ac94d331ea37b765ac8/ruff-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73b4cae449597e7195a49eb1cdca89fd9fbb16140c7579899e87f4c85bf82f73", size = 11958829, upload-time = "2025-07-24T13:26:03.721Z" },
+ { url = "https://files.pythonhosted.org/packages/25/bf/cd07e8f6a3a6ec746c62556b4c4b79eeb9b0328b362bb8431b7b8afd3856/ruff-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b13489c3dc50de5e2d40110c0cce371e00186b880842e245186ca862bf9a1ac", size = 11626619, upload-time = "2025-07-24T13:26:06.118Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/c9/c2ccb3b8cbb5661ffda6925f81a13edbb786e623876141b04919d1128370/ruff-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1504fea81461cf4841778b3ef0a078757602a3b3ea4b008feb1308cb3f23e08", size = 13221894, upload-time = "2025-07-24T13:26:08.292Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/58/68a5be2c8e5590ecdad922b2bcd5583af19ba648f7648f95c51c3c1eca81/ruff-0.12.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c7da4129016ae26c32dfcbd5b671fe652b5ab7fc40095d80dcff78175e7eddd4", size = 14163909, upload-time = "2025-07-24T13:26:10.474Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/d1/ef6b19622009ba8386fdb792c0743f709cf917b0b2f1400589cbe4739a33/ruff-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ca972c80f7ebcfd8af75a0f18b17c42d9f1ef203d163669150453f50ca98ab7b", size = 13583652, upload-time = "2025-07-24T13:26:13.381Z" },
+ { url = "https://files.pythonhosted.org/packages/62/e3/1c98c566fe6809a0c83751d825a03727f242cdbe0d142c9e292725585521/ruff-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8dbbf9f25dfb501f4237ae7501d6364b76a01341c6f1b2cd6764fe449124bb2a", size = 12700451, upload-time = "2025-07-24T13:26:15.488Z" },
+ { url = "https://files.pythonhosted.org/packages/24/ff/96058f6506aac0fbc0d0fc0d60b0d0bd746240a0594657a2d94ad28033ba/ruff-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c47dea6ae39421851685141ba9734767f960113d51e83fd7bb9958d5be8763a", size = 12937465, upload-time = "2025-07-24T13:26:17.808Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d3/68bc5e7ab96c94b3589d1789f2dd6dd4b27b263310019529ac9be1e8f31b/ruff-0.12.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5076aa0e61e30f848846f0265c873c249d4b558105b221be1828f9f79903dc5", size = 11771136, upload-time = "2025-07-24T13:26:20.422Z" },
+ { url = "https://files.pythonhosted.org/packages/52/75/7356af30a14584981cabfefcf6106dea98cec9a7af4acb5daaf4b114845f/ruff-0.12.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a5a4c7830dadd3d8c39b1cc85386e2c1e62344f20766be6f173c22fb5f72f293", size = 11601644, upload-time = "2025-07-24T13:26:22.928Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/67/91c71d27205871737cae11025ee2b098f512104e26ffd8656fd93d0ada0a/ruff-0.12.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:46699f73c2b5b137b9dc0fc1a190b43e35b008b398c6066ea1350cce6326adcb", size = 12478068, upload-time = "2025-07-24T13:26:26.134Z" },
+ { url = "https://files.pythonhosted.org/packages/34/04/b6b00383cf2f48e8e78e14eb258942fdf2a9bf0287fbf5cdd398b749193a/ruff-0.12.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a655a0a0d396f0f072faafc18ebd59adde8ca85fb848dc1b0d9f024b9c4d3bb", size = 12991537, upload-time = "2025-07-24T13:26:28.533Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/b9/053d6445dc7544fb6594785056d8ece61daae7214859ada4a152ad56b6e0/ruff-0.12.5-py3-none-win32.whl", hash = "sha256:dfeb2627c459b0b78ca2bbdc38dd11cc9a0a88bf91db982058b26ce41714ffa9", size = 11751575, upload-time = "2025-07-24T13:26:30.835Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/0f/ab16e8259493137598b9149734fec2e06fdeda9837e6f634f5c4e35916da/ruff-0.12.5-py3-none-win_amd64.whl", hash = "sha256:ae0d90cf5f49466c954991b9d8b953bd093c32c27608e409ae3564c63c5306a5", size = 12882273, upload-time = "2025-07-24T13:26:32.929Z" },
+ { url = "https://files.pythonhosted.org/packages/00/db/c376b0661c24cf770cb8815268190668ec1330eba8374a126ceef8c72d55/ruff-0.12.5-py3-none-win_arm64.whl", hash = "sha256:48cdbfc633de2c5c37d9f090ba3b352d1576b0015bfc3bc98eaf230275b7e805", size = 11951564, upload-time = "2025-07-24T13:26:34.994Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "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, upload-time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+]
+
+[[package]]
+name = "snowballstemmer"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/75/a7/9810d872919697c9d01295633f5d574fb416d47e535f258272ca1f01f447/snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895", size = 105575, upload-time = "2025-05-09T16:34:51.843Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064", size = 103274, upload-time = "2025-05-09T16:34:50.371Z" },
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" },
+]
+
+[[package]]
+name = "sphinx"
+version = "7.4.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "alabaster" },
+ { name = "babel" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "docutils" },
+ { name = "imagesize" },
+ { name = "importlib-metadata", marker = "python_full_version < '3.10'" },
+ { name = "jinja2" },
+ { name = "packaging" },
+ { name = "pygments" },
+ { name = "requests" },
+ { name = "snowballstemmer" },
+ { name = "sphinxcontrib-applehelp" },
+ { name = "sphinxcontrib-devhelp" },
+ { name = "sphinxcontrib-htmlhelp" },
+ { name = "sphinxcontrib-jsmath" },
+ { name = "sphinxcontrib-qthelp" },
+ { name = "sphinxcontrib-serializinghtml" },
+ { name = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/be/50e50cb4f2eff47df05673d361095cafd95521d2a22521b920c67a372dcb/sphinx-7.4.7.tar.gz", hash = "sha256:242f92a7ea7e6c5b406fdc2615413890ba9f699114a9c09192d7dfead2ee9cfe", size = 8067911, upload-time = "2024-07-20T14:46:56.059Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624, upload-time = "2024-07-20T14:46:52.142Z" },
+]
+
+[[package]]
+name = "sphinx-autodoc-typehints"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/74/cd/03e7b917230dc057922130a79ba0240df1693bfd76727ea33fae84b39138/sphinx_autodoc_typehints-2.3.0.tar.gz", hash = "sha256:535c78ed2d6a1bad393ba9f3dfa2602cf424e2631ee207263e07874c38fde084", size = 40709, upload-time = "2024-08-29T16:25:48.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f3/e0a4ce49da4b6f4e4ce84b3c39a0677831884cb9d8a87ccbf1e9e56e53ac/sphinx_autodoc_typehints-2.3.0-py3-none-any.whl", hash = "sha256:3098e2c6d0ba99eacd013eb06861acc9b51c6e595be86ab05c08ee5506ac0c67", size = 19836, upload-time = "2024-08-29T16:25:46.707Z" },
+]
+
+[[package]]
+name = "sphinx-copybutton"
+version = "0.5.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/2b/a964715e7f5295f77509e59309959f4125122d648f86b4fe7d70ca1d882c/sphinx-copybutton-0.5.2.tar.gz", hash = "sha256:4cf17c82fb9646d1bc9ca92ac280813a3b605d8c421225fd9913154103ee1fbd", size = 23039, upload-time = "2023-04-14T08:10:22.998Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl", hash = "sha256:fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e", size = 13343, upload-time = "2023-04-14T08:10:20.844Z" },
+]
+
+[[package]]
+name = "sphinx-jinja2-compat"
+version = "0.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "markupsafe" },
+ { name = "standard-imghdr", marker = "python_full_version >= '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/26/df/27282da6f8c549f765beca9de1a5fc56f9651ed87711a5cac1e914137753/sphinx_jinja2_compat-0.3.0.tar.gz", hash = "sha256:f3c1590b275f42e7a654e081db5e3e5fb97f515608422bde94015ddf795dfe7c", size = 4998, upload-time = "2024-06-19T10:27:00.781Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6f/42/2fd09d672eaaa937d6893d8b747d07943f97a6e5e30653aee6ebd339b704/sphinx_jinja2_compat-0.3.0-py3-none-any.whl", hash = "sha256:b1e4006d8e1ea31013fa9946d1b075b0c8d2a42c6e3425e63542c1e9f8be9084", size = 7883, upload-time = "2024-06-19T10:26:59.121Z" },
+]
+
+[[package]]
+name = "sphinx-prompt"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version == '3.10.*'",
+ "python_full_version < '3.10'",
+]
+dependencies = [
+ { name = "docutils", marker = "python_full_version < '3.11'" },
+ { name = "pygments", marker = "python_full_version < '3.11'" },
+ { name = "sphinx", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e7/fb/7a07b8df1ca2418147a6b13e3f6b445071f2565198b45efa631d0d6ef0cd/sphinx_prompt-1.8.0.tar.gz", hash = "sha256:47482f86fcec29662fdfd23e7c04ef03582714195d01f5d565403320084372ed", size = 5121, upload-time = "2023-09-14T12:46:13.449Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/49/f890a2668b7cbf375f5528b549c8d36dd2e801b0fbb7b2b5ef65663ecb6c/sphinx_prompt-1.8.0-py3-none-any.whl", hash = "sha256:369ecc633f0711886f9b3a078c83264245be1adf46abeeb9b88b5519e4b51007", size = 7298, upload-time = "2023-09-14T12:46:12.373Z" },
+]
+
+[[package]]
+name = "sphinx-prompt"
+version = "1.10.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.13'",
+ "python_full_version >= '3.11' and python_full_version < '3.13'",
+]
+dependencies = [
+ { name = "certifi", marker = "python_full_version >= '3.11'" },
+ { name = "docutils", marker = "python_full_version >= '3.11'" },
+ { name = "idna", marker = "python_full_version >= '3.11'" },
+ { name = "jinja2", marker = "python_full_version >= '3.11'" },
+ { name = "pygments", marker = "python_full_version >= '3.11'" },
+ { name = "requests", marker = "python_full_version >= '3.11'" },
+ { name = "sphinx", marker = "python_full_version >= '3.11'" },
+ { name = "urllib3", marker = "python_full_version >= '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c7/2b/8f3a87784e6313e48b4d91dfb4aae1e5af3fa0c94ef9e875eb2e471e1418/sphinx_prompt-1.10.0.tar.gz", hash = "sha256:23dca4c07ade840c9e87089d79d3499040fa524b3c422941427454e215fdd111", size = 5181, upload-time = "2025-06-24T08:32:18.684Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/5e/f359e06019dbf0d7f8e23f46c535085c7dc367190a7e19456a09a0153a70/sphinx_prompt-1.10.0-py3-none-any.whl", hash = "sha256:d62f7a1aa346225d30222a271dc78997031204a5f199ce5006c14ece0d94b217", size = 5308, upload-time = "2025-06-24T08:32:17.768Z" },
+]
+
+[[package]]
+name = "sphinx-rtd-theme"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docutils" },
+ { name = "sphinx" },
+ { name = "sphinxcontrib-jquery" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fe/33/2a35a9cdbfda9086bda11457bcc872173ab3565b16b6d7f6b3efaa6dc3d6/sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b", size = 2785005, upload-time = "2023-11-28T04:14:03.104Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/46/00fda84467815c29951a9c91e3ae7503c409ddad04373e7cfc78daad4300/sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586", size = 2824721, upload-time = "2023-11-28T04:13:59.589Z" },
+]
+
+[[package]]
+name = "sphinx-tabs"
+version = "3.4.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docutils" },
+ { name = "pygments" },
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/27/32/ab475e252dc2b704e82a91141fa404cdd8901a5cf34958fd22afacebfccd/sphinx-tabs-3.4.5.tar.gz", hash = "sha256:ba9d0c1e3e37aaadd4b5678449eb08176770e0fc227e769b6ce747df3ceea531", size = 16070, upload-time = "2024-01-21T12:13:39.392Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/9f/4ac7dbb9f23a2ff5a10903a4f9e9f43e0ff051f63a313e989c962526e305/sphinx_tabs-3.4.5-py3-none-any.whl", hash = "sha256:92cc9473e2ecf1828ca3f6617d0efc0aa8acb06b08c56ba29d1413f2f0f6cf09", size = 9904, upload-time = "2024-01-21T12:13:37.67Z" },
+]
+
+[[package]]
+name = "sphinx-toolbox"
+version = "3.10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "apeye" },
+ { name = "autodocsumm" },
+ { name = "beautifulsoup4" },
+ { name = "cachecontrol", extra = ["filecache"] },
+ { name = "dict2css" },
+ { name = "docutils" },
+ { name = "domdf-python-tools" },
+ { name = "filelock" },
+ { name = "html5lib" },
+ { name = "ruamel-yaml" },
+ { name = "sphinx" },
+ { name = "sphinx-autodoc-typehints" },
+ { name = "sphinx-jinja2-compat" },
+ { name = "sphinx-prompt", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
+ { name = "sphinx-prompt", version = "1.10.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
+ { name = "sphinx-tabs" },
+ { name = "tabulate" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f4/2d/d916dc5a70bc7b006af8a31bba1a2767e99cdb884f3dfa47aa79a60cc1e9/sphinx_toolbox-3.10.0.tar.gz", hash = "sha256:6afea9ac9afabe76bd5bd4d2b01edfdad81d653a1a34768e776e6a56d5a6f572", size = 113656, upload-time = "2025-05-06T17:36:50.926Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/ec/d09521ae2059fe89d8b59b2b34f5a1713b82a14e70a9a018fca8d3d514be/sphinx_toolbox-3.10.0-py3-none-any.whl", hash = "sha256:675e5978eaee31adf21701054fa75bacf820459d56e93ac30ad01eaee047a6ef", size = 195622, upload-time = "2025-05-06T17:36:48.81Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-applehelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-devhelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-htmlhelp"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-jquery"
+version = "4.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sphinx" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/de/f3/aa67467e051df70a6330fe7770894b3e4f09436dea6881ae0b4f3d87cad8/sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a", size = 122331, upload-time = "2023-03-14T15:01:01.944Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104, upload-time = "2023-03-14T15:01:00.356Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-jsmath"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-qthelp"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" },
+]
+
+[[package]]
+name = "sphinxcontrib-serializinghtml"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" },
+]
+
+[[package]]
+name = "standard-imghdr"
+version = "3.10.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/d2/2eb5521072c9598886035c65c023f39f7384bcb73eed70794f469e34efac/standard_imghdr-3.10.14.tar.gz", hash = "sha256:2598fe2e7c540dbda34b233295e10957ab8dc8ac6f3bd9eaa8d38be167232e52", size = 5474, upload-time = "2024-04-21T18:55:10.859Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/d0/9852f70eb01f814843530c053542b72d30e9fbf74da7abb0107e71938389/standard_imghdr-3.10.14-py3-none-any.whl", hash = "sha256:cdf6883163349624dee9a81d2853a20260337c4cd41c04e99c082e01833a08e2", size = 5598, upload-time = "2024-04-21T18:54:48.587Z" },
+]
+
+[[package]]
+name = "tabulate"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" },
+]
+
+[[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, upload-time = "2024-11-27T22:38:36.873Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" },
+ { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" },
+ { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" },
+ { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" },
+ { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" },
+ { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" },
+ { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" },
+ { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" },
+ { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" },
+ { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" },
+ { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" },
+ { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" },
+]
+
+[[package]]
+name = "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, upload-time = "2025-01-15T12:07:24.262Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.14.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
+]
+
+[[package]]
+name = "virtualenv"
+version = "20.32.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "distlib" },
+ { name = "filelock" },
+ { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/96/0834f30fa08dca3738614e6a9d42752b6420ee94e58971d702118f7cfd30/virtualenv-20.32.0.tar.gz", hash = "sha256:886bf75cadfdc964674e6e33eb74d787dff31ca314ceace03ca5810620f4ecf0", size = 6076970, upload-time = "2025-07-21T04:09:50.985Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/c6/f8f28009920a736d0df434b52e9feebfb4d702ba942f15338cb4a83eafc1/virtualenv-20.32.0-py3-none-any.whl", hash = "sha256:2c310aecb62e5aa1b06103ed7c2977b81e042695de2697d01017ff0f1034af56", size = 6057761, upload-time = "2025-07-21T04:09:48.059Z" },
+]
+
+[[package]]
+name = "webencodings"
+version = "0.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.20.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/65/7fed0d774abf47487c64be14e9223749468922817b5e8792b8a64792a1bb/yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4", size = 132910, upload-time = "2025-06-10T00:42:31.108Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/7b/988f55a52da99df9e56dc733b8e4e5a6ae2090081dc2754fc8fd34e60aa0/yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a", size = 90644, upload-time = "2025-06-10T00:42:33.851Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/de/30d98f03e95d30c7e3cc093759982d038c8833ec2451001d45ef4854edc1/yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed", size = 89322, upload-time = "2025-06-10T00:42:35.688Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/7a/f2f314f5ebfe9200724b0b748de2186b927acb334cf964fd312eb86fc286/yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e", size = 323786, upload-time = "2025-06-10T00:42:37.817Z" },
+ { url = "https://files.pythonhosted.org/packages/15/3f/718d26f189db96d993d14b984ce91de52e76309d0fd1d4296f34039856aa/yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73", size = 319627, upload-time = "2025-06-10T00:42:39.937Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/76/8fcfbf5fa2369157b9898962a4a7d96764b287b085b5b3d9ffae69cdefd1/yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e", size = 339149, upload-time = "2025-06-10T00:42:42.627Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/95/d7fc301cc4661785967acc04f54a4a42d5124905e27db27bb578aac49b5c/yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8", size = 333327, upload-time = "2025-06-10T00:42:44.842Z" },
+ { url = "https://files.pythonhosted.org/packages/65/94/e21269718349582eee81efc5c1c08ee71c816bfc1585b77d0ec3f58089eb/yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23", size = 326054, upload-time = "2025-06-10T00:42:47.149Z" },
+ { url = "https://files.pythonhosted.org/packages/32/ae/8616d1f07853704523519f6131d21f092e567c5af93de7e3e94b38d7f065/yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70", size = 315035, upload-time = "2025-06-10T00:42:48.852Z" },
+ { url = "https://files.pythonhosted.org/packages/48/aa/0ace06280861ef055855333707db5e49c6e3a08840a7ce62682259d0a6c0/yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb", size = 338962, upload-time = "2025-06-10T00:42:51.024Z" },
+ { url = "https://files.pythonhosted.org/packages/20/52/1e9d0e6916f45a8fb50e6844f01cb34692455f1acd548606cbda8134cd1e/yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2", size = 335399, upload-time = "2025-06-10T00:42:53.007Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/65/60452df742952c630e82f394cd409de10610481d9043aa14c61bf846b7b1/yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30", size = 338649, upload-time = "2025-06-10T00:42:54.964Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/f5/6cd4ff38dcde57a70f23719a838665ee17079640c77087404c3d34da6727/yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309", size = 358563, upload-time = "2025-06-10T00:42:57.28Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/90/c42eefd79d0d8222cb3227bdd51b640c0c1d0aa33fe4cc86c36eccba77d3/yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24", size = 357609, upload-time = "2025-06-10T00:42:59.055Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c8/cea6b232cb4617514232e0f8a718153a95b5d82b5290711b201545825532/yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13", size = 350224, upload-time = "2025-06-10T00:43:01.248Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/a3/eaa0ab9712f1f3d01faf43cf6f1f7210ce4ea4a7e9b28b489a2261ca8db9/yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8", size = 81753, upload-time = "2025-06-10T00:43:03.486Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/34/e4abde70a9256465fe31c88ed02c3f8502b7b5dead693a4f350a06413f28/yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16", size = 86817, upload-time = "2025-06-10T00:43:05.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/18/893b50efc2350e47a874c5c2d67e55a0ea5df91186b2a6f5ac52eff887cd/yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e", size = 133833, upload-time = "2025-06-10T00:43:07.393Z" },
+ { url = "https://files.pythonhosted.org/packages/89/ed/b8773448030e6fc47fa797f099ab9eab151a43a25717f9ac043844ad5ea3/yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b", size = 91070, upload-time = "2025-06-10T00:43:09.538Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/e3/409bd17b1e42619bf69f60e4f031ce1ccb29bd7380117a55529e76933464/yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b", size = 89818, upload-time = "2025-06-10T00:43:11.575Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/77/64d8431a4d77c856eb2d82aa3de2ad6741365245a29b3a9543cd598ed8c5/yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4", size = 347003, upload-time = "2025-06-10T00:43:14.088Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/d2/0c7e4def093dcef0bd9fa22d4d24b023788b0a33b8d0088b51aa51e21e99/yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1", size = 336537, upload-time = "2025-06-10T00:43:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/f3/fc514f4b2cf02cb59d10cbfe228691d25929ce8f72a38db07d3febc3f706/yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833", size = 362358, upload-time = "2025-06-10T00:43:18.704Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/6d/a313ac8d8391381ff9006ac05f1d4331cee3b1efaa833a53d12253733255/yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d", size = 357362, upload-time = "2025-06-10T00:43:20.888Z" },
+ { url = "https://files.pythonhosted.org/packages/00/70/8f78a95d6935a70263d46caa3dd18e1f223cf2f2ff2037baa01a22bc5b22/yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8", size = 348979, upload-time = "2025-06-10T00:43:23.169Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/05/42773027968968f4f15143553970ee36ead27038d627f457cc44bbbeecf3/yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf", size = 337274, upload-time = "2025-06-10T00:43:27.111Z" },
+ { url = "https://files.pythonhosted.org/packages/05/be/665634aa196954156741ea591d2f946f1b78ceee8bb8f28488bf28c0dd62/yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e", size = 363294, upload-time = "2025-06-10T00:43:28.96Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/90/73448401d36fa4e210ece5579895731f190d5119c4b66b43b52182e88cd5/yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389", size = 358169, upload-time = "2025-06-10T00:43:30.701Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/b0/fce922d46dc1eb43c811f1889f7daa6001b27a4005587e94878570300881/yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f", size = 362776, upload-time = "2025-06-10T00:43:32.51Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/0d/b172628fce039dae8977fd22caeff3eeebffd52e86060413f5673767c427/yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845", size = 381341, upload-time = "2025-06-10T00:43:34.543Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/9b/5b886d7671f4580209e855974fe1cecec409aa4a89ea58b8f0560dc529b1/yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1", size = 379988, upload-time = "2025-06-10T00:43:36.489Z" },
+ { url = "https://files.pythonhosted.org/packages/73/be/75ef5fd0fcd8f083a5d13f78fd3f009528132a1f2a1d7c925c39fa20aa79/yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e", size = 371113, upload-time = "2025-06-10T00:43:38.592Z" },
+ { url = "https://files.pythonhosted.org/packages/50/4f/62faab3b479dfdcb741fe9e3f0323e2a7d5cd1ab2edc73221d57ad4834b2/yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773", size = 81485, upload-time = "2025-06-10T00:43:41.038Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/09/d9c7942f8f05c32ec72cd5c8e041c8b29b5807328b68b4801ff2511d4d5e/yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e", size = 86686, upload-time = "2025-06-10T00:43:42.692Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" },
+ { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" },
+ { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" },
+ { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" },
+ { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" },
+ { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" },
+ { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" },
+ { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" },
+ { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" },
+ { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" },
+ { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" },
+ { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" },
+ { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" },
+ { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" },
+ { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" },
+ { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" },
+ { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" },
+ { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" },
+ { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" },
+ { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" },
+ { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" },
+ { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" },
+ { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" },
+ { url = "https://files.pythonhosted.org/packages/01/75/0d37402d208d025afa6b5b8eb80e466d267d3fd1927db8e317d29a94a4cb/yarl-1.20.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e42ba79e2efb6845ebab49c7bf20306c4edf74a0b20fc6b2ccdd1a219d12fad3", size = 134259, upload-time = "2025-06-10T00:45:29.882Z" },
+ { url = "https://files.pythonhosted.org/packages/73/84/1fb6c85ae0cf9901046f07d0ac9eb162f7ce6d95db541130aa542ed377e6/yarl-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41493b9b7c312ac448b7f0a42a089dffe1d6e6e981a2d76205801a023ed26a2b", size = 91269, upload-time = "2025-06-10T00:45:32.917Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/9c/eae746b24c4ea29a5accba9a06c197a70fa38a49c7df244e0d3951108861/yarl-1.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5a5928ff5eb13408c62a968ac90d43f8322fd56d87008b8f9dabf3c0f6ee983", size = 89995, upload-time = "2025-06-10T00:45:35.066Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/30/693e71003ec4bc1daf2e4cf7c478c417d0985e0a8e8f00b2230d517876fc/yarl-1.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c41ad5d717b3961b2dd785593b67d386b73feca30522048d37298fee981805", size = 325253, upload-time = "2025-06-10T00:45:37.052Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/a2/5264dbebf90763139aeb0b0b3154763239398400f754ae19a0518b654117/yarl-1.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:59febc3969b0781682b469d4aca1a5cab7505a4f7b85acf6db01fa500fa3f6ba", size = 320897, upload-time = "2025-06-10T00:45:39.962Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/17/77c7a89b3c05856489777e922f41db79ab4faf58621886df40d812c7facd/yarl-1.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b6fb3622b7e5bf7a6e5b679a69326b4279e805ed1699d749739a61d242449e", size = 340696, upload-time = "2025-06-10T00:45:41.915Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/55/28409330b8ef5f2f681f5b478150496ec9cf3309b149dab7ec8ab5cfa3f0/yarl-1.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:749d73611db8d26a6281086f859ea7ec08f9c4c56cec864e52028c8b328db723", size = 335064, upload-time = "2025-06-10T00:45:43.893Z" },
+ { url = "https://files.pythonhosted.org/packages/85/58/cb0257cbd4002828ff735f44d3c5b6966c4fd1fc8cc1cd3cd8a143fbc513/yarl-1.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9427925776096e664c39e131447aa20ec738bdd77c049c48ea5200db2237e000", size = 327256, upload-time = "2025-06-10T00:45:46.393Z" },
+ { url = "https://files.pythonhosted.org/packages/53/f6/c77960370cfa46f6fb3d6a5a79a49d3abfdb9ef92556badc2dcd2748bc2a/yarl-1.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff70f32aa316393eaf8222d518ce9118148eddb8a53073c2403863b41033eed5", size = 316389, upload-time = "2025-06-10T00:45:48.358Z" },
+ { url = "https://files.pythonhosted.org/packages/64/ab/be0b10b8e029553c10905b6b00c64ecad3ebc8ace44b02293a62579343f6/yarl-1.20.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c7ddf7a09f38667aea38801da8b8d6bfe81df767d9dfc8c88eb45827b195cd1c", size = 340481, upload-time = "2025-06-10T00:45:50.663Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/c3/3f327bd3905a4916029bf5feb7f86dcf864c7704f099715f62155fb386b2/yarl-1.20.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57edc88517d7fc62b174fcfb2e939fbc486a68315d648d7e74d07fac42cec240", size = 336941, upload-time = "2025-06-10T00:45:52.554Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/42/040bdd5d3b3bb02b4a6ace4ed4075e02f85df964d6e6cb321795d2a6496a/yarl-1.20.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dab096ce479d5894d62c26ff4f699ec9072269d514b4edd630a393223f45a0ee", size = 339936, upload-time = "2025-06-10T00:45:54.919Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/1c/911867b8e8c7463b84dfdc275e0d99b04b66ad5132b503f184fe76be8ea4/yarl-1.20.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14a85f3bd2d7bb255be7183e5d7d6e70add151a98edf56a770d6140f5d5f4010", size = 360163, upload-time = "2025-06-10T00:45:56.87Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/31/8c389f6c6ca0379b57b2da87f1f126c834777b4931c5ee8427dd65d0ff6b/yarl-1.20.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c89b5c792685dd9cd3fa9761c1b9f46fc240c2a3265483acc1565769996a3f8", size = 359108, upload-time = "2025-06-10T00:45:58.869Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/09/ae4a649fb3964324c70a3e2b61f45e566d9ffc0affd2b974cbf628957673/yarl-1.20.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:69e9b141de5511021942a6866990aea6d111c9042235de90e08f94cf972ca03d", size = 351875, upload-time = "2025-06-10T00:46:01.45Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/43/bbb4ed4c34d5bb62b48bf957f68cd43f736f79059d4f85225ab1ef80f4b9/yarl-1.20.1-cp39-cp39-win32.whl", hash = "sha256:b5f307337819cdfdbb40193cad84978a029f847b0a357fbe49f712063cfc4f06", size = 82293, upload-time = "2025-06-10T00:46:03.763Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/cd/ce185848a7dba68ea69e932674b5c1a42a1852123584bccc5443120f857c/yarl-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:eae7bfe2069f9c1c5b05fc7fe5d612e5bbc089a39309904ee8b829e322dcad00", size = 87385, upload-time = "2025-06-10T00:46:05.655Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" },
+]
+
+[[package]]
+name = "zipp"
+version = "3.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
+]